fix: 回滚网页访问技能

This commit is contained in:
hp0912 2026-09-06 21:01:43 +08:00
parent 153e90cb89
commit ae5ba75b80
2 changed files with 27 additions and 465 deletions

View File

@ -7,7 +7,7 @@ description: "网页内容读取、自动化交互和截图工具。当用户提
## 描述
这是一个网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。浏览器禁止访问 `file:` 和 `filesystem:` 本地文件地址。
这是一个本地网页读取、自动化交互和截图技能。它使用基础镜像中的 Chromium 以 headless 模式打开网页,通过 Chrome DevTools Protocol 在本地完成页面渲染、点击、输入、表单操作、正文抽取和截图,不调用外部 AI 接口。
技能脚本位于 `scripts/web_page.ts`,依赖基础镜像提供的 Node.js 24+ 和全局安装的 `tsx`(用于直接运行 TypeScript)以及 Chromium。基础镜像中已配置 `CHROME_BIN=/usr/bin/chromium` 和 `CHROME_PATH=/usr/bin/chromium` 时,无需额外安装浏览器。

View File

@ -8,7 +8,6 @@ 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;
@ -19,19 +18,6 @@ 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";
@ -138,12 +124,6 @@ interface ElementPoint {
x: number;
y: number;
description: string;
navigationUrl: string;
}
interface PageSession {
targetId: string;
sessionId: string;
}
interface ExtractedLink {
@ -391,48 +371,7 @@ function parseActions(
);
}
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 {
function validateUrl(value: string): string {
if (!value || !value.trim()) {
throw new Error("缺少网页链接");
}
@ -448,58 +387,6 @@ export 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();
@ -799,15 +686,6 @@ 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;
@ -822,7 +700,6 @@ class CdpClient {
private nextId: number;
private readonly pending: Map<number, PendingRequest>;
private listeners: CdpEventListener[];
private handlers: CdpEventHandler[];
private ws: WebSocket | null;
constructor(websocketUrl: string) {
@ -830,7 +707,6 @@ class CdpClient {
this.nextId = 1;
this.pending = new Map();
this.listeners = [];
this.handlers = [];
this.ws = null;
}
@ -907,25 +783,6 @@ 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.
}
}
}
}
@ -968,18 +825,6 @@ 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();
@ -987,247 +832,7 @@ class CdpClient {
}
}
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> {
async function createPage(client: CdpClient, params: Params): Promise<string> {
const target = await client.send<{ targetId: string }>(
"Target.createTarget",
{
@ -1242,8 +847,9 @@ async function createPage(
},
);
const sessionId = attached.sessionId;
await installSessionLocalFileGuards(client, sessionId, guard);
await client.send("Page.enable", {}, sessionId);
await client.send("Runtime.enable", {}, sessionId);
await client.send("Network.enable", {}, sessionId);
if (shouldUseWeixinUserAgent(params.url)) {
await client.send(
"Emulation.setUserAgentOverride",
@ -1314,33 +920,22 @@ window.navigator.permissions.query = (params) =>
},
sessionId,
);
return { targetId: target.targetId, sessionId };
return 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);
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);
await client.send("Page.navigate", { url: params.url }, sessionId);
await loadEvent;
if (params.waitMs > 0) {
await guard.race(wait(params.waitMs));
await wait(params.waitMs);
}
guard.throwIfBlocked();
}
interface RuntimeEvaluateResult {
@ -1410,12 +1005,10 @@ 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 : '',
};
})()`,
);
@ -1430,12 +1023,8 @@ 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),
@ -1791,11 +1380,10 @@ async function runSingleAction(
client: CdpClient,
sessionId: string,
action: NormalizedAction,
guard: LocalFileAccessGuard,
): Promise<void> {
switch (action.type) {
case "click":
await clickElement(client, sessionId, action, guard);
await clickElement(client, sessionId, action);
return;
case "fill":
await fillElement(client, sessionId, action);
@ -1839,10 +1427,8 @@ async function runActions(
client: CdpClient,
sessionId: string,
params: Params,
guard: LocalFileAccessGuard,
): Promise<void> {
if (!params.actions.length) {
guard.throwIfBlocked();
return;
}
@ -1854,20 +1440,17 @@ async function runActions(
.waitForEvent("Page.loadEventFired", sessionId, action.timeoutMs)
.catch(() => null)
: null;
await guard.race(
withTimeout(
runSingleAction(client, sessionId, action, guard),
await withTimeout(
runSingleAction(client, sessionId, action),
action.timeoutMs,
`执行 action 超时: ${action.type}`,
),
);
if (loadEvent) {
await guard.race(loadEvent);
await loadEvent;
}
if (action.waitMsAfter > 0) {
await guard.race(wait(action.waitMsAfter));
await wait(action.waitMsAfter);
}
guard.throwIfBlocked();
} catch (error) {
throw new Error(
`第 ${index + 1} 个 action(${action.type}) 执行失败: ${errorMessage(error)}`,
@ -2120,9 +1703,7 @@ function postJson(
timeoutMs: number,
): Promise<string> {
return new Promise<string>((resolve, reject) => {
const privateToken = (
process.env.ROBOT_CLIENT_PRIVATE_TOKEN ?? ""
).trim();
const privateToken = (process.env.ROBOT_CLIENT_PRIVATE_TOKEN ?? "").trim();
const parsed = new URL(url);
const payload = Buffer.from(JSON.stringify(body), "utf8");
const request = http.request(
@ -2191,28 +1772,18 @@ 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 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();
const sessionId = await createPage(client, params);
await navigate(client, sessionId, params);
await runActions(client, sessionId, params);
if (params.mode === "content") {
const content = await guard.race(
extractContent(client, page.sessionId, params),
);
guard.throwIfBlocked();
stdout(content);
stdout(await extractContent(client, sessionId, params));
return;
}
const screenshot = await guard.race(
captureScreenshot(client, page.sessionId, params),
);
guard.throwIfBlocked();
const screenshot = await captureScreenshot(client, sessionId, params);
const sent = await maybeSendScreenshot(params, screenshot.output);
if (sent) {
const suffix = screenshot.clip.truncated
@ -2231,18 +1802,9 @@ async function run(): Promise<void> {
}
}
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);
});
}