@shangxueink/koishi-plugin-puppeteer-without-canvas
Version:
Browser API service for Koishi
581 lines (500 loc) • 22.2 kB
text/typescript
import puppeteer, { Browser, 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 { pathToFileURL } from 'node:url'
import { resolve } from 'node:path'
import { } from '@cordisjs/plugin-proxy-agent'
import { } from 'koishi-plugin-fonts'
export * from './svg'
declare module 'koishi' {
interface Context {
puppeteer: Puppeteer
}
}
type RenderCallback = (page: Page, next: (handle?: ElementHandle) => Promise<string>) => Promise<string>
class Puppeteer extends Service {
static [Service.provide] = 'puppeteer'
static inject = ['http']
browser: Browser
executable: string
private browserWSEndpoint: string
constructor(ctx: Context, public config: Puppeteer.Config) {
super(ctx, 'puppeteer')
if (this.config.enableCanvas !== false) {
ctx.plugin(Canvas)
}
// 注册 HTML 组件
// 只在构造时注册一次 用于指令重启
this.registerHtmlComponent()
// 根据配置决定是否注册重启指令
if (this.config.enableRestartCommand !== false) {
ctx.command('puppeteer.restart', '重启 Puppeteer 浏览器服务')
.action(async ({ session }) => {
try {
session?.send('正在重启 Puppeteer 服务...')
// 停止当前浏览器实例
await this.stopBrowser()
// 重新启动浏览器
await this.startBrowser()
return '✅ Puppeteer 服务重启成功'
} catch (error) {
ctx.logger.error('Puppeteer 服务重启失败:', error)
return `❌ Puppeteer 服务重启失败: ${error.message}`
}
})
}
}
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>`)
}
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() {
await this.startBrowser()
}
private async startBrowser() {
const { remote, endpoint, executablePath, headers, headless, args = [], ...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.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()
this.ctx.logger.debug('从 HTTP URL 获取 WebSocket 端点: %c', this.browserWSEndpoint)
}
} 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 {
// 查找可执行文件路径
this.executable = executablePath || find()
if (!this.executable) {
throw new Error('未找到 Chrome 可执行文件,请手动指定 executablePath 参数')
}
if (!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('正在启动本地浏览器...')
this.browser = await puppeteer.launch({
executablePath: this.executable,
headless,
args: localArgs,
...config
})
this.ctx.logger.info('本地浏览器启动成功。')
this.browserWSEndpoint = this.browser.wsEndpoint()
} catch (e) {
if (e.message?.includes('Failed to launch')) {
throw new Error(`启动浏览器失败,请检查 Chrome 是否已安装或路径是否正确: ${e.message}`)
} else {
throw new Error(`启动浏览器失败: ${e.message || e}`)
}
}
}
} catch (error) {
this.ctx.logger.error(`Puppeteer 初始化失败: `, error)
throw error
}
}
async stop() {
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
}
} catch (error) {
this.ctx.logger.warn('停止浏览器时出现错误:', error.message)
}
}
// 检查浏览器连接状态并尝试重连
private async ensureConnected() {
// 如果浏览器已连接,直接返回
if (this.browser.connected) return
// 如果未启用重连,则抛出异常
if (this.config.enableReconnect === false) {
throw new Error('浏览器连接已断开,且未启用自动重连')
}
// 检查是否有可用的重连端点
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 new Promise(resolve => setTimeout(resolve, this.config.reconnectInterval))
}
// 准备连接选项
const connectOptions: ConnectOptions = { ...this.config }
// 优先使用保存的 WebSocket 端点,如果没有则使用配置的端点
if (this.browserWSEndpoint) {
connectOptions.browserWSEndpoint = this.browserWSEndpoint
this.ctx.logger.debug('使用保存的 WebSocket 端点重连: %c', 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
this.ctx.logger.debug('使用配置的 WebSocket 端点重连: %c', this.config.endpoint)
} else if (['http:', 'https:'].includes(endpointURL.protocol)) {
connectOptions.browserURL = this.config.endpoint
this.ctx.logger.debug('使用配置的 HTTP 端点重连: %c', this.config.endpoint)
}
} catch (e) {
this.ctx.logger.warn('解析端点 URL 失败: %c', e.message)
}
}
// 尝试重新连接
this.browser = await puppeteer.connect(connectOptions)
// 检查连接是否成功
if (this.browser.connected) {
this.ctx.logger.info('浏览器重新连接成功')
// 保存新的 WebSocket 端点
if (this.browser.wsEndpoint) {
this.browserWSEndpoint = this.browser.wsEndpoint()
this.ctx.logger.debug('保存新的 WebSocket 端点: %c', this.browserWSEndpoint)
}
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)
// 检查连接是否成功
if (this.browser.connected) {
this.ctx.logger.info('使用原始配置重新连接成功')
// 保存新的 WebSocket 端点
if (this.browser.wsEndpoint) {
this.browserWSEndpoint = this.browser.wsEndpoint()
this.ctx.logger.debug('保存新的 WebSocket 端点: %c', this.browserWSEndpoint)
}
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 new Promise(resolve => setTimeout(resolve, this.config.reconnectInterval * retryCount))
}
}
}
}
page = async (options?: Puppeteer.PageOptions) => {
let page
try {
// 确保浏览器已连接
await this.ensureConnected()
// 创建新页面
page = await this.browser.newPage()
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)
}
if (options?.families?.length && this.ctx.fonts) {
try {
const fonts = await this.ctx.fonts.get(options.families)
await Promise.all(fonts.map(async (font) => {
if (font.format === 'google') {
await page.addStyleTag({ content: ` url('${font.path}')` })
} else {
await page.evaluate((font) => {
const fontFace = new FontFace(
font.family,
`url(${font.path}) format('${font.format}')`,
font.descriptors,
)
document.fonts.add(fontFace)
}, font)
}
}))
} catch (e) {
this.ctx.logger.warn('加载字体失败,将使用系统默认字体:', e.message)
}
}
}
} 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, families?: string[]) => {
// 确保浏览器已连接
await this.ensureConnected()
const url = resolve(__dirname, '../index.html')
const page = await this.page({ url, content, families })
const renderConfig = this.config.render || {}
if (families?.length) {
try {
await page.addStyleTag({ content: `* {font-family: ${families.map((f) => `'${f}'`).join(', ')};}` })
await page.evaluate(async () => {
await document.fonts.ready
await new Promise(resolve => setTimeout(resolve, 100))
})
} catch (e) {
this.ctx.logger.warn('应用字体样式失败:', e.message)
}
}
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()
})
page.close()
return output
}
}
namespace Puppeteer {
export interface PageOptions {
beforeGotoPage?: (page: Page) => Promise<void>
url: string
gotoOptions?: GoToOptions
content?: string
families?: 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(自带服务,无需额外安装)
- **可选服务:** [fonts](/market?keyword=font+email:shigma10826@gmail.com+email:saarchaffee@qq.com)
---
<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
enableRestartCommand?: boolean
remote?: boolean
endpoint?: string
headers?: Record<string, string>
enableReconnect?: boolean
reconnectInterval?: number
maxReconnectRetries?: 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。'
),
}),
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),
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"] : []),
}),
]),
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),
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({
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({
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),
}).description('浏览器设置'),
]) as Schema<Config>
}
export default Puppeteer