@shangxueink/koishi-plugin-puppeteer-without-canvas
Version:
1,220 lines (1,199 loc) • 46.9 kB
JavaScript
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, "&").replace(/"/g, """).replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">");
}
__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
});