601 lines
29 KiB
JavaScript
601 lines
29 KiB
JavaScript
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;
|
||
});
|
||
}
|