#!/usr/bin/env node "use strict"; const fs = require("fs"); const fsp = require("fs/promises"); const http = require("http"); const os = require("os"); const path = require("path"); const { spawn } = require("child_process"); const DEFAULT_VIEWPORT_WIDTH = 1365; const DEFAULT_VIEWPORT_HEIGHT = 900; const DEFAULT_WAIT_MS = 1500; const DEFAULT_TIMEOUT_MS = 45000; 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) { process.stdout.write(`${message}\n`); } function parseArgs(argv) { const result = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { throw new Error(`存在不支持的参数: ${token}`); } const equalIndex = token.indexOf("="); let key = token.slice(2); let value; if (equalIndex >= 0) { key = token.slice(2, equalIndex); value = token.slice(equalIndex + 1); } else { const next = argv[index + 1]; if (next === undefined || next.startsWith("--")) { value = "true"; } else { value = next; index += 1; } } result[key] = value; } return result; } function parseNumber(value, name, fallback = undefined) { if (value === undefined || value === "") { return fallback; } const parsed = Number(value); if (!Number.isFinite(parsed)) { throw new Error(`${name} 必须是数字`); } return parsed; } function parseInteger(value, name, fallback) { const parsed = parseNumber(value, name, fallback); if (!Number.isInteger(parsed)) { throw new Error(`${name} 必须是整数`); } 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) { if (!value || !value.trim()) { throw new Error("缺少网页链接"); } let parsed; try { parsed = new URL(value.trim()); } catch (error) { throw new Error(`网页链接格式不正确: ${value}`); } if (!["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) { throw new Error("网页链接必须是 http 或 https 地址"); } return parsed.toString(); } function normalizeParams(raw) { const url = validateUrl(raw.url || ""); const mode = raw.mode || "content"; if (!["content", "screenshot"].includes(mode)) { throw new Error("mode 只能是 content 或 screenshot"); } const screenshotMode = raw.screenshot_mode || "full"; if (!["full", "viewport", "selector", "region"].includes(screenshotMode)) { throw new Error( "screenshot_mode 只能是 full、viewport、selector 或 region", ); } const viewportWidth = parseInteger( raw.viewport_width || raw.width, "width", DEFAULT_VIEWPORT_WIDTH, ); const viewportHeight = parseInteger( raw.viewport_height || raw.height, "height", DEFAULT_VIEWPORT_HEIGHT, ); const waitMs = parseInteger(raw.wait_ms, "wait_ms", DEFAULT_WAIT_MS); const timeoutMs = parseInteger( raw.timeout_ms, "timeout_ms", DEFAULT_TIMEOUT_MS, ); const maxChars = parseInteger(raw.max_chars, "max_chars", DEFAULT_MAX_CHARS); const maxFullHeight = parseInteger( raw.max_full_height, "max_full_height", 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) { throw new Error("视口 width 和 height 必须大于 0"); } if (waitMs < 0) { throw new Error("wait_ms 不能小于 0"); } if (timeoutMs <= 0) { throw new Error("timeout_ms 必须大于 0"); } if (maxChars <= 0) { throw new Error("max_chars 必须大于 0"); } if (maxFullHeight <= 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 = { url, mode, screenshotMode, selector: raw.selector || "", x: parseNumber(raw.x, "x"), y: parseNumber(raw.y, "y"), regionWidth: parseNumber( raw.region_width || (screenshotMode === "region" ? raw.width : undefined), "width", ), regionHeight: parseNumber( raw.region_height || (screenshotMode === "region" ? raw.height : undefined), "height", ), viewportWidth, viewportHeight, waitMs, timeoutMs, maxChars, maxFullHeight, actionTimeoutMs, actions, captchaStrategy, output: raw.output || "", send: raw.send || "auto", }; if (!["auto", "true", "false"].includes(params.send)) { throw new Error("send 只能是 auto、true 或 false"); } if ( mode === "screenshot" && screenshotMode === "selector" && !params.selector.trim() ) { throw new Error("selector 截图模式必须传 selector"); } if (mode === "screenshot" && screenshotMode === "region") { for (const key of ["x", "y", "regionWidth", "regionHeight"]) { if (params[key] === undefined) { throw new Error("region 截图模式必须传 x、y、width、height"); } } if (params.regionWidth <= 0 || params.regionHeight <= 0) { throw new Error("region 截图区域 width 和 height 必须大于 0"); } } return params; } function fileExists(filePath) { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch (error) { return false; } } function findChromium() { const candidates = [ process.env.CHROME_BIN, process.env.CHROME_PATH, "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", ].filter(Boolean); for (const candidate of candidates) { if (fileExists(candidate)) { return candidate; } } throw new Error("未找到 Chromium,请设置 CHROME_BIN 或 CHROME_PATH"); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function withTimeout(promise, ms, message) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } function waitForProcessExit(child, timeoutMs) { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve(); } return withTimeout( new Promise((resolve) => child.once("exit", resolve)), timeoutMs, "等待 Chromium 退出超时", ); } async function closeChromium(client, browser) { try { await withTimeout(client.send("Browser.close"), 1500, "关闭 Chromium 超时"); } catch (error) { if ( browser.chrome.exitCode === null && browser.chrome.signalCode === null ) { browser.chrome.kill("SIGTERM"); } } try { await waitForProcessExit(browser.chrome, 3000); } catch (error) { if ( browser.chrome.exitCode === null && browser.chrome.signalCode === null ) { browser.chrome.kill("SIGKILL"); } await waitForProcessExit(browser.chrome, 3000).catch(() => null); } finally { client.close(); } } async function cleanupUserDataDir(userDataDir) { try { await fsp.rm(userDataDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 200, }); } catch (error) { stdout(`清理 Chromium 临时目录失败,已忽略: ${error.message || error}`); } } async function launchChromium(params) { const chromeBin = findChromium(); const userDataDir = await fsp.mkdtemp( path.join(os.tmpdir(), "web-page-chromium-"), ); const args = [ "--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--disable-background-networking", "--disable-default-apps", "--disable-extensions", "--disable-sync", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check", "--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, `--window-size=${params.viewportWidth},${params.viewportHeight}`, "about:blank", ]; const chrome = spawn(chromeBin, args, { stdio: ["ignore", "pipe", "pipe"] }); let logBuffer = ""; const websocketUrlPromise = new Promise((resolve, reject) => { const onData = (chunk) => { logBuffer += chunk.toString("utf8"); const match = logBuffer.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (match) { resolve(match[1]); } }; chrome.stdout.on("data", onData); chrome.stderr.on("data", onData); chrome.once("error", reject); chrome.once("exit", (code) => { reject( new Error(`Chromium 启动失败,退出码: ${code}; ${logBuffer.trim()}`), ); }); }); const websocketUrl = await withTimeout( websocketUrlPromise, params.timeoutMs, "等待 Chromium DevTools 地址超时", ); return { chrome, userDataDir, websocketUrl }; } class CdpClient { constructor(websocketUrl) { this.websocketUrl = websocketUrl; this.nextId = 1; this.pending = new Map(); this.listeners = []; this.ws = null; } async connect(timeoutMs) { if (typeof WebSocket === "undefined") { throw new Error( "当前 Node.js 缺少 WebSocket 支持,请使用 Node.js 22+ 或基础镜像中的 Node.js 24+", ); } this.ws = new WebSocket(this.websocketUrl); this.ws.addEventListener("message", (event) => this.handleMessage(event.data), ); await withTimeout( new Promise((resolve, reject) => { this.ws.addEventListener("open", resolve, { once: true }); this.ws.addEventListener( "error", () => reject(new Error("连接 Chromium DevTools 失败")), { once: true }, ); }), timeoutMs, "连接 Chromium DevTools 超时", ); } handleMessage(raw) { let rawText; if (typeof raw === "string") { rawText = raw; } else if (Buffer.isBuffer(raw)) { rawText = raw.toString("utf8"); } else if (raw instanceof ArrayBuffer) { rawText = Buffer.from(raw).toString("utf8"); } else { rawText = String(raw); } let message; try { message = JSON.parse(rawText); } catch (error) { return; } if (message.id && this.pending.has(message.id)) { const { resolve, reject } = this.pending.get(message.id); this.pending.delete(message.id); if (message.error) { reject( new Error(message.error.message || JSON.stringify(message.error)), ); } else { resolve(message.result || {}); } return; } if (message.method) { for (const listener of [...this.listeners]) { if (listener.method !== message.method) { continue; } if (listener.sessionId && listener.sessionId !== message.sessionId) { continue; } listener.resolve(message.params || {}); this.listeners = this.listeners.filter((item) => item !== listener); } } } send(method, params = {}, sessionId = undefined) { const id = this.nextId; this.nextId += 1; const payload = { id, method, params }; if (sessionId) { payload.sessionId = sessionId; } return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify(payload)); }); } waitForEvent(method, sessionId, timeoutMs) { return withTimeout( new Promise((resolve) => { this.listeners.push({ method, sessionId, resolve }); }), timeoutMs, `等待事件 ${method} 超时`, ); } close() { if (this.ws) { this.ws.close(); } } } async function createPage(client, params) { const target = await client.send("Target.createTarget", { url: "about:blank", }); const attached = await client.send("Target.attachToTarget", { targetId: target.targetId, flatten: true, }); const sessionId = attached.sessionId; await client.send("Page.enable", {}, sessionId); await client.send("Runtime.enable", {}, sessionId); await client.send("Network.enable", {}, sessionId); await client.send( "Emulation.setDeviceMetricsOverride", { width: params.viewportWidth, height: params.viewportHeight, deviceScaleFactor: 1, mobile: false, }, sessionId, ); return sessionId; } async function navigate(client, sessionId, params) { 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); } } async function evaluate(client, sessionId, expression) { const result = await client.send( "Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }, sessionId, ); if (result.exceptionDetails) { throw new Error(result.exceptionDetails.text || "页面脚本执行失败"); } 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) { return String(text || "") .replace(/\u00a0/g, " ") .split("\n") .map((line) => line.trim().replace(/[ \t]+/g, " ")) .filter((line, index, lines) => line || lines[index - 1]) .join("\n") .trim(); } async function extractContent(client, sessionId, params) { const data = await evaluate( client, sessionId, `(() => { const textOf = (node) => (node && node.innerText ? node.innerText.trim() : ''); const meta = (selector) => { const el = document.querySelector(selector); return el ? (el.getAttribute('content') || '').trim() : ''; }; const candidates = Array.from(document.querySelectorAll('article, main, [role="main"], #content, .content, .article, .post, .entry-content')); let root = document.body; const bodyLength = textOf(document.body).length; let bestLength = 0; for (const candidate of candidates) { const length = textOf(candidate).length; if (length > bestLength) { root = candidate; bestLength = length; } } if (bestLength < bodyLength * 0.25) { root = document.body; } const headings = Array.from(document.querySelectorAll('h1, h2, h3')) .map((el) => el.innerText.trim()) .filter(Boolean) .slice(0, 30); const links = Array.from(document.querySelectorAll('a[href]')) .map((el) => ({ text: el.innerText.trim().replace(/\s+/g, ' '), href: el.href })) .filter((item) => item.text && item.href && /^https?:/.test(item.href)) .slice(0, 40); return { title: document.title || '', url: location.href, lang: document.documentElement.lang || '', description: meta('meta[name="description"]') || meta('meta[property="og:description"]'), text: textOf(root) || textOf(document.body), headings, links, }; })()`, ); const text = cleanText(data.text || ""); const truncated = text.length > params.maxChars; const body = truncated ? `${text.slice(0, params.maxChars)}\n\n[内容过长,已截断到 ${params.maxChars} 字]` : text; const headings = [...new Set(data.headings || [])].slice(0, 20); const links = []; const seenLinks = new Set(); for (const link of data.links || []) { const key = `${link.text}\t${link.href}`; if (seenLinks.has(key)) { continue; } seenLinks.add(key); links.push(link); if (links.length >= 20) { break; } } const parts = [ `标题:${data.title || "(无标题)"}`, `URL:${data.url || params.url}`, ]; if (data.lang) { parts.push(`语言:${data.lang}`); } if (data.description) { parts.push(`页面描述:${cleanText(data.description)}`); } if (headings.length) { parts.push( `主要标题:\n${headings.map((heading) => `- ${heading}`).join("\n")}`, ); } parts.push(`正文:\n${body || "(未抽取到可见正文)"}`); if (links.length) { parts.push( `主要链接:\n${links.map((link) => `- ${link.text}: ${link.href}`).join("\n")}`, ); } return parts.join("\n\n"); } async function getLayoutMetrics(client, sessionId) { const metrics = await client.send("Page.getLayoutMetrics", {}, sessionId); return ( metrics.cssContentSize || metrics.contentSize || { x: 0, y: 0, width: 0, height: 0 } ); } async function getSelectorClip(client, sessionId, selector) { const encodedSelector = JSON.stringify(selector); const rect = await evaluate( client, sessionId, `(async () => { const el = document.querySelector(${encodedSelector}); if (!el) return null; el.scrollIntoView({ block: 'center', inline: 'center' }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const r = el.getBoundingClientRect(); return { x: r.left + window.scrollX, y: r.top + window.scrollY, width: r.width, height: r.height }; })()`, ); if (!rect) { throw new Error(`未找到 selector 对应元素: ${selector}`); } if (rect.width <= 0 || rect.height <= 0) { throw new Error(`selector 对应元素尺寸为空: ${selector}`); } return rect; } async function getScreenshotClip(client, sessionId, params) { if (params.screenshotMode === "region") { return { x: params.x, y: params.y, width: params.regionWidth, height: params.regionHeight, scale: 1, }; } if (params.screenshotMode === "selector") { const rect = await getSelectorClip(client, sessionId, params.selector); return { ...rect, scale: 1 }; } if (params.screenshotMode === "viewport") { return { x: 0, y: 0, width: params.viewportWidth, height: params.viewportHeight, scale: 1, }; } const contentSize = await getLayoutMetrics(client, sessionId); const width = Math.max( 1, Math.ceil(contentSize.width || params.viewportWidth), ); const rawHeight = Math.max( 1, Math.ceil(contentSize.height || params.viewportHeight), ); const height = Math.min(rawHeight, params.maxFullHeight); return { x: 0, y: 0, width, height, scale: 1, truncated: rawHeight > height, rawHeight, }; } async function captureScreenshot(client, sessionId, params) { const clip = await getScreenshotClip(client, sessionId, params); const result = await client.send( "Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: true, clip: { x: Math.max(0, clip.x), y: Math.max(0, clip.y), width: Math.max(1, clip.width), height: Math.max(1, clip.height), scale: 1, }, }, sessionId, ); if (!result.data) { throw new Error("Chromium 未返回截图数据"); } const output = params.output || path.join(os.tmpdir(), `web-page-screenshot-${Date.now()}.png`); await fsp.mkdir(path.dirname(output), { recursive: true }); await fsp.writeFile(output, Buffer.from(result.data, "base64")); return { output, clip }; } function postJson(url, body, timeoutMs) { return new Promise((resolve, reject) => { const parsed = new URL(url); const payload = Buffer.from(JSON.stringify(body), "utf8"); const request = http.request( { hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": payload.length, }, timeout: timeoutMs, }, (response) => { const chunks = []; response.on("data", (chunk) => chunks.push(chunk)); response.on("end", () => { const text = Buffer.concat(chunks).toString("utf8"); if (response.statusCode < 200 || response.statusCode >= 300) { reject(new Error(`HTTP ${response.statusCode}: ${text}`)); return; } resolve(text); }); }, ); request.on("error", reject); request.on("timeout", () => request.destroy(new Error("请求机器人客户端超时")), ); request.write(payload); request.end(); }); } async function maybeSendScreenshot(params, filePath) { if (params.send === "false") { return false; } const clientPort = (process.env.ROBOT_WECHAT_CLIENT_PORT || "").trim(); const toWxid = (process.env.ROBOT_FROM_WX_ID || "").trim(); if (!clientPort || !toWxid) { if (params.send === "true") { throw new Error( "环境变量 ROBOT_WECHAT_CLIENT_PORT 或 ROBOT_FROM_WX_ID 未配置", ); } return false; } const sendUrl = `http://127.0.0.1:${clientPort}/api/v1/robot/message/send/image/local`; await postJson( sendUrl, { to_wxid: toWxid, file_path: filePath }, params.timeoutMs, ); return true; } async function run() { const params = normalizeParams(parseArgs(process.argv.slice(2))); const browser = await launchChromium(params); const client = new CdpClient(browser.websocketUrl); try { await client.connect(params.timeoutMs); const sessionId = await createPage(client, params); await navigate(client, sessionId, params); await runActions(client, sessionId, params); if (params.mode === "content") { stdout(await extractContent(client, sessionId, params)); return; } const screenshot = await captureScreenshot(client, sessionId, params); const sent = await maybeSendScreenshot(params, screenshot.output); if (sent) { const suffix = screenshot.clip.truncated ? `,页面过长,已截取前 ${Math.round(screenshot.clip.height)}px / ${Math.round(screenshot.clip.rawHeight)}px` : ""; stdout(`页面截图已发送${suffix}`); } else { const suffix = screenshot.clip.truncated ? `(页面过长,已截取前 ${Math.round(screenshot.clip.height)}px / ${Math.round(screenshot.clip.rawHeight)}px)` : ""; stdout(`页面截图已保存: ${screenshot.output}${suffix}`); } } finally { await closeChromium(client, browser); await cleanupUserDataDir(browser.userDataDir); } } run().catch((error) => { stdout(`执行失败: ${error && error.stack ? error.stack : error}`); process.exit(1); });