feat: 新增一些自动化能力
This commit is contained in:
parent
0fb13558e0
commit
bd88102978
@ -1,14 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: web-page
|
name: web-page
|
||||||
description: "网页内容读取和截图工具。当用户提供网页链接并希望了解页面内容、总结页面讲了什么,或需要截取整个网页/可视区域/指定元素/指定区域时使用。"
|
description: "网页内容读取、自动化交互和截图工具。当用户提供网页链接并希望了解页面内容、点击按钮、填写表单、等待页面变化,或需要截取整个网页/可视区域/指定元素/指定区域时使用。"
|
||||||
argument-hint: "需要 url;mode 可为 content 或 screenshot;截图可选 screenshot_mode、selector、x、y、width、height。"
|
argument-hint: "需要 url;mode 可为 content 或 screenshot;自动化可传 actions/actions_file;截图可选 screenshot_mode、selector、x、y、width、height。"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Web Page Skill
|
# Web Page Skill
|
||||||
|
|
||||||
## 描述
|
## 描述
|
||||||
|
|
||||||
这是一个本地网页读取和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、正文抽取和截图,不调用外部 AI 接口。
|
这是一个本地网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。
|
||||||
|
|
||||||
|
验证码处理采用安全边界内的自动检测:脚本可以识别常见验证码/人机验证页面并停止自动化,给出明确错误提示;不提供破解、绕过、代答验证码或规避网站风控的能力。遇到验证码时,应让用户在合法授权下人工完成验证,或使用已验证后的页面/会话环境继续处理。
|
||||||
|
|
||||||
技能脚本位于 `scripts/web_page.js`,依赖基础镜像提供的 Node.js 24+ 和 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。
|
技能脚本位于 `scripts/web_page.js`,依赖基础镜像提供的 Node.js 24+ 和 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。
|
||||||
|
|
||||||
@ -16,6 +18,7 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;截图可选 s
|
|||||||
|
|
||||||
- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。
|
- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。
|
||||||
- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。
|
- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。
|
||||||
|
- 用户要求打开网页后点击按钮/链接、填写输入框、选择下拉框、勾选复选框/单选框、提交表单、等待某个元素出现或滚动页面。
|
||||||
- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。
|
- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。
|
||||||
- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。
|
- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。
|
||||||
|
|
||||||
@ -68,6 +71,26 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;截图可选 s
|
|||||||
"wait_ms": {
|
"wait_ms": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "页面 load 之后额外等待的毫秒数,默认 1500。遇到前端渲染较慢的网站可调大。"
|
"description": "页面 load 之后额外等待的毫秒数,默认 1500。遇到前端渲染较慢的网站可调大。"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "页面打开后、抽取内容或截图前要顺序执行的自动化动作数组。也可通过命令行传 JSON 字符串。",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions_file": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "包含 actions JSON 的本地文件路径。适合动作较多或命令行不方便转义时使用。"
|
||||||
|
},
|
||||||
|
"action_timeout_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "单个自动化动作默认超时时间,默认 15000。"
|
||||||
|
},
|
||||||
|
"captcha_strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["detect", "ignore"],
|
||||||
|
"description": "验证码处理策略。detect 表示执行动作前后检测疑似验证码并停止;ignore 表示不检测。传 actions 时默认 detect,否则默认 ignore。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["url"],
|
"required": ["url"],
|
||||||
@ -84,9 +107,71 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;截图可选 s
|
|||||||
- `--x <数字> --y <数字> --width <数字> --height <数字>` 可选,`region` 截图模式必填
|
- `--x <数字> --y <数字> --width <数字> --height <数字>` 可选,`region` 截图模式必填
|
||||||
- `--max_chars <数字>` 可选,默认 `16000`
|
- `--max_chars <数字>` 可选,默认 `16000`
|
||||||
- `--wait_ms <毫秒>` 可选,默认 `1500`
|
- `--wait_ms <毫秒>` 可选,默认 `1500`
|
||||||
|
- `--actions '<JSON数组>'` 可选,页面打开后顺序执行的自动化动作
|
||||||
|
- `--actions_file <本地JSON路径>` 可选,从文件读取自动化动作
|
||||||
|
- `--action_timeout_ms <毫秒>` 可选,单个动作默认超时时间,默认 `15000`
|
||||||
|
- `--captcha_strategy <detect|ignore>` 可选,传 actions 时默认 `detect`,否则默认 `ignore`
|
||||||
- `--output <本地PNG路径>` 可选,仅截图模式使用
|
- `--output <本地PNG路径>` 可选,仅截图模式使用
|
||||||
- `--send <auto|true|false>` 可选,仅截图模式使用,默认 `auto`
|
- `--send <auto|true|false>` 可选,仅截图模式使用,默认 `auto`
|
||||||
|
|
||||||
|
## 自动化动作
|
||||||
|
|
||||||
|
`actions` 是一个 JSON 数组,脚本会在页面 `load` 并等待 `wait_ms` 后,按顺序执行这些动作,然后再进行正文抽取或截图。
|
||||||
|
|
||||||
|
通用字段:
|
||||||
|
|
||||||
|
- `type`:动作类型,必填。
|
||||||
|
- `selector`:CSS 选择器。多数表单动作必填。
|
||||||
|
- `text`:按可见文本、`aria-label`、`title`、`placeholder` 等匹配元素。`click` 和 `scroll_to` 可用。
|
||||||
|
- `exact`:文本是否精确匹配,默认 `false`。
|
||||||
|
- `index`:匹配到多个元素时使用第几个,从 `0` 开始,默认 `0`。
|
||||||
|
- `timeout_ms`:当前动作超时时间,默认使用 `action_timeout_ms`。
|
||||||
|
- `wait_ms_after`:动作完成后的额外等待毫秒数,默认 `300`。
|
||||||
|
- `wait_for_navigation`:点击或按键后是否等待页面 load,默认 `false`。
|
||||||
|
|
||||||
|
支持的动作:
|
||||||
|
|
||||||
|
- `click`:点击元素。需要 `selector` 或 `text`。
|
||||||
|
- `fill`:清空并填写输入框、文本域或可编辑元素。需要 `selector` 和 `value`。
|
||||||
|
- `type`:向当前焦点或指定元素追加输入文本。需要 `text`,可选 `selector`。
|
||||||
|
- `press`:按键。需要 `key`,例如 `Enter`、`Tab`、`Escape`、`Backspace`、`ArrowDown`。
|
||||||
|
- `select`:设置 `<select>` 的值。需要 `selector` 和 `value`。
|
||||||
|
- `check` / `uncheck`:勾选或取消勾选复选框/单选框。需要 `selector`。
|
||||||
|
- `wait`:固定等待。需要 `ms`。
|
||||||
|
- `wait_for_selector`:等待元素状态。需要 `selector`,可选 `state` 为 `attached`、`visible`、`hidden`、`detached`,默认 `visible`。
|
||||||
|
- `scroll`:按偏移滚动页面。可选 `x`、`y`,默认 `x=0,y=600`。
|
||||||
|
- `scroll_to`:滚动到坐标或元素。可传 `x/y`,或传 `selector` / `text`。
|
||||||
|
- `captcha_check`:主动检查当前页面是否疑似出现验证码/人机验证。
|
||||||
|
|
||||||
|
示例:点击链接后读取跳转页面内容。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/web_page.js \
|
||||||
|
--url 'https://example.com' \
|
||||||
|
--mode content \
|
||||||
|
--actions '[{"type":"click","text":"Learn more","wait_for_navigation":true}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:填写并提交表单。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/web_page.js \
|
||||||
|
--url 'https://httpbin.org/forms/post' \
|
||||||
|
--mode content \
|
||||||
|
--actions '[{"type":"fill","selector":"input[name=custname]","value":"Alice"},{"type":"check","selector":"input[name=size]","index":2},{"type":"check","selector":"input[name=topping]","index":1},{"type":"click","text":"Submit order","wait_for_navigation":true}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
示例:交互后截图指定元素。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/web_page.js \
|
||||||
|
--url 'https://example.com' \
|
||||||
|
--mode screenshot \
|
||||||
|
--screenshot_mode selector \
|
||||||
|
--selector 'body' \
|
||||||
|
--actions '[{"type":"scroll_to","selector":"body"}]'
|
||||||
|
```
|
||||||
|
|
||||||
## 执行步骤
|
## 执行步骤
|
||||||
|
|
||||||
### 读取网页内容
|
### 读取网页内容
|
||||||
@ -101,6 +186,14 @@ node scripts/web_page.js --url 'https://example.com' --mode content
|
|||||||
3. 脚本会输出页面标题、最终 URL、meta 描述、主要标题、正文文本和主要链接。
|
3. 脚本会输出页面标题、最终 URL、meta 描述、主要标题、正文文本和主要链接。
|
||||||
4. 智能体必须基于脚本输出回答用户问题;如果用户要求总结,不要把原始抽取过程当成最终回复。
|
4. 智能体必须基于脚本输出回答用户问题;如果用户要求总结,不要把原始抽取过程当成最终回复。
|
||||||
|
|
||||||
|
### 自动化后读取或截图
|
||||||
|
|
||||||
|
1. 当用户要求先点击、填写、提交、滚动或等待页面变化时,构造 `actions`。
|
||||||
|
2. 如果动作会触发页面跳转,给对应动作设置 `wait_for_navigation: true`。
|
||||||
|
3. 如果需要等待异步渲染结果,使用 `wait_for_selector` 或设置更长的 `wait_ms_after`。
|
||||||
|
4. 自动化动作完成后,继续按 `mode=content` 输出页面内容,或按 `mode=screenshot` 输出/发送截图。
|
||||||
|
5. 默认情况下,只要传了 `actions`,脚本会启用 `captcha_strategy=detect`。检测到疑似验证码时会停止操作并输出失败原因;不要尝试破解或绕过验证码。
|
||||||
|
|
||||||
### 截图网页
|
### 截图网页
|
||||||
|
|
||||||
1. 当用户要求截图时,使用 `screenshot` 模式。
|
1. 当用户要求截图时,使用 `screenshot` 模式。
|
||||||
@ -138,11 +231,18 @@ node scripts/web_page.js --url 'https://example.com' --mode screenshot --screens
|
|||||||
- `selector` 截图模式必须传 `selector`。
|
- `selector` 截图模式必须传 `selector`。
|
||||||
- `region` 截图模式必须传 `x`、`y`、`width`、`height`,并且宽高必须大于 0。
|
- `region` 截图模式必须传 `x`、`y`、`width`、`height`,并且宽高必须大于 0。
|
||||||
- `wait_ms`、`max_chars`、视口宽高必须大于 0。
|
- `wait_ms`、`max_chars`、视口宽高必须大于 0。
|
||||||
|
- `actions` 必须是 JSON 数组,或包含 `actions` 数组的 JSON 对象。
|
||||||
|
- `actions_file` 必须是可读取的本地 JSON 文件。
|
||||||
|
- `action_timeout_ms` 和单个动作的 `timeout_ms` 必须大于 0。
|
||||||
|
- `captcha_strategy` 只能是 `detect` 或 `ignore`。
|
||||||
|
- 自动化动作必须符合对应类型的必填字段要求,例如 `fill` 必须传 `selector` 和 `value`,`click` 必须传 `selector` 或 `text`。
|
||||||
- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。
|
- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。
|
||||||
|
|
||||||
## 回复要求
|
## 回复要求
|
||||||
|
|
||||||
- `content` 模式成功时,智能体应基于脚本输出给用户总结、解释或回答问题。
|
- `content` 模式成功时,智能体应基于脚本输出给用户总结、解释或回答问题。
|
||||||
|
- 自动化动作失败时,脚本会输出具体失败在第几个 action 及失败原因。
|
||||||
|
- 检测到验证码/人机验证时,回复用户需要人工完成验证或提供已验证后的页面环境;不要承诺自动破解验证码。
|
||||||
- `screenshot` 模式成功且已发送图片时,脚本输出「页面截图已发送」。
|
- `screenshot` 模式成功且已发送图片时,脚本输出「页面截图已发送」。
|
||||||
- `screenshot` 模式成功但未发送图片时,脚本输出本地 PNG 路径,智能体可继续用 `send-local-image` 技能发送。
|
- `screenshot` 模式成功但未发送图片时,脚本输出本地 PNG 路径,智能体可继续用 `send-local-image` 技能发送。
|
||||||
- 失败时,返回脚本输出的具体错误信息。
|
- 失败时,返回脚本输出的具体错误信息。
|
||||||
|
|||||||
@ -13,6 +13,22 @@ const DEFAULT_VIEWPORT_HEIGHT = 900;
|
|||||||
const DEFAULT_WAIT_MS = 1500;
|
const DEFAULT_WAIT_MS = 1500;
|
||||||
const DEFAULT_TIMEOUT_MS = 45000;
|
const DEFAULT_TIMEOUT_MS = 45000;
|
||||||
const DEFAULT_MAX_CHARS = 16000;
|
const DEFAULT_MAX_CHARS = 16000;
|
||||||
|
const DEFAULT_ACTION_TIMEOUT_MS = 15000;
|
||||||
|
const DEFAULT_ACTION_WAIT_MS = 300;
|
||||||
|
const ACTION_TYPES = new Set([
|
||||||
|
"click",
|
||||||
|
"fill",
|
||||||
|
"type",
|
||||||
|
"press",
|
||||||
|
"select",
|
||||||
|
"check",
|
||||||
|
"uncheck",
|
||||||
|
"wait",
|
||||||
|
"wait_for_selector",
|
||||||
|
"scroll",
|
||||||
|
"scroll_to",
|
||||||
|
"captcha_check",
|
||||||
|
]);
|
||||||
|
|
||||||
function stdout(message) {
|
function stdout(message) {
|
||||||
process.stdout.write(`${message}\n`);
|
process.stdout.write(`${message}\n`);
|
||||||
@ -66,6 +82,126 @@ function parseInteger(value, name, fallback) {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readActionsFile(filePath) {
|
||||||
|
if (!filePath || !filePath.trim()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(filePath, "utf8");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`读取 actions_file 失败: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonActions(rawActions, rawActionsFile) {
|
||||||
|
const source =
|
||||||
|
rawActions !== undefined ? rawActions : readActionsFile(rawActionsFile);
|
||||||
|
if (source === undefined || source === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(source);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`actions 必须是合法 JSON: ${error.message || error}`);
|
||||||
|
}
|
||||||
|
if (parsed && !Array.isArray(parsed) && Array.isArray(parsed.actions)) {
|
||||||
|
parsed = parsed.actions;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("actions 必须是动作数组,或包含 actions 数组的对象");
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAction(rawAction, index, defaultActionTimeoutMs) {
|
||||||
|
if (!rawAction || typeof rawAction !== "object" || Array.isArray(rawAction)) {
|
||||||
|
throw new Error(`第 ${index + 1} 个 action 必须是对象`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = String(rawAction.type || "").trim();
|
||||||
|
if (!ACTION_TYPES.has(type)) {
|
||||||
|
throw new Error(`第 ${index + 1} 个 action.type 不支持: ${type || "(空)"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = {
|
||||||
|
...rawAction,
|
||||||
|
type,
|
||||||
|
selector:
|
||||||
|
rawAction.selector === undefined ? "" : String(rawAction.selector),
|
||||||
|
text: rawAction.text === undefined ? "" : String(rawAction.text),
|
||||||
|
value: rawAction.value === undefined ? "" : String(rawAction.value),
|
||||||
|
key: rawAction.key === undefined ? "" : String(rawAction.key),
|
||||||
|
exact: Boolean(rawAction.exact),
|
||||||
|
index: parseInteger(rawAction.index, `actions[${index}].index`, 0),
|
||||||
|
timeoutMs: parseInteger(
|
||||||
|
rawAction.timeout_ms,
|
||||||
|
`actions[${index}].timeout_ms`,
|
||||||
|
defaultActionTimeoutMs,
|
||||||
|
),
|
||||||
|
waitMsAfter: parseInteger(
|
||||||
|
rawAction.wait_ms_after,
|
||||||
|
`actions[${index}].wait_ms_after`,
|
||||||
|
DEFAULT_ACTION_WAIT_MS,
|
||||||
|
),
|
||||||
|
waitForNavigation: Boolean(rawAction.wait_for_navigation),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action.index < 0) {
|
||||||
|
throw new Error(`actions[${index}].index 不能小于 0`);
|
||||||
|
}
|
||||||
|
if (action.timeoutMs <= 0) {
|
||||||
|
throw new Error(`actions[${index}].timeout_ms 必须大于 0`);
|
||||||
|
}
|
||||||
|
if (action.waitMsAfter < 0) {
|
||||||
|
throw new Error(`actions[${index}].wait_ms_after 不能小于 0`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "click" && !action.selector && !action.text) {
|
||||||
|
throw new Error(
|
||||||
|
`第 ${index + 1} 个 click action 必须提供 selector 或 text`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
["fill", "select", "check", "uncheck"].includes(type) &&
|
||||||
|
!action.selector
|
||||||
|
) {
|
||||||
|
throw new Error(`第 ${index + 1} 个 ${type} action 必须提供 selector`);
|
||||||
|
}
|
||||||
|
if (type === "type" && !action.text) {
|
||||||
|
throw new Error(`第 ${index + 1} 个 type action 必须提供 text`);
|
||||||
|
}
|
||||||
|
if (type === "press" && !action.key) {
|
||||||
|
throw new Error(`第 ${index + 1} 个 press action 必须提供 key`);
|
||||||
|
}
|
||||||
|
if (type === "wait_for_selector" && !action.selector) {
|
||||||
|
throw new Error("wait_for_selector action 必须提供 selector");
|
||||||
|
}
|
||||||
|
if (type === "wait") {
|
||||||
|
action.ms = parseInteger(rawAction.ms, `actions[${index}].ms`, undefined);
|
||||||
|
if (action.ms === undefined || action.ms < 0) {
|
||||||
|
throw new Error("wait action 必须提供大于等于 0 的 ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === "scroll") {
|
||||||
|
action.x = parseNumber(rawAction.x, `actions[${index}].x`, 0);
|
||||||
|
action.y = parseNumber(rawAction.y, `actions[${index}].y`, 600);
|
||||||
|
}
|
||||||
|
if (type === "scroll_to") {
|
||||||
|
action.x = parseNumber(rawAction.x, `actions[${index}].x`, undefined);
|
||||||
|
action.y = parseNumber(rawAction.y, `actions[${index}].y`, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActions(raw, actionTimeoutMs) {
|
||||||
|
return parseJsonActions(raw.actions, raw.actions_file).map((action, index) =>
|
||||||
|
normalizeAction(action, index, actionTimeoutMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateUrl(value) {
|
function validateUrl(value) {
|
||||||
if (!value || !value.trim()) {
|
if (!value || !value.trim()) {
|
||||||
throw new Error("缺少网页链接");
|
throw new Error("缺少网页链接");
|
||||||
@ -118,6 +254,14 @@ function normalizeParams(raw) {
|
|||||||
"max_full_height",
|
"max_full_height",
|
||||||
60000,
|
60000,
|
||||||
);
|
);
|
||||||
|
const actionTimeoutMs = parseInteger(
|
||||||
|
raw.action_timeout_ms,
|
||||||
|
"action_timeout_ms",
|
||||||
|
DEFAULT_ACTION_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
const actions = parseActions(raw, actionTimeoutMs);
|
||||||
|
const captchaStrategy =
|
||||||
|
raw.captcha_strategy || (actions.length ? "detect" : "ignore");
|
||||||
|
|
||||||
if (viewportWidth <= 0 || viewportHeight <= 0) {
|
if (viewportWidth <= 0 || viewportHeight <= 0) {
|
||||||
throw new Error("视口 width 和 height 必须大于 0");
|
throw new Error("视口 width 和 height 必须大于 0");
|
||||||
@ -134,6 +278,12 @@ function normalizeParams(raw) {
|
|||||||
if (maxFullHeight <= 0) {
|
if (maxFullHeight <= 0) {
|
||||||
throw new Error("max_full_height 必须大于 0");
|
throw new Error("max_full_height 必须大于 0");
|
||||||
}
|
}
|
||||||
|
if (actionTimeoutMs <= 0) {
|
||||||
|
throw new Error("action_timeout_ms 必须大于 0");
|
||||||
|
}
|
||||||
|
if (!["detect", "ignore"].includes(captchaStrategy)) {
|
||||||
|
throw new Error("captcha_strategy 只能是 detect 或 ignore");
|
||||||
|
}
|
||||||
|
|
||||||
const params = {
|
const params = {
|
||||||
url,
|
url,
|
||||||
@ -157,6 +307,9 @@ function normalizeParams(raw) {
|
|||||||
timeoutMs,
|
timeoutMs,
|
||||||
maxChars,
|
maxChars,
|
||||||
maxFullHeight,
|
maxFullHeight,
|
||||||
|
actionTimeoutMs,
|
||||||
|
actions,
|
||||||
|
captchaStrategy,
|
||||||
output: raw.output || "",
|
output: raw.output || "",
|
||||||
send: raw.send || "auto",
|
send: raw.send || "auto",
|
||||||
};
|
};
|
||||||
@ -492,6 +645,446 @@ async function evaluate(client, sessionId, expression) {
|
|||||||
return result.result ? result.result.value : undefined;
|
return result.result ? result.result.value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getElementPoint(client, sessionId, action) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const point = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(async () => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const isVisible = (el) => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
const textOf = (el) => normalize([
|
||||||
|
el.innerText,
|
||||||
|
el.value,
|
||||||
|
el.getAttribute('aria-label'),
|
||||||
|
el.getAttribute('title'),
|
||||||
|
el.getAttribute('alt'),
|
||||||
|
el.getAttribute('placeholder'),
|
||||||
|
].filter(Boolean).join(' '));
|
||||||
|
const matchesText = (el) => {
|
||||||
|
if (!action.text) return true;
|
||||||
|
const haystack = textOf(el);
|
||||||
|
return action.exact ? haystack === action.text : haystack.includes(action.text);
|
||||||
|
};
|
||||||
|
const candidates = action.selector
|
||||||
|
? Array.from(document.querySelectorAll(action.selector))
|
||||||
|
: Array.from(document.querySelectorAll('button, a, input, textarea, select, label, [role="button"], [onclick], [tabindex]'));
|
||||||
|
const matches = candidates.filter((el) => isVisible(el) && matchesText(el));
|
||||||
|
const el = matches[action.index || 0];
|
||||||
|
if (!el) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2)),
|
||||||
|
y: Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2)),
|
||||||
|
description: action.selector || action.text || textOf(el),
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!point) {
|
||||||
|
const target = action.selector || `text=${action.text}`;
|
||||||
|
throw new Error(`未找到可操作元素: ${target}`);
|
||||||
|
}
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickElement(client, sessionId, action) {
|
||||||
|
const point = await getElementPoint(client, sessionId, action);
|
||||||
|
const clickCount = Math.max(
|
||||||
|
1,
|
||||||
|
parseInteger(action.click_count, "click_count", 1),
|
||||||
|
);
|
||||||
|
await client.send(
|
||||||
|
"Input.dispatchMouseEvent",
|
||||||
|
{
|
||||||
|
type: "mouseMoved",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button: "none",
|
||||||
|
clickCount: 0,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
for (let index = 0; index < clickCount; index += 1) {
|
||||||
|
await client.send(
|
||||||
|
"Input.dispatchMouseEvent",
|
||||||
|
{
|
||||||
|
type: "mousePressed",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button: "left",
|
||||||
|
buttons: 1,
|
||||||
|
clickCount: index + 1,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
await client.send(
|
||||||
|
"Input.dispatchMouseEvent",
|
||||||
|
{
|
||||||
|
type: "mouseReleased",
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
button: "left",
|
||||||
|
buttons: 0,
|
||||||
|
clickCount: index + 1,
|
||||||
|
},
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillElement(client, sessionId, action) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const ok = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const el = document.querySelectorAll(action.selector)[action.index || 0];
|
||||||
|
if (!el) return false;
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
el.focus();
|
||||||
|
const value = action.value;
|
||||||
|
if (el.isContentEditable) {
|
||||||
|
el.textContent = value;
|
||||||
|
} else if ('value' in el) {
|
||||||
|
el.value = value;
|
||||||
|
} else {
|
||||||
|
el.textContent = value;
|
||||||
|
}
|
||||||
|
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`未找到可填写元素: ${action.selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusElement(client, sessionId, action) {
|
||||||
|
if (!action.selector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const ok = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const el = document.querySelectorAll(action.selector)[action.index || 0];
|
||||||
|
if (!el) return false;
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
el.focus();
|
||||||
|
return document.activeElement === el || el.contains(document.activeElement);
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`未找到可聚焦元素: ${action.selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeText(client, sessionId, action) {
|
||||||
|
await focusElement(client, sessionId, action);
|
||||||
|
await client.send("Input.insertText", { text: action.text }, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyDefinition(key) {
|
||||||
|
const special = {
|
||||||
|
Enter: { code: "Enter", windowsVirtualKeyCode: 13 },
|
||||||
|
Tab: { code: "Tab", windowsVirtualKeyCode: 9 },
|
||||||
|
Escape: { code: "Escape", windowsVirtualKeyCode: 27 },
|
||||||
|
Backspace: { code: "Backspace", windowsVirtualKeyCode: 8 },
|
||||||
|
Delete: { code: "Delete", windowsVirtualKeyCode: 46 },
|
||||||
|
ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38 },
|
||||||
|
ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40 },
|
||||||
|
ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37 },
|
||||||
|
ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39 },
|
||||||
|
};
|
||||||
|
if (special[key]) {
|
||||||
|
return { key, ...special[key] };
|
||||||
|
}
|
||||||
|
if (key.length === 1) {
|
||||||
|
const upper = key.toUpperCase();
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
code: `Key${upper}`,
|
||||||
|
text: key,
|
||||||
|
windowsVirtualKeyCode: upper.charCodeAt(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { key, code: key, windowsVirtualKeyCode: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressKey(client, sessionId, action) {
|
||||||
|
await focusElement(client, sessionId, action);
|
||||||
|
const key = keyDefinition(action.key);
|
||||||
|
await client.send(
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
{ type: "keyDown", ...key },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
await client.send(
|
||||||
|
"Input.dispatchKeyEvent",
|
||||||
|
{ type: "keyUp", ...key },
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectElement(client, sessionId, action) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const ok = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const el = document.querySelectorAll(action.selector)[action.index || 0];
|
||||||
|
if (!el || !(el instanceof HTMLSelectElement)) return false;
|
||||||
|
el.focus();
|
||||||
|
el.value = action.value;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`未找到可选择的 select 元素: ${action.selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setChecked(client, sessionId, action, checked) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const ok = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const el = document.querySelectorAll(action.selector)[action.index || 0];
|
||||||
|
if (!el || !('checked' in el)) return false;
|
||||||
|
el.focus();
|
||||||
|
el.checked = ${checked ? "true" : "false"};
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error(`未找到可勾选元素: ${action.selector}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSelector(client, sessionId, action) {
|
||||||
|
const state = action.state || "visible";
|
||||||
|
if (!["attached", "visible", "hidden", "detached"].includes(state)) {
|
||||||
|
throw new Error(
|
||||||
|
"wait_for_selector.state 只能是 attached、visible、hidden 或 detached",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt <= action.timeoutMs) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const result = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
const el = document.querySelectorAll(action.selector)[action.index || 0];
|
||||||
|
if (!el) return { attached: false, visible: false };
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
attached: true,
|
||||||
|
visible: style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0,
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(state === "attached" && result.attached) ||
|
||||||
|
(state === "visible" && result.visible) ||
|
||||||
|
(state === "hidden" && result.attached && !result.visible) ||
|
||||||
|
(state === "detached" && !result.attached)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await wait(100);
|
||||||
|
}
|
||||||
|
throw new Error(`等待 selector 超时: ${action.selector}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollPage(client, sessionId, action) {
|
||||||
|
await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`window.scrollBy(${JSON.stringify(action.x)}, ${JSON.stringify(action.y)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToTarget(client, sessionId, action) {
|
||||||
|
const encodedAction = JSON.stringify(action);
|
||||||
|
const ok = await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const action = ${encodedAction};
|
||||||
|
if (action.selector || action.text) {
|
||||||
|
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
const textOf = (el) => normalize([el.innerText, el.value, el.getAttribute('aria-label'), el.getAttribute('title')].filter(Boolean).join(' '));
|
||||||
|
const candidates = action.selector
|
||||||
|
? Array.from(document.querySelectorAll(action.selector))
|
||||||
|
: Array.from(document.querySelectorAll('button, a, input, textarea, select, label, [role="button"], [onclick], [tabindex], main, article, section, div'));
|
||||||
|
const matches = candidates.filter((el) => {
|
||||||
|
if (!action.text) return true;
|
||||||
|
const haystack = textOf(el);
|
||||||
|
return action.exact ? haystack === action.text : haystack.includes(action.text);
|
||||||
|
});
|
||||||
|
const el = matches[action.index || 0];
|
||||||
|
if (!el) return false;
|
||||||
|
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
window.scrollTo(action.x || 0, action.y || 0);
|
||||||
|
return true;
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
const target = action.selector || `text=${action.text}`;
|
||||||
|
throw new Error(`未找到可滚动到的元素: ${target}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectCaptcha(client, sessionId) {
|
||||||
|
return await evaluate(
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
`(() => {
|
||||||
|
const signals = [];
|
||||||
|
const add = (value) => {
|
||||||
|
if (value && !signals.includes(value)) signals.push(value);
|
||||||
|
};
|
||||||
|
const text = (document.body && document.body.innerText || '').replace(/\s+/g, ' ').slice(0, 20000);
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
if (/captcha|recaptcha|hcaptcha|turnstile|verify you are human|security check/.test(lowerText)) add('页面文本疑似包含人机验证');
|
||||||
|
if (/验证码|人机验证|安全验证|滑块|拖动滑块|请完成验证|行为验证/.test(text)) add('页面文本疑似包含中文验证码提示');
|
||||||
|
for (const el of Array.from(document.querySelectorAll('iframe, input, div, canvas'))) {
|
||||||
|
const value = [
|
||||||
|
el.getAttribute('src'),
|
||||||
|
el.getAttribute('title'),
|
||||||
|
el.getAttribute('name'),
|
||||||
|
el.getAttribute('id'),
|
||||||
|
el.getAttribute('class'),
|
||||||
|
el.getAttribute('aria-label'),
|
||||||
|
el.getAttribute('data-sitekey'),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
if (/captcha|recaptcha|hcaptcha|turnstile|geetest|aliyun|tencent|cloudflare/.test(value)) {
|
||||||
|
add('页面元素疑似验证码组件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: signals.length > 0, signals };
|
||||||
|
})()`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNoCaptcha(client, sessionId) {
|
||||||
|
const result = await detectCaptcha(client, sessionId);
|
||||||
|
if (result && result.found) {
|
||||||
|
throw new Error(
|
||||||
|
`页面疑似出现验证码/人机验证,已停止自动化操作: ${(result.signals || []).join(";") || "未知信号"}。请改用人工验证后的页面、降低访问频率,或设置 captcha_strategy=ignore 仅继续非验证码相关操作。`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleAction(client, sessionId, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case "click":
|
||||||
|
await clickElement(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "fill":
|
||||||
|
await fillElement(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "type":
|
||||||
|
await typeText(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "press":
|
||||||
|
await pressKey(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "select":
|
||||||
|
await selectElement(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "check":
|
||||||
|
await setChecked(client, sessionId, action, true);
|
||||||
|
return;
|
||||||
|
case "uncheck":
|
||||||
|
await setChecked(client, sessionId, action, false);
|
||||||
|
return;
|
||||||
|
case "wait":
|
||||||
|
await wait(action.ms);
|
||||||
|
return;
|
||||||
|
case "wait_for_selector":
|
||||||
|
await waitForSelector(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "scroll":
|
||||||
|
await scrollPage(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "scroll_to":
|
||||||
|
await scrollToTarget(client, sessionId, action);
|
||||||
|
return;
|
||||||
|
case "captcha_check":
|
||||||
|
await assertNoCaptcha(client, sessionId);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new Error(`不支持的 action.type: ${action.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runActions(client, sessionId, params) {
|
||||||
|
if (!params.actions.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < params.actions.length; index += 1) {
|
||||||
|
const action = params.actions[index];
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
params.captchaStrategy === "detect" &&
|
||||||
|
action.type !== "captcha_check"
|
||||||
|
) {
|
||||||
|
await assertNoCaptcha(client, sessionId);
|
||||||
|
}
|
||||||
|
const loadEvent = action.waitForNavigation
|
||||||
|
? client
|
||||||
|
.waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs)
|
||||||
|
.catch(() => null)
|
||||||
|
: null;
|
||||||
|
await withTimeout(
|
||||||
|
runSingleAction(client, sessionId, action),
|
||||||
|
action.timeoutMs,
|
||||||
|
`执行 action 超时: ${action.type}`,
|
||||||
|
);
|
||||||
|
if (loadEvent) {
|
||||||
|
await loadEvent;
|
||||||
|
}
|
||||||
|
if (action.waitMsAfter > 0) {
|
||||||
|
await wait(action.waitMsAfter);
|
||||||
|
}
|
||||||
|
if (params.captchaStrategy === "detect") {
|
||||||
|
await assertNoCaptcha(client, sessionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`第 ${index + 1} 个 action(${action.type}) 执行失败: ${error.message || error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cleanText(text) {
|
function cleanText(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.replace(/\u00a0/g, " ")
|
.replace(/\u00a0/g, " ")
|
||||||
@ -764,6 +1357,7 @@ async function run() {
|
|||||||
await client.connect(params.timeoutMs);
|
await client.connect(params.timeoutMs);
|
||||||
const sessionId = await createPage(client, params);
|
const sessionId = await createPage(client, params);
|
||||||
await navigate(client, sessionId, params);
|
await navigate(client, sessionId, params);
|
||||||
|
await runActions(client, sessionId, params);
|
||||||
|
|
||||||
if (params.mode === "content") {
|
if (params.mode === "content") {
|
||||||
stdout(await extractContent(client, sessionId, params));
|
stdout(await extractContent(client, sessionId, params));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user