fix: 修复网页读取技能安全性问题
This commit is contained in:
parent
43c8637326
commit
999da6083c
@ -7,7 +7,7 @@ description: "网页内容读取、自动化交互和截图工具。当用户提
|
||||
|
||||
## 描述
|
||||
|
||||
这是一个本地网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。
|
||||
这是一个网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。浏览器禁止访问 `file:` 和 `filesystem:` 本地文件地址。
|
||||
|
||||
技能脚本位于 `scripts/web_page.ts`,依赖基础镜像提供的 Node.js 24+ 和全局安装的 `tsx`(用于直接运行 TypeScript)以及 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。
|
||||
|
||||
|
||||
@ -2,6 +2,10 @@
|
||||
"name": "web-page-skill",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --experimental-strip-types --test web_page.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"tsx": "^4.22.4",
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"target": "ES2022",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
|
||||
181
skills/web-page/scripts/web_page.test.ts
Normal file
181
skills/web-page/scripts/web_page.test.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { isLocalFileUrl, validateUrl } from "./web_page.ts";
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = path.join(SCRIPT_DIR, "web_page.ts");
|
||||
const PASSWD_MARKER = "root:x:0:0";
|
||||
|
||||
interface ScriptResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
function runWebPage(url: string, args: string[] = []): Promise<ScriptResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--experimental-strip-types",
|
||||
SCRIPT_PATH,
|
||||
"--url",
|
||||
url,
|
||||
"--mode",
|
||||
"content",
|
||||
"--wait_ms",
|
||||
"300",
|
||||
"--timeout_ms",
|
||||
"8000",
|
||||
"--max_chars",
|
||||
"4000",
|
||||
...args,
|
||||
],
|
||||
{
|
||||
cwd: SCRIPT_DIR,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.once("error", reject);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`web_page.ts 执行超时\nstdout: ${stdout}\nstderr: ${stderr}`));
|
||||
}, 20000);
|
||||
child.once("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertLocalFileBlocked(result: ScriptResult): void {
|
||||
const output = `${result.stdout}\n${result.stderr}`;
|
||||
assert.notEqual(result.code, 0, output);
|
||||
assert.match(
|
||||
output,
|
||||
/已阻止浏览器|网页链接必须是 http 或 https 地址|网页导航失败/,
|
||||
);
|
||||
assert.doesNotMatch(output, new RegExp(PASSWD_MARKER));
|
||||
}
|
||||
|
||||
test("web-page 本地文件访问防护", async (t) => {
|
||||
const server = http.createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url || "/", "http://127.0.0.1");
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
switch (requestUrl.pathname) {
|
||||
case "/redirect-file":
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", "file:///etc/passwd");
|
||||
response.end();
|
||||
return;
|
||||
case "/click-file":
|
||||
response.end(
|
||||
'<!doctype html><title>click file</title><a id="local-file" href="file:///etc/passwd">打开本地文件</a>',
|
||||
);
|
||||
return;
|
||||
case "/js-location":
|
||||
response.end(
|
||||
'<!doctype html><title>js location</title><p>safe</p><script>setTimeout(() => { location.href = "file:///etc/passwd"; }, 50);</script>',
|
||||
);
|
||||
return;
|
||||
case "/iframe-file":
|
||||
response.end(
|
||||
'<!doctype html><title>iframe file</title><p>safe</p><iframe src="file:///etc/passwd"></iframe>',
|
||||
);
|
||||
return;
|
||||
case "/popup-file":
|
||||
response.end(
|
||||
'<!doctype html><title>popup file</title><button id="open-popup" onclick="window.open(\'file:///etc/passwd\', \'_blank\')">打开弹窗</button>',
|
||||
);
|
||||
return;
|
||||
case "/redirect-http":
|
||||
response.statusCode = 302;
|
||||
response.setHeader("Location", "/search?q=normal-http-redirect");
|
||||
response.end();
|
||||
return;
|
||||
case "/search":
|
||||
response.end(
|
||||
`<!doctype html><title>search ok</title><main>SEARCH_OK:${requestUrl.searchParams.get("q") || ""}</main><script>console.log("file:///not-an-access-attempt")</script>`,
|
||||
);
|
||||
return;
|
||||
default:
|
||||
response.statusCode = 404;
|
||||
response.end("not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
t.after(() => server.close());
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
await t.test("直接访问 file:///etc/passwd", async () => {
|
||||
assertLocalFileBlocked(await runWebPage("file:///etc/passwd"));
|
||||
});
|
||||
|
||||
await t.test("HTTP 302 跳转到 file://", async () => {
|
||||
assertLocalFileBlocked(await runWebPage(`${baseUrl}/redirect-file`));
|
||||
});
|
||||
|
||||
await t.test("点击 file:// 链接", async () => {
|
||||
assertLocalFileBlocked(
|
||||
await runWebPage(`${baseUrl}/click-file`, [
|
||||
"--actions",
|
||||
JSON.stringify([{ type: "click", selector: "#local-file" }]),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("JavaScript 修改 location", async () => {
|
||||
assertLocalFileBlocked(await runWebPage(`${baseUrl}/js-location`));
|
||||
});
|
||||
|
||||
await t.test("iframe 加载本地文件", async () => {
|
||||
assertLocalFileBlocked(await runWebPage(`${baseUrl}/iframe-file`));
|
||||
});
|
||||
|
||||
await t.test("弹窗加载本地文件", async () => {
|
||||
assertLocalFileBlocked(
|
||||
await runWebPage(`${baseUrl}/popup-file`, [
|
||||
"--actions",
|
||||
JSON.stringify([{ type: "click", selector: "#open-popup" }]),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("正常 HTTP/HTTPS 搜索及浏览不受影响", async () => {
|
||||
assert.equal(
|
||||
validateUrl("https://example.com/search?q=normal-https"),
|
||||
"https://example.com/search?q=normal-https",
|
||||
);
|
||||
assert.equal(isLocalFileUrl("https://example.com/file.txt"), false);
|
||||
assert.equal(isLocalFileUrl("file:///etc/passwd"), true);
|
||||
assert.equal(isLocalFileUrl("filesystem:https://example.com/temporary/a"), true);
|
||||
|
||||
const normal = await runWebPage(`${baseUrl}/redirect-http`);
|
||||
assert.equal(normal.code, 0, `${normal.stdout}\n${normal.stderr}`);
|
||||
assert.match(normal.stdout, /SEARCH_OK:normal-http-redirect/);
|
||||
assert.doesNotMatch(normal.stdout, new RegExp(PASSWD_MARKER));
|
||||
});
|
||||
});
|
||||
@ -8,6 +8,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parseArgs as parseNodeArgs } from "node:util";
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1365;
|
||||
@ -18,6 +19,19 @@ const DEFAULT_MAX_CHARS = 16000;
|
||||
const DEFAULT_ACTION_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_ACTION_WAIT_MS = 300;
|
||||
const DEFAULT_TIMEZONE = "Asia/Shanghai";
|
||||
const LOCAL_FILE_PROTOCOLS = new Set(["file:", "filesystem:"]);
|
||||
const LOCAL_FILE_BLOCK_PATTERNS = ["file://*", "filesystem:*"];
|
||||
const PAGE_TARGET_TYPES = new Set([
|
||||
"page",
|
||||
"iframe",
|
||||
"webview",
|
||||
"background_page",
|
||||
]);
|
||||
const TARGET_AUTO_ATTACH_OPTIONS = {
|
||||
autoAttach: true,
|
||||
waitForDebuggerOnStart: true,
|
||||
flatten: true,
|
||||
};
|
||||
const WEIXIN_USER_AGENT =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.62(0x18003e2f) NetType/WIFI Language/zh_CN";
|
||||
|
||||
@ -124,6 +138,12 @@ interface ElementPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
description: string;
|
||||
navigationUrl: string;
|
||||
}
|
||||
|
||||
interface PageSession {
|
||||
targetId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface ExtractedLink {
|
||||
@ -371,7 +391,48 @@ function parseActions(
|
||||
);
|
||||
}
|
||||
|
||||
function validateUrl(value: string): string {
|
||||
function localFileProtocol(value: string): string | null {
|
||||
let candidate = String(value || "").trim();
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let depth = 0; depth < 4; depth += 1) {
|
||||
const lowered = candidate.toLowerCase();
|
||||
if (lowered.startsWith("view-source:")) {
|
||||
candidate = candidate.slice("view-source:".length);
|
||||
continue;
|
||||
}
|
||||
if (lowered.startsWith("blob:")) {
|
||||
candidate = candidate.slice("blob:".length);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const protocol = new URL(candidate).protocol.toLowerCase();
|
||||
return LOCAL_FILE_PROTOCOLS.has(protocol) ? protocol : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blockedLocalFileUrlFromLogText(value: string): string {
|
||||
const text = String(value || "");
|
||||
if (!/(?:not allowed to load local resource|refused to load)/i.test(text)) {
|
||||
return "";
|
||||
}
|
||||
const match = text.match(
|
||||
/(?:view-source:|blob:)*(?:file|filesystem):[^\s"'<>)]*/i,
|
||||
);
|
||||
return match ? match[0] : "";
|
||||
}
|
||||
|
||||
export function isLocalFileUrl(value: string): boolean {
|
||||
return localFileProtocol(value) !== null;
|
||||
}
|
||||
|
||||
export function validateUrl(value: string): string {
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error("缺少网页链接");
|
||||
}
|
||||
@ -387,6 +448,58 @@ function validateUrl(value: string): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
class LocalFileAccessGuard {
|
||||
private blockedError: Error | null = null;
|
||||
private readonly blockedPromise: Promise<Error>;
|
||||
private resolveBlocked!: (error: Error) => void;
|
||||
|
||||
constructor() {
|
||||
this.blockedPromise = new Promise<Error>((resolve) => {
|
||||
this.resolveBlocked = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
block(url: string, source: string): boolean {
|
||||
const protocol = localFileProtocol(url);
|
||||
if (!protocol) {
|
||||
return false;
|
||||
}
|
||||
if (!this.blockedError) {
|
||||
this.blockedError = new Error(
|
||||
`已阻止浏览器通过${source}访问本地文件协议 ${protocol}`,
|
||||
);
|
||||
this.resolveBlocked(this.blockedError);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fail(error: unknown, source: string): void {
|
||||
if (this.blockedError) {
|
||||
return;
|
||||
}
|
||||
this.blockedError = new Error(
|
||||
`${source}失败: ${errorMessage(error)}`,
|
||||
);
|
||||
this.resolveBlocked(this.blockedError);
|
||||
}
|
||||
|
||||
throwIfBlocked(): void {
|
||||
if (this.blockedError) {
|
||||
throw this.blockedError;
|
||||
}
|
||||
}
|
||||
|
||||
async race<T>(operation: Promise<T>): Promise<T> {
|
||||
this.throwIfBlocked();
|
||||
const result = await Promise.race([
|
||||
operation,
|
||||
this.blockedPromise.then((error) => Promise.reject(error)),
|
||||
]);
|
||||
this.throwIfBlocked();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseWeixinUserAgent(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
@ -686,6 +799,15 @@ interface CdpEventListener {
|
||||
resolve: (params: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface CdpEventHandler {
|
||||
method: string;
|
||||
sessionId?: string;
|
||||
handle: (
|
||||
params: Record<string, unknown>,
|
||||
sessionId: string | undefined,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface CdpMessage {
|
||||
id?: number;
|
||||
method?: string;
|
||||
@ -700,6 +822,7 @@ class CdpClient {
|
||||
private nextId: number;
|
||||
private readonly pending: Map<number, PendingRequest>;
|
||||
private listeners: CdpEventListener[];
|
||||
private handlers: CdpEventHandler[];
|
||||
private ws: WebSocket | null;
|
||||
|
||||
constructor(websocketUrl: string) {
|
||||
@ -707,6 +830,7 @@ class CdpClient {
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.listeners = [];
|
||||
this.handlers = [];
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
@ -783,6 +907,25 @@ class CdpClient {
|
||||
listener.resolve(message.params || {});
|
||||
this.listeners = this.listeners.filter((item) => item !== listener);
|
||||
}
|
||||
for (const handler of [...this.handlers]) {
|
||||
if (handler.method !== message.method) {
|
||||
continue;
|
||||
}
|
||||
if (handler.sessionId && handler.sessionId !== message.sessionId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const handled = handler.handle(
|
||||
message.params || {},
|
||||
message.sessionId,
|
||||
);
|
||||
if (handled instanceof Promise) {
|
||||
void handled.catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// Security-sensitive handlers report failures through their guard.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -825,6 +968,18 @@ class CdpClient {
|
||||
);
|
||||
}
|
||||
|
||||
onEvent(
|
||||
method: string,
|
||||
handle: CdpEventHandler["handle"],
|
||||
sessionId?: string,
|
||||
): () => void {
|
||||
const handler: CdpEventHandler = { method, sessionId, handle };
|
||||
this.handlers.push(handler);
|
||||
return () => {
|
||||
this.handlers = this.handlers.filter((item) => item !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
@ -832,7 +987,247 @@ class CdpClient {
|
||||
}
|
||||
}
|
||||
|
||||
async function createPage(client: CdpClient, params: Params): Promise<string> {
|
||||
function stringField(
|
||||
value: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): string {
|
||||
const field = value?.[key];
|
||||
return typeof field === "string" ? field : "";
|
||||
}
|
||||
|
||||
function headerValue(
|
||||
headers: Record<string, unknown> | undefined,
|
||||
name: string,
|
||||
): string {
|
||||
if (!headers) {
|
||||
return "";
|
||||
}
|
||||
const normalizedName = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === normalizedName && value !== undefined) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function inspectRedirectResponse(
|
||||
rawResponse: unknown,
|
||||
guard: LocalFileAccessGuard,
|
||||
): void {
|
||||
if (!isPlainObject(rawResponse)) {
|
||||
return;
|
||||
}
|
||||
const status = Number(rawResponse.status ?? 0);
|
||||
if (status < 300 || status >= 400) {
|
||||
return;
|
||||
}
|
||||
const headers = isPlainObject(rawResponse.headers)
|
||||
? rawResponse.headers
|
||||
: undefined;
|
||||
const location = headerValue(headers, "location");
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const responseUrl = stringField(rawResponse, "url");
|
||||
try {
|
||||
guard.block(new URL(location, responseUrl || undefined).toString(), "HTTP 重定向");
|
||||
} catch {
|
||||
guard.block(location, "HTTP 重定向");
|
||||
}
|
||||
}
|
||||
|
||||
async function installSessionLocalFileGuards(
|
||||
client: CdpClient,
|
||||
sessionId: string,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
client.onEvent(
|
||||
"Page.frameRequestedNavigation",
|
||||
(params) => {
|
||||
guard.block(stringField(params, "url"), "页面导航");
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Page.frameStartedNavigating",
|
||||
(params) => {
|
||||
guard.block(stringField(params, "url"), "页面导航");
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Page.frameNavigated",
|
||||
(params) => {
|
||||
if (!isPlainObject(params.frame)) {
|
||||
return;
|
||||
}
|
||||
guard.block(stringField(params.frame, "url"), "页面导航");
|
||||
guard.block(stringField(params.frame, "unreachableUrl"), "页面导航");
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Page.windowOpen",
|
||||
(params) => {
|
||||
guard.block(stringField(params, "url"), "弹窗");
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Log.entryAdded",
|
||||
(params) => {
|
||||
const entry = isPlainObject(params.entry) ? params.entry : undefined;
|
||||
guard.block(stringField(entry, "url"), "浏览器安全日志");
|
||||
guard.block(
|
||||
blockedLocalFileUrlFromLogText(stringField(entry, "text")),
|
||||
"浏览器安全日志",
|
||||
);
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Network.requestWillBeSent",
|
||||
(params) => {
|
||||
if (isPlainObject(params.request)) {
|
||||
guard.block(stringField(params.request, "url"), "网络请求");
|
||||
}
|
||||
inspectRedirectResponse(params.redirectResponse, guard);
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Network.responseReceived",
|
||||
(params) => {
|
||||
inspectRedirectResponse(params.response, guard);
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
client.onEvent(
|
||||
"Fetch.requestPaused",
|
||||
(params) => {
|
||||
void (async () => {
|
||||
const requestId = stringField(params, "requestId");
|
||||
const request = isPlainObject(params.request)
|
||||
? params.request
|
||||
: undefined;
|
||||
const requestUrl = stringField(request, "url");
|
||||
if (guard.block(requestUrl, "网络请求")) {
|
||||
await client.send(
|
||||
"Fetch.failRequest",
|
||||
{ requestId, errorReason: "BlockedByClient" },
|
||||
sessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await client.send("Fetch.continueRequest", { requestId }, sessionId);
|
||||
})().catch((error) => guard.fail(error, "本地文件请求拦截"));
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
|
||||
await client.send("Page.enable", {}, sessionId);
|
||||
await client.send("Network.enable", {}, sessionId);
|
||||
await client.send("Log.enable", {}, sessionId);
|
||||
await client.send(
|
||||
"Network.setBlockedURLs",
|
||||
{ urls: LOCAL_FILE_BLOCK_PATTERNS },
|
||||
sessionId,
|
||||
);
|
||||
await client.send(
|
||||
"Fetch.enable",
|
||||
{
|
||||
patterns: LOCAL_FILE_BLOCK_PATTERNS.map((urlPattern) => ({
|
||||
urlPattern,
|
||||
requestStage: "Request",
|
||||
})),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function enableNewTargetLocalFileGuards(
|
||||
client: CdpClient,
|
||||
primaryPage: PageSession,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
const guardedTargetIds = new Set([primaryPage.targetId]);
|
||||
|
||||
const closeBlockedTarget = (targetId: string): void => {
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
void client
|
||||
.send("Target.closeTarget", { targetId })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const inspectTargetInfo = (rawTargetInfo: unknown): boolean => {
|
||||
if (!isPlainObject(rawTargetInfo)) {
|
||||
return false;
|
||||
}
|
||||
const targetUrl = stringField(rawTargetInfo, "url");
|
||||
if (!guard.block(targetUrl, "新页面")) {
|
||||
return false;
|
||||
}
|
||||
closeBlockedTarget(stringField(rawTargetInfo, "targetId"));
|
||||
return true;
|
||||
};
|
||||
|
||||
client.onEvent("Target.targetCreated", (params) => {
|
||||
inspectTargetInfo(params.targetInfo);
|
||||
});
|
||||
client.onEvent("Target.targetInfoChanged", (params) => {
|
||||
inspectTargetInfo(params.targetInfo);
|
||||
});
|
||||
client.onEvent("Target.attachedToTarget", (params) => {
|
||||
void (async () => {
|
||||
const sessionId = stringField(params, "sessionId");
|
||||
const targetInfo = isPlainObject(params.targetInfo)
|
||||
? params.targetInfo
|
||||
: undefined;
|
||||
const targetId = stringField(targetInfo, "targetId");
|
||||
const targetType = stringField(targetInfo, "type");
|
||||
const waitingForDebugger = Boolean(params.waitingForDebugger);
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error("新页面缺少 CDP sessionId");
|
||||
}
|
||||
if (inspectTargetInfo(targetInfo)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
PAGE_TARGET_TYPES.has(targetType) &&
|
||||
!guardedTargetIds.has(targetId)
|
||||
) {
|
||||
guardedTargetIds.add(targetId);
|
||||
await installSessionLocalFileGuards(client, sessionId, guard);
|
||||
}
|
||||
await client.send(
|
||||
"Target.setAutoAttach",
|
||||
TARGET_AUTO_ATTACH_OPTIONS,
|
||||
sessionId,
|
||||
);
|
||||
if (waitingForDebugger) {
|
||||
await client.send("Runtime.runIfWaitingForDebugger", {}, sessionId);
|
||||
}
|
||||
})().catch((error) => guard.fail(error, "新页面本地文件防护"));
|
||||
});
|
||||
|
||||
await client.send("Target.setDiscoverTargets", { discover: true });
|
||||
await client.send("Target.setAutoAttach", TARGET_AUTO_ATTACH_OPTIONS);
|
||||
await client.send(
|
||||
"Target.setAutoAttach",
|
||||
TARGET_AUTO_ATTACH_OPTIONS,
|
||||
primaryPage.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
async function createPage(
|
||||
client: CdpClient,
|
||||
params: Params,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<PageSession> {
|
||||
const target = await client.send<{ targetId: string }>(
|
||||
"Target.createTarget",
|
||||
{
|
||||
@ -847,9 +1242,8 @@ async function createPage(client: CdpClient, params: Params): Promise<string> {
|
||||
},
|
||||
);
|
||||
const sessionId = attached.sessionId;
|
||||
await client.send("Page.enable", {}, sessionId);
|
||||
await installSessionLocalFileGuards(client, sessionId, guard);
|
||||
await client.send("Runtime.enable", {}, sessionId);
|
||||
await client.send("Network.enable", {}, sessionId);
|
||||
if (shouldUseWeixinUserAgent(params.url)) {
|
||||
await client.send(
|
||||
"Emulation.setUserAgentOverride",
|
||||
@ -920,22 +1314,33 @@ window.navigator.permissions.query = (params) =>
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
return sessionId;
|
||||
return { targetId: target.targetId, sessionId };
|
||||
}
|
||||
|
||||
async function navigate(
|
||||
client: CdpClient,
|
||||
sessionId: string,
|
||||
params: Params,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
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);
|
||||
const navigation = await guard.race(
|
||||
client.send<{ errorText?: string }>(
|
||||
"Page.navigate",
|
||||
{ url: params.url },
|
||||
sessionId,
|
||||
),
|
||||
);
|
||||
if (navigation.errorText) {
|
||||
throw new Error(`网页导航失败: ${navigation.errorText}`);
|
||||
}
|
||||
await guard.race(loadEvent);
|
||||
if (params.waitMs > 0) {
|
||||
await guard.race(wait(params.waitMs));
|
||||
}
|
||||
guard.throwIfBlocked();
|
||||
}
|
||||
|
||||
interface RuntimeEvaluateResult {
|
||||
@ -1005,10 +1410,12 @@ async function getElementPoint(
|
||||
el.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const rect = el.getBoundingClientRect();
|
||||
const anchor = el.closest('a[href]');
|
||||
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),
|
||||
navigationUrl: anchor ? anchor.href : '',
|
||||
};
|
||||
})()`,
|
||||
);
|
||||
@ -1023,8 +1430,12 @@ async function clickElement(
|
||||
client: CdpClient,
|
||||
sessionId: string,
|
||||
action: NormalizedAction,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
const point = await getElementPoint(client, sessionId, action);
|
||||
if (guard.block(point.navigationUrl, "点击链接")) {
|
||||
guard.throwIfBlocked();
|
||||
}
|
||||
const clickCount = Math.max(
|
||||
1,
|
||||
parseInteger(action.click_count, "click_count", 1),
|
||||
@ -1380,10 +1791,11 @@ async function runSingleAction(
|
||||
client: CdpClient,
|
||||
sessionId: string,
|
||||
action: NormalizedAction,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
switch (action.type) {
|
||||
case "click":
|
||||
await clickElement(client, sessionId, action);
|
||||
await clickElement(client, sessionId, action, guard);
|
||||
return;
|
||||
case "fill":
|
||||
await fillElement(client, sessionId, action);
|
||||
@ -1427,8 +1839,10 @@ async function runActions(
|
||||
client: CdpClient,
|
||||
sessionId: string,
|
||||
params: Params,
|
||||
guard: LocalFileAccessGuard,
|
||||
): Promise<void> {
|
||||
if (!params.actions.length) {
|
||||
guard.throwIfBlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1440,17 +1854,20 @@ async function runActions(
|
||||
.waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
await withTimeout(
|
||||
runSingleAction(client, sessionId, action),
|
||||
await guard.race(
|
||||
withTimeout(
|
||||
runSingleAction(client, sessionId, action, guard),
|
||||
action.timeoutMs,
|
||||
`执行 action 超时: ${action.type}`,
|
||||
),
|
||||
);
|
||||
if (loadEvent) {
|
||||
await loadEvent;
|
||||
await guard.race(loadEvent);
|
||||
}
|
||||
if (action.waitMsAfter > 0) {
|
||||
await wait(action.waitMsAfter);
|
||||
await guard.race(wait(action.waitMsAfter));
|
||||
}
|
||||
guard.throwIfBlocked();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`第 ${index + 1} 个 action(${action.type}) 执行失败: ${errorMessage(error)}`,
|
||||
@ -1774,18 +2191,28 @@ async function run(): Promise<void> {
|
||||
const params = normalizeParams(parseCliArgs(process.argv.slice(2)));
|
||||
const browser = await launchChromium(params);
|
||||
const client = new CdpClient(browser.websocketUrl);
|
||||
const guard = new LocalFileAccessGuard();
|
||||
try {
|
||||
await client.connect(params.timeoutMs);
|
||||
const sessionId = await createPage(client, params);
|
||||
await navigate(client, sessionId, params);
|
||||
await runActions(client, sessionId, params);
|
||||
const page = await createPage(client, params, guard);
|
||||
await enableNewTargetLocalFileGuards(client, page, guard);
|
||||
await navigate(client, page.sessionId, params, guard);
|
||||
await runActions(client, page.sessionId, params, guard);
|
||||
guard.throwIfBlocked();
|
||||
|
||||
if (params.mode === "content") {
|
||||
stdout(await extractContent(client, sessionId, params));
|
||||
const content = await guard.race(
|
||||
extractContent(client, page.sessionId, params),
|
||||
);
|
||||
guard.throwIfBlocked();
|
||||
stdout(content);
|
||||
return;
|
||||
}
|
||||
|
||||
const screenshot = await captureScreenshot(client, sessionId, params);
|
||||
const screenshot = await guard.race(
|
||||
captureScreenshot(client, page.sessionId, params),
|
||||
);
|
||||
guard.throwIfBlocked();
|
||||
const sent = await maybeSendScreenshot(params, screenshot.output);
|
||||
if (sent) {
|
||||
const suffix = screenshot.clip.truncated
|
||||
@ -1804,9 +2231,18 @@ async function run(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((error: unknown) => {
|
||||
function isMainModule(): boolean {
|
||||
const entry = process.argv[1];
|
||||
return Boolean(
|
||||
entry && import.meta.url === pathToFileURL(path.resolve(entry)).href,
|
||||
);
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
run().catch((error: unknown) => {
|
||||
stdout(
|
||||
`执行失败: ${error instanceof Error && error.stack ? error.stack : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user