feat: 使用浏览器技能
This commit is contained in:
parent
20b976587c
commit
0b57f218dd
148
skills/web-page/SKILL.md
Normal file
148
skills/web-page/SKILL.md
Normal file
@ -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>` 可选,默认 `content`
|
||||||
|
- `--screenshot_mode <full|viewport|selector|region>` 可选,默认 `full`
|
||||||
|
- `--selector <CSS选择器>` 可选,`selector` 截图模式必填
|
||||||
|
- `--x <数字> --y <数字> --width <数字> --height <数字>` 可选,`region` 截图模式必填
|
||||||
|
- `--max_chars <数字>` 可选,默认 `16000`
|
||||||
|
- `--wait_ms <毫秒>` 可选,默认 `1500`
|
||||||
|
- `--output <本地PNG路径>` 可选,仅截图模式使用
|
||||||
|
- `--send <auto|true|false>` 可选,仅截图模式使用,默认 `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` 技能发送。
|
||||||
|
- 失败时,返回脚本输出的具体错误信息。
|
||||||
745
skills/web-page/scripts/web_page.js
Executable file
745
skills/web-page/scripts/web_page.js
Executable file
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user