UNPKG

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

Version:
1,220 lines (1,199 loc) 46.9 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { SVG: () => SVG, Tag: () => Tag, default: () => src_default, injectDefaultFont: () => injectDefaultFont }); module.exports = __toCommonJS(src_exports); var import_puppeteer_core = __toESM(require("puppeteer-core")); var import_koishi3 = require("koishi"); // src/svg.ts var import_koishi = require("koishi"); function hyphenate(source) { const result = {}; for (const key in source) { result[key.replace(/[A-Z]/g, (str) => "-" + str.toLowerCase())] = source[key]; } return result; } __name(hyphenate, "hyphenate"); function escapeHtml(source) { return source.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); } __name(escapeHtml, "escapeHtml"); var Tag = class _Tag { constructor(tag) { this.tag = tag; } static { __name(this, "Tag"); } parent; children = []; attributes = {}; innerText = ""; child(tag) { const child = new _Tag(tag); child.parent = this; this.children.push(child); return child; } attr(attributes) { this.attributes = { ...this.attributes, ...attributes }; return this; } data(innerText) { this.innerText = innerText; return this; } line(x1, y1, x2, y2, attr = {}) { this.child("line").attr({ ...hyphenate(attr), x1, y1, x2, y2 }); return this; } circle(cx, cy, r, attr = {}) { this.child("circle").attr({ ...hyphenate(attr), cx, cy, r }); return this; } rect(x1, y1, x2, y2, attr = {}) { this.child("rect").attr({ ...hyphenate(attr), x: x1, y: y1, width: y2 - y1, height: x2 - x1 }); return this; } text(text, x, y, attr = {}) { this.child("text").attr({ ...hyphenate(attr), x, y }).data(text); return this; } g(attr = {}) { return this.child("g").attr(hyphenate(attr)); } get outer() { const attrText = Object.keys(this.attributes).map((key) => ` ${key}="${escapeHtml(String(this.attributes[key]))}"`).join(""); return `<${this.tag}${attrText}>${this.inner}</${this.tag}>`; } get inner() { return this.children.length ? this.children.map((child) => child.outer).join("") : this.innerText; } }; var SVG = class extends Tag { static { __name(this, "SVG"); } view; width; height; constructor(options = {}) { super("svg"); const { size = 200, viewSize = size, width = size, height = size } = options; this.width = width; this.height = height; const ratio = viewSize / size; const { left = 0, top = 0, bottom = height * ratio, right = width * ratio } = options.viewBox || {}; this.view = { left, bottom, top, right }; this.attr({ width, height, viewBox: `${left} ${top} ${right} ${bottom}`, xmlns: "http://www.w3.org/2000/svg", version: "1.1" }); } fill(color) { this.rect(this.view.top, this.view.left, this.view.bottom, this.view.right, { style: `fill: ${color}` }); return this; } async render(ctx) { const page = await ctx.puppeteer.page(); await page.setContent(this.outer); const buffer = await page.screenshot({ clip: { x: 0, y: 0, width: this.width, height: this.height } }); page.close(); return import_koishi.h.image(buffer, "image/png"); } }; // src/index.ts var import_puppeteer_finder = __toESM(require("puppeteer-finder")); // src/canvas.ts var import_canvas = __toESM(require("@koishijs/canvas")); var import_koishi2 = require("koishi"); var import_node_url = require("node:url"); var import_node_path = require("node:path"); var kElement = Symbol("element"); var BaseElement = class { constructor(page, id) { this.page = page; this.id = id; } static { __name(this, "BaseElement"); } [kElement] = true; get selector() { return `document.querySelector("#${this.id}")`; } async dispose() { await this.page.evaluate(`${this.selector}?.remove()`); this.id = null; } }; var CanvasElement = class extends BaseElement { constructor(page, id, width, height, fontFaceSet = [], styleHandles = []) { super(page, id); this.width = width; this.height = height; this.fontFaceSet = fontFaceSet; this.styleHandles = styleHandles; } static { __name(this, "CanvasElement"); } stmts = []; ctx = new Proxy({ canvas: this, direction: "inherit", fillStyle: "#000000", filter: "none", font: "10px sans-serif", fontKerning: "auto", fontStretch: "normal", fontVariantCaps: "normal", globalAlpha: 1, globalCompositeOperation: "source-over", imageSmoothingEnabled: true, imageSmoothingQuality: "low", letterSpacing: "0px", lineCap: "butt", lineDashOffset: 0, lineJoin: "miter", lineWidth: 1, miterLimit: 10, shadowBlur: 0, shadowColor: "rgba(0, 0, 0, 0)", shadowOffsetX: 0, shadowOffsetY: 0, strokeStyle: "#000000", textAlign: "start", textBaseline: "alphabetic", textRendering: "auto", wordSpacing: "0px" }, { get: /* @__PURE__ */ __name((target, prop, receiver) => { if (Reflect.has(target, prop) || typeof prop === "symbol") { return Reflect.get(target, prop, receiver); } return new Proxy(() => { }, { apply: /* @__PURE__ */ __name((target2, thisArg, argArray) => { this.stmts.push(`ctx.${prop}(${argArray.map((value) => { if (value[kElement]) return value.selector; return JSON.stringify(value); }).join(", ")});`); }, "apply") }); }, "get"), set: /* @__PURE__ */ __name((target, prop, value, receiver) => { if (Reflect.has(target, prop)) { if (typeof prop !== "symbol") { this.stmts.push(`ctx.${prop} = ${JSON.stringify(value)};`); } return Reflect.set(target, prop, value, receiver); } }, "set") }); getContext(type) { return this.ctx; } async toDataURL(type) { if (!this.id) throw new Error("canvas has been disposed"); try { this.stmts.unshift(`(async (ctx) => {`); const expr = this.stmts.join("\n ") + ` })(${this.selector}.getContext('2d'))`; this.stmts = []; await this.page.evaluate(expr); return await this.page.evaluate(`${this.selector}.toDataURL(${JSON.stringify(type)})`); } catch (err) { await this.dispose(); throw err; } } async toBuffer(type) { const url = await this.toDataURL(type); return Buffer.from(url.slice(url.indexOf(",") + 1), "base64"); } async dispose() { await super.dispose(); await Promise.all(this.fontFaceSet.map(async (fontFace) => { await this.page.evaluate((fontFace2) => { document.fonts.delete(fontFace2); }, fontFace); })); await Promise.all(this.styleHandles.map(async (handle) => { try { await handle.evaluate((node) => node.remove()); await handle.dispose(); } catch (e) { } })); } }; var ImageElement = class extends BaseElement { constructor(ctx, page, id, source, type) { super(page, id); this.ctx = ctx; this.source = source; this.type = type; } static { __name(this, "ImageElement"); } naturalHeight; naturalWidth; async initialize() { let base64; if (this.source instanceof URL) { this.source = this.source.href; } if (typeof this.source === "string") { const file = await this.ctx.http.file(this.source); base64 = import_koishi2.Binary.toBase64(file.data); } else if (Buffer.isBuffer(this.source)) { base64 = this.source.toString("base64"); } else { base64 = import_koishi2.Binary.toBase64(this.source); } const size = await this.page.evaluate(`loadImage(${JSON.stringify(this.id)}, ${JSON.stringify(base64)}, ${JSON.stringify(this.type)})`); this.naturalWidth = size.width; this.naturalHeight = size.height; } }; var canvas_default = class extends import_canvas.default { static { __name(this, "default"); } static inject = ["puppeteer", "http"]; page; counter = 0; async start() { const page = await this.ctx.puppeteer.page(); try { await page.goto((0, import_node_url.pathToFileURL)((0, import_node_path.resolve)(__dirname, "../index.html")).href); this.page = page; } catch (err) { await page.close(); throw err; } } async stop() { await this.page?.close(); this.page = null; } async createCanvas(width, height) { const fontFaceSet = []; const styleHandles = []; try { const name = `canvas_${++this.counter}`; await this.page.evaluate([ `const ${name} = document.createElement('canvas');`, `${name}.width = ${width};`, `${name}.height = ${height};`, `${name}.id = ${JSON.stringify(name)};`, `document.body.appendChild(${name});` ].join("\n")); return new CanvasElement(this.page, name, width, height, fontFaceSet, styleHandles); } catch (err) { this.ctx.logger("puppeteer").warn(err); throw err; } } async render(width, height, callback) { let canvas; try { canvas = await this.createCanvas(width, height); await callback(canvas.getContext("2d")); const buffer = await canvas.toBuffer("image/png"); return import_koishi2.h.image(buffer, "image/png"); } catch (err) { this.ctx.logger("puppeteer").warn(err); throw err; } finally { try { await canvas.dispose(); } catch (err) { this.ctx.logger("puppeteer").warn(err); } } } async loadImage(source, type) { const id = `image_${++this.counter}`; const image = new ImageElement(this.ctx, this.page, id, source, type); await image.initialize(); return image; } }; // src/index.ts var import_node_os = require("node:os"); var import_node_url2 = require("node:url"); var import_node_path2 = require("node:path"); var import_node_fs = require("node:fs"); async function injectDefaultFont(page, ctx, config, fontDataUrl) { if (!config.enableFont) { return; } if (!fontDataUrl) { return; } try { 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; } 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); } })(); }) `); } } catch (error) { ctx.logger.error("默认字体注入失败:", error.message); } } __name(injectDefaultFont, "injectDefaultFont"); var Puppeteer = class extends import_koishi3.Service { constructor(ctx, config) { super(ctx, "puppeteer"); this.config = config; if (this.config.enableCanvas !== false) { ctx.plugin(canvas_default); } 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 指令已被禁用"); } } static { __name(this, "Puppeteer"); } static [import_koishi3.Service.provide] = "puppeteer"; static inject = { required: ["http"], optional: ["glyph"] }; browser; executable; browserWSEndpoint; activePageCount = 0; activeContextCount = 0; isRestarting = false; // 是否正在重启 disposeKeepAlive = null; // 保活定时器销毁函数 originalBrowserMethods = /* @__PURE__ */ new WeakMap(); patchedBrowsers = /* @__PURE__ */ new WeakSet(); trackedPages = /* @__PURE__ */ new WeakSet(); trackedContexts = /* @__PURE__ */ new WeakSet(); patchBrowser(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) => { 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) => { 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); }; } trackBrowserContext(context) { 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) => { const page = await originalNewPage(...args); return this.trackPage(page); }; const originalClose = context.close.bind(context); let released = false; const release = /* @__PURE__ */ __name(async () => { if (released || !this.config.immediateClose) { return; } released = true; this.activeContextCount = Math.max(0, this.activeContextCount - 1); await this.checkAndCloseBrowser(); }, "release"); context.close = async () => { try { await originalClose(); } finally { await release(); } }; return context; } trackPage(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 = /* @__PURE__ */ __name(async () => { if (released || !this.config.immediateClose) { return; } released = true; this.activePageCount = Math.max(0, this.activePageCount - 1); await this.checkAndCloseBrowser(); }, "release"); page.once("close", () => { void release(); }); page.close = async (...args) => { try { await originalClose(...args); } finally { await release(); } }; return page; } getFontCacheDir(customDir) { if (customDir && customDir.trim()) { const dir = (0, import_node_path2.resolve)(customDir.trim()); if (!(0, import_node_fs.existsSync)(dir)) { (0, import_node_fs.mkdirSync)(dir, { recursive: true }); } return dir; } const defaultDir = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), ".koishi-puppeteer-userDataDir"); if (!(0, import_node_fs.existsSync)(defaultDir)) { (0, import_node_fs.mkdirSync)(defaultDir, { recursive: true }); } return defaultDir; } registerHtmlComponent() { const transformStyle = /* @__PURE__ */ __name((source, base = {}) => { return Object.entries({ ...base, ...source }).map(([key, value]) => { return `${(0, import_koishi3.hyphenate)(key)}: ${Array.isArray(value) ? value.join(", ") : value}`; }).join("; "); }, "transformStyle"); this.ctx.component("html", async (attrs, children) => { const head = []; const transform = /* @__PURE__ */ __name((element) => { if (element.type === "head") { head.push(...element.children); return; } const attrs2 = { ...element.attrs }; if (typeof attrs2.style === "object") { attrs2.style = transformStyle(attrs2.style); } return (0, import_koishi3.h)(element.type, attrs2, element.children.map(transform).filter(Boolean)); }, "transform"); await this.ensureConnected(); const page = await this.page(); try { if (attrs.src) { await page.goto(attrs.src); } else { await page.goto((0, import_node_url2.pathToFileURL)((0, import_node_path2.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>`); const fontDataUrl = this.getFontDataUrl(); await injectDefaultFont(page, this.ctx, this.config, fontDataUrl); } await page.waitForNetworkIdle({ timeout: attrs.timeout ? +attrs.timeout : void 0 }); const body = await page.$(attrs.selector || "body"); const clip = await body.boundingBox(); const screenshot = await page.screenshot({ clip }); return import_koishi3.h.image(screenshot, "image/png"); } finally { await page?.close(); } }); } async start() { if (!this.config.immediateClose) { await this.startBrowser(); this.startKeepAlive(); } } 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 = { headers, ...config }; try { const endpointURL = new URL(endpoint); if (["ws:", "wss:"].includes(endpointURL.protocol)) { 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 import_puppeteer_core.default.connect(connectOptions); this.patchBrowser(this.browser); this.ctx.logger.info("远程浏览器连接成功。"); if (connectOptions.browserWSEndpoint) { this.browserWSEndpoint = connectOptions.browserWSEndpoint; } else if (this.browser.wsEndpoint) { 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 && (0, import_node_fs.existsSync)(executablePath)) { this.executable = executablePath; this.ctx.logger.info("使用配置指定的浏览器路径: %c", this.executable); } else { if (executablePath) { this.ctx.logger.warn("配置的可执行文件路径不可用: %c,将从环境自动查找", executablePath); } this.executable = (0, import_puppeteer_finder.default)(); if (!this.executable) { const termuxChromiumPath = "/data/data/com.termux/files/usr/bin/chromium-browser"; if ((0, import_node_fs.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 = { executablePath: this.executable, headless, args: localArgs, ...config }; if (enableTempUserDataDir) { const userDataDir = this.getFontCacheDir(TempUserDataDir); launchOptions.userDataDir = userDataDir; this.ctx.logger.info("用户数据目录: %c", userDataDir); } const maxLaunchRetries = 3; let launched = false; for (let attempt = 1; attempt <= maxLaunchRetries; attempt++) { try { this.browser = await import_puppeteer_core.default.launch(launchOptions); this.patchBrowser(this.browser); this.browserWSEndpoint = this.browser.wsEndpoint(); this.ctx.logger.info("本地浏览器启动成功。"); launched = true; break; } catch (e) { if (attempt < maxLaunchRetries) { this.ctx.logger.warn(`浏览器启动失败,1 秒后重试 (${attempt}/${maxLaunchRetries}): ${e.message}`); await this.ctx.sleep(1e3); } 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(); } 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); } } // 启动保活机制 startKeepAlive() { if (!this.config.enableKeepAlive || this.config.immediateClose) { return; } this.stopKeepAlive(); const interval = this.config.keepAliveInterval || 3e4; this.disposeKeepAlive = this.ctx.setInterval(async () => { await this.checkBrowserHealth(); }, interval); } // 停止保活机制 stopKeepAlive() { if (this.disposeKeepAlive) { this.disposeKeepAlive(); this.disposeKeepAlive = null; } } // 检查浏览器健康状态 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(); } } // 安全地重启浏览器 async restartBrowserSafely() { if (this.isRestarting) { return; } this.isRestarting = true; try { this.ctx.logger.info("开始重启浏览器..."); await this.stopBrowser(); await this.ctx.sleep(1e3); await this.startBrowser(); this.ctx.logger.info("浏览器重启成功"); } catch (error) { this.ctx.logger.error("浏览器重启失败: %s", error.message); } finally { this.isRestarting = false; } } // 检查并关闭浏览器 async checkAndCloseBrowser() { if (!this.config.immediateClose) return; if (this.activePageCount <= 0 && this.activeContextCount <= 0 && this.browser) { try { const pages = await this.browser.pages(); const nonBlankPages = pages.filter((page) => page.url() !== "about:blank"); if (nonBlankPages.length === 0) { await this.stopBrowser(); } } catch (error) { this.ctx.logger.warn("检查浏览器状态时出现错误:", error.message); } } } // 检查浏览器连接状态并尝试重连 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("浏览器连接已断开,且未启用自动重连"); } 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 = { ...this.config }; 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 import_puppeteer_core.default.connect(connectOptions); this.patchBrowser(this.browser); if (this.browser.connected) { this.ctx.logger.info("浏览器重新连接成功"); if (this.browser.wsEndpoint) { this.browserWSEndpoint = this.browser.wsEndpoint(); } return; } else { throw new Error("连接后浏览器状态仍为断开"); } } catch (e) { retryCount++; if (e.message?.includes("404") || e.message?.includes("Unexpected server response: 404")) { this.ctx.logger.warn("检测到 404 错误,尝试使用原始配置重新连接"); try { this.browserWSEndpoint = null; const originalOptions = { ...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 import_puppeteer_core.default.connect(originalOptions); this.patchBrowser(this.browser); if (this.browser.connected) { this.ctx.logger.info("使用原始配置重新连接成功"); 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 插件) getFontDataUrl() { if (!this.config.enableFont || !this.config.fontName) { return null; } 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 = /* @__PURE__ */ __name(async (options) => { let page; try { await this.ensureConnected(); page = await this.browser.newPage(); page = this.trackPage(page); if (this.config.defaultTimeout !== void 0) { 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, options2) => { const result = await originalSetContent(html, options2); const fontDataUrl2 = this.getFontDataUrl(); await injectDefaultFont(page, this.ctx, this.config, fontDataUrl2); return result; }; if (options) { if (options?.beforeGotoPage) { await options.beforeGotoPage(page); } await page.goto(`${(0, import_node_url2.pathToFileURL)(options.url)}`, options?.gotoOptions); if (options?.content) { await page.setContent(options.content); const fontDataUrl2 = this.getFontDataUrl(); await injectDefaultFont(page, this.ctx, this.config, fontDataUrl2); } } } catch (err) { if (page) { await page.close(); } this.ctx.logger.error("failed to create page: %s", err); throw err; } return page; }, "page"); svg = /* @__PURE__ */ __name(async (options) => { await this.ensureConnected(); return new SVG(options); }, "svg"); render = /* @__PURE__ */ __name(async (content, callback) => { await this.ensureConnected(); const url = (0, import_node_path2.resolve)(__dirname, "../index.html"); const page = await this.page({ url, content }); const renderConfig = this.config.render || {}; callback ||= /* @__PURE__ */ __name(async (_, next) => page.$("body").then(next), "callback"); const output = await callback(page, async (handle) => { const clip = handle ? await handle.boundingBox() : null; const screenshotOptions = { clip, ...renderConfig }; const buffer = await page.screenshot(screenshotOptions); const imageType = renderConfig.type || "png"; return import_koishi3.h.image(buffer, `image/${imageType}`).toString(); }); await page.close(); return output; }, "render"); }; ((Puppeteer2) => { Puppeteer2.filter = false; Puppeteer2.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> --- `; Puppeteer2.Config = import_koishi3.Schema.intersect([ import_koishi3.Schema.object({ remote: import_koishi3.Schema.boolean().description("是否连接到远程浏览器。").default(false) }).description("连接设置"), import_koishi3.Schema.union([ import_koishi3.Schema.object({ remote: import_koishi3.Schema.const(true).required(), endpoint: import_koishi3.Schema.string().description( "远程浏览器的端点。<br>例:<br>WebSocket URL: `ws://localhost:14550/devtools/browser/[id]`<br>HTTP URL: `http://localhost:14550`<br>" ).required(), headers: import_koishi3.Schema.dict(String).role("table").description( "连接到远程浏览器时使用的 HTTP 请求头。<br>注意:这与本地模式的 `args` 不同,headers 只影响连接请求,不影响浏览器本身的行为。<br>常用于设置身份验证、API密钥或自定义标识等。详细示例请参考 README。" ) }).description("远程浏览器设置"), import_koishi3.Schema.object({ remote: import_koishi3.Schema.const(false).default(false), executablePath: import_koishi3.Schema.string().description( "`Chrome/Chromium 可执行文件`的路径。一般无需指定。<br>**缺省或路径不可用时**,将自动从系统环境中查找。<br>仅当自动查找失败时,才需要手动指定此路径。" ), headless: import_koishi3.Schema.boolean().description("是否开启[无头模式](https://developer.chrome.com/blog/headless-chrome/)。无头模式下浏览器不会显示界面。").default(true), immediateClose: import_koishi3.Schema.boolean().description("是否在渲染完成后 立即关闭浏览器连接。<br>启用后 会增加每次渲染的启动时间。适用于低频率渲染场景。").default(false).experimental(), args: import_koishi3.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("本地浏览器设置") ]), import_koishi3.Schema.object({ enablePuppeteer: import_koishi3.Schema.boolean().description("是否注册 puppeteer 服务。").default(true), enableCanvas: import_koishi3.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: import_koishi3.Schema.boolean().description("是否注册 `component:html` 服务。<br>注意: 启用后会覆盖 Koishi 的默认 `html` 组件行为。").default(true) }).description("服务注册"), import_koishi3.Schema.object({ enableRestartCommand: import_koishi3.Schema.boolean().description("是否注册 `puppeteer.restart` 重启指令。启用后可通过指令重启浏览器服务。").default(false), enableReconnect: import_koishi3.Schema.boolean().description("是否启用浏览器自动重连功能。当浏览器连接断开时,会尝试重新连接。").default(true), reconnectInterval: import_koishi3.Schema.number().description("浏览器重连尝试的间隔时间(毫秒)。").default(1e3), maxReconnectRetries: import_koishi3.Schema.number().description("浏览器重连最大尝试次数。").default(3) }).description("重连功能设置"), import_koishi3.Schema.object({ enableKeepAlive: import_koishi3.Schema.boolean().description("是否启用浏览器保活机制。定期检查浏览器健康状态,自动重启被杀死的浏览器进程。<br>**注意**: 仅在非立即关闭模式下有效(即 `immediateClose` 配置项关闭时)。").default(false), keepAliveInterval: import_koishi3.Schema.number().description("浏览器健康检查的间隔时间(毫秒)。").default(3e4).min(5e3) }).description("保活功能设置"), import_koishi3.Schema.object({ render: import_koishi3.Schema.intersect([ import_koishi3.Schema.object({ type: import_koishi3.Schema.union(["png", "jpeg", "webp"]).description("默认渲染的图片类型。").default("png") }), import_koishi3.Schema.union([ import_koishi3.Schema.object({ type: import_koishi3.Schema.const("png") }), import_koishi3.Schema.object({ quality: import_koishi3.Schema.number().min(0).max(100).step(1).description("默认渲染的图片质量。").default(80) }) ]) ]) }).description("渲染设置"), import_koishi3.Schema.object({ defaultTimeout: import_koishi3.Schema.number().description("页面渲染的默认超时时间(毫秒)。<br>设置为 0 表示禁用超时。").default(3e4).min(0), defaultViewport: import_koishi3.Schema.object({ width: import_koishi3.Schema.natural().description("默认的视图宽度。").default(1280), height: import_koishi3.Schema.natural().description("默认的视图高度。").default(768), deviceScaleFactor: import_koishi3.Schema.number().min(0).description("默认的设备缩放比率。").default(2) }), ignoreHTTPSErrors: import_koishi3.Schema.boolean().description("在导航时忽略 HTTPS 错误。").default(false), enableTempUserDataDir: import_koishi3.Schema.boolean().description("是否固定用户数据目录。<br>- 需要使用本地浏览器。远程浏览器无效。").default(false).experimental() }).description("浏览器设置"), import_koishi3.Schema.union([ import_koishi3.Schema.object({ enableTempUserDataDir: import_koishi3.Schema.const(false) }), import_koishi3.Schema.object({ enableTempUserDataDir: import_koishi3.Schema.const(true).required(), TempUserDataDir: import_koishi3.Schema.string().experimental().default(null).description("用户数据目录路径。建议保持默认值。<br>默认目录:系统`temp`目录下的 `.koishi-puppeteer-userDataDir`。") }) ]), import_koishi3.Schema.object({ enableFont: import_koishi3.Schema.boolean().description("是否为页面注入字体。<br>需要安装 `glyph` 插件并配置字体。").default(false).experimental() }).description("字体注入设置"), import_koishi3.Schema.union([ import_koishi3.Schema.object({ enableFont: import_koishi3.Schema.const(true).required(), fontName: import_koishi3.Schema.dynamic("glyph.fonts").description("选择要注入的字体。<br>需要先安装并配置 `glyph` 插件。<br>**注意**: 动态配置项在开发模式下不显示选项,请在生产模式下查看。").experimental(), fontInjectMode: import_koishi3.Schema.union([ import_koishi3.Schema.const("smart").description("1.判断注入"), import_koishi3.Schema.const("force").description("2.强制注入") ]).default("smart").experimental().description("字体注入模式。<br>1.判断注入,仅在页面未设置字体时注入<br>2.强制注入,无论页面是否已设置字体都会注入") }), import_koishi3.Schema.object({ enableFont: import_koishi3.Schema.const(false) }) ]) ]); })(Puppeteer || (Puppeteer = {})); var src_default = Puppeteer; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { SVG, Tag, injectDefaultFont });