UNPKG

@shangxueink/koishi-plugin-puppeteer-without-canvas

Version:
1,123 lines (963 loc) 40.5 kB
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: ` @font-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