diff --git a/skills/web-page/SKILL.md b/skills/web-page/SKILL.md new file mode 100644 index 0000000..7cbd0f9 --- /dev/null +++ b/skills/web-page/SKILL.md @@ -0,0 +1,148 @@ +--- +name: web-page +description: "网页内容读取和截图工具。当用户提供网页链接并希望了解页面内容、总结页面讲了什么,或需要截取整个网页/可视区域/指定元素/指定区域时使用。" +argument-hint: "需要 url;mode 可为 content 或 screenshot;截图可选 screenshot_mode、selector、x、y、width、height。" +--- + +# Web Page Skill + +## 描述 + +这是一个本地网页读取和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、正文抽取和截图,不调用外部 AI 接口。 + +技能脚本位于 `scripts/web_page.js`,依赖基础镜像提供的 Node.js 24+ 和 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。 + +## 触发条件 + +- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。 +- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。 +- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。 +- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。 + +## 入参规范 + +```json +{ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "需要打开的网页链接,必须是 http 或 https 地址。" + }, + "mode": { + "type": "string", + "enum": ["content", "screenshot"], + "description": "content 表示抽取网页内容;screenshot 表示截图。默认 content。", + "default": "content" + }, + "screenshot_mode": { + "type": "string", + "enum": ["full", "viewport", "selector", "region"], + "description": "截图模式。full 截整个页面,viewport 截当前可视区域,selector 截指定 CSS 选择器元素,region 截指定页面坐标区域。默认 full。", + "default": "full" + }, + "selector": { + "type": "string", + "description": "当 screenshot_mode 为 selector 时必填,表示要截图的 CSS 选择器。" + }, + "x": { + "type": "number", + "description": "当 screenshot_mode 为 region 时必填,区域左上角 x 坐标,单位为 CSS 像素,相对于页面左上角。" + }, + "y": { + "type": "number", + "description": "当 screenshot_mode 为 region 时必填,区域左上角 y 坐标,单位为 CSS 像素,相对于页面左上角。" + }, + "width": { + "type": "number", + "description": "region 截图宽度,或浏览器视口宽度。截图区域必须大于 0。" + }, + "height": { + "type": "number", + "description": "region 截图高度,或浏览器视口高度。截图区域必须大于 0。" + }, + "max_chars": { + "type": "integer", + "description": "content 模式下最多输出的正文字数,默认 16000。" + }, + "wait_ms": { + "type": "integer", + "description": "页面 load 之后额外等待的毫秒数,默认 1500。遇到前端渲染较慢的网站可调大。" + } + }, + "required": ["url"], + "additionalProperties": false +} +``` + +对应命令行参数: + +- `--url <网页链接>` 必填,必须是 `http` 或 `https` 地址 +- `--mode ` 可选,默认 `content` +- `--screenshot_mode ` 可选,默认 `full` +- `--selector ` 可选,`selector` 截图模式必填 +- `--x <数字> --y <数字> --width <数字> --height <数字>` 可选,`region` 截图模式必填 +- `--max_chars <数字>` 可选,默认 `16000` +- `--wait_ms <毫秒>` 可选,默认 `1500` +- `--output <本地PNG路径>` 可选,仅截图模式使用 +- `--send ` 可选,仅截图模式使用,默认 `auto` + +## 执行步骤 + +### 读取网页内容 + +1. 当用户想了解网页内容、总结页面、回答页面相关问题时,使用 `content` 模式。 +2. 在该技能目录执行脚本,例如: + +```bash +node scripts/web_page.js --url 'https://example.com' --mode content +``` + +3. 脚本会输出页面标题、最终 URL、meta 描述、主要标题、正文文本和主要链接。 +4. 智能体必须基于脚本输出回答用户问题;如果用户要求总结,不要把原始抽取过程当成最终回复。 + +### 截图网页 + +1. 当用户要求截图时,使用 `screenshot` 模式。 +2. 截整个页面: + +```bash +node scripts/web_page.js --url 'https://example.com' --mode screenshot --screenshot_mode full +``` + +3. 截当前可视区域: + +```bash +node scripts/web_page.js --url 'https://example.com' --mode screenshot --screenshot_mode viewport --width 1365 --height 900 +``` + +4. 截指定元素: + +```bash +node scripts/web_page.js --url 'https://example.com' --mode screenshot --screenshot_mode selector --selector 'main' +``` + +5. 截指定页面区域: + +```bash +node scripts/web_page.js --url 'https://example.com' --mode screenshot --screenshot_mode region --x 0 --y 300 --width 800 --height 600 +``` + +6. 在机器人环境中,脚本默认会调用客户端接口 `POST http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/image/local` 发送截图;如果缺少机器人环境变量,则只输出本地截图路径。 + +## 校验规则 + +- `url` 不能为空,且必须是 `http` 或 `https` 地址。 +- `mode` 只能是 `content` 或 `screenshot`。 +- `screenshot_mode` 只能是 `full`、`viewport`、`selector` 或 `region`。 +- `selector` 截图模式必须传 `selector`。 +- `region` 截图模式必须传 `x`、`y`、`width`、`height`,并且宽高必须大于 0。 +- `wait_ms`、`max_chars`、视口宽高必须大于 0。 +- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。 + +## 回复要求 + +- `content` 模式成功时,智能体应基于脚本输出给用户总结、解释或回答问题。 +- `screenshot` 模式成功且已发送图片时,脚本输出「页面截图已发送」。 +- `screenshot` 模式成功但未发送图片时,脚本输出本地 PNG 路径,智能体可继续用 `send-local-image` 技能发送。 +- 失败时,返回脚本输出的具体错误信息。 diff --git a/skills/web-page/scripts/web_page.js b/skills/web-page/scripts/web_page.js new file mode 100755 index 0000000..e97f835 --- /dev/null +++ b/skills/web-page/scripts/web_page.js @@ -0,0 +1,745 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const fsp = require("fs/promises"); +const http = require("http"); +const os = require("os"); +const path = require("path"); +const { spawn } = require("child_process"); + +const DEFAULT_VIEWPORT_WIDTH = 1365; +const DEFAULT_VIEWPORT_HEIGHT = 900; +const DEFAULT_WAIT_MS = 1500; +const DEFAULT_TIMEOUT_MS = 45000; +const DEFAULT_MAX_CHARS = 16000; + +function stdout(message) { + process.stdout.write(`${message}\n`); +} + +function parseArgs(argv) { + const result = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + throw new Error(`存在不支持的参数: ${token}`); + } + + const equalIndex = token.indexOf("="); + let key = token.slice(2); + let value; + if (equalIndex >= 0) { + key = token.slice(2, equalIndex); + value = token.slice(equalIndex + 1); + } else { + const next = argv[index + 1]; + if (next === undefined || next.startsWith("--")) { + value = "true"; + } else { + value = next; + index += 1; + } + } + + result[key] = value; + } + return result; +} + +function parseNumber(value, name, fallback = undefined) { + if (value === undefined || value === "") { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`${name} 必须是数字`); + } + return parsed; +} + +function parseInteger(value, name, fallback) { + const parsed = parseNumber(value, name, fallback); + if (!Number.isInteger(parsed)) { + throw new Error(`${name} 必须是整数`); + } + return parsed; +} + +function validateUrl(value) { + if (!value || !value.trim()) { + throw new Error("缺少网页链接"); + } + let parsed; + try { + parsed = new URL(value.trim()); + } catch (error) { + throw new Error(`网页链接格式不正确: ${value}`); + } + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) { + throw new Error("网页链接必须是 http 或 https 地址"); + } + return parsed.toString(); +} + +function normalizeParams(raw) { + const url = validateUrl(raw.url || ""); + const mode = raw.mode || "content"; + if (!["content", "screenshot"].includes(mode)) { + throw new Error("mode 只能是 content 或 screenshot"); + } + + const screenshotMode = raw.screenshot_mode || "full"; + if (!["full", "viewport", "selector", "region"].includes(screenshotMode)) { + throw new Error( + "screenshot_mode 只能是 full、viewport、selector 或 region", + ); + } + + const viewportWidth = parseInteger( + raw.viewport_width || raw.width, + "width", + DEFAULT_VIEWPORT_WIDTH, + ); + const viewportHeight = parseInteger( + raw.viewport_height || raw.height, + "height", + DEFAULT_VIEWPORT_HEIGHT, + ); + const waitMs = parseInteger(raw.wait_ms, "wait_ms", DEFAULT_WAIT_MS); + const timeoutMs = parseInteger( + raw.timeout_ms, + "timeout_ms", + DEFAULT_TIMEOUT_MS, + ); + const maxChars = parseInteger(raw.max_chars, "max_chars", DEFAULT_MAX_CHARS); + const maxFullHeight = parseInteger( + raw.max_full_height, + "max_full_height", + 60000, + ); + + if (viewportWidth <= 0 || viewportHeight <= 0) { + throw new Error("视口 width 和 height 必须大于 0"); + } + if (waitMs < 0) { + throw new Error("wait_ms 不能小于 0"); + } + if (timeoutMs <= 0) { + throw new Error("timeout_ms 必须大于 0"); + } + if (maxChars <= 0) { + throw new Error("max_chars 必须大于 0"); + } + if (maxFullHeight <= 0) { + throw new Error("max_full_height 必须大于 0"); + } + + const params = { + url, + mode, + screenshotMode, + selector: raw.selector || "", + x: parseNumber(raw.x, "x"), + y: parseNumber(raw.y, "y"), + regionWidth: parseNumber( + raw.region_width || (screenshotMode === "region" ? raw.width : undefined), + "width", + ), + regionHeight: parseNumber( + raw.region_height || + (screenshotMode === "region" ? raw.height : undefined), + "height", + ), + viewportWidth, + viewportHeight, + waitMs, + timeoutMs, + maxChars, + maxFullHeight, + output: raw.output || "", + send: raw.send || "auto", + }; + + if (!["auto", "true", "false"].includes(params.send)) { + throw new Error("send 只能是 auto、true 或 false"); + } + if ( + mode === "screenshot" && + screenshotMode === "selector" && + !params.selector.trim() + ) { + throw new Error("selector 截图模式必须传 selector"); + } + if (mode === "screenshot" && screenshotMode === "region") { + for (const key of ["x", "y", "regionWidth", "regionHeight"]) { + if (params[key] === undefined) { + throw new Error("region 截图模式必须传 x、y、width、height"); + } + } + if (params.regionWidth <= 0 || params.regionHeight <= 0) { + throw new Error("region 截图区域 width 和 height 必须大于 0"); + } + } + + return params; +} + +function fileExists(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch (error) { + return false; + } +} + +function findChromium() { + const candidates = [ + process.env.CHROME_BIN, + process.env.CHROME_PATH, + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ].filter(Boolean); + + for (const candidate of candidates) { + if (fileExists(candidate)) { + return candidate; + } + } + throw new Error("未找到 Chromium,请设置 CHROME_BIN 或 CHROME_PATH"); +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function withTimeout(promise, ms, message) { + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +async function launchChromium(params) { + const chromeBin = findChromium(); + const userDataDir = await fsp.mkdtemp( + path.join(os.tmpdir(), "web-page-chromium-"), + ); + const args = [ + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--disable-dev-shm-usage", + "--disable-background-networking", + "--disable-default-apps", + "--disable-extensions", + "--disable-sync", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + `--user-data-dir=${userDataDir}`, + `--window-size=${params.viewportWidth},${params.viewportHeight}`, + "about:blank", + ]; + + const chrome = spawn(chromeBin, args, { stdio: ["ignore", "pipe", "pipe"] }); + let logBuffer = ""; + const websocketUrlPromise = new Promise((resolve, reject) => { + const onData = (chunk) => { + logBuffer += chunk.toString("utf8"); + const match = logBuffer.match(/DevTools listening on (ws:\/\/[^\s]+)/); + if (match) { + resolve(match[1]); + } + }; + chrome.stdout.on("data", onData); + chrome.stderr.on("data", onData); + chrome.once("error", reject); + chrome.once("exit", (code) => { + reject( + new Error(`Chromium 启动失败,退出码: ${code}; ${logBuffer.trim()}`), + ); + }); + }); + + const websocketUrl = await withTimeout( + websocketUrlPromise, + params.timeoutMs, + "等待 Chromium DevTools 地址超时", + ); + + return { chrome, userDataDir, websocketUrl }; +} + +class CdpClient { + constructor(websocketUrl) { + this.websocketUrl = websocketUrl; + this.nextId = 1; + this.pending = new Map(); + this.listeners = []; + this.ws = null; + } + + async connect(timeoutMs) { + if (typeof WebSocket === "undefined") { + throw new Error( + "当前 Node.js 缺少 WebSocket 支持,请使用 Node.js 22+ 或基础镜像中的 Node.js 24+", + ); + } + + this.ws = new WebSocket(this.websocketUrl); + this.ws.addEventListener("message", (event) => + this.handleMessage(event.data), + ); + + await withTimeout( + new Promise((resolve, reject) => { + this.ws.addEventListener("open", resolve, { once: true }); + this.ws.addEventListener( + "error", + () => reject(new Error("连接 Chromium DevTools 失败")), + { once: true }, + ); + }), + timeoutMs, + "连接 Chromium DevTools 超时", + ); + } + + handleMessage(raw) { + let rawText; + if (typeof raw === "string") { + rawText = raw; + } else if (Buffer.isBuffer(raw)) { + rawText = raw.toString("utf8"); + } else if (raw instanceof ArrayBuffer) { + rawText = Buffer.from(raw).toString("utf8"); + } else { + rawText = String(raw); + } + + let message; + try { + message = JSON.parse(rawText); + } catch (error) { + return; + } + + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id); + this.pending.delete(message.id); + if (message.error) { + reject( + new Error(message.error.message || JSON.stringify(message.error)), + ); + } else { + resolve(message.result || {}); + } + return; + } + + if (message.method) { + for (const listener of [...this.listeners]) { + if (listener.method !== message.method) { + continue; + } + if (listener.sessionId && listener.sessionId !== message.sessionId) { + continue; + } + listener.resolve(message.params || {}); + this.listeners = this.listeners.filter((item) => item !== listener); + } + } + } + + send(method, params = {}, sessionId = undefined) { + const id = this.nextId; + this.nextId += 1; + const payload = { id, method, params }; + if (sessionId) { + payload.sessionId = sessionId; + } + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(payload)); + }); + } + + waitForEvent(method, sessionId, timeoutMs) { + return withTimeout( + new Promise((resolve) => { + this.listeners.push({ method, sessionId, resolve }); + }), + timeoutMs, + `等待事件 ${method} 超时`, + ); + } + + close() { + if (this.ws) { + this.ws.close(); + } + } +} + +async function createPage(client, params) { + const target = await client.send("Target.createTarget", { + url: "about:blank", + }); + const attached = await client.send("Target.attachToTarget", { + targetId: target.targetId, + flatten: true, + }); + const sessionId = attached.sessionId; + await client.send("Page.enable", {}, sessionId); + await client.send("Runtime.enable", {}, sessionId); + await client.send("Network.enable", {}, sessionId); + await client.send( + "Emulation.setDeviceMetricsOverride", + { + width: params.viewportWidth, + height: params.viewportHeight, + deviceScaleFactor: 1, + mobile: false, + }, + sessionId, + ); + return sessionId; +} + +async function navigate(client, sessionId, params) { + const loadEvent = client + .waitForEvent("Page.loadEventFired", sessionId, params.timeoutMs) + .catch(() => null); + await client.send("Page.navigate", { url: params.url }, sessionId); + await loadEvent; + if (params.waitMs > 0) { + await wait(params.waitMs); + } +} + +async function evaluate(client, sessionId, expression) { + const result = await client.send( + "Runtime.evaluate", + { + expression, + awaitPromise: true, + returnByValue: true, + }, + sessionId, + ); + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || "页面脚本执行失败"); + } + return result.result ? result.result.value : undefined; +} + +function cleanText(text) { + return String(text || "") + .replace(/\u00a0/g, " ") + .split("\n") + .map((line) => line.trim().replace(/[ \t]+/g, " ")) + .filter((line, index, lines) => line || lines[index - 1]) + .join("\n") + .trim(); +} + +async function extractContent(client, sessionId, params) { + const data = await evaluate( + client, + sessionId, + `(() => { + const textOf = (node) => (node && node.innerText ? node.innerText.trim() : ''); + const meta = (selector) => { + const el = document.querySelector(selector); + return el ? (el.getAttribute('content') || '').trim() : ''; + }; + const candidates = Array.from(document.querySelectorAll('article, main, [role="main"], #content, .content, .article, .post, .entry-content')); + let root = document.body; + const bodyLength = textOf(document.body).length; + let bestLength = 0; + for (const candidate of candidates) { + const length = textOf(candidate).length; + if (length > bestLength) { + root = candidate; + bestLength = length; + } + } + if (bestLength < bodyLength * 0.25) { + root = document.body; + } + const headings = Array.from(document.querySelectorAll('h1, h2, h3')) + .map((el) => el.innerText.trim()) + .filter(Boolean) + .slice(0, 30); + const links = Array.from(document.querySelectorAll('a[href]')) + .map((el) => ({ text: el.innerText.trim().replace(/\s+/g, ' '), href: el.href })) + .filter((item) => item.text && item.href && /^https?:/.test(item.href)) + .slice(0, 40); + return { + title: document.title || '', + url: location.href, + lang: document.documentElement.lang || '', + description: meta('meta[name="description"]') || meta('meta[property="og:description"]'), + text: textOf(root) || textOf(document.body), + headings, + links, + }; + })()`, + ); + + const text = cleanText(data.text || ""); + const truncated = text.length > params.maxChars; + const body = truncated + ? `${text.slice(0, params.maxChars)}\n\n[内容过长,已截断到 ${params.maxChars} 字]` + : text; + const headings = [...new Set(data.headings || [])].slice(0, 20); + const links = []; + const seenLinks = new Set(); + for (const link of data.links || []) { + const key = `${link.text}\t${link.href}`; + if (seenLinks.has(key)) { + continue; + } + seenLinks.add(key); + links.push(link); + if (links.length >= 20) { + break; + } + } + + const parts = [ + `标题:${data.title || "(无标题)"}`, + `URL:${data.url || params.url}`, + ]; + if (data.lang) { + parts.push(`语言:${data.lang}`); + } + if (data.description) { + parts.push(`页面描述:${cleanText(data.description)}`); + } + if (headings.length) { + parts.push( + `主要标题:\n${headings.map((heading) => `- ${heading}`).join("\n")}`, + ); + } + parts.push(`正文:\n${body || "(未抽取到可见正文)"}`); + if (links.length) { + parts.push( + `主要链接:\n${links.map((link) => `- ${link.text}: ${link.href}`).join("\n")}`, + ); + } + return parts.join("\n\n"); +} + +async function getLayoutMetrics(client, sessionId) { + const metrics = await client.send("Page.getLayoutMetrics", {}, sessionId); + return ( + metrics.cssContentSize || + metrics.contentSize || { x: 0, y: 0, width: 0, height: 0 } + ); +} + +async function getSelectorClip(client, sessionId, selector) { + const encodedSelector = JSON.stringify(selector); + const rect = await evaluate( + client, + sessionId, + `(async () => { + const el = document.querySelector(${encodedSelector}); + if (!el) return null; + el.scrollIntoView({ block: 'center', inline: 'center' }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const r = el.getBoundingClientRect(); + return { x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height }; + })()`, + ); + if (!rect) { + throw new Error(`未找到 selector 对应元素: ${selector}`); + } + if (rect.width <= 0 || rect.height <= 0) { + throw new Error(`selector 对应元素尺寸为空: ${selector}`); + } + return rect; +} + +async function getScreenshotClip(client, sessionId, params) { + if (params.screenshotMode === "region") { + return { + x: params.x, + y: params.y, + width: params.regionWidth, + height: params.regionHeight, + scale: 1, + }; + } + if (params.screenshotMode === "selector") { + const rect = await getSelectorClip(client, sessionId, params.selector); + return { ...rect, scale: 1 }; + } + if (params.screenshotMode === "viewport") { + return { + x: 0, + y: 0, + width: params.viewportWidth, + height: params.viewportHeight, + scale: 1, + }; + } + + const contentSize = await getLayoutMetrics(client, sessionId); + const width = Math.max( + 1, + Math.ceil(contentSize.width || params.viewportWidth), + ); + const rawHeight = Math.max( + 1, + Math.ceil(contentSize.height || params.viewportHeight), + ); + const height = Math.min(rawHeight, params.maxFullHeight); + return { + x: 0, + y: 0, + width, + height, + scale: 1, + truncated: rawHeight > height, + rawHeight, + }; +} + +async function captureScreenshot(client, sessionId, params) { + const clip = await getScreenshotClip(client, sessionId, params); + const result = await client.send( + "Page.captureScreenshot", + { + format: "png", + fromSurface: true, + captureBeyondViewport: true, + clip: { + x: Math.max(0, clip.x), + y: Math.max(0, clip.y), + width: Math.max(1, clip.width), + height: Math.max(1, clip.height), + scale: 1, + }, + }, + sessionId, + ); + if (!result.data) { + throw new Error("Chromium 未返回截图数据"); + } + const output = + params.output || + path.join(os.tmpdir(), `web-page-screenshot-${Date.now()}.png`); + await fsp.mkdir(path.dirname(output), { recursive: true }); + await fsp.writeFile(output, Buffer.from(result.data, "base64")); + return { output, clip }; +} + +function postJson(url, body, timeoutMs) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const payload = Buffer.from(JSON.stringify(body), "utf8"); + const request = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": payload.length, + }, + timeout: timeoutMs, + }, + (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + if (response.statusCode < 200 || response.statusCode >= 300) { + reject(new Error(`HTTP ${response.statusCode}: ${text}`)); + return; + } + resolve(text); + }); + }, + ); + request.on("error", reject); + request.on("timeout", () => + request.destroy(new Error("请求机器人客户端超时")), + ); + request.write(payload); + request.end(); + }); +} + +async function maybeSendScreenshot(params, filePath) { + if (params.send === "false") { + return false; + } + const clientPort = (process.env.ROBOT_WECHAT_CLIENT_PORT || "").trim(); + const toWxid = (process.env.ROBOT_FROM_WX_ID || "").trim(); + if (!clientPort || !toWxid) { + if (params.send === "true") { + throw new Error( + "环境变量 ROBOT_WECHAT_CLIENT_PORT 或 ROBOT_FROM_WX_ID 未配置", + ); + } + return false; + } + const sendUrl = `http://127.0.0.1:${clientPort}/api/v1/robot/message/send/image/local`; + await postJson( + sendUrl, + { to_wxid: toWxid, file_path: filePath }, + params.timeoutMs, + ); + return true; +} + +async function run() { + const params = normalizeParams(parseArgs(process.argv.slice(2))); + const browser = await launchChromium(params); + const client = new CdpClient(browser.websocketUrl); + try { + await client.connect(params.timeoutMs); + const sessionId = await createPage(client, params); + await navigate(client, sessionId, params); + + if (params.mode === "content") { + stdout(await extractContent(client, sessionId, params)); + return; + } + + const screenshot = await captureScreenshot(client, sessionId, params); + const sent = await maybeSendScreenshot(params, screenshot.output); + if (sent) { + const suffix = screenshot.clip.truncated + ? `,页面过长,已截取前 ${Math.round(screenshot.clip.height)}px / ${Math.round(screenshot.clip.rawHeight)}px` + : ""; + stdout(`页面截图已发送${suffix}`); + } else { + const suffix = screenshot.clip.truncated + ? `(页面过长,已截取前 ${Math.round(screenshot.clip.height)}px / ${Math.round(screenshot.clip.rawHeight)}px)` + : ""; + stdout(`页面截图已保存: ${screenshot.output}${suffix}`); + } + } finally { + client.close(); + browser.chrome.kill("SIGKILL"); + await fsp.rm(browser.userDataDir, { recursive: true, force: true }); + } +} + +run().catch((error) => { + stdout(`执行失败: ${error && error.stack ? error.stack : error}`); + process.exit(1); +});