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; }); }