fix: 优化浏览器skill

This commit is contained in:
hp0912 2026-06-04 21:34:04 +08:00
parent 7f9e36160e
commit a95f840d8b
3 changed files with 65 additions and 78 deletions

View File

@ -19,6 +19,7 @@ argument-hint: "无需参数,直接调用即可"
1. 使用 `web-page` 这个网页内容读取、自动化交互和截图技能,访问 `https://quote.eastmoney.com/stockhotmap/` 这个网页。
2. 网页上有这个类名(`topzs`)的 div 元素,展示了`上证指数`、`深证成指`、`创业板指`、`科创综指`、`北证50`的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
3. 网页上有这个类名(`stockmap`)的 div 元素,展示了各个板块的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
4. 这个网页是 ajax 动态渲染的,访问后等待 3 秒钟再提取内容。
## 回复要求

View File

@ -16,7 +16,7 @@ argument-hint: "需要 urlmode 可为 content 或 screenshot自动化可
- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。
- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。
- 用户要求打开网页后点击按钮/链接、填写输入框、选择下拉框、勾选复选框/单选框、提交表单、等待某个元素出现或滚动页面。
- 用户要求打开网页后点击按钮/链接、填写输入框、选择下拉框、勾选复选框/单选框、提交表单、删除某个 DOM 元素、等待某个元素出现或滚动页面。
- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。
- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。
@ -114,7 +114,7 @@ argument-hint: "需要 urlmode 可为 content 或 screenshot自动化可
- `type`:动作类型,必填。
- `selector`CSS 选择器。多数表单动作必填。
- `text`:按可见文本、`aria-label`、`title`、`placeholder` 等匹配元素。`click` 和 `scroll_to` 可用。
- `text`:按可见文本、`aria-label`、`title`、`placeholder` 等匹配元素。`click`、`remove``scroll_to` 可用。
- `exact`:文本是否精确匹配,默认 `false`
- `index`:匹配到多个元素时使用第几个,从 `0` 开始,默认 `0`
- `timeout_ms`:当前动作超时时间,默认使用 `action_timeout_ms`
@ -129,6 +129,7 @@ argument-hint: "需要 urlmode 可为 content 或 screenshot自动化可
- `press`:按键。需要 `key`,例如 `Enter`、`Tab`、`Escape`、`Backspace`、`ArrowDown`。
- `select`:设置 `<select>` 的值。需要 `selector``value`
- `check` / `uncheck`:勾选或取消勾选复选框/单选框。需要 `selector`
- `remove`:删除匹配到的 DOM 元素。需要 `selector``text`,可用 `index` 选择第几个匹配项。
- `wait`:固定等待。需要 `ms`
- `wait_for_selector`:等待元素状态。需要 `selector`,可选 `state``attached`、`visible`、`hidden`、`detached`,默认 `visible`
- `scroll`:按偏移滚动页面。可选 `x`、`y`,默认 `x=0,y=600`
@ -163,6 +164,15 @@ tsx scripts/web_page.ts \
--actions '[{"type":"scroll_to","selector":"body"}]'
```
示例:截图前删除页面遮罩或弹窗。
```bash
tsx scripts/web_page.ts \
--url 'https://example.com' \
--mode screenshot \
--actions '[{"type":"remove","selector":".modal-backdrop"},{"type":"remove","text":"Accept cookies"}]'
```
## 执行步骤
### 读取网页内容
@ -179,7 +189,7 @@ tsx scripts/web_page.ts --url 'https://example.com' --mode content
### 自动化后读取或截图
1. 当用户要求先点击、填写、提交、滚动或等待页面变化时,构造 `actions`
1. 当用户要求先点击、填写、提交、删除页面元素、滚动或等待页面变化时,构造 `actions`
2. 如果动作会触发页面跳转,给对应动作设置 `wait_for_navigation: true`
3. 如果需要等待异步渲染结果,使用 `wait_for_selector` 或设置更长的 `wait_ms_after`
4. 自动化动作完成后,继续按 `mode=content` 输出页面内容,或按 `mode=screenshot` 输出/发送截图。
@ -224,7 +234,7 @@ tsx scripts/web_page.ts --url 'https://example.com' --mode screenshot --screensh
- `actions` 必须是 JSON 数组,或包含 `actions` 数组的 JSON 对象。
- `actions_file` 必须是可读取的本地 JSON 文件。
- `action_timeout_ms` 和单个动作的 `timeout_ms` 必须大于 0。
- 自动化动作必须符合对应类型的必填字段要求,例如 `fill` 必须传 `selector``value``click` 必须传 `selector``text`
- 自动化动作必须符合对应类型的必填字段要求,例如 `fill` 必须传 `selector``value``click` / `remove` 必须传 `selector``text`
- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。
## 回复要求

View File

@ -25,11 +25,11 @@ const ACTION_TYPES = new Set<string>([
"select",
"check",
"uncheck",
"remove",
"wait",
"wait_for_selector",
"scroll",
"scroll_to",
"captcha_check",
]);
type ActionType =
@ -40,11 +40,11 @@ type ActionType =
| "select"
| "check"
| "uncheck"
| "remove"
| "wait"
| "wait_for_selector"
| "scroll"
| "scroll_to"
| "captcha_check";
| "scroll_to";
type RawArgs = Record<string, string>;
@ -83,7 +83,6 @@ interface Params {
maxFullHeight: number;
actionTimeoutMs: number;
actions: NormalizedAction[];
captchaStrategy: "detect" | "ignore";
output: string;
send: "auto" | "true" | "false";
}
@ -100,11 +99,6 @@ interface ElementPoint {
description: string;
}
interface CaptchaResult {
found: boolean;
signals: string[];
}
interface ExtractedLink {
text: string;
href: string;
@ -308,6 +302,9 @@ function normalizeAction(
if (type === "press" && !action.key) {
throw new Error(`${index + 1} 个 press action 必须提供 key`);
}
if (type === "remove" && !action.selector && !action.text) {
throw new Error(`${index + 1} 个 remove action 必须提供 selector 或 text`);
}
if (type === "wait_for_selector" && !action.selector) {
throw new Error("wait_for_selector action 必须提供 selector");
}
@ -396,8 +393,6 @@ function normalizeParams(raw: RawArgs): Params {
DEFAULT_ACTION_TIMEOUT_MS,
);
const actions = parseActions(raw, actionTimeoutMs);
const captchaStrategy =
raw.captcha_strategy || (actions.length ? "detect" : "ignore");
if (viewportWidth <= 0 || viewportHeight <= 0) {
throw new Error("视口 width 和 height 必须大于 0");
@ -417,9 +412,6 @@ function normalizeParams(raw: RawArgs): Params {
if (actionTimeoutMs <= 0) {
throw new Error("action_timeout_ms 必须大于 0");
}
if (!["detect", "ignore"].includes(captchaStrategy)) {
throw new Error("captcha_strategy 只能是 detect 或 ignore");
}
const send = raw.send || "auto";
if (!["auto", "true", "false"].includes(send)) {
@ -450,7 +442,6 @@ function normalizeParams(raw: RawArgs): Params {
maxFullHeight,
actionTimeoutMs,
actions,
captchaStrategy: captchaStrategy as Params["captchaStrategy"],
output: raw.output || "",
send: send as Params["send"],
};
@ -1147,6 +1138,47 @@ async function setChecked(
}
}
async function removeElement(
client: CdpClient,
sessionId: string,
action: NormalizedAction,
): Promise<void> {
const encodedAction = JSON.stringify(action);
const ok = await evaluate<boolean>(
client,
sessionId,
`(() => {
const action = ${encodedAction};
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
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('body *'));
const matches = candidates.filter((el) => matchesText(el));
const el = matches[action.index || 0];
if (!el) return false;
el.remove();
return true;
})()`,
);
if (!ok) {
const target = action.selector || `text=${action.text}`;
throw new Error(`未找到可删除元素: ${target}`);
}
}
async function waitForSelector(
client: CdpClient,
sessionId: string,
@ -1238,53 +1270,6 @@ async function scrollToTarget(
}
}
async function detectCaptcha(
client: CdpClient,
sessionId: string,
): Promise<CaptchaResult> {
return await evaluate<CaptchaResult>(
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: CdpClient,
sessionId: string,
): Promise<void> {
const result = await detectCaptcha(client, sessionId);
if (result && result.found) {
throw new Error(
`页面疑似出现验证码/人机验证,已停止自动化操作: ${(result.signals || []).join("") || "未知信号"}。请改用人工验证后的页面、降低访问频率,或设置 captcha_strategy=ignore 仅继续非验证码相关操作。`,
);
}
}
async function runSingleAction(
client: CdpClient,
sessionId: string,
@ -1312,6 +1297,9 @@ async function runSingleAction(
case "uncheck":
await setChecked(client, sessionId, action, false);
return;
case "remove":
await removeElement(client, sessionId, action);
return;
case "wait":
await wait(action.ms ?? 0);
return;
@ -1324,9 +1312,6 @@ async function runSingleAction(
case "scroll_to":
await scrollToTarget(client, sessionId, action);
return;
case "captcha_check":
await assertNoCaptcha(client, sessionId);
return;
default:
throw new Error(`不支持的 action.type: ${String(action.type)}`);
}
@ -1344,12 +1329,6 @@ async function runActions(
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)
@ -1366,9 +1345,6 @@ async function runActions(
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}) 执行失败: ${errorMessage(error)}`,