@shangxueink/koishi-plugin-puppeteer-without-canvas
Version:
1,123 lines (963 loc) • 40.5 kB
text/typescript
import puppeteer, { Browser, BrowserContext, ConnectOptions, ElementHandle, GoToOptions, Page } from 'puppeteer-core'
import { Context, h, hyphenate, Schema, Service } from 'koishi'
import { SVG, SVGOptions } from './svg'
import find from 'puppeteer-finder'
import Canvas from './canvas'
import { tmpdir } from 'node:os'
import { pathToFileURL } from 'node:url'
import { resolve, join } from 'node:path'
import { existsSync, mkdirSync } from 'node:fs'
import type { } from '@cordisjs/plugin-proxy-agent'
import type { } from 'koishi-plugin-glyph'
export * from './svg'
declare module 'koishi' {
interface Context {
puppeteer: Puppeteer
}
}
type RenderCallback = (page: Page, next: (handle?: ElementHandle) => Promise<string>) => Promise<string>
export async function injectDefaultFont(page: Page, ctx: Context, config: Puppeteer.Config, fontDataUrl?: string) {
if (!config.enableFont) {
return
}
if (!fontDataUrl) {
return
}
try {
// 使用 Data URL 注入字体,无需 Service Worker 缓存
await page.addStyleTag({
content: `
-face {
font-family: "KoishiDefaultFont";
src: url("${fontDataUrl}");
font-display: swap;
}
`
})
// 根据注入模式决定是否需要检查页面字体
let shouldInject = true
if (config.fontInjectMode === 'smart') {
const hasAnyFontFamily = await page.evaluate(() => {
for (let i = 0; i < document.styleSheets.length; i++) {
try {
const styleSheet = document.styleSheets[i]
if (styleSheet.cssRules) {
for (let j = 0; j < styleSheet.cssRules.length; j++) {
const rule = styleSheet.cssRules[j]
if (rule instanceof CSSStyleRule && rule.style.fontFamily) {
return true
}
}
}
} catch (e) {
// 跨域样式表可能无法访问,忽略错误
}
}
const elementsWithInlineFont = document.querySelectorAll('[style*="font-family"]')
if (elementsWithInlineFont.length > 0) {
return true
}
return false
})
// 判断模式:只有在页面没有设置字体时才注入
shouldInject = !hasAnyFontFamily
}
// force 模式:shouldInject 保持为 true,无条件注入
// 根据判断结果决定是否注入字体样式
if (shouldInject) {
await page.addStyleTag({
content: `
/* 全局应用默认字体 */
*, *::before, *::after {
font-family: "KoishiDefaultFont" !important;
}
html, body, div, span, p, h1, h2, h3, h4, h5, h6,
input, textarea, button, select, option, canvas {
font-family: "KoishiDefaultFont" !important;
}
`
})
await page.evaluate(`
new Promise((resolve) => {
let resolved = false;
// 设置超时保护,最多等待 3 秒
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve();
}
}, 3000);
// 检查字体加载
(function() {
if (document.fonts && document.fonts.check) {
try {
const fontLoaded = document.fonts.check('16px "KoishiDefaultFont"');
if (fontLoaded) {
if (!resolved) {
resolved = true;
resolve();
}
return;
}
} catch (e) {
// 如果 check 方法失败,继续使用其他方法
}
}
// 备用方法:等待 document.fonts.ready
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => {
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve();
}
}, 100);
}).catch(() => {
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve();
}
}, 500);
});
} else {
setTimeout(() => {
if (!resolved) {
resolved = true;
resolve();
}
}, 500);
}
})();
})
`)
}
// 如果页面已经设置了 font-family,则不注入任何字体样式
} catch (error) {
ctx.logger.error('默认字体注入失败:', error.message)
}
}
class Puppeteer extends Service {
static [Service.provide] = 'puppeteer'
static inject = {
required: ['http'],
optional: ['glyph']
}
browser: Browser
executable: string
private browserWSEndpoint: string
private activePageCount: number = 0
private activeContextCount: number = 0
private isRestarting: boolean = false // 是否正在重启
private disposeKeepAlive: (() => void) | null = null // 保活定时器销毁函数
private originalBrowserMethods = new WeakMap<Browser, {
newPage: Browser['newPage']
createBrowserContext: Browser['createBrowserContext']
}>()
private patchedBrowsers = new WeakSet<Browser>()
private trackedPages = new WeakSet<Page>()
private trackedContexts = new WeakSet<BrowserContext>()
constructor(ctx: Context, public config: Puppeteer.Config) {
super(ctx, 'puppeteer')
if (this.config.enableCanvas !== false) {
ctx.plugin(Canvas)
}
// 根据配置注册 HTML 组件
if (this.config.registerHtmlComponent) {
this.registerHtmlComponent()
}
// 根据配置决定是否注册重启指令
if (this.config.enableRestartCommand !== false && !this.config.immediateClose) {
ctx.command('puppeteer.restart', '重启 Puppeteer 浏览器服务')
.action(async ({ session }) => {
try {
await session?.send('正在重启 Puppeteer 服务...')
// 停止当前浏览器实例
await this.stopBrowser()
// 重新启动浏览器
await this.startBrowser()
return '✅ Puppeteer 服务重启成功'
} catch (error) {
ctx.logger.error('Puppeteer 服务重启失败:', error)
return `❌ Puppeteer 服务重启失败: ${error.message}`
}
})
} else if (this.config.immediateClose && this.config.enableRestartCommand !== false) {
ctx.logger.warn('immediateClose 模式下 puppeteer.restart 指令已被禁用')
}
}
private patchBrowser(browser: Browser) {
if (this.patchedBrowsers.has(browser)) {
return
}
this.patchedBrowsers.add(browser)
this.originalBrowserMethods.set(browser, {
newPage: browser.newPage.bind(browser),
createBrowserContext: browser.createBrowserContext.bind(browser),
})
browser.newPage = async (...args: Parameters<Browser['newPage']>) => {
await this.ensureConnected()
const currentBrowser = this.browser
if (!currentBrowser) {
throw new Error('浏览器尚未启动')
}
const originalMethods = this.originalBrowserMethods.get(currentBrowser)
if (!originalMethods) {
throw new Error('浏览器实例尚未完成方法补丁注册')
}
const page = await originalMethods.newPage(...args)
return this.trackPage(page)
}
browser.createBrowserContext = async (...args: Parameters<Browser['createBrowserContext']>) => {
await this.ensureConnected()
const currentBrowser = this.browser
if (!currentBrowser) {
throw new Error('浏览器尚未启动')
}
const originalMethods = this.originalBrowserMethods.get(currentBrowser)
if (!originalMethods) {
throw new Error('浏览器实例尚未完成方法补丁注册')
}
const context = await originalMethods.createBrowserContext(...args)
return this.trackBrowserContext(context)
}
}
private trackBrowserContext(context: BrowserContext) {
if (this.trackedContexts.has(context)) {
return context
}
this.trackedContexts.add(context)
if (this.config.immediateClose) {
this.activeContextCount++
}
const originalNewPage = context.newPage.bind(context)
context.newPage = async (...args: Parameters<BrowserContext['newPage']>) => {
const page = await originalNewPage(...args)
return this.trackPage(page)
}
const originalClose = context.close.bind(context)
let released = false
const release = async () => {
if (released || !this.config.immediateClose) {
return
}
released = true
this.activeContextCount = Math.max(0, this.activeContextCount - 1)
await this.checkAndCloseBrowser()
}
context.close = async () => {
try {
await originalClose()
} finally {
await release()
}
}
return context
}
private trackPage(page: Page) {
if (this.trackedPages.has(page)) {
return page
}
this.trackedPages.add(page)
if (this.config.immediateClose) {
this.activePageCount++
}
const originalClose = page.close.bind(page)
let released = false
const release = async () => {
if (released || !this.config.immediateClose) {
return
}
released = true
this.activePageCount = Math.max(0, this.activePageCount - 1)
await this.checkAndCloseBrowser()
}
page.once('close', () => {
void release()
})
page.close = async (...args: Parameters<Page['close']>) => {
try {
await originalClose(...args)
} finally {
await release()
}
}
return page
}
private getFontCacheDir(customDir?: string): string {
if (customDir && customDir.trim()) {
// 使用用户指定的目录
const dir = resolve(customDir.trim())
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
return dir
}
// 使用默认目录
const defaultDir = join(tmpdir(), '.koishi-puppeteer-userDataDir');
if (!existsSync(defaultDir)) {
mkdirSync(defaultDir, { recursive: true })
}
return defaultDir
}
private registerHtmlComponent() {
const transformStyle = (source: {}, base = {}) => {
return Object.entries({ ...base, ...source }).map(([key, value]) => {
return `${hyphenate(key)}: ${Array.isArray(value) ? value.join(', ') : value}`
}).join('; ')
}
this.ctx.component('html', async (attrs, children) => {
const head: h[] = []
const transform = (element: h) => {
if (element.type === 'head') {
head.push(...element.children)
return
}
const attrs = { ...element.attrs }
if (typeof attrs.style === 'object') {
attrs.style = transformStyle(attrs.style)
}
return h(element.type, attrs, element.children.map(transform).filter(Boolean))
}
// 确保浏览器已连接
await this.ensureConnected()
const page = await this.page()
try {
if (attrs.src) {
await page.goto(attrs.src)
} else {
await page.goto(pathToFileURL(resolve(__dirname, '../index.html')).href)
const bodyStyle = typeof attrs.style === 'object'
? transformStyle({ display: 'inline-block' }, attrs.style)
: ['display: inline-block', attrs.style].filter(Boolean).join('; ')
const content = children.map(transform).filter(Boolean).join('')
const lang = attrs.lang ? ` lang="${attrs.lang}"` : ''
await page.setContent(`<html${lang}>
<head>${head.join('')}</head>
<body style="${bodyStyle}">${content}</body>
</html>`)
// setContent 后重新注入默认字体
const fontDataUrl = this.getFontDataUrl()
await injectDefaultFont(page, this.ctx, this.config, fontDataUrl)
}
await page.waitForNetworkIdle({
timeout: attrs.timeout ? +attrs.timeout : undefined,
})
const body = await page.$(attrs.selector || 'body')
const clip = await body.boundingBox()
const screenshot = await page.screenshot({ clip }) as Buffer
return h.image(screenshot, 'image/png')
} finally {
await page?.close()
}
})
}
async start() {
// 如果启用了立即关闭模式,则不在启动时初始化浏览器
if (!this.config.immediateClose) {
await this.startBrowser()
// 启动保活机制
this.startKeepAlive()
}
}
private async startBrowser() {
const { remote, endpoint, executablePath, headers, headless, args = [], enableTempUserDataDir, TempUserDataDir, ...config } = this.config
try {
if (remote) {
if (!endpoint) {
throw new Error('远程浏览器模式下必须提供 endpoint 参数')
}
const connectOptions: ConnectOptions = { headers, ...config }
try {
const endpointURL = new URL(endpoint)
if (['ws:', 'wss:'].includes(endpointURL.protocol)) {
// 不再检查路径格式,接受任何有效的 WebSocket URL
connectOptions.browserWSEndpoint = endpoint
} else if (['http:', 'https:'].includes(endpointURL.protocol)) {
connectOptions.browserURL = endpoint
} else {
throw new Error(`不支持的协议: ${endpointURL.protocol},endpoint 必须以 ws://, wss://, http:// 或 https:// 开头`)
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`无效的 endpoint URL: ${endpoint},请检查格式是否正确`)
}
throw e
}
try {
this.ctx.logger.info('正在连接远程浏览器: %c', endpoint)
this.browser = await puppeteer.connect(connectOptions)
this.patchBrowser(this.browser)
this.ctx.logger.info('远程浏览器连接成功。')
// 保存 WebSocket 端点,无论是直接提供的还是从 HTTP URL 获取的
if (connectOptions.browserWSEndpoint) {
this.browserWSEndpoint = connectOptions.browserWSEndpoint
} else if (this.browser.wsEndpoint) {
// 如果使用 HTTP URL,从浏览器实例获取 WebSocket 端点
this.browserWSEndpoint = this.browser.wsEndpoint()
}
} catch (e) {
// 处理连接错误
if (e.message?.includes('ECONNREFUSED')) {
throw new Error(`无法连接到远程浏览器 ${endpoint},请确保远程浏览器已启动并且端口可访问`)
} else if (e.message?.includes('not opened')) {
throw new Error(`远程浏览器连接被拒绝,请确保提供的 endpoint 是正确的并且浏览器已启动调试模式`)
} else {
// 确保包含原始错误信息
throw new Error(`连接远程浏览器失败: ${e.message || e}`)
}
}
} else {
// 当配置路径存在且可访问时直接使用,否则(未配置或路径不可用)从环境自动查找
if (executablePath && existsSync(executablePath)) {
this.executable = executablePath
this.ctx.logger.info('使用配置指定的浏览器路径: %c', this.executable)
} else {
if (executablePath) {
// 用户填写了路径但文件不存在,给出警告后回退到自动查找
this.ctx.logger.warn('配置的可执行文件路径不可用: %c,将从环境自动查找', executablePath)
}
// 调用 puppeteer-finder 从环境自动发现
this.executable = find()
// 找不到时尝试 Termux 特殊路径
if (!this.executable) {
const termuxChromiumPath = '/data/data/com.termux/files/usr/bin/chromium-browser'
if (existsSync(termuxChromiumPath)) {
this.executable = termuxChromiumPath
this.ctx.logger.info('在 Termux 环境中找到浏览器: %c', termuxChromiumPath)
}
}
if (!this.executable) {
throw new Error('未找到 Chrome 可执行文件,请手动指定 executablePath 参数')
}
this.ctx.logger.info('找到 Chrome 可执行文件: %c', this.executable)
}
// 处理代理设置
const localArgs = [...args]
const { proxyAgent } = this.ctx.http.config
if (proxyAgent && !localArgs.some(arg => arg.startsWith('--proxy-server'))) {
localArgs.push(`--proxy-server=${proxyAgent}`)
}
try {
// 启动浏览器
this.ctx.logger.info('正在启动本地浏览器...')
// 准备启动选项
const launchOptions: any = {
executablePath: this.executable,
headless,
args: localArgs,
...config
}
// 固定用户数据目录
if (enableTempUserDataDir) {
const userDataDir = this.getFontCacheDir(TempUserDataDir)
launchOptions.userDataDir = userDataDir
this.ctx.logger.info('用户数据目录: %c', userDataDir)
}
// 最多重试 3 次,应对端口短暂占用或进程未完全释放等偶发问题
const maxLaunchRetries = 3
let launched = false
for (let attempt = 1; attempt <= maxLaunchRetries; attempt++) {
try {
this.browser = await puppeteer.launch(launchOptions)
this.patchBrowser(this.browser)
this.browserWSEndpoint = this.browser.wsEndpoint()
this.ctx.logger.info('本地浏览器启动成功。')
launched = true
break
} catch (e) {
if (attempt < maxLaunchRetries) {
// 遇到端口占用或启动失败时,稍等片刻后重试(Chrome 会自动换端口)
this.ctx.logger.warn(`浏览器启动失败,1 秒后重试 (${attempt}/${maxLaunchRetries}): ${e.message}`)
await this.ctx.sleep(1000)
} else {
// 已达最大重试次数,抛出详细错误
if (e.message?.includes('Failed to launch') || e.message?.includes('EADDRINUSE')) {
throw new Error(`启动浏览器失败(已重试 ${maxLaunchRetries} 次),请检查 Chrome 是否已安装或端口是否被其他进程持续占用: ${e.message}`)
} else {
throw new Error(`启动浏览器失败: ${e.message || e}`)
}
}
}
}
if (!launched) {
throw new Error('浏览器启动失败:未知原因导致重试循环退出')
}
} catch (e) {
// 重新抛出,由外层统一处理
throw e
}
}
} catch (error) {
this.ctx.logger.error(`Puppeteer 初始化失败: `, error)
throw error
}
}
async stop() {
// 停止保活定时器
this.stopKeepAlive()
await this.stopBrowser()
}
private async stopBrowser() {
try {
if (this.browser) {
if (this.config.remote) {
await this.browser.disconnect()
} else {
await this.browser.close()
}
this.browser = null
this.browserWSEndpoint = null
this.activePageCount = 0
this.activeContextCount = 0
}
} catch (error) {
this.ctx.logger.warn('停止浏览器时出现错误:', error.message)
}
}
// 启动保活机制
private startKeepAlive() {
// 如果未启用保活或启用了立即关闭模式,则不启动保活
if (!this.config.enableKeepAlive || this.config.immediateClose) {
return
}
// 清除已有的定时器
this.stopKeepAlive()
const interval = this.config.keepAliveInterval || 30000 // 默认30秒检查一次
this.disposeKeepAlive = this.ctx.setInterval(async () => {
await this.checkBrowserHealth()
}, interval)
}
// 停止保活机制
private stopKeepAlive() {
if (this.disposeKeepAlive) {
this.disposeKeepAlive()
this.disposeKeepAlive = null
}
}
// 检查浏览器健康状态
private async checkBrowserHealth() {
// 如果正在重启,跳过本次检查
if (this.isRestarting) {
return
}
// 如果浏览器未初始化,跳过检查
if (!this.browser) {
return
}
try {
// 检查浏览器连接状态
if (!this.browser.connected) {
this.ctx.logger.warn('检测到浏览器连接已断开,尝试重新连接...')
await this.restartBrowserSafely()
return
}
// 尝试获取浏览器版本来验证浏览器是否真正存活
await this.browser.version()
// 如果能成功获取版本,说明浏览器正常
} catch (error) {
// 如果获取版本失败,说明浏览器进程可能已被杀死
this.ctx.logger.warn('浏览器健康检查失败: %s,尝试重启...', error.message)
await this.restartBrowserSafely()
}
}
// 安全地重启浏览器
private async restartBrowserSafely() {
// 防止并发重启
if (this.isRestarting) {
return
}
this.isRestarting = true
try {
this.ctx.logger.info('开始重启浏览器...')
// 停止当前浏览器
await this.stopBrowser()
// 等待一小段时间确保资源释放
await this.ctx.sleep(1000)
// 重新启动浏览器
await this.startBrowser()
this.ctx.logger.info('浏览器重启成功')
} catch (error) {
this.ctx.logger.error('浏览器重启失败: %s', error.message)
} finally {
this.isRestarting = false
}
}
// 检查并关闭浏览器
private async checkAndCloseBrowser() {
if (!this.config.immediateClose) return
// 只有页面和上下文都已释放后,才允许关闭整个浏览器。
if (this.activePageCount <= 0 && this.activeContextCount <= 0 && this.browser) {
try {
// 获取所有页面
const pages = await this.browser.pages()
// 检查是否只剩下空白页(about:blank)
const nonBlankPages = pages.filter(page => page.url() !== 'about:blank')
if (nonBlankPages.length === 0) {
await this.stopBrowser()
}
} catch (error) {
this.ctx.logger.warn('检查浏览器状态时出现错误:', error.message)
}
}
}
// 检查浏览器连接状态并尝试重连
private async ensureConnected() {
// 如果启用了立即关闭模式,且浏览器未连接,则启动浏览器
if (this.config.immediateClose && (!this.browser || !this.browser.connected)) {
await this.startBrowser()
return
}
// 如果浏览器已连接,直接返回
if (this.browser && this.browser.connected) return
// 如果未启用重连,则抛出异常
if (this.config.enableReconnect === false) {
throw new Error('浏览器连接已断开,且未启用自动重连')
}
// 本地模式:浏览器进程重启后端点(含随机端口)会失效,不应尝试 connect() 旧端点
// 直接重新启动一个新的浏览器进程,避免因旧端口被其他进程占用导致连接失败
if (!this.config.remote) {
this.ctx.logger.warn('本地浏览器连接已断开,将重新启动浏览器...')
await this.restartBrowserSafely()
return
}
// 远程模式:检查是否有可用的重连端点
const hasReconnectEndpoint = this.browserWSEndpoint || this.config.endpoint
if (!hasReconnectEndpoint) {
throw new Error('远程浏览器连接已断开,且没有可用的重连端点')
}
// 尝试重连,使用配置的最大重试次数
let retryCount = 0
const maxRetries = this.config.maxReconnectRetries ?? 3
while (retryCount < maxRetries) {
try {
this.ctx.logger.info(`浏览器连接已断开,尝试重新连接... (尝试 ${retryCount + 1}/${maxRetries})`)
// 如果设置了重连间隔,则等待指定时间
if (this.config.reconnectInterval > 0) {
await this.ctx.sleep(this.config.reconnectInterval)
}
// 准备连接选项
const connectOptions: ConnectOptions = { ...this.config }
// 优先使用保存的 WebSocket 端点,如果没有则使用配置的端点
if (this.browserWSEndpoint) {
connectOptions.browserWSEndpoint = this.browserWSEndpoint
} else if (this.config.endpoint) {
// 检查端点类型
try {
const endpointURL = new URL(this.config.endpoint)
if (['ws:', 'wss:'].includes(endpointURL.protocol)) {
connectOptions.browserWSEndpoint = this.config.endpoint
} else if (['http:', 'https:'].includes(endpointURL.protocol)) {
connectOptions.browserURL = this.config.endpoint
}
} catch (e) {
this.ctx.logger.warn('解析端点 URL 失败: %c', e.message)
}
}
// 尝试重新连接
this.browser = await puppeteer.connect(connectOptions)
this.patchBrowser(this.browser)
// 检查连接是否成功
if (this.browser.connected) {
this.ctx.logger.info('浏览器重新连接成功')
// 保存新的 WebSocket 端点
if (this.browser.wsEndpoint) {
this.browserWSEndpoint = this.browser.wsEndpoint()
}
return
} else {
throw new Error('连接后浏览器状态仍为断开')
}
} catch (e) {
retryCount++
// 检查是否是 404 错误,如果是,则尝试使用原始配置重新连接
if (e.message?.includes('404') || e.message?.includes('Unexpected server response: 404')) {
this.ctx.logger.warn('检测到 404 错误,尝试使用原始配置重新连接')
try {
// 重置端点信息
this.browserWSEndpoint = null
// 使用原始配置重新连接
const originalOptions: ConnectOptions = { ...this.config }
if (this.config.endpoint) {
const endpointURL = new URL(this.config.endpoint)
if (['ws:', 'wss:'].includes(endpointURL.protocol)) {
originalOptions.browserWSEndpoint = this.config.endpoint
} else if (['http:', 'https:'].includes(endpointURL.protocol)) {
originalOptions.browserURL = this.config.endpoint
}
}
this.browser = await puppeteer.connect(originalOptions)
this.patchBrowser(this.browser)
// 检查连接是否成功
if (this.browser.connected) {
this.ctx.logger.info('使用原始配置重新连接成功')
// 保存新的 WebSocket 端点
if (this.browser.wsEndpoint) {
this.browserWSEndpoint = this.browser.wsEndpoint()
}
return
}
} catch (reconnectError) {
this.ctx.logger.warn('使用原始配置重新连接失败:', reconnectError.message)
}
}
if (retryCount >= maxRetries) {
this.ctx.logger.error(`浏览器重新连接失败 (${retryCount}/${maxRetries}):`, e.message)
throw new Error(`浏览器重新连接失败: ${e.message}`)
} else {
this.ctx.logger.warn(`浏览器重新连接失败,将重试 (${retryCount}/${maxRetries}):`, e.message)
// 增加重试间隔
await this.ctx.sleep(this.config.reconnectInterval * retryCount)
}
}
}
}
// 获取字体 Data URL(从 glyph 插件)
private getFontDataUrl(): string | null {
if (!this.config.enableFont || !this.config.fontName) {
return null
}
// 从 glyph 服务获取字体 Data URL
if (this.ctx.glyph) {
try {
const fontDataUrl = this.ctx.glyph.getFontDataUrl(this.config.fontName)
if (!fontDataUrl) {
this.ctx.logger.warn(`未找到字体: ${this.config.fontName}`)
return null
}
return fontDataUrl
} catch (error) {
this.ctx.logger.error('获取字体 Data URL 失败:', error.message)
return null
}
}
return null
}
page = async (options?: Puppeteer.PageOptions) => {
let page: Page | undefined
try {
// 确保浏览器已连接
await this.ensureConnected()
// 创建新页面
// 创建新页面,并统一纳入生命周期跟踪。
page = await this.browser.newPage()
page = this.trackPage(page)
// 设置默认超时时间
if (this.config.defaultTimeout !== undefined) {
page.setDefaultTimeout(this.config.defaultTimeout)
}
// 注入默认字体
const fontDataUrl = this.getFontDataUrl()
await injectDefaultFont(page, this.ctx, this.config, fontDataUrl)
const originalSetContent = page.setContent.bind(page)
page.setContent = async (html: string, options?: any) => {
const result = await originalSetContent(html, options)
// setContent 后重新注入默认字体
const fontDataUrl = this.getFontDataUrl()
await injectDefaultFont(page, this.ctx, this.config, fontDataUrl)
return result
}
if (options) {
if (options?.beforeGotoPage) {
await options.beforeGotoPage(page)
}
await page.goto(`${pathToFileURL(options.url)}`, options?.gotoOptions)
if (options?.content) {
await page.setContent(options.content)
// setContent 后重新注入默认字体
const fontDataUrl = this.getFontDataUrl()
await injectDefaultFont(page, this.ctx, this.config, fontDataUrl)
}
}
} catch (err) {
if (page) {
await page.close()
}
this.ctx.logger.error('failed to create page: %s', err)
throw err
}
return page
}
svg = async (options?: SVGOptions) => {
// 确保浏览器已连接
await this.ensureConnected()
return new SVG(options)
}
render = async (content: string, callback?: RenderCallback) => {
// 确保浏览器已连接
await this.ensureConnected()
const url = resolve(__dirname, '../index.html')
const page = await this.page({ url, content })
const renderConfig = this.config.render || {}
callback ||= async (_, next) => page.$('body').then(next)
const output = await callback(page, async (handle) => {
const clip = handle ? await handle.boundingBox() : null
const screenshotOptions = { clip, ...renderConfig }
const buffer = await page.screenshot(screenshotOptions) as Buffer
const imageType = renderConfig.type || 'png'
return h.image(buffer, `image/${imageType}`).toString()
})
await page.close()
return output
}
}
namespace Puppeteer {
export interface PageOptions {
beforeGotoPage?: (page: Page) => Promise<void>
url: string
gotoOptions?: GoToOptions
content?: string
}
export const filter = false;
export const usage = `
---
本插件提供浏览器 API 服务,主要用于网页截图、生成图片等功能。
**重要提示:**
1. **浏览器环境要求:** 为确保插件正常运行,请确保您的系统已安装 Chromium 浏览器,或已配置远程浏览器服务。
2. **版本匹配:** 建议保持 Chromium/Chrome 浏览器与本插件同步更新,以避免因版本不匹配导致的功能异常。
3. **Windows 系统兼容性:** 如果您在 Windows 服务器上运行,为支持最新版 Puppeteer 及其捆绑的 Chromium,您的操作系统至少需要是 **Windows Server 2016 或更高版本**。
注意: Windows Server 2012 R2 及更早版本已无法满足最新 Chrome 的运行要求。
---
**服务依赖:**
- **必需服务:** http(自带服务,无需额外安装)
- **可选服务:** [glyph](/market?keyword=glyph) - 用于字体注入功能(启用字体注入时需要)
---
<p>➣ <a href="https://github.com/shangxueink/koishi-plugin-puppeteer-without-canvas" target="_blank">点我前往项目地址</a></p>
---
`;
type LaunchOptions = Parameters<typeof puppeteer.launch>[0] & {}
export type ImageType = 'png' | 'jpeg' | 'webp'
export interface Config extends LaunchOptions, ConnectOptions {
enablePuppeteer?: boolean
enableCanvas?: boolean
registerHtmlComponent?: boolean
enableRestartCommand?: boolean
remote?: boolean
endpoint?: string
headers?: Record<string, string>
enableReconnect?: boolean
reconnectInterval?: number
maxReconnectRetries?: number
immediateClose?: boolean
enableKeepAlive?: boolean
keepAliveInterval?: number
enableFont?: boolean
fontName?: string
fontInjectMode?: 'force' | 'smart'
enableTempUserDataDir?: boolean
TempUserDataDir?: string
defaultTimeout?: number
render?: {
type?: ImageType
quality?: number
}
}
export const Config = Schema.intersect([
Schema.object({
remote: Schema.boolean().description('是否连接到远程浏览器。').default(false),
}).description('连接设置'),
Schema.union([
Schema.object({
remote: Schema.const(true).required(),
endpoint: Schema.string().description(
'远程浏览器的端点。<br>' +
'例:<br>' +
'WebSocket URL: `ws://localhost:14550/devtools/browser/[id]`<br>' +
'HTTP URL: `http://localhost:14550`<br>'
).required(),
headers: Schema.dict(String).role('table').description(
'连接到远程浏览器时使用的 HTTP 请求头。<br>' +
'注意:这与本地模式的 `args` 不同,headers 只影响连接请求,不影响浏览器本身的行为。<br>' +
'常用于设置身份验证、API密钥或自定义标识等。详细示例请参考 README。'
),
}).description('远程浏览器设置'),
Schema.object({
remote: Schema.const(false).default(false),
executablePath: Schema.string().description(
'`Chrome/Chromium 可执行文件`的路径。一般无需指定。<br>' +
'**缺省或路径不可用时**,将自动从系统环境中查找。<br>' +
'仅当自动查找失败时,才需要手动指定此路径。'
),
headless: Schema.boolean().description('是否开启[无头模式](https://developer.chrome.com/blog/headless-chrome/)。无头模式下浏览器不会显示界面。').default(true),
immediateClose: Schema.boolean().description('是否在渲染完成后 立即关闭浏览器连接。<br>启用后 会增加每次渲染的启动时间。适用于低频率渲染场景。').default(false).experimental(),
args: Schema.array(String)
.description(
'启动 Chrome/Chromium 浏览器时传递的命令行参数。<br>' +
'常用参数:<br>' +
'`--no-sandbox`: 禁用沙箱(在 Docker 或 root 用户下常用)<br>' +
'`--disable-gpu`: 禁用 GPU 加速<br>' +
'更多 [Chromium 参数请参考这个页面](https://peter.sh/experiments/chromium-command-line-switches/)。'
)
.default(process.getuid?.() === 0 ? ["--no-sandbox", "--disable-gpu", "--disable-web-security"] : ["--disable-web-security"]),
}).description('本地浏览器设置'),
]),
Schema.object({
enablePuppeteer: Schema.boolean().description('是否注册 puppeteer 服务。').default(true),
enableCanvas: Schema.boolean().description('是否注册 canvas 服务。(默认关闭。)<br>注意: 这与[`koishi-plugin-canvas`](/market?keyword=koishi-plugin-canvas+email:shigma10826@gmail.com+email:void@anillc.cn+email:i.dlist@outlook.com)的`canvas`服务同名 但API不一致。').default(false),
registerHtmlComponent: Schema.boolean().description('是否注册 `component:html` 服务。<br>注意: 启用后会覆盖 Koishi 的默认 `html` 组件行为。').default(true),
}).description('服务注册'),
Schema.object({
enableRestartCommand: Schema.boolean().description('是否注册 `puppeteer.restart` 重启指令。启用后可通过指令重启浏览器服务。').default(false),
enableReconnect: Schema.boolean().description('是否启用浏览器自动重连功能。当浏览器连接断开时,会尝试重新连接。').default(true),
reconnectInterval: Schema.number().description('浏览器重连尝试的间隔时间(毫秒)。').default(1000),
maxReconnectRetries: Schema.number().description('浏览器重连最大尝试次数。').default(3),
}).description('重连功能设置'),
Schema.object({
enableKeepAlive: Schema.boolean().description('是否启用浏览器保活机制。定期检查浏览器健康状态,自动重启被杀死的浏览器进程。<br>**注意**: 仅在非立即关闭模式下有效(即 `immediateClose` 配置项关闭时)。').default(false),
keepAliveInterval: Schema.number().description('浏览器健康检查的间隔时间(毫秒)。').default(30000).min(5000),
}).description('保活功能设置'),
Schema.object({
render: Schema.intersect([
Schema.object({
type: Schema.union(['png', 'jpeg', 'webp'] as const).description('默认渲染的图片类型。').default('png'),
}),
Schema.union([
Schema.object({
type: Schema.const('png'),
}),
Schema.object({
quality: Schema.number().min(0).max(100).step(1).description('默认渲染的图片质量。').default(80),
}),
]),
]),
}).description('渲染设置'),
Schema.object({
defaultTimeout: Schema.number().description('页面渲染的默认超时时间(毫秒)。<br>设置为 0 表示禁用超时。').default(30000).min(0),
defaultViewport: Schema.object({
width: Schema.natural().description('默认的视图宽度。').default(1280),
height: Schema.natural().description('默认的视图高度。').default(768),
deviceScaleFactor: Schema.number().min(0).description('默认的设备缩放比率。').default(2),
}),
ignoreHTTPSErrors: Schema.boolean().description('在导航时忽略 HTTPS 错误。').default(false),
enableTempUserDataDir: Schema.boolean().description('是否固定用户数据目录。<br>- 需要使用本地浏览器。远程浏览器无效。').default(false).experimental(),
}).description('浏览器设置'),
Schema.union([
Schema.object({
enableTempUserDataDir: Schema.const(false)
}),
Schema.object({
enableTempUserDataDir: Schema.const(true).required(),
TempUserDataDir: Schema.string().experimental().default(null)
.description('用户数据目录路径。建议保持默认值。<br>默认目录:系统`temp`目录下的 `.koishi-puppeteer-userDataDir`。'),
}),
]),
Schema.object({
enableFont: Schema.boolean().description('是否为页面注入字体。<br>需要安装 `glyph` 插件并配置字体。').default(false).experimental(),
}).description('字体注入设置'),
Schema.union([
Schema.object({
enableFont: Schema.const(true).required(),
fontName: Schema.dynamic('glyph.fonts').description('选择要注入的字体。<br>需要先安装并配置 `glyph` 插件。<br>**注意**: 动态配置项在开发模式下不显示选项,请在生产模式下查看。').experimental(),
fontInjectMode: Schema.union([
Schema.const('smart').description('1.判断注入'),
Schema.const('force').description('2.强制注入'),
]).default('smart').experimental()
.description('字体注入模式。<br>1.判断注入,仅在页面未设置字体时注入<br>2.强制注入,无论页面是否已设置字体都会注入'),
}),
Schema.object({
enableFont: Schema.const(false)
}),
]),
]) as Schema<Config>
}
export default Puppeteer