fix: 优化浏览器skill
This commit is contained in:
parent
7f9e36160e
commit
a95f840d8b
@ -19,6 +19,7 @@ argument-hint: "无需参数,直接调用即可"
|
|||||||
1. 使用 `web-page` 这个网页内容读取、自动化交互和截图技能,访问 `https://quote.eastmoney.com/stockhotmap/` 这个网页。
|
1. 使用 `web-page` 这个网页内容读取、自动化交互和截图技能,访问 `https://quote.eastmoney.com/stockhotmap/` 这个网页。
|
||||||
2. 网页上有这个类名(`topzs`)的 div 元素,展示了`上证指数`、`深证成指`、`创业板指`、`科创综指`、`北证50`的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
|
2. 网页上有这个类名(`topzs`)的 div 元素,展示了`上证指数`、`深证成指`、`创业板指`、`科创综指`、`北证50`的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
|
||||||
3. 网页上有这个类名(`stockmap`)的 div 元素,展示了各个板块的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
|
3. 网页上有这个类名(`stockmap`)的 div 元素,展示了各个板块的涨跌情况。你需要这些信息的时候,就从这个元素里提取出来。
|
||||||
|
4. 这个网页是 ajax 动态渲染的,访问后等待 3 秒钟再提取内容。
|
||||||
|
|
||||||
## 回复要求
|
## 回复要求
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;自动化可
|
|||||||
|
|
||||||
- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。
|
- 用户发来网页链接,并问「这个网页说了什么」「帮我看看这个链接」「总结一下这个页面」。
|
||||||
- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。
|
- 用户要求读取网页正文、标题、描述、主要内容或页面中的链接。
|
||||||
- 用户要求打开网页后点击按钮/链接、填写输入框、选择下拉框、勾选复选框/单选框、提交表单、等待某个元素出现或滚动页面。
|
- 用户要求打开网页后点击按钮/链接、填写输入框、选择下拉框、勾选复选框/单选框、提交表单、删除某个 DOM 元素、等待某个元素出现或滚动页面。
|
||||||
- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。
|
- 用户要求「截图这个网页」「截整个页面」「截当前可视区域」「截页面里某个区域」。
|
||||||
- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。
|
- 用户提供 CSS 选择器并要求截取对应元素,例如「截取 `.article` 这一块」。
|
||||||
|
|
||||||
@ -114,7 +114,7 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;自动化可
|
|||||||
|
|
||||||
- `type`:动作类型,必填。
|
- `type`:动作类型,必填。
|
||||||
- `selector`:CSS 选择器。多数表单动作必填。
|
- `selector`:CSS 选择器。多数表单动作必填。
|
||||||
- `text`:按可见文本、`aria-label`、`title`、`placeholder` 等匹配元素。`click` 和 `scroll_to` 可用。
|
- `text`:按可见文本、`aria-label`、`title`、`placeholder` 等匹配元素。`click`、`remove` 和 `scroll_to` 可用。
|
||||||
- `exact`:文本是否精确匹配,默认 `false`。
|
- `exact`:文本是否精确匹配,默认 `false`。
|
||||||
- `index`:匹配到多个元素时使用第几个,从 `0` 开始,默认 `0`。
|
- `index`:匹配到多个元素时使用第几个,从 `0` 开始,默认 `0`。
|
||||||
- `timeout_ms`:当前动作超时时间,默认使用 `action_timeout_ms`。
|
- `timeout_ms`:当前动作超时时间,默认使用 `action_timeout_ms`。
|
||||||
@ -129,6 +129,7 @@ argument-hint: "需要 url;mode 可为 content 或 screenshot;自动化可
|
|||||||
- `press`:按键。需要 `key`,例如 `Enter`、`Tab`、`Escape`、`Backspace`、`ArrowDown`。
|
- `press`:按键。需要 `key`,例如 `Enter`、`Tab`、`Escape`、`Backspace`、`ArrowDown`。
|
||||||
- `select`:设置 `<select>` 的值。需要 `selector` 和 `value`。
|
- `select`:设置 `<select>` 的值。需要 `selector` 和 `value`。
|
||||||
- `check` / `uncheck`:勾选或取消勾选复选框/单选框。需要 `selector`。
|
- `check` / `uncheck`:勾选或取消勾选复选框/单选框。需要 `selector`。
|
||||||
|
- `remove`:删除匹配到的 DOM 元素。需要 `selector` 或 `text`,可用 `index` 选择第几个匹配项。
|
||||||
- `wait`:固定等待。需要 `ms`。
|
- `wait`:固定等待。需要 `ms`。
|
||||||
- `wait_for_selector`:等待元素状态。需要 `selector`,可选 `state` 为 `attached`、`visible`、`hidden`、`detached`,默认 `visible`。
|
- `wait_for_selector`:等待元素状态。需要 `selector`,可选 `state` 为 `attached`、`visible`、`hidden`、`detached`,默认 `visible`。
|
||||||
- `scroll`:按偏移滚动页面。可选 `x`、`y`,默认 `x=0,y=600`。
|
- `scroll`:按偏移滚动页面。可选 `x`、`y`,默认 `x=0,y=600`。
|
||||||
@ -163,6 +164,15 @@ tsx scripts/web_page.ts \
|
|||||||
--actions '[{"type":"scroll_to","selector":"body"}]'
|
--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`。
|
2. 如果动作会触发页面跳转,给对应动作设置 `wait_for_navigation: true`。
|
||||||
3. 如果需要等待异步渲染结果,使用 `wait_for_selector` 或设置更长的 `wait_ms_after`。
|
3. 如果需要等待异步渲染结果,使用 `wait_for_selector` 或设置更长的 `wait_ms_after`。
|
||||||
4. 自动化动作完成后,继续按 `mode=content` 输出页面内容,或按 `mode=screenshot` 输出/发送截图。
|
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` 必须是 JSON 数组,或包含 `actions` 数组的 JSON 对象。
|
||||||
- `actions_file` 必须是可读取的本地 JSON 文件。
|
- `actions_file` 必须是可读取的本地 JSON 文件。
|
||||||
- `action_timeout_ms` 和单个动作的 `timeout_ms` 必须大于 0。
|
- `action_timeout_ms` 和单个动作的 `timeout_ms` 必须大于 0。
|
||||||
- 自动化动作必须符合对应类型的必填字段要求,例如 `fill` 必须传 `selector` 和 `value`,`click` 必须传 `selector` 或 `text`。
|
- 自动化动作必须符合对应类型的必填字段要求,例如 `fill` 必须传 `selector` 和 `value`,`click` / `remove` 必须传 `selector` 或 `text`。
|
||||||
- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。
|
- Chromium 路径优先读取 `CHROME_BIN`,其次读取 `CHROME_PATH`,再回退到常见系统路径。
|
||||||
|
|
||||||
## 回复要求
|
## 回复要求
|
||||||
|
|||||||
@ -25,11 +25,11 @@ const ACTION_TYPES = new Set<string>([
|
|||||||
"select",
|
"select",
|
||||||
"check",
|
"check",
|
||||||
"uncheck",
|
"uncheck",
|
||||||
|
"remove",
|
||||||
"wait",
|
"wait",
|
||||||
"wait_for_selector",
|
"wait_for_selector",
|
||||||
"scroll",
|
"scroll",
|
||||||
"scroll_to",
|
"scroll_to",
|
||||||
"captcha_check",
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type ActionType =
|
type ActionType =
|
||||||
@ -40,11 +40,11 @@ type ActionType =
|
|||||||
| "select"
|
| "select"
|
||||||
| "check"
|
| "check"
|
||||||
| "uncheck"
|
| "uncheck"
|
||||||
|
| "remove"
|
||||||
| "wait"
|
| "wait"
|
||||||
| "wait_for_selector"
|
| "wait_for_selector"
|
||||||
| "scroll"
|
| "scroll"
|
||||||
| "scroll_to"
|
| "scroll_to";
|
||||||
| "captcha_check";
|
|
||||||
|
|
||||||
type RawArgs = Record<string, string>;
|
type RawArgs = Record<string, string>;
|
||||||
|
|
||||||
@ -83,7 +83,6 @@ interface Params {
|
|||||||
maxFullHeight: number;
|
maxFullHeight: number;
|
||||||
actionTimeoutMs: number;
|
actionTimeoutMs: number;
|
||||||
actions: NormalizedAction[];
|
actions: NormalizedAction[];
|
||||||
captchaStrategy: "detect" | "ignore";
|
|
||||||
output: string;
|
output: string;
|
||||||
send: "auto" | "true" | "false";
|
send: "auto" | "true" | "false";
|
||||||
}
|
}
|
||||||
@ -100,11 +99,6 @@ interface ElementPoint {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CaptchaResult {
|
|
||||||
found: boolean;
|
|
||||||
signals: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtractedLink {
|
interface ExtractedLink {
|
||||||
text: string;
|
text: string;
|
||||||
href: string;
|
href: string;
|
||||||
@ -308,6 +302,9 @@ function normalizeAction(
|
|||||||
if (type === "press" && !action.key) {
|
if (type === "press" && !action.key) {
|
||||||
throw new Error(`第 ${index + 1} 个 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) {
|
if (type === "wait_for_selector" && !action.selector) {
|
||||||
throw new Error("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,
|
DEFAULT_ACTION_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const actions = parseActions(raw, actionTimeoutMs);
|
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");
|
||||||
@ -417,9 +412,6 @@ function normalizeParams(raw: RawArgs): Params {
|
|||||||
if (actionTimeoutMs <= 0) {
|
if (actionTimeoutMs <= 0) {
|
||||||
throw new Error("action_timeout_ms 必须大于 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";
|
const send = raw.send || "auto";
|
||||||
if (!["auto", "true", "false"].includes(send)) {
|
if (!["auto", "true", "false"].includes(send)) {
|
||||||
@ -450,7 +442,6 @@ function normalizeParams(raw: RawArgs): Params {
|
|||||||
maxFullHeight,
|
maxFullHeight,
|
||||||
actionTimeoutMs,
|
actionTimeoutMs,
|
||||||
actions,
|
actions,
|
||||||
captchaStrategy: captchaStrategy as Params["captchaStrategy"],
|
|
||||||
output: raw.output || "",
|
output: raw.output || "",
|
||||||
send: send as Params["send"],
|
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(
|
async function waitForSelector(
|
||||||
client: CdpClient,
|
client: CdpClient,
|
||||||
sessionId: string,
|
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(
|
async function runSingleAction(
|
||||||
client: CdpClient,
|
client: CdpClient,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@ -1312,6 +1297,9 @@ async function runSingleAction(
|
|||||||
case "uncheck":
|
case "uncheck":
|
||||||
await setChecked(client, sessionId, action, false);
|
await setChecked(client, sessionId, action, false);
|
||||||
return;
|
return;
|
||||||
|
case "remove":
|
||||||
|
await removeElement(client, sessionId, action);
|
||||||
|
return;
|
||||||
case "wait":
|
case "wait":
|
||||||
await wait(action.ms ?? 0);
|
await wait(action.ms ?? 0);
|
||||||
return;
|
return;
|
||||||
@ -1324,9 +1312,6 @@ async function runSingleAction(
|
|||||||
case "scroll_to":
|
case "scroll_to":
|
||||||
await scrollToTarget(client, sessionId, action);
|
await scrollToTarget(client, sessionId, action);
|
||||||
return;
|
return;
|
||||||
case "captcha_check":
|
|
||||||
await assertNoCaptcha(client, sessionId);
|
|
||||||
return;
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`不支持的 action.type: ${String(action.type)}`);
|
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) {
|
for (let index = 0; index < params.actions.length; index += 1) {
|
||||||
const action = params.actions[index];
|
const action = params.actions[index];
|
||||||
try {
|
try {
|
||||||
if (
|
|
||||||
params.captchaStrategy === "detect" &&
|
|
||||||
action.type !== "captcha_check"
|
|
||||||
) {
|
|
||||||
await assertNoCaptcha(client, sessionId);
|
|
||||||
}
|
|
||||||
const loadEvent = action.waitForNavigation
|
const loadEvent = action.waitForNavigation
|
||||||
? client
|
? client
|
||||||
.waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs)
|
.waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs)
|
||||||
@ -1366,9 +1345,6 @@ async function runActions(
|
|||||||
if (action.waitMsAfter > 0) {
|
if (action.waitMsAfter > 0) {
|
||||||
await wait(action.waitMsAfter);
|
await wait(action.waitMsAfter);
|
||||||
}
|
}
|
||||||
if (params.captchaStrategy === "detect") {
|
|
||||||
await assertNoCaptcha(client, sessionId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`第 ${index + 1} 个 action(${action.type}) 执行失败: ${errorMessage(error)}`,
|
`第 ${index + 1} 个 action(${action.type}) 执行失败: ${errorMessage(error)}`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user