assistant/tools/learning/browser-worker.mjs

601 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import readline from 'node:readline';
import process from 'node:process';
import { pathToFileURL } from 'node:url';
const HOME = path.resolve(process.env.ASSISTANT_LEARNING_HOME || '.runtime/learning');
const CHANNEL = process.env.ASSISTANT_BROWSER_CHANNEL || 'chromium';
const HEADLESS = process.env.ASSISTANT_BROWSER_HEADLESS === '1';
const TIMEOUT = 8_000;
const MAX_STEPS = 30;
const sessions = new Map();
const contexts = new Set();
const accountOwners = new Map();
const closingAccounts = new Map();
let chromiumPromise;
let shuttingDown = false;
let shutdownPromise;
export class WorkerError extends Error {
constructor(message, code = 'WORKER_ERROR') {
super(message);
this.code = code;
}
}
function requireString(value, name, max = 2_000) {
if (typeof value !== 'string' || !value.trim() || value.length > max) {
throw new WorkerError(`${name} 必须是非空文本,最多 ${max} 字符`, 'INVALID_INPUT');
}
return value.trim();
}
function httpUrl(value) {
let url;
try { url = new URL(requireString(value, 'entry_url', 4_096)); }
catch { throw new WorkerError('仅支持明确的 http 或 https 页面地址', 'INVALID_URL'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new WorkerError('仅支持不含账号密码的 http 或 https 地址', 'INVALID_URL');
}
return url;
}
const digest = value => createHash('sha256').update(value).digest('hex');
const redactText = value => String(value || '')
.replace(/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g, '[手机号已隐藏]')
.replace(/((?:验证码|校验码|动态码|短信码|password|verification\s*code)\s*[::]?\s*)[\w-]{4,12}/gi, '$1[已隐藏]');
function redactUrl(value) {
try {
const url = new URL(value);
for (const key of [...url.searchParams.keys()]) {
if (/password|passwd|phone|mobile|captcha|verification|(?:^|_)code$|token|secret|session|credential/i.test(key)) {
url.searchParams.set(key, '[已隐藏]');
}
}
if (/token|password|secret|session|credential/i.test(url.hash)) url.hash = '[已隐藏]';
return redactText(url.href);
} catch { return redactText(value); }
}
async function chromium() {
if (!chromiumPromise) chromiumPromise = import('playwright').then(module => module.chromium).catch(() => {
throw new WorkerError('缺少 Playwright 依赖,请先在项目中安装依赖', 'MISSING_DEPENDENCY');
});
return chromiumPromise;
}
function sessionFor(taskId, requireReady = true) {
const key = requireString(taskId, 'task_id', 128);
const session = sessions.get(key);
if (!session || session.closed || session.page.isClosed()) {
throw new WorkerError('浏览器会话已关闭,请重新打开并人工确认当前页面', 'SESSION_CLOSED');
}
if (session.blockedNavigation) {
throw new WorkerError('页面尝试离开本次学习的站点范围,请关闭会话后检查入口', 'SCOPE_BLOCKED');
}
if (requireReady && !session.ready) {
throw new WorkerError('请先在官方页面完成登录,再明确开始观察;登录阶段不采集证据', 'AWAITING_USER');
}
return session;
}
function requireOrigin(session, url) {
let parsed;
try { parsed = new URL(url); } catch { throw new WorkerError('当前页面地址不可核验', 'SCOPE_BLOCKED'); }
if (parsed.origin !== session.origin) {
throw new WorkerError('当前页面不属于本次学习允许的站点', 'SCOPE_BLOCKED');
}
}
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 3_000 }).catch(() => {});
await page.waitForTimeout(180);
}
async function open(params) {
const taskId = requireString(params.task_id, 'task_id', 128);
const accountKey = requireString(params.account_key, 'account_key', 256);
const entry = httpUrl(params.entry_url);
const existing = sessions.get(taskId);
if (existing && !existing.closed && !existing.page.isClosed()) {
if (existing.accountKey !== accountKey || existing.origin !== entry.origin) {
throw new WorkerError('当前任务已经持有不同的账号或站点会话,请先关闭', 'SESSION_CONFLICT');
}
return sessionInfo(existing);
}
const accountId = digest(`${entry.origin}\0${accountKey}`);
if (closingAccounts.has(accountId)) await closingAccounts.get(accountId);
const owner = accountOwners.get(accountId);
if (owner && owner !== taskId) {
throw new WorkerError('同一平台账号正在被另一学习任务使用,请先关闭原会话', 'ACCOUNT_BUSY');
}
const profile = path.join(HOME, 'profiles', accountId);
await fs.mkdir(profile, { recursive: true });
accountOwners.set(accountId, taskId);
let context;
try {
const browserType = await chromium();
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
context = await browserType.launchPersistentContext(profile, {
channel: CHANNEL,
headless: HEADLESS,
viewport: { width: 1280, height: 840 },
acceptDownloads: false,
timeout: 30_000,
});
contexts.add(context);
context.once('close', () => contexts.delete(context));
// An EOF may arrive while launching. Never expose a newly launched context
// after shutdown has already taken its snapshot of active contexts.
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
const page = context.pages()[0] || await context.newPage();
await Promise.allSettled(context.pages().filter(other => other !== page).map(other => other.close()));
page.setDefaultTimeout(TIMEOUT);
const session = {
taskId, accountKey, accountId, context, page, origin: entry.origin,
entryUrl: entry.href, sessionId: randomUUID(), ready: false, closed: false,
identityLabel: null, lastCapture: null, blockedNavigation: null,
automating: false, blockedWrites: 0,
};
await context.route('**/*', async route => {
const request = route.request();
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
let target;
try { target = new URL(request.url()); } catch { target = null; }
if (!target || target.origin !== session.origin) {
session.blockedNavigation = true;
await route.abort('blockedbyclient');
return;
}
}
// Automation in this first read-only learning slice never sends write requests.
// Human login happens before ready and does not use this automation flag.
if (session.automating && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
session.blockedWrites += 1;
await route.abort('blockedbyclient');
return;
}
await route.continue();
});
context.on('page', popup => {
if (popup !== page) void popup.close().catch(() => {});
});
const markClosed = () => {
session.closed = true;
session.ready = false;
session.lastCapture = null;
if (accountOwners.get(accountId) === taskId) accountOwners.delete(accountId);
};
context.on('close', markClosed);
page.on('close', () => {
session.closed = true;
session.ready = false;
session.lastCapture = null;
void closeContext(session);
});
sessions.set(taskId, session);
await page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 25_000 });
requireOrigin(session, page.url());
return sessionInfo(session);
} catch (error) {
if (context) await context.close().catch(() => {});
sessions.delete(taskId);
if (accountOwners.get(accountId) === taskId) accountOwners.delete(accountId);
if (/executable|browser.*not found|distribution.*not found/i.test(error.message)) {
throw new WorkerError(`浏览器不可用。请安装 Playwright Chromium,或配置本机已安装的浏览器通道。当前通道 ${CHANNEL}`, 'BROWSER_UNAVAILABLE');
}
throw error;
}
}
function sessionInfo(session) {
return {
session_id: session.sessionId,
status: session.closed ? 'closed' : session.ready ? 'observing' : 'awaiting_user',
url: redactUrl(session.page.url()),
manual_confirmation: session.ready,
identity_label: session.identityLabel,
};
}
async function ready(params) {
const session = sessionFor(params.task_id, false);
requireOrigin(session, session.page.url());
const label = requireString(params.identity_label, 'identity_label', 100);
if (/(?:\+?86[-\s]?)?1[3-9]\d{9}|验证码|password|密码/i.test(label)) {
throw new WorkerError('确认标签请使用测试账号昵称或代号,不填写手机号、密码或验证码', 'SENSITIVE_INPUT');
}
session.ready = true;
session.identityLabel = label;
session.lastCapture = null;
return { ...sessionInfo(session), identity_verification: 'operator_confirmed',
note: '此记录是维护者人工确认,不代表自动完成平台账号身份核验' };
}
async function describePage(page) {
return page.evaluate(() => {
const PRIVATE = /password|passwd|one.time.code|verification|captcha|phone|mobile|(?:^|[-_])tel(?:$|[-_])|sms|验证码|校验码|动态码|短信码|手机号|手机号码|密码|口令|登录|登陆|sign.?in|log.?in/i;
const phone = /(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g;
const clean = text => String(text || '').replace(phone, '[手机号已隐藏]').trim();
const fieldLabel = element => {
const id = element.id;
const explicit = id ? document.querySelector(`label[for="${CSS.escape(id)}"]`)?.textContent : '';
return [element.getAttribute('aria-label'), element.getAttribute('placeholder'), explicit,
element.getAttribute('title'), element.getAttribute('name'), element.getAttribute('autocomplete')]
.filter(Boolean).join(' ').trim();
};
const isSensitive = element => {
if (element.matches('input[type=password],input[type=tel],[autocomplete="one-time-code"]')) return true;
if (PRIVATE.test(fieldLabel(element))) return true;
const form = element.closest('form,[role=dialog]');
return Boolean(form && (form.querySelector('input[type=password],input[type=tel],[autocomplete="one-time-code"]') ||
PRIVATE.test([form.getAttribute('id'), form.getAttribute('class'), form.getAttribute('aria-label'), form.textContent?.slice(0, 1200)].join(' '))));
};
const visible = element => {
const style = getComputedStyle(element), rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0;
};
const selectorFor = element => {
if (element.getAttribute('data-testid')) return `[data-testid=${JSON.stringify(element.getAttribute('data-testid'))}]`;
if (element.id) return `#${CSS.escape(element.id)}`;
if (element.getAttribute('data-learning-read')) return `[data-learning-read=${JSON.stringify(element.getAttribute('data-learning-read'))}]`;
const segments = [];
let current = element;
for (let level = 0; current && current !== document.body && level < 7; level += 1, current = current.parentElement) {
if (current.id) { segments.unshift(`#${CSS.escape(current.id)}`); break; }
const tag = current.tagName.toLowerCase();
const siblings = current.parentElement ? Array.from(current.parentElement.children).filter(node => node.tagName === current.tagName) : [];
segments.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);
}
return segments.join(' > ');
};
const stripPrivate = root => {
root.querySelectorAll('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]').forEach(node => node.remove());
root.querySelectorAll('form,[role=dialog]').forEach(node => {
if (PRIVATE.test([node.getAttribute('id'), node.getAttribute('class'), node.getAttribute('aria-label'), node.textContent?.slice(0, 1200)].join(' '))) node.remove();
});
return clean(root.textContent).replace(/\s+/g, ' ');
};
const clone = document.body.cloneNode(true);
const text = stripPrivate(clone).slice(0, 16_000);
const elements = [], seen = new Set(), masks = new Set();
for (const element of document.querySelectorAll('body *')) {
if (element.children.length === 0 && /(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(element.textContent || '')) {
masks.add(selectorFor(element));
}
}
const candidates = document.querySelectorAll('input,textarea,button,a,[data-testid],[data-learning-read],main,article,[role=main],[role=button]');
for (const element of candidates) {
if (!visible(element)) continue;
const selector = selectorFor(element);
if (!selector) continue;
if (isSensitive(element)) {
const form = element.closest('form,[role=dialog]');
masks.add(selectorFor(form || element));
continue;
}
if (seen.has(selector) || elements.length >= 120) continue;
const tag = element.tagName.toLowerCase();
const kind = ['input', 'textarea'].includes(tag) ? 'fill' : (tag === 'button' || tag === 'a' || element.getAttribute('role') === 'button') ? 'click' : 'read';
if (kind === 'fill' && !['text','search','url',''].includes((element.getAttribute('type') || '').toLowerCase())) continue;
if (element.hasAttribute('disabled')) continue;
let label = kind === 'fill' ? fieldLabel(element) : stripPrivate(element.cloneNode(true));
label = clean(label || element.getAttribute('aria-label') || tag).slice(0, 180);
if (!label) label = tag;
let count = 0;
try { count = document.querySelectorAll(selector).length; } catch { continue; }
seen.add(selector);
elements.push({ selector, tag, label, kind, unique: count === 1, count, sensitive: false });
}
return { title: clean(document.title).slice(0, 250), text, elements, masks: [...masks].filter(Boolean) };
});
}
async function captureSession(session, note = '') {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
requireOrigin(session, session.page.url());
const description = await describePage(session.page);
const captureId = randomUUID();
const directory = path.join(HOME, 'evidence', digest(session.taskId).slice(0, 24));
await fs.mkdir(directory, { recursive: true });
const screenshotPath = path.join(directory, `${captureId}.png`);
const mask = description.masks.map(selector => session.page.locator(selector));
mask.push(session.page.locator('input,textarea,[contenteditable=true]'));
await session.page.screenshot({ path: screenshotPath, fullPage: false, mask, maskColor: '#1d252b', timeout: 10_000 });
const snapshot = {
capture_id: captureId,
url: redactUrl(session.page.url()),
title: redactText(description.title),
text: redactText(description.text),
elements: description.elements,
screenshot_path: screenshotPath,
relative_screenshot_path: path.relative(HOME, screenshotPath).split(path.sep).join('/'),
captured_at: new Date().toISOString(),
note: redactText(note).slice(0, 1000),
identity_verification: 'operator_confirmed',
};
session.lastCapture = snapshot;
return snapshot;
}
async function capture(params) {
const session = sessionFor(params.task_id);
return captureSession(session, params.note || '');
}
const DANGEROUS = /关注|私信|发送|提交|支付|购买|删除|移除|注销|退出登录|确认订单|下单|转账|follow|unfollow|send|submit|purchase|delete|remove|logout|sign.out|pay(?:ment)?/i;
const READ_ONLY_BUTTON = /搜索|查询|检索|筛选|展开|收起|更多|下一页|上一页|刷新|加载|search|filter|expand|collapse|more|next|previous|refresh|load/i;
async function actionSession(session, params, { captureBefore = true } = {}) {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
const kind = params.kind;
if (!['fill', 'click', 'read'].includes(kind)) throw new WorkerError('首版只支持 fill、click、read 三类步骤', 'UNSUPPORTED_ACTION');
const selector = requireString(params.selector, 'selector', 1_000);
const previous = session.lastCapture;
if (!previous || previous.url !== redactUrl(session.page.url())) {
throw new WorkerError('页面已变化或尚未采集,请先重新采集当前页面', 'FRESH_CAPTURE_REQUIRED');
}
const observed = previous.elements.find(element => element.selector === selector);
if (!observed || observed.sensitive) throw new WorkerError('定位必须来自最近一次页面采集的可用元素', 'UNOBSERVED_SELECTOR');
if (kind === 'fill' && observed.kind !== 'fill') throw new WorkerError('该元素不是可填写的普通输入框', 'ACTION_MISMATCH');
if (kind === 'click' && observed.kind !== 'click') throw new WorkerError('该元素不是已观察到的链接或按钮', 'ACTION_MISMATCH');
const locator = session.page.locator(selector);
const count = await locator.count();
if (!count || (kind !== 'read' && count !== 1)) throw new WorkerError(`定位当前匹配 ${count} 个元素,操作要求唯一目标`, 'NON_UNIQUE_TARGET');
if (kind === 'read' && count > 200) throw new WorkerError('单次读取最多 200 个对象,请缩小定位范围', 'READ_LIMIT');
const current = await describePage(session.page);
const currentObserved = current.elements.find(element => element.selector === selector);
if (!currentObserved || (kind !== 'read' && (currentObserved.label !== observed.label || currentObserved.tag !== observed.tag))) {
throw new WorkerError('目标语义已变化,请重新采集后确认', 'STALE_TARGET');
}
const before = captureBefore ? await captureSession(session) : previous;
session.automating = true;
const initialBlockedWrites = session.blockedWrites;
let result;
try {
if (kind === 'fill') {
if (typeof params.value !== 'string' || params.value.length > 2_000) throw new WorkerError('填写值必须是最多 2000 字符的文本', 'INVALID_INPUT');
if (/(?:\+?86[-\s]?)?1[3-9]\d{9}|(?:验证码|密码|password)\s*[::]/i.test(params.value)) {
throw new WorkerError('此学习步骤不接受平台手机号、密码或验证码', 'SENSITIVE_INPUT');
}
await locator.fill(params.value, { timeout: TIMEOUT });
result = { filled: true, selector };
} else if (kind === 'click') {
const target = await locator.evaluate(element => ({
tag: element.tagName.toLowerCase(),
label: [element.textContent, element.getAttribute('aria-label'), element.getAttribute('title')].filter(Boolean).join(' ').trim(),
href: element instanceof HTMLAnchorElement ? element.href : '',
formMethod: element.closest('form')?.getAttribute('method') || 'get',
}));
if (DANGEROUS.test(target.label) || DANGEROUS.test(target.href)) {
throw new WorkerError('首版学习验证不执行关注、私信、发送、提交、支付或删除动作', 'WRITE_ACTION_BLOCKED');
}
if (target.tag === 'a' && target.href) {
const url = httpUrl(target.href);
requireOrigin(session, url.href);
} else if (!READ_ONLY_BUTTON.test(target.label) || target.formMethod.toLowerCase() !== 'get') {
throw new WorkerError('首版仅允许同站点链接或明确的搜索、查询、展开、加载按钮', 'WRITE_ACTION_BLOCKED');
}
await locator.click({ timeout: TIMEOUT });
await settle(session.page);
requireOrigin(session, session.page.url());
result = { clicked: true, selector, url: redactUrl(session.page.url()) };
} else {
result = await locator.evaluateAll(elements => {
const sensitive = /password|passwd|one.time.code|verification|captcha|phone|mobile|sms|验证码|校验码|动态码|短信码|手机号|手机号码|密码|口令|登录|登陆|sign.?in|log.?in/i;
return elements.map(element => {
const clone = element.cloneNode(true);
clone.querySelectorAll('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]').forEach(node => node.remove());
clone.querySelectorAll('form,[role=dialog]').forEach(node => {
if (sensitive.test([node.id, node.className, node.getAttribute('aria-label'), node.textContent?.slice(0, 1200)].join(' '))) node.remove();
});
const text = (clone.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 4_000)
.replace(/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g, '[手机号已隐藏]');
let href;
if (element instanceof HTMLAnchorElement && ['http:', 'https:'].includes(new URL(element.href).protocol)) href = element.href;
return href ? { text, href } : { text };
});
});
result = result.map(item => ({ ...item, text: redactText(item.text), ...(item.href ? { href: redactUrl(item.href) } : {}) }));
}
await session.page.waitForTimeout(120);
if (session.blockedWrites > initialBlockedWrites) throw new WorkerError('页面尝试产生写请求,已阻止并停止本次只读步骤', 'WRITE_REQUEST_BLOCKED');
} finally {
session.automating = false;
}
const after = await captureSession(session);
return { result, before, after };
}
async function action(params) {
return actionSession(sessionFor(params.task_id), params);
}
async function close(params) {
const taskId = requireString(params.task_id, 'task_id', 128);
const session = sessions.get(taskId);
if (!session) return { closed: true, profile_preserved: true };
session.closed = true;
session.ready = false;
session.lastCapture = null;
await closeContext(session);
sessions.delete(taskId);
if (accountOwners.get(session.accountId) === taskId) accountOwners.delete(session.accountId);
return { closed: true, profile_preserved: true };
}
async function closeContext(session) {
if (closingAccounts.has(session.accountId)) return closingAccounts.get(session.accountId);
const promise = session.context.close().catch(() => {}).finally(() => {
if (accountOwners.get(session.accountId) === session.taskId) accountOwners.delete(session.accountId);
closingAccounts.delete(session.accountId);
});
closingAccounts.set(session.accountId, promise);
return promise;
}
function assertExpected(expected, outputs) {
if (!expected || typeof expected !== 'object' || Array.isArray(expected) || !Object.keys(expected).length) {
throw new WorkerError('实际试跑必须提供至少一个明确的输出预期', 'EXPECTED_OUTPUT_REQUIRED');
}
const errors = [];
for (const [name, value] of Object.entries(expected)) {
const samples = Array.isArray(value) ? value : [value];
if (!samples.length || samples.some(item => typeof item !== 'string' || !item.trim())) {
errors.push(`输出 ${name} 的预期必须是非空文本或非空文本列表`);
continue;
}
if (!Object.hasOwn(outputs, name)) {
errors.push(`没有读回预期输出 ${name}`);
continue;
}
const actual = Array.isArray(outputs[name]) ? outputs[name].map(item => item.text || '').join('\n') : '';
if (!actual.trim()) { errors.push(`输出 ${name} 的实际读取结果为空`); continue; }
for (const sample of samples) if (!actual.includes(sample)) errors.push(`输出 ${name} 未包含预期内容 ${redactText(sample).slice(0, 120)}`);
}
return errors;
}
async function verify(params) {
const startedAt = new Date().toISOString();
const session = sessionFor(params.task_id);
const outputs = Object.create(null), evidence = [], errors = [];
const expected = params.expected_outputs;
// Validate assertions before any browser movement.
assertExpected(expected, {});
for (const value of Object.values(expected)) {
const samples = Array.isArray(value) ? value : [value];
if (!samples.length || samples.some(item => typeof item !== 'string' || !item.trim())) {
throw new WorkerError('每个输出预期必须是非空文本或非空文本列表', 'INVALID_EXPECTED_OUTPUT');
}
}
if (!Array.isArray(params.steps) || !params.steps.length || params.steps.length > MAX_STEPS) {
throw new WorkerError(`试跑要求 1–${MAX_STEPS} 个固定步骤`, 'INVALID_STEPS');
}
const entry = httpUrl(params.entry_url);
requireOrigin(session, entry.href);
const parameters = params.parameters && typeof params.parameters === 'object' ? params.parameters : {};
const deadline = Date.now() + 90_000;
try {
await session.page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 20_000 });
await settle(session.page);
evidence.push(await captureSession(session, '变参试跑入口'));
for (let index = 0; index < params.steps.length; index += 1) {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
if (Date.now() > deadline) throw new WorkerError('试跑超过本轮时间预算', 'VERIFY_BUDGET');
const step = params.steps[index];
if (!step || typeof step !== 'object' || !['fill', 'click', 'read'].includes(step.kind)) throw new WorkerError(`第 ${index + 1} 步类型无效`, 'INVALID_STEPS');
const actionParams = { ...step };
if (step.kind === 'fill' && step.parameter) {
if (!Object.hasOwn(parameters, step.parameter)) throw new WorkerError(`缺少运行参数 ${step.parameter}`, 'MISSING_PARAMETER');
actionParams.value = String(parameters[step.parameter]);
}
if (step.kind === 'read') requireString(step.output_name, 'output_name', 80);
const result = await actionSession(session, actionParams, { captureBefore: false });
evidence.push(result.after);
if (step.kind === 'read') outputs[step.output_name] = result.result;
}
errors.push(...assertExpected(expected, outputs));
} catch (error) {
errors.push(redactText(error.message));
if (!session.closed && !session.page.isClosed()) {
await captureSession(session, '失败后页面状态').then(snapshot => evidence.push(snapshot)).catch(() => {});
}
}
return {
passed: errors.length === 0,
outputs, errors, evidence,
os: process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : process.platform,
browser_version: session.context.browser()?.version() || 'unknown',
identity_verification: 'operator_confirmed',
started_at: startedAt, finished_at: new Date().toISOString(),
model_calls: 0,
};
}
const methods = { open, ready, capture, action, close, verify,
status: async params => {
const session = sessions.get(requireString(params.task_id, 'task_id', 128));
return session && !session.closed ? sessionInfo(session) : { status: 'closed' };
},
};
export async function dispatch(method, params = {}) {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
if (!Object.hasOwn(methods, method)) throw new WorkerError('不支持的工作进程方法', 'UNKNOWN_METHOD');
return methods[method](params);
}
function shutdown() {
if (shutdownPromise) return shutdownPromise;
shuttingDown = true;
for (const session of sessions.values()) {
session.closed = true;
session.ready = false;
session.lastCapture = null;
}
shutdownPromise = (async () => {
// Closing the context interrupts a pending goto/click/capture immediately;
// it must not wait for the serial RPC loop to finish the current request.
await Promise.allSettled([...contexts].map(context => context.close()));
await Promise.allSettled([...closingAccounts.values()]);
sessions.clear();
accountOwners.clear();
})();
return shutdownPromise;
}
async function main() {
await fs.mkdir(HOME, { recursive: true });
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
const stop = () => {
input.close();
process.stdin.pause();
void shutdown();
};
input.once('close', stop);
process.stdin.once('error', stop);
// A terminated parent may close its read pipe before closing our stdin.
// Treat EPIPE as loss of the owner, never an unhandled stream exception.
process.stdout.on('error', error => {
if (error.code !== 'EPIPE') process.exitCode = 1;
stop();
});
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
const reply = value => {
if (!shuttingDown && !process.stdout.destroyed) process.stdout.write(JSON.stringify(value) + '\n');
};
try {
for await (const line of input) {
if (shuttingDown) break;
if (!line.trim()) continue;
let request;
try {
if (line.length > 1_000_000) throw new WorkerError('请求超过大小限制', 'REQUEST_TOO_LARGE');
request = JSON.parse(line);
if (!request || typeof request !== 'object' || request.id === undefined) throw new WorkerError('请求缺少 id', 'INVALID_REQUEST');
const result = await dispatch(request.method, request.params || {});
reply({ id: request.id, result });
} catch (error) {
const code = error.code || 'BROWSER_ERROR';
reply({ id: request?.id ?? null, error: { message: redactText(error.message).slice(0, 1_200), code } });
}
}
} finally {
input.close();
process.stdin.pause();
await shutdown();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => {
process.stderr.write(`学习工作进程启动失败:${redactText(error.message)}\n`);
process.exitCode = 1;
});
}