assistant/tools/learning/browser-worker.mjs

2210 lines
145 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';
import { ACTION_KINDS, ActionInputError, validateAction, validateSelector } from './action-contract.mjs';
import { detectSession } from './session-detection.mjs';
import { beginSearchRequestPolicy, beginNoteOpenRequestPolicy, beginReadonlyRequestPolicy, beginNativeUiRequestPolicy, endPlatformRequestPolicy, evaluatePlatformRequest, NAVIGATION_UI_POLICY_VERSION } from './platform-policy.mjs';
import {NAVIGATION_UI_PROFILE,isNativeUiProfile,validNavigationPage,navigationPopupMatches,resolveExecutionProfile,executionMetadata,executionContractMatches,validNativePage,assertNativeUiAction,assertNativeUiResultLocation,nativeSearchKeyword,nativeReadRootsValid} from './execution-profile.mjs';
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 MAX_BLOCKED_REQUESTS = 12;
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;
}
}
const TIMING_PHASES=['validate','inspect_current','capture_before','resolve_target','execute','settle','request_guard','capture_after','failure_capture','batch_capture'];
function actionTrace(){
const start=performance.now(),costs=Object.fromEntries(TIMING_PHASES.map(phase=>[phase,0]));
const visited=new Set(['validate']);
let phase='validate',last=start;
const trace={before:null,after:null,snapshot(position,kind,status){
const now=performance.now(),values={...costs};values[phase]+=now-last;
const duration=Math.max(0,Math.round(now-start)),rounded=Object.fromEntries([...visited].map(key=>[key,Math.max(0,Math.floor(values[key]))]));
rounded[phase]+=duration-Object.values(rounded).reduce((sum,value)=>sum+value,0);
return {step_position:position,kind,status,duration_ms:duration,phase_ms:rounded};
}};
Object.defineProperty(trace,'phase',{get:()=>phase,set:value=>{
if(!TIMING_PHASES.includes(value)) throw new WorkerError('未知的受限执行计时阶段','INVALID_TIMING_PHASE');
const now=performance.now();costs[phase]+=now-last;last=now;phase=value;visited.add(value);
}});
return trace;
}
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[已隐藏]');
export 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 = '[已隐藏]';
// A public note ID is an opaque hexadecimal identifier, even when it
// happens to contain eleven digits matching the phone-number pattern.
// Preserve only the verified official note path; all other URL parts keep
// the existing sensitive-key and ordinary-text redaction rules.
if(url.origin==='https://www.xiaohongshu.com' && !url.username && !url.password && /^\/(?:(?:explore|search_result)\/[a-fA-F0-9]{24}|user\/profile\/[a-fA-F0-9]{24})$/.test(url.pathname)) {
return url.origin+url.pathname+redactText(url.search+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 (session.popupBlocked) throw new WorkerError('出现未知或站点范围外的弹窗,已关闭并暂停,请维护者核对', 'POPUP_BLOCKED');
if (requireReady && !session.ready) {
throw new WorkerError('请先在官方页面完成登录,再明确开始观察;登录阶段不采集证据', 'AWAITING_USER');
}
if(requireReady && isNativeUiProfile(session.executionProfile)) requireOrigin(session,session.page.url());
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');
}
if(isNativeUiProfile(session.executionProfile) && !(session.executionProfile===NAVIGATION_UI_PROFILE ? validNavigationPage(url) : validNativePage(url))) throw new WorkerError('当前页面不在此执行模式声明的搜索或笔记范围','SCOPE_BLOCKED');
}
function boundNoteSource(value, origin) {
try {
const url=new URL(value),match=/^\/(?:explore|search_result)\/([a-fA-F0-9]{24})$/.exec(url.pathname);
return url.origin===origin && !url.username && !url.password && match ?
{note_id:match[1].toLowerCase(),source_url:url.origin+url.pathname} : null;
}catch{return null;}
}
function requireBoundNotePage(session,binding) {
if(!binding?.opened) return;
const actual=boundNoteSource(session.page.url(),session.origin);
if(!actual || actual.note_id!==binding.note_id){
binding.url_checked=false;
throw new WorkerError('当前笔记来源与本次已选笔记绑定不一致,停止读取','NOTE_BINDING_MISMATCH');
}
binding.url_checked=true;
binding.source_url=actual.source_url;
}
function makeNoteBinding(value,entry,steps,parameters) {
if(!value || typeof value!=='object' || Array.isArray(value) || Object.keys(value).length!==1 || !Object.hasOwn(value,'note_id') ||
typeof value.note_id!=='string' || !/^[a-fA-F0-9]{24}$/.test(value.note_id)) throw new WorkerError('笔记绑定必须是明确的公共笔记编号','INVALID_NOTE_BINDING');
const fail=()=>{throw new WorkerError('笔记绑定仅支持已固化的七步或有界定位八步详情与首层评论合同','NOTE_BINDING_CONTRACT');};
const official=entry.origin==='https://www.xiaohongshu.com',demo=['127.0.0.1','localhost','[::1]'].includes(entry.hostname);
if(!official && !demo) fail();
const seek=steps.length===8 ? steps[3] : null;
const readingSteps=seek ? steps.filter((_,index)=>index!==3) : steps;
if(readingSteps.length!==7 || readingSteps.some((step,index)=>step?.kind!==['fill','press','wait','click','wait','extract','extract'][index])) fail();
try{steps.forEach(validateAction);}catch{fail();}
const [fill,press,waiting,click,detailWaiting,detail,comments]=readingSteps;
if(seek && (seek.kind!=='scroll' || !seek.until_target || /^(?:body|html)$/i.test(seek.selector.trim()) || seek.until_target.selector!==click.selector ||
seek.until_target.field!==click.target?.field || seek.until_target.parameter!==click.target?.parameter || seek.until_target.parameter!=='note_title')) fail();
if(fill.parameter!=='keyword' || press.selector!==fill.selector || press.key!=='Enter' || waiting.state!=='visible' || !Number.isInteger(waiting.min_count) || waiting.min_count<1 ||
click.target?.field!=='text' || click.target?.parameter!=='note_title' || detailWaiting.state!=='visible' ||
typeof parameters.keyword!=='string' || !parameters.keyword.trim() || typeof parameters.note_title!=='string' || !parameters.note_title.trim() || parameters.note_title.length>2000) fail();
const fieldsMatch=(step,names,content=false)=>step.fields.length===names.length && names.every(name=>{
const field=step.fields.find(field=>field.name===name);
return field?.required===true && (name==='comment_id' ? field.source==='attribute' && field.attribute==='id' : field.source==='text' || (content && name==='body' && field.source==='content'));
});
if(detail.output_name!=='note_detail' || detail.limit!==1 || detail.target || detail.batch || detail.allow_empty===true || !fieldsMatch(detail,['title','body','author']) ||
comments.output_name!=='comments' || !comments.row_selector || comments.complete_only!==true || !comments.batch || comments.batch.dedupe_field!=='comment_id' || comments.allow_empty===true ||
!fieldsMatch(comments,['comment_id','author','body'],true)) fail();
return {note_id:value.note_id.toLowerCase(),href_checked:false,url_checked:false,source_url:null,title:null,opened:false,note_title:parameters.note_title.trim(),seek_timeout_ms:seek?.timeout_ms || 0};
}
const bindingResult=binding=>({note_id:binding.note_id,href_checked:binding.href_checked,url_checked:binding.url_checked,source_url:binding.source_url,title:binding.title});
function safePathname(pathname) {
return pathname.split('/').map(segment => {
let decoded;
try { decoded = decodeURIComponent(segment); } catch { return ':redacted'; }
if (/password|passwd|credential|token|secret|session|cookie|authorization|captcha|verification|phone|mobile|验证码|密码|手机号/i.test(decoded)) return ':redacted';
if (/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(decoded) || decoded.length > 64 ||
/^[a-f0-9-]{16,}$/i.test(decoded) || /^[A-Za-z0-9_=-]{32,}$/.test(decoded) || decoded.includes('@')) return ':id';
return segment;
}).join('/').slice(0, 300);
}
function observeRequest(session, request) {
if (!['document', 'fetch', 'xhr'].includes(request.resourceType())) return null;
let url;
try { url = new URL(request.url()); } catch { return null; }
if (!['http:', 'https:'].includes(url.protocol)) return null;
const record = { sequence: ++session.requestSequence, recorded_at: new Date().toISOString(),
method: request.method().slice(0, 16), origin: url.origin, pathname: safePathname(url.pathname),
resource_type: request.resourceType().slice(0, 32), automating: session.automating, blocked: false };
session.requestObservations.push(record);
if (session.requestObservations.length > 30) session.requestObservations.shift();
return record;
}
function recordBlockedRequest(session, request, observation, decision) {
const policyDiagnostic=decision ? {policy_reason:decision.reason,
...(decision.policy_version ? {active_policy_version:decision.policy_version} : {}),
...(decision.expected_resource_types ? {expected_resource_types:[...decision.expected_resource_types]} : {})} : {};
if (observation) Object.assign(observation,{blocked:true},policyDiagnostic);
const diagnostic = session.actionDiagnostics;
if (!diagnostic) return;
diagnostic.blocked_request_count += 1;
if (diagnostic.blocked_requests.length >= MAX_BLOCKED_REQUESTS) return;
let url;
try { url = new URL(request.url()); } catch { return; }
// Deliberately omit query/hash, request bodies, headers and credentials.
const summary = { method: request.method().slice(0, 16), origin: url.origin,
pathname: safePathname(url.pathname), resource_type: request.resourceType().slice(0, 32),...policyDiagnostic };
if (!diagnostic.blocked_requests.some(previous => JSON.stringify(previous)===JSON.stringify(summary))) {
diagnostic.blocked_requests.push(summary);
}
}
function recordPopup(session, popup, phase, decision, reason) {
const record = { sequence: ++session.popupSequence, recorded_at: new Date().toISOString(), phase, decision, reason };
let url;
try { url = new URL(popup.url()); } catch { url = null; }
if (url && ['http:', 'https:'].includes(url.protocol)) {
record.origin = url.origin;
record.pathname = safePathname(url.pathname);
}
session.popupObservations.push(record);
if (session.popupObservations.length > 10) session.popupObservations.shift();
}
function watchPage(session, page) {
page.on('close', () => {
// Closing a former search tab must not invalidate the adopted detail tab.
if (session.page !== page || session.closed || session.popupPermission?.opener === page ||
(!session.ready && !session.automating && session.pendingPopups.size > 0)) return;
session.closed = true;
session.ready = false;
session.lastCapture = null;
void closeContext(session);
});
}
// Only fixed Page/Frame objects obtained from Playwright are passed here. This
// decides ownership of the existing capability; it never issues or renews one.
export function nativePopupRequestContext({executionProfile,automating,policy,policyPage,currentPage,permission,requestPage,requestFrame,popupOpener,now=Date.now()}){
try{
if(executionProfile!==NAVIGATION_UI_PROFILE || automating!==true || !policy || policy.version!==NAVIGATION_UI_POLICY_VERSION ||
policy.scope!=='native_ui' || !Number.isFinite(policy.expiresAt) || !Number.isFinite(now) || now>=policy.expiresAt ||
!permission || permission.manual || !permission.opener || popupOpener!==permission.opener || requestPage===permission.opener ||
requestFrame!==requestPage.mainFrame() || requestFrame.page()!==requestPage ||
!validNavigationPage(permission.sourceUrl) || permission.opener.url()!==permission.sourceUrl ||
!navigationPopupMatches(permission.expectedUrl,requestPage.url()) ||
(permission.pendingPopup && permission.pendingPopup!==requestPage)) return null;
const before=permission.adopted===false && currentPage===permission.opener && policyPage===permission.opener;
const after=permission.adopted===true && currentPage===requestPage && policyPage===requestPage && permission.pendingPopup===requestPage;
return before || after ? {activeMainFrame:true,pageUrl:requestPage.url()} : null;
}catch{return null;}
}
function receivePopup(session, popup) {
const manual = !session.ready && !session.automating;
const phase = manual ? 'manual' : session.automating ? 'automation' : 'confirmed';
const permission = session.popupPermission || (manual ? { opener: session.page, adopted: false, manual: true } : null);
const pending = (async () => {
const opener = await popup.opener().catch(() => null);
if (!permission || permission.adopted || (opener && opener !== permission.opener) || session.closed || shuttingDown) {
session.popupBlocked = true;
recordPopup(session, popup, phase, 'blocked', 'unrequested_popup');
await popup.close().catch(() => {});
return;
}
if(session.executionProfile===NAVIGATION_UI_PROFILE && !permission.manual){
if(permission.pendingPopup && permission.pendingPopup!==popup){
session.popupBlocked=true;
recordPopup(session,popup,phase,'blocked','unrequested_popup');
await popup.close().catch(()=>{});
return;
}
permission.pendingPopup=popup;
}
// The context route checks every top-level navigation, including this
// popup's first request, before this event can adopt it.
// about:blank may already have fired domcontentloaded when context emits
// page. Wait for the actual bounded navigation before checking the URL.
await popup.waitForURL(url => ['http:', 'https:'].includes(url.protocol), { waitUntil: 'domcontentloaded', timeout: 3_000 }).catch(() => {});
let url;
try { url = new URL(popup.url()); } catch { url = null; }
if (!url || !['http:', 'https:'].includes(url.protocol) || url.origin !== session.origin ||
(session.executionProfile===NAVIGATION_UI_PROFILE && (!validNavigationPage(url.href) || (!permission.manual && !navigationPopupMatches(permission.expectedUrl,url.href)))) ||
url.username || url.password || session.blockedNavigation || permission.adopted ||
(!permission.manual && !opener && (session.executionProfile===NAVIGATION_UI_PROFILE ?
!navigationPopupMatches(permission.expectedUrl,url.href) : (!permission.expectedUrl || url.href !== permission.expectedUrl)))) {
session.popupBlocked = true;
recordPopup(session, popup, phase, 'blocked', !url || !['http:', 'https:'].includes(url.protocol) ? 'unconfirmed_origin' :
url.origin !== session.origin || session.blockedNavigation ? 'outside_scope' : 'unrequested_popup');
await popup.close().catch(() => {});
return;
}
const transferred=nativePopupRequestContext({executionProfile:session.executionProfile,automating:session.automating,
policy:session.platformRequestPolicy,policyPage:session.platformRequestPage,currentPage:session.page,
permission,requestPage:popup,requestFrame:popup.mainFrame(),popupOpener:opener});
permission.adopted = true;
watchPage(session, popup);
session.page = popup;
if(transferred) session.platformRequestPage=popup;
session.lastCapture = null;
// A same-origin page opened during manual login remains a page awaiting
// confirmation; adopting the page never authorizes automated actions.
if (permission.manual) session.ready = false;
recordPopup(session, popup, phase, 'adopted', permission.manual ? 'same_origin_manual' : 'same_origin_click');
})();
session.pendingPopups.add(pending);
void pending.finally(() => {
session.pendingPopups.delete(pending);
if (!session.ready && !session.automating && !session.pendingPopups.size && session.page.isClosed() && !session.closed) {
session.closed = true;
session.lastCapture = null;
void closeContext(session);
}
}).catch(() => {});
}
async function settlePopups(session) {
while (session.pendingPopups.size) await Promise.all([...session.pendingPopups]);
if (session.popupBlocked) throw new WorkerError('出现未获明确点击授权或站点范围外的弹窗,已关闭并暂停', 'POPUP_BLOCKED');
if (session.blockedNavigation) throw new WorkerError('页面尝试离开本次学习站点,已阻止并暂停', 'SCOPE_BLOCKED');
}
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 3_000 }).catch(() => {});
await page.waitForTimeout(180);
}
async function refreshLoginDetection(session) {
session.loginDetection = await detectSession(session.page);
if (['logged_out', 'verification_required'].includes(session.loginDetection.state)) {
session.ready = false;
session.lastCapture = null;
}
return session.loginDetection;
}
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);
resolveExecutionProfile(params.execution_profile);
if(isNativeUiProfile(params.execution_profile) && !(params.execution_profile===NAVIGATION_UI_PROFILE ? validNavigationPage(entry.href) : validNativePage(entry.href))) throw new WorkerError('新执行模式仅支持已声明的小红书官网搜索或笔记页面','EXECUTION_PROFILE_INVALID');
const existing = sessions.get(taskId);
if (existing && !existing.closed && !existing.page.isClosed()) {
if (existing.accountKey !== accountKey || existing.origin !== entry.origin || existing.executionProfile!==params.execution_profile) {
throw new WorkerError('当前任务已经持有不同的账号或站点会话,请先关闭', 'SESSION_CONFLICT');
}
await refreshLoginDetection(existing);
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,executionProfile:params.execution_profile,
entryUrl: entry.href, sessionId: randomUUID(), ready: false, closed: false,
identityLabel: null, lastCapture: null, blockedNavigation: null,
loginDetection: null,
observedSelectors: new Set(),
automating: false, blockedWrites: 0, actionDiagnostics: null,
popupPermission: null, pendingPopups: new Set(), popupBlocked: false,
requestSequence: 0, requestObservations: [],
popupSequence: 0, popupObservations: [],
platformRequestPolicy: null, platformRequestPage: null,
};
await context.route('**/*', async route => {
const request = route.request();
const observation = observeRequest(session, request);
let topLevelNavigation = false;
if (request.isNavigationRequest()) {
// A popup's first navigation can arrive before Playwright creates its
// Frame. Treat that case as top-level; never bypass its scope check.
try { topLevelNavigation = request.frame().parentFrame() === null; }
catch { topLevelNavigation = true; }
}
if (topLevelNavigation) {
let target;
try { target = new URL(request.url()); } catch { target = null; }
if (!target || target.origin !== session.origin || (session.executionProfile===NAVIGATION_UI_PROFILE && !validNavigationPage(target.href))) {
session.blockedNavigation = true;
recordBlockedRequest(session, request, observation,{reason:'navigation_out_of_scope'});
await route.abort('blockedbyclient').catch(() => {});
return;
}
}
// Unknown non-GET requests remain blocked. Only the finite experimental
// official UI action policy can allow its observed auxiliary requests.
// Human login does not use the automation flag.
if (session.automating && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
let activeMainFrame = false;
let policyPageUrl=session.page.url();
try {
const frame=request.frame(),requestPage=frame.page(),permission=session.popupPermission;
if(session.executionProfile===NAVIGATION_UI_PROFILE && permission && requestPage!==permission.opener){
const popupOpener=await requestPage.opener();
if(session.popupPermission===permission){
const popupContext=nativePopupRequestContext({executionProfile:session.executionProfile,automating:session.automating,
policy:session.platformRequestPolicy,policyPage:session.platformRequestPage,currentPage:session.page,
permission,requestPage,requestFrame:frame,popupOpener});
if(popupContext){
if(!permission.pendingPopup) permission.pendingPopup=requestPage;
activeMainFrame=popupContext.activeMainFrame;policyPageUrl=popupContext.pageUrl;
}
}
}else activeMainFrame=session.platformRequestPage===session.page && frame===session.page.mainFrame();
} catch {}
const policyDecision = evaluatePlatformRequest({ policy: session.platformRequestPolicy, automating: session.automating,
sessionOrigin: session.origin, pageUrl: policyPageUrl, activeMainFrame,
method: request.method(), resourceType: request.resourceType(), url: request.url() });
if (policyDecision.allowed) {
if (observation) Object.assign(observation,{policy_version:policyDecision.policy_version,policy_rule:policyDecision.policy_rule});
await route.continue().catch(() => {});
return;
}
session.blockedWrites += 1;
recordBlockedRequest(session, request, observation,policyDecision);
await route.abort('blockedbyclient').catch(() => {});
return;
}
await route.continue().catch(() => {});
});
context.on('page', popup => { if (popup !== session.page) receivePopup(session, popup); });
const markClosed = () => {
session.closed = true;
session.ready = false;
session.lastCapture = null;
if (accountOwners.get(accountId) === taskId) accountOwners.delete(accountId);
};
context.on('close', markClosed);
watchPage(session, page);
sessions.set(taskId, session);
await page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 25_000 });
requireOrigin(session, page.url());
await settle(page);
await refreshLoginDetection(session);
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 {
...executionMetadata(session.executionProfile),
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,
login_detection: session.loginDetection,
block_reason: session.blockedNavigation ? 'scope_blocked' : session.popupBlocked ? 'popup_blocked' : null,
popup_observations: session.popupObservations.map(record => ({ ...record })),
};
}
async function ready(params) {
const session = sessionFor(params.task_id, false);
await settlePopups(session);
requireOrigin(session, session.page.url());
const detection = await refreshLoginDetection(session);
if (detection.state === 'logged_out') throw new WorkerError('当前可见页面仍要求登录,请在官方页面完成后重新确认', 'LOGIN_REQUIRED');
if (detection.state === 'verification_required') throw new WorkerError('当前可见页面需要人工验证,请在官方页面处理后重新确认', 'VERIFICATION_REQUIRED');
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: '此记录是维护者人工确认,不代表自动完成平台账号身份核验' };
}
export async function describePage(page,request=null) {
return page.evaluate(request => {
const metadataOnly=Boolean(request?.selector);
const PRIVATE = /password|passwd|credential|token|secret|cookie|authorization|session|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();
// This evaluate callback is synchronous and makes no DOM writes. Reuse the
// same node facts only within this observation; scrolling, style changes
// and later captures always start with fresh caches.
const memo = read => {
const values = new WeakMap();
return element => {
if (!values.has(element)) values.set(element, read(element));
return values.get(element);
};
};
const rectFor = memo(element => element.getBoundingClientRect());
const styleFor = memo(element => getComputedStyle(element));
const fieldLabel = memo(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'), element.id]
.filter(Boolean).join(' ').trim();
});
const stableFieldLabel = memo(element => {
const explicit = element.id ? document.querySelector(`label[for="${CSS.escape(element.id)}"]`)?.textContent : '';
const fields = [element.getAttribute('aria-label'),element.getAttribute('title'),element.getAttribute('name'),explicit,element.id,
element.getAttribute('type') || (element.tagName==='INPUT' ? 'text' : 'textarea'),element.getAttribute('autocomplete')]
.map(value=>clean(value).replace(/\s+/g,' '));
const result = JSON.stringify(fields);
return fields.some(Boolean) && result.length<=500 ? result : '';
});
const isSensitive = memo(element => {
if (element.matches('input[type=password],input[type=tel],[autocomplete="one-time-code"]')) return true;
if (PRIVATE.test(fieldLabel(element))) return true;
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (PRIVATE.test([parent.id, parent.getAttribute('aria-label'), parent.getAttribute('title')].filter(Boolean).join(' '))) 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 = memo(element => {
const rect = rectFor(element);
if (rect.width <= 0 || rect.height <= 0) return false;
for (let current = element; current; current = current.parentElement) {
const style = styleFor(current);
if (style.display === 'none' || ['hidden', 'collapse'].includes(style.visibility) || Number(style.opacity) === 0) return false;
}
return true;
});
const inViewport = memo(element => {
const rect = rectFor(element);
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right);
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const style = styleFor(parent), box = rectFor(parent);
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowY)) { top = Math.max(top, box.top); bottom = Math.min(bottom, box.bottom); }
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowX)) { left = Math.max(left, box.left); right = Math.min(right, box.right); }
}
return visible(element) && bottom > top && right > left;
});
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 rawTextCaches = [new WeakMap(), new WeakMap()];
const displayedText = (root, viewportOnly = false) => {
const cache = rawTextCaches[Number(viewportOnly)];
const walk = node => {
if (cache.has(node)) return cache.get(node);
let text = '';
if (node.nodeType === Node.TEXT_NODE) text = node.textContent;
else if (node.nodeType === Node.ELEMENT_NODE && !node.matches('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]') &&
!isSensitive(node) && visible(node) && (!viewportOnly || inViewport(node))) {
text = Array.from(node.childNodes, walk).join(' ');
}
cache.set(node, text);
return text;
};
return clean(walk(root)).replace(/\s+/g, ' ').slice(0, 16_000);
};
const text = metadataOnly ? '' : displayedText(document.body);
const viewportText = metadataOnly ? '' : displayedText(document.body, true);
const elements = [], seen = new Set(), masks = new Set();
for (const element of metadataOnly ? [] : document.querySelectorAll('body *')) {
if (PRIVATE.test([element.id, element.getAttribute('aria-label'), element.getAttribute('title')].filter(Boolean).join(' '))) {
masks.add(selectorFor(element));
}
if (element.children.length === 0 && /(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(element.textContent || '')) {
masks.add(selectorFor(element));
}
}
const safeToken = value => /^[A-Za-z][A-Za-z0-9_-]{0,95}$/.test(value || '') && !PRIVATE.test(value) && !/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(value);
const readable = memo(element => !element.matches('script,style,noscript,iframe,input,textarea,select,[contenteditable=true]') && !isSensitive(element));
const scrollInfo = memo(element => {
if (!readable(element) || !/(?:auto|scroll)/.test(styleFor(element).overflowY) || element.clientHeight <= 0) return null;
return { can_scroll: element.scrollHeight > element.clientHeight + 1, scroll_top: element.scrollTop,
scroll_height: element.scrollHeight, client_height: element.clientHeight };
});
const relativeSelector = (root, element) => {
if (safeToken(element.id)) return `#${CSS.escape(element.id)}`;
for (const name of [...element.classList].filter(safeToken).slice(0, 3)) {
const selector = `${element.tagName.toLowerCase()}.${CSS.escape(name)}`;
if (root.querySelectorAll(selector).length === 1) return selector;
}
const parts = [];
for (let current = element; current && current !== root && parts.length < 6; current = current.parentElement) {
const tag = current.tagName.toLowerCase();
const siblings = [...current.parentElement.children].filter(node => node.tagName === current.tagName);
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(current) + 1})` : tag);
if (current.parentElement === root) return `:scope > ${parts.join(' > ')}`;
}
return null;
};
const readHints = root => {
const hints = [], seenHints = new Set();
const media=[...root.querySelectorAll('img,video')].slice(0,40).filter(element=>visible(element) && readable(element) && !element.closest('a') &&
![element,...function*(node){for(let parent=node.parentElement;parent;parent=parent.parentElement) yield parent;}(element)].some(node=>/avatar|头像/i.test([node.id,...node.classList].join(' '))))
.sort((a,b)=>Number(inViewport(b))-Number(inViewport(a))).slice(0,4);
for(const child of media){
const selector=relativeSelector(root,child);if(!selector || selector.length>350 || seenHints.has(selector)) continue;
const image=child.tagName==='IMG',count=root.querySelectorAll(selector).length;
seenHints.add(selector);hints.push({selector,tag:child.tagName.toLowerCase(),label:image ? '图片元素(未解释)' : '视频元素(未解释)',unique:count===1,count,
in_viewport:inViewport(child),link:false,source:'media',media_kind:image ? 'image' : 'video',loaded:image ? child.complete && child.naturalWidth>0 : child.readyState>=2 && child.videoWidth>0});
}
const children = [...root.querySelectorAll('a,h1,h2,h3,h4,h5,h6,p,time,span,div[id],div[class]')].slice(0, 200)
.filter(element => visible(element) && readable(element))
.sort((a, b) => Number(inViewport(b)) - Number(inViewport(a)) || Number(b.children.length === 0) - Number(a.children.length === 0));
for (const child of children) {
if (hints.length >= 12) break;
const selector = relativeSelector(root, child);
if (!selector || selector.length > 350 || seenHints.has(selector)) continue;
const count = root.querySelectorAll(selector).length;
const label = displayedText(child).slice(0, 100);
if (!label) continue;
seenHints.add(selector);
const link = child.tagName === 'A';
const datetime = child.tagName === 'TIME' && child.hasAttribute('datetime');
hints.push({ selector, tag: child.tagName.toLowerCase(), label, unique: count === 1, count,
in_viewport: inViewport(child), link, source: datetime ? 'attribute' : link ? 'href' : 'text', ...(datetime ? { attribute: 'datetime' } : {}) });
}
return hints;
};
const elementMetadata=(element,selector,count)=>{
const tag=element.tagName.toLowerCase();
const kind=['input','textarea'].includes(tag) ? 'fill' : (tag==='button' || tag==='a' || element.getAttribute('role')==='button') ? 'click' : 'read';
if(!visible(element) || isSensitive(element) || element.hasAttribute('disabled') ||
(kind==='fill' && !['text','search','url',''].includes((element.getAttribute('type') || '').toLowerCase()))) return null;
let label=kind==='fill' ? fieldLabel(element) : displayedText(element);
label=clean(label || element.getAttribute('aria-label') || tag).slice(0,180) || tag;
const searchDescription=[element.getAttribute('type'),element.id,fieldLabel(element)].filter(Boolean).join(' ');
const form=element.form || element.closest('form');
const searchForm=form && (form.getAttribute('method') || 'get').toLowerCase()==='get' &&
(/search|query|搜索|查询|检索/i.test([form.getAttribute('action'),form.getAttribute('role')].join(' ')) ||
[...form.querySelectorAll('button,input[type=submit]')].some(button=>/search|搜索|查询|检索/i.test([button.textContent,button.getAttribute('aria-label'),button.getAttribute('value')].join(' '))));
const search_input=kind==='fill' && !/comment|reply|message|私信|评论|回复|发言|留言|发送/i.test(searchDescription) &&
(!form || (form.getAttribute('method') || 'get').toLowerCase()==='get') && (/search|query|keyword|搜索|查询|检索/i.test(searchDescription) || Boolean(searchForm));
const scroll_y=scrollInfo(element);
return {selector,tag,label,kind,unique:count===1,count,sensitive:false,in_viewport:inViewport(element),search_input,
...(search_input ? {stable_label:stableFieldLabel(element)} : {}),
selector_basis:safeToken(element.id) ? 'id' : scroll_y ? 'scroll_container' : 'semantic',...(scroll_y ? {scroll_y} : {})};
};
if(metadataOnly){
const matches=[...document.querySelectorAll(request.selector)];
if(matches.length>500) return {elements:[],read_limit:true};
if(!matches.length || matches.some(element=>isSensitive(element))) return {elements:[]};
if(request.requireCanonical && matches.some(element=>selectorFor(element)!==request.selector)) return {elements:[]};
const first=matches.find(element=>visible(element) && inViewport(element)) || (!request.collection && matches.find(visible));
if(!first || matches.some(element=>element.tagName!==first.tagName) || (request.collection && matches.some(element=>!readable(element)))) return {elements:[]};
const metadata=elementMetadata(first,request.selector,matches.length);
return {elements:metadata ? [metadata] : []};
}
const base = [...document.querySelectorAll('input,textarea,button,a,[data-testid],[data-learning-read],main,article,[role=main],[role=button]')];
const semantic = [...document.querySelectorAll('body [id],h1,h2,h3,h4,h5,h6,section,[role=heading],[role=region],[role=list],[role=listitem]')]
.slice(0, 2000).filter(element => readable(element) && (safeToken(element.id) || element.matches('h1,h2,h3,h4,h5,h6,section,[role=heading],[role=region],[role=list],[role=listitem]')));
const scrollRoots = new Set();
for (const element of [...semantic, ...base].filter(inViewport).slice(0, 240)) {
for (let parent = element, depth = 0; parent && parent !== document.body && depth < 7; parent = parent.parentElement, depth += 1) {
if (scrollInfo(parent)) scrollRoots.add(parent);
}
}
const foreground = memo(element => {
if (!inViewport(element)) return false;
const rect = rectFor(element);
const hit = document.elementFromPoint(Math.max(0, Math.min(innerWidth - 1, (Math.max(0, rect.left) + Math.min(innerWidth, rect.right)) / 2)),
Math.max(0, Math.min(innerHeight - 1, (Math.max(0, rect.top) + Math.min(innerHeight, rect.bottom)) / 2)));
return Boolean(hit && (hit === element || element.contains(hit)));
});
const controlRank = element => element.matches('input,textarea') ? 0 : element.matches('button,[role=button]') ? 1 : element.tagName==='A' ? 3 : 2;
const candidates = [...new Set([...semantic, ...scrollRoots, ...base])]
.sort((a, b) => Number(inViewport(b)) - Number(inViewport(a)) || controlRank(a)-controlRank(b) || Number(foreground(b))-Number(foreground(a)));
let loadedOffscreen = 0;
let readCount = 0, hintedRoots = 0, fillCount = 0, buttonCount = 0;
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 >= 108) continue;
const in_viewport = inViewport(element);
if (!in_viewport && loadedOffscreen >= 30) 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 === 'read' && readCount >= 48) continue;
if ((kind==='fill' && fillCount>=16) || (kind==='click' && tag!=='a' && buttonCount>=24)) continue;
if (kind === 'fill' && !['text','search','url',''].includes((element.getAttribute('type') || '').toLowerCase())) continue;
if (element.hasAttribute('disabled')) continue;
let count = 0;
try { count = document.querySelectorAll(selector).length; } catch { continue; }
seen.add(selector);
if (kind === 'read') readCount += 1;
if (kind === 'fill') fillCount += 1;
if (kind === 'click' && tag!=='a') buttonCount += 1;
if (!in_viewport) loadedOffscreen += 1;
const metadata=elementMetadata(element,selector,count);
if(!metadata) continue;
const structure = kind === 'read' && hintedRoots < 16 ? readHints(element) : [];
if (structure.length) hintedRoots += 1;
elements.push({...metadata,...(structure.length ? {read_hints:structure} : {})});
}
const addCollection = (selector, basis, sources) => {
if (elements.length >= 120 || seen.has(selector) || selector.length > 1000) return;
let matches;
try { matches = [...document.querySelectorAll(selector)]; } catch { return; }
if (!matches.length || matches.length > 200 || matches.some(element => !readable(element))) return;
const first = matches.find(element => visible(element) && inViewport(element));
if (!first || matches.some(element => element.tagName !== first.tagName)) return;
const tag = first.tagName.toLowerCase(), kind = tag === 'a' ? 'click' : 'read';
if (kind === 'click' && matches.some(element => !(element instanceof HTMLAnchorElement))) return;
seen.add(selector);
elements.push({ selector, tag, label: displayedText(first).slice(0,180) || tag, kind, unique: matches.length === 1,
count: matches.length, sensitive:false, in_viewport:true, search_input:false, collection:true, selector_basis:basis,
source_selectors:sources.slice(0,3), read_hints:readHints(first) });
};
for (const element of candidates.filter(element => readable(element) && visible(element) && inViewport(element)).slice(0, 400)) {
if (elements.length >= 120) break;
const family = safeToken(element.id) && element.id.match(/^([A-Za-z][A-Za-z_-]{1,47}[-_])(?:[a-fA-F0-9]{16,32}|\d{1,12})$/);
const parent = element.parentElement;
if (family && parent && parent !== document.body) {
const scope = selectorFor(parent);
if (scope && document.querySelectorAll(scope).length === 1) {
const selector = `${scope} > ${element.tagName.toLowerCase()}[id^=${JSON.stringify(family[1])}]`;
const members = [...document.querySelectorAll(selector)];
if (members.every(member => member.id.match(/^([A-Za-z][A-Za-z_-]{1,47}[-_])(?:[a-fA-F0-9]{16,32}|\d{1,12})$/)?.[1] === family[1])) {
addCollection(selector, 'id_family', members.slice(0,3).map(selectorFor));
}
}
}
for (const name of [...element.classList].filter(safeToken).slice(0, 2)) {
for (let scopeNode = parent, depth = 0; scopeNode && scopeNode !== document.body && depth < 12; scopeNode = scopeNode.parentElement, depth += 1) {
if (!safeToken(scopeNode.id) && !scopeNode.matches('main,article,section,[role=main],[role=region]')) continue;
const scope = selectorFor(scopeNode), selector = `${scope} ${element.tagName.toLowerCase()}.${CSS.escape(name)}`;
if (!scope || document.querySelectorAll(scope).length !== 1) continue;
const members = [...document.querySelectorAll(selector)];
if (members.length >= 2 && members.length <= 200) {
addCollection(selector, 'sibling_class', members.slice(0,3).map(selectorFor));
break;
}
}
}
}
return { title: clean(document.title).slice(0, 250), text, viewport_text: viewportText,
text_scope: 'displayed_loaded_dom', viewport: { width: innerWidth, height: innerHeight, scroll_x: scrollX, scroll_y: scrollY },
elements, masks: [...masks].filter(Boolean) };
},request);
}
async function describeCurrentSelector(page,selector,observed){
const current=await describePage(page,{selector,collection:observed?.collection===true,requireCanonical:Boolean(observed && !observed.collection)});
if(current.read_limit) throw new WorkerError('当前定位超过500个对象,请重新限定范围','READ_LIMIT');
return current.elements[0];
}
async function captureSession(session, note = '') {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
if(session.closed || session.page.isClosed()) throw new WorkerError('浏览器会话已关闭,不能继续采集','SESSION_CLOSED');
requireOrigin(session, session.page.url());
const description = await describePage(session.page);
if(session.closed || session.page.isClosed()) throw new WorkerError('会话已中断,不保存迟到的页面描述','SESSION_CLOSED');
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 });
if(session.closed || session.page.isClosed()) throw new WorkerError('浏览器会话已关闭,不追加迟到的采集','SESSION_CLOSED');
const snapshot = {
...executionMetadata(session.executionProfile),
capture_id: captureId,
url: redactUrl(session.page.url()),
title: redactText(description.title),
text: redactText(description.text),
viewport_text: redactText(description.viewport_text),
text_scope: description.text_scope,
viewport: description.viewport,
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',
request_observations: session.requestObservations.map(record => ({ ...record })),
popup_observations: session.popupObservations.map(record => ({ ...record })),
};
session.lastCapture = snapshot;
for (const element of snapshot.elements) if (!element.sensitive) session.observedSelectors.add(element.selector);
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;
// Runs only the fixed inspection below, never caller-provided JavaScript.
export function inspectElements(elements, { target, fields, origin, completeOnly, untilSelector, rowSelector, fixedRootSelector, fixedSourceUrl,nativeProfile=false,navigationProfile=false }) {
if (!Array.isArray(elements)) elements = [elements];
if(fixedRootSelector && (location.href!==fixedSourceUrl || elements.length!==1 || !elements[0].isConnected ||
document.querySelectorAll(fixedRootSelector).length!==1 || document.querySelector(fixedRootSelector)!==elements[0])) return [{scope_changed:true}];
if(rowSelector){
elements=elements.flatMap(element=>[...element.querySelectorAll(rowSelector)]);
if(elements.length>500) return [{row_limit:true}];
}
const PRIVATE = /password|passwd|credential|token|secret|cookie|authorization|session|one.time.code|verification|captcha|phone|mobile|(?:^|[-_])tel(?:$|[-_])|sms|验证码|校验码|动态码|短信码|手机号|手机号码|密码|口令|登录|登陆|sign.?in|log.?in/i;
const excluded = 'script,style,noscript,iframe,input,textarea,select,[contenteditable=true]';
const sensitive = element => {
const description = [element.getAttribute('aria-label'), element.getAttribute('placeholder'), element.getAttribute('title'),
element.getAttribute('name'), element.getAttribute('autocomplete'), element.id].filter(Boolean).join(' ');
if (element.matches('input[type=password],input[type=tel],[autocomplete="one-time-code"]') || PRIVATE.test(description)) return true;
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
if (PRIVATE.test([parent.id, parent.getAttribute('aria-label'), parent.getAttribute('title')].filter(Boolean).join(' '))) 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.id, form.className, form.getAttribute('aria-label'), form.textContent?.slice(0, 1200)].join(' '))));
};
const visible = element => {
const rect = element.getBoundingClientRect();
if (rect.width<=0 || rect.height<=0) return false;
for (let current=element;current;current=current.parentElement) {
const style=getComputedStyle(current);
if (style.display==='none' || ['hidden','collapse'].includes(style.visibility) || Number(style.opacity)===0) return false;
}
return true;
};
const viewport = element => {
const rect = element.getBoundingClientRect();
let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right);
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const style = getComputedStyle(parent), box = parent.getBoundingClientRect();
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowY)) { top = Math.max(top, box.top); bottom = Math.min(bottom, box.bottom); }
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowX)) { left = Math.max(left, box.left); right = Math.min(right, box.right); }
}
return visible(element) && bottom > top && right > left;
};
const fullyViewport = (element, container = null) => {
if (!visible(element)) return false;
const rect=element.getBoundingClientRect();
let top=0,bottom=innerHeight,left=0,right=innerWidth;
for(let parent=element.parentElement;parent;parent=parent.parentElement){
const style=getComputedStyle(parent),box=parent.getBoundingClientRect();
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowY)) {top=Math.max(top,box.top+parent.clientTop);bottom=Math.min(bottom,box.top+parent.clientTop+parent.clientHeight);}
if (/(?:auto|scroll|hidden|clip)/.test(style.overflowX)) {left=Math.max(left,box.left+parent.clientLeft);right=Math.min(right,box.left+parent.clientLeft+parent.clientWidth);}
}
if(container){const box=container.getBoundingClientRect();top=Math.max(top,box.top+container.clientTop);bottom=Math.min(bottom,box.top+container.clientTop+container.clientHeight);left=Math.max(left,box.left+container.clientLeft);right=Math.min(right,box.left+container.clientLeft+container.clientWidth);}
return rect.top>=top-0.5 && rect.bottom<=bottom+0.5 && rect.left>=left-0.5 && rect.right<=right+0.5;
};
const clean = value => String(value || '').replace(/\s+/g, ' ').trim().slice(0, 4_000);
const stableFieldLabel = element => {
const explicit = element.id ? document.querySelector(`label[for="${CSS.escape(element.id)}"]`)?.textContent : '';
const fields = [element.getAttribute('aria-label'),element.getAttribute('title'),element.getAttribute('name'),explicit,element.id,
element.getAttribute('type') || (element.tagName==='INPUT' ? 'text' : 'textarea'),element.getAttribute('autocomplete')]
.map(value=>String(value || '').replace(/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/g,'[手机号已隐藏]').replace(/\s+/g,' ').trim());
const result = JSON.stringify(fields);
return fields.some(Boolean) && result.length<=500 ? result : '';
};
const textOf = root => {
if (root.matches(excluded) || sensitive(root)) return null;
const walk = node => {
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE || node.matches(excluded) || sensitive(node) || !visible(node)) return '';
return Array.from(node.childNodes, walk).join(' ');
};
return clean(walk(root));
};
const hrefOf = element => {
if (!(element instanceof HTMLAnchorElement) || !element.hasAttribute('href')) return null;
const url = new URL(element.href, document.baseURI);
if (!['http:', 'https:'].includes(url.protocol) || url.origin !== origin || url.username || url.password) return { error: 'UNSAFE_OUTPUT' };
return url.href;
};
const contentOf=(root,field,row)=>{
const textNodes=[...root.querySelectorAll(field.text_selector)],mediaNodes=[...root.querySelectorAll(field.media_selector)];
if(textNodes.length>1) return {error:'NON_UNIQUE_FIELD'};
if(mediaNodes.length>20) return {error:'READ_LIMIT'};
if(textNodes.some(node=>node.matches(excluded) || sensitive(node))) return {error:'SENSITIVE_OUTPUT'};
const textNode=textNodes[0],text=textNode && visible(textNode) ? textOf(textNode) || null : null;
if(textNode && visible(textNode) && !fullyViewport(textNode) && !completeOnly) return {error:'TARGET_NOT_IN_VIEWPORT'};
let incomplete=Boolean(completeOnly && (!fullyViewport(row) || !fullyViewport(root) || (textNode && visible(textNode) && !fullyViewport(textNode))));
const media=[];
for(const node of mediaNodes){
if(!['IMG','VIDEO'].includes(node.tagName) || sensitive(node)) return {error:'SENSITIVE_OUTPUT'};
if(node.closest('a')) return {error:'UNSAFE_CONTENT_MEDIA'};
for(let parent=node;parent;parent=parent.parentElement) if(/avatar|头像/i.test([parent.id,...parent.classList].join(' '))) return {error:'UNSAFE_CONTENT_MEDIA'};
const shown=visible(node),complete=shown && fullyViewport(node);
const loaded=node.tagName==='IMG' ? node.complete && node.naturalWidth>0 : node.readyState>=2 && node.videoWidth>0;
if(completeOnly && shown && !complete) incomplete=true;
if(!shown) continue;
if(!loaded){if(incomplete) continue;return {error:'CONTENT_MEDIA_NOT_READY'};}
if(!complete && !completeOnly) return {error:'TARGET_NOT_IN_VIEWPORT'};
const safeToken=value=>/^[A-Za-z][A-Za-z0-9_-]{0,95}$/.test(value || '') && !PRIVATE.test(value) && !/(?:\+?86[-\s]?)?1[3-9]\d{9}(?!\d)/.test(value);
let selector=safeToken(node.id) ? '#'+CSS.escape(node.id) : null;
for(const name of [...node.classList].filter(safeToken).slice(0,3)) if(!selector && root.querySelectorAll(node.tagName.toLowerCase()+'.'+CSS.escape(name)).length===1) selector=node.tagName.toLowerCase()+'.'+CSS.escape(name);
if(!selector){
const parts=[];
for(let current=node;current && current!==root && parts.length<8;current=current.parentElement){
const tag=current.tagName.toLowerCase(),siblings=[...current.parentElement.children].filter(child=>child.tagName===current.tagName);
parts.unshift(siblings.length>1 ? tag+':nth-of-type('+(siblings.indexOf(current)+1)+')' : tag);
if(current.parentElement===root){selector=':scope > '+parts.join(' > ');break;}
}
}
if(!selector || selector.length>1000) return {error:'UNSAFE_CONTENT_MEDIA'};
media.push({kind:node.tagName==='IMG' ? 'image' : 'video',selector});
}
if(!text && !media.length){
if(incomplete && mediaNodes.length) return {incomplete:true,value:null};
return {error:'REQUIRED_FIELD_MISSING'};
}
return {incomplete,value:{kind:text ? media.length ? 'mixed' : 'text' : 'media',text,media:media.slice(0,field.max_media ?? 4),media_truncated:media.length>(field.max_media ?? 4)}};
};
return elements.map((element, index) => {
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(innerWidth - 1, (Math.max(0, rect.left) + Math.min(innerWidth, rect.right)) / 2));
const y = Math.max(0, Math.min(innerHeight - 1, (Math.max(0, rect.top) + Math.min(innerHeight, rect.bottom)) / 2));
const hit = document.elementFromPoint(x, y);
const searchDescription = [element.getAttribute('type'), element.id, element.getAttribute('name'), element.getAttribute('aria-label'),
element.getAttribute('placeholder'), element.getAttribute('title'),
element.id ? document.querySelector(`label[for="${CSS.escape(element.id)}"]`)?.textContent : ''].filter(Boolean).join(' ');
const form = element.form || element.closest('form');
const searchForm = form && (form.getAttribute('method') || 'get').toLowerCase() === 'get' &&
(/search|query|搜索|查询|检索/i.test([form.getAttribute('action'), form.getAttribute('role')].join(' ')) ||
[...form.querySelectorAll('button,input[type=submit]')].some(button => /search|搜索|查询|检索/i.test([button.textContent, button.getAttribute('aria-label'), button.getAttribute('value')].join(' '))));
const searchInput = element.matches('input[type=text],input[type=search],input:not([type]),textarea') && !sensitive(element) &&
!/comment|reply|message|私信|评论|回复|发言|留言|发送/i.test(searchDescription) &&
(!form || (form.getAttribute('method') || 'get').toLowerCase() === 'get') &&
(/search|query|keyword|搜索|查询|检索/i.test(searchDescription) || Boolean(searchForm));
const state = { index, tag: element.tagName.toLowerCase(), role:element.getAttribute('role'), sensitive: sensitive(element), forbidden: element.matches(excluded), search_input: searchInput,
stable_label:searchInput ? stableFieldLabel(element) : '',
readonly_root: !sensitive(element) && !element.matches(`${excluded},button,form`) && !element.closest('form'),
visible: visible(element), in_viewport: viewport(element), unobscured: Boolean(hit && (hit === element || element.contains(hit))),
fully_in_viewport:fullyViewport(element),
busy:element.closest('[aria-busy="true"]')!==null || [...element.querySelectorAll('[aria-busy="true"],[role="progressbar"]')].some(visible),
text: textOf(element), href: hrefOf(element),
label: clean([element.textContent, element.getAttribute('aria-label'), element.getAttribute('title')].filter(Boolean).join(' ')),
form_method: element.closest('form')?.getAttribute('method') || 'get' };
if(nativeProfile){
const role=element.getAttribute('role'),editable=element.isContentEditable || Boolean(element.closest('[contenteditable]')?.isContentEditable);
const inForm=Boolean(element.closest('form'));
const interactiveAncestor=Boolean(element.parentElement?.closest('button,a,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"],[role="menuitem"]'));
state.native_context={
search_input:searchInput && !editable && (!form || (form.getAttribute('method') || 'get').toLowerCase()==='get') &&
['search-input-in-feeds','search-input-ai'].includes(element.id) && !interactiveAncestor && [null,'searchbox','textbox','combobox'].includes(role),
note_link:element instanceof HTMLAnchorElement && !inForm && !editable && !interactiveAncestor && [null,'link'].includes(role) &&
!element.hasAttribute('download') && ['', '_self'].includes(element.getAttribute('target') || ''),
read_root:state.readonly_root && !editable && !['a','body','html'].includes(state.tag) &&
!['button','link','checkbox','radio','switch','combobox','textbox','menuitem'].includes(role) && !interactiveAncestor,
};
if(navigationProfile){
// Fixed worker metadata only. Caller input cannot assert an anchor is
// a profile/navigation link, and editable/nested controls stay out.
const safeAnchor=element instanceof HTMLAnchorElement && !inForm && !editable && !interactiveAncestor && [null,'link'].includes(role) &&
!element.hasAttribute('download') && ['', '_self','_blank'].includes(element.getAttribute('target') || '');
let destination=null;
try{
const url=new URL(element.href);
if(url.origin===origin && !url.username && !url.password && !url.port) destination=url;
}catch{}
state.native_context.note_link=Boolean(safeAnchor && destination && /^\/(?:explore|search_result)\/[a-fA-F0-9]{24}$/.test(destination.pathname));
state.native_context.profile_link=Boolean(safeAnchor && destination && /^\/user\/profile\/[a-fA-F0-9]{24}$/.test(destination.pathname));
state.native_context.navigation_link=Boolean(safeAnchor && destination && ['/explore','/search_result','/search_result_ai','/notification'].includes(destination.pathname));
}
}
if (target) state.match_value = target.field === 'text' ? state.text : target.field === 'href' ? state.href : element.getAttribute(target.attribute);
if (untilSelector) {
const matches=[...element.querySelectorAll(untilSelector)];
state.until={count:matches.length,complete_count:0};
if(matches.length>200) state.until.error='READ_LIMIT';
else if(matches.some(child=>sensitive(child) || child.matches(`${excluded},button,form`) || child.closest('form'))) state.until.error='SENSITIVE_OUTPUT';
else state.until.complete_count=matches.filter(child=>fullyViewport(child,element)).length;
}
if (fields) {
state.values = Object.create(null);
for (const field of fields) {
const matches = field.selector ? Array.from(element.querySelectorAll(field.selector)) : [element];
if (matches.length > 1) { state.error = 'NON_UNIQUE_FIELD'; break; }
const child = matches[0];
let value = null;
if (child && (child.matches(excluded) || sensitive(child))) { state.error = 'SENSITIVE_OUTPUT'; break; }
const inView=child && viewport(child);
const actualVisible=child && visible(child);
if(field.source==='content'){
if(!child || !actualVisible) {state.error='REQUIRED_FIELD_MISSING';break;}
if(child.matches('button,form') || child.closest('form')) {state.error='SENSITIVE_OUTPUT';break;}
const content=contentOf(child,field,element);
if(content.error){state.error=content.error;break;}
if(content.incomplete) state.incomplete_fields=true;
state.values[field.name]=content.value;
continue;
}
if (child && actualVisible && (inView || (completeOnly && field.required))) value = field.source === 'text' ? textOf(child) : field.source === 'href' ? hrefOf(child) : child.getAttribute(field.attribute);
if (value && typeof value === 'object') { state.error = value.error; break; }
if (field.required && (value === null || clean(value) === '')) { state.error = 'REQUIRED_FIELD_MISSING'; break; }
if (completeOnly && field.required && actualVisible && !fullyViewport(child)) {
state.incomplete_fields=true;
state.values[field.name]=null;
continue;
}
if (!inView) value=null;
state.values[field.name] = value === null ? null : clean(value);
}
}
return state;
});
}
async function inspectLocator(session, selector, params) {
// CSS is validated before the locator is constructed so Playwright's other
// selector engines cannot be reached through this interface.
return session.page.locator(selector).evaluateAll(inspectElements, {
nativeProfile:isNativeUiProfile(session.executionProfile),navigationProfile:session.executionProfile===NAVIGATION_UI_PROFILE,
origin: session.origin, target: params.target, fields: params.kind === 'extract' ? params.fields : undefined,
completeOnly:params.complete_only===true,untilSelector:params.kind==='scroll' ? params.until_selector : undefined,
});
}
function resolveTarget(states, target) {
if (!target) return states;
if (typeof target.value !== 'string' || !target.value.trim()) throw new WorkerError('动态目标缺少实际匹配值', 'MISSING_PARAMETER');
const matches = states.filter(state => typeof state.match_value === 'string' && state.match_value === target.value.trim());
if (matches.length !== 1) throw new WorkerError(`动态目标当前匹配 ${matches.length} 个对象,必须唯一核对`, 'NON_UNIQUE_TARGET');
return matches;
}
function requireOperable(state, { viewport = true, obstruction = false, data = false } = {}) {
if (state.sensitive || (data && state.forbidden)) throw new WorkerError('不能操作或读取敏感元素、脚本及表单值', 'SENSITIVE_OUTPUT');
if (!state.visible || (viewport && !state.in_viewport)) throw new WorkerError('目标当前不可见或不在视口,请先显式滚动并重新采集', 'TARGET_NOT_IN_VIEWPORT');
if (obstruction && !state.unobscured) throw new WorkerError('目标被其他元素遮挡,不能执行操作', 'TARGET_OBSCURED');
}
function renderExtractValue(field,value){
if(value===null) return null;
if(field?.source==='content') return {kind:value.kind,text:value.text===null ? null : redactText(value.text),media:value.media.map(item=>({...item})),media_truncated:value.media_truncated};
return field?.source==='href' ? redactUrl(value) : redactText(value);
}
function selectExtractRows(states, params, { completeRows = false } = {}) {
if(states.some(state=>state.row_limit)) throw new WorkerError('单次最多检查 500 个候选对象,请缩小范围','READ_LIMIT');
const visibleStates=states.filter(state=>state.visible && state.in_viewport),selected=[];
let skippedIncomplete=0;
for(const state of visibleStates){
if(selected.length>=params.limit) break;
requireOperable(state,{data:true});
if(params.row_selector && state.readonly_root!==true) throw new WorkerError('容器内行不能属于交互或表单区域','SENSITIVE_OUTPUT');
if(state.error) throw new WorkerError(`结构化字段读取失败:${state.error}`,state.error);
if(params.complete_only===true && (state.incomplete_fields || (completeRows && !state.fully_in_viewport))){skippedIncomplete+=1;continue;}
selected.push(state);
}
const items=selected.map(state=>Object.fromEntries(Object.entries(state.values).map(([key,value])=>
[key,renderExtractValue(params.fields.find(field=>field.name===key),value)])));
return {items,selected,visibleStates,skippedIncomplete,truncated:states.length>selected.length || items.some(item=>params.fields.some(field=>field.source==='content' && item[field.name]?.media_truncated))};
}
async function extractInContainer(session, params, { observed, currentObserved, enableReadonlyPolicy, trace, initialBlockedWrites, noteBinding, batchBudgetMs=15_000 }) {
const page=session.page,sourceUrl=page.url(),rootLocator=page.locator(params.selector);
if(!observed || observed.sensitive || observed.unique!==true || !currentObserved || currentObserved.unique!==true || currentObserved.tag!==observed.tag || await rootLocator.count()!==1) {
throw new WorkerError('容器提取必须使用最近观察到且当前仍唯一的普通容器','UNOBSERVED_CURRENT_SELECTOR');
}
const root=await rootLocator.elementHandle({timeout:TIMEOUT});
const scrollSelector=params.batch?.scroll_selector;
let scrollRoot;
if(scrollSelector){
const scrollObserved=trace.before.elements.find(element=>element.selector===scrollSelector);
const currentScroll=currentObserved.selector===scrollSelector ? currentObserved : await describeCurrentSelector(page,scrollSelector,scrollObserved);
if(!scrollObserved || scrollObserved.sensitive || scrollObserved.unique!==true || !scrollObserved.scroll_y || !currentScroll || currentScroll.unique!==true || !currentScroll.scroll_y || await page.locator(scrollSelector).count()!==1) {
throw new WorkerError('批次滚动根必须为最近观察到且当前唯一的普通容器','UNOBSERVED_CURRENT_SELECTOR');
}
scrollRoot=await page.locator(scrollSelector).elementHandle({timeout:TIMEOUT});
}
const rootState=async()=>{
requireBoundNotePage(session,noteBinding);
if(session.page!==page || page.isClosed() || page.url()!==sourceUrl) throw new WorkerError('批次读取来源已变化,停止混合不同来源','SOURCE_CHANGED');
sessionFor(session.taskId);
requireOrigin(session,page.url());
if(!root || !await root.evaluate((element,selector)=>element.isConnected && document.querySelectorAll(selector).length===1 && document.querySelector(selector)===element,params.selector)) {
throw new WorkerError('读取容器对象已替换,必须重新观察','STALE_TARGET');
}
const states=await root.evaluate(inspectElements,{origin:session.origin,nativeProfile:isNativeUiProfile(session.executionProfile)});
requireOperable(states[0],{data:true});
if(states[0].readonly_root!==true) throw new WorkerError('读取容器不能是敏感、交互或表单区域','SENSITIVE_OUTPUT');
if(isNativeUiProfile(session.executionProfile) && !nativeReadRootsValid(states)) throw new WorkerError('新模式不读取可交互或可编辑区域','SENSITIVE_OUTPUT');
if(scrollRoot){
const stable=await scrollRoot.evaluate((element,{selector,rootSelector,rowSelector})=>{
const container=document.querySelector(rootSelector),matches=document.querySelectorAll(selector),rows=container?.querySelectorAll(rowSelector);
return element.isConnected && matches.length===1 && matches[0]===element && container?.contains(element) && rows.length<=500 && [...rows].every(row=>element.contains(row));
},{selector:scrollSelector,rootSelector:params.selector,rowSelector:params.row_selector});
if(!stable) throw new WorkerError('批次滚动容器或行的所属范围已变化','SOURCE_CHANGED');
const scrolling=await scrollRoot.evaluate(inspectElements,{origin:session.origin,nativeProfile:isNativeUiProfile(session.executionProfile)});
requireOperable(scrolling[0],{data:true});
if(scrolling[0].readonly_root!==true) throw new WorkerError('批次滚动根不能是敏感、交互或表单区域','SENSITIVE_OUTPUT');
if(isNativeUiProfile(session.executionProfile) && !nativeReadRootsValid(scrolling)) throw new WorkerError('新模式不滚动可交互或可编辑区域','SENSITIVE_OUTPUT');
if(!await scrollRoot.evaluate(element=>/(?:auto|scroll)/.test(getComputedStyle(element).overflowY) && element.clientHeight>0)) {
throw new WorkerError('批次滚动根不再是已观察的纵向滚动容器','STALE_TARGET');
}
}
return states[0];
};
const inspectRows=()=>root.evaluate(inspectElements,{origin:session.origin,rowSelector:params.row_selector,fields:params.fields,completeOnly:params.complete_only===true});
const guardRequests=()=>{
if(session.blockedWrites>initialBlockedWrites) throw new WorkerError('页面尝试产生写请求,已阻止并停止本次只读步骤','WRITE_REQUEST_BLOCKED');
};
const initialRoot=await rootState();
enableReadonlyPolicy([initialRoot]);
if(params.batch){
const startedMs=Date.now(),policyEnd=session.platformRequestPolicy?.expiresAt;
const budgetMs=Math.max(1,Math.min(batchBudgetMs,15_000,Number.isFinite(policyEnd) ? policyEnd-startedMs : 15_000));
const reserve=Math.min(2_000,Math.max(1,Math.floor(budgetMs/4)));
const deadline=startedMs+budgetMs-reserve,items=[],seen=new Map();
let duplicateCount=0,skippedIncomplete=0,stopReason='batch_budget';
let committedBatches=0;
trace.batch_evidence=[];
const output=reason=>({items:structuredClone(items),count:items.length,truncated:true,source_url:noteBinding?.opened ? noteBinding.source_url : redactUrl(sourceUrl),
coverage:!items.length ? 'empty' : items.length===params.limit ? 'limit_reached' : 'bounded',skipped_incomplete_count:skippedIncomplete,
batch_summary:{batches_read:committedBatches,duplicates_removed:duplicateCount,stop_reason:reason}});
return withBatchDeadline(async control=>{
for(let index=1;index<=params.batch.max_batches;index+=1){
if(index>1 && Date.now()>=deadline){stopReason='time_budget';break;}
control.check();
await rootState();
await refreshLoginDetection(session);
sessionFor(session.taskId);
const batchStates=await inspectRows();
const batch=selectExtractRows(batchStates,{...params,limit:100},{completeRows:true});
control.check();
let added=0,duplicates=0;
const stagedItems=[...items],stagedSeen=new Map(seen);
let conflict=false;
for(const item of batch.items){
const id=item[params.batch.dedupe_field];
if(typeof id!=='string' || !id.trim() || id.includes('[已隐藏]') || id.includes('[手机号已隐藏]')) throw new WorkerError('批次业务标识为空或不适合稳定去重','UNSAFE_DEDUPE_ID');
if(stagedSeen.has(id)){
duplicates+=1;
if(JSON.stringify(stagedSeen.get(id))!==JSON.stringify(item)) conflict=true;
}else if(stagedItems.length<params.limit){stagedSeen.set(id,item);stagedItems.push(item);added+=1;}
}
control.check();
trace.phase='batch_capture';
const snapshot=await captureSession(session,`有界读取第 ${index} 批`);
trace.batch_evidence.push({batch_index:index,added_count:added,duplicate_count:duplicates,capture:snapshot});
control.check();
trace.phase='execute';
await rootState();
await refreshLoginDetection(session);
sessionFor(session.taskId);
guardRequests();
control.check();
if(conflict) throw new WorkerError('同一业务标识在批次间出现不同字段值,停止合并','DUPLICATE_ID_CONFLICT');
items.splice(0,items.length,...stagedItems);seen.clear();for(const [id,item] of stagedSeen)seen.set(id,item);
duplicateCount+=duplicates;skippedIncomplete+=batch.skippedIncomplete;committedBatches+=1;
trace.batch_partial_result=output('time_budget');
if(items.length>=params.limit){stopReason='limit_reached';break;}
if(Date.now()>=deadline){stopReason='time_budget';break;}
const boundary=await scrollRoot.evaluate(element=>element.scrollTop+element.clientHeight>=element.scrollHeight-1);
if(boundary){
const previousHeight=await scrollRoot.evaluate(element=>element.scrollHeight),pollEnd=Math.min(deadline,Date.now()+1_000);
const fingerprint=states=>JSON.stringify(states.map(state=>({index:state.index,values:state.values,visible:state.in_viewport})));
const initialRows=fingerprint(batchStates);
let rowsChanged=false;
while(Date.now()<pollEnd){
await page.waitForTimeout(Math.min(100,pollEnd-Date.now()));
await rootState();
guardRequests();
control.check();
rowsChanged=fingerprint(await inspectRows())!==initialRows;
if(rowsChanged || await scrollRoot.evaluate(element=>element.scrollHeight)!==previousHeight) break;
}
if(rowsChanged && index<params.batch.max_batches) continue;
if(await scrollRoot.evaluate(element=>element.scrollTop+element.clientHeight>=element.scrollHeight-1)) {stopReason='boundary';break;}
}
if(index===params.batch.max_batches) break;
await rootState();
control.check();
await scrollRoot.evaluate((element,pixels)=>element.scrollBy({top:pixels,behavior:'instant'}),params.batch.pixels);
await page.waitForTimeout(80);
guardRequests();
}
return output(stopReason);
},{budgetMs,startedMs,cleanupReserveMs:reserve,onTimeout:()=>{
trace.batch_timeout=true;
trace.batch_request_blocked=session.blockedWrites>initialBlockedWrites;
session.closed=true;session.ready=false;session.lastCapture=null;
endPlatformRequestPolicy(session.platformRequestPolicy);
session.platformRequestPolicy=null;session.platformRequestPage=null;session.automating=false;
const closeStart=performance.now();
session.context.once('close',()=>{trace.batch_context_closed=true;});
return closeContext(session).finally(()=>{trace.batch_close_ms=Math.max(0,Math.round(performance.now()-closeStart));});
}});
}
const rows=await inspectRows();
if(rows.length===0 && params.allow_empty===true){
const proof=async()=>{
const state=await rootState();
await refreshLoginDetection(session);
sessionFor(session.taskId);
if(state.busy || !state.fully_in_viewport) throw new WorkerError('空态容器仍忙碌或未完整可见','EMPTY_STATE_UNCONFIRMED');
if((await inspectRows()).length!==0) throw new WorkerError('行集合已变化,不能确认空态','EMPTY_STATE_UNCONFIRMED');
const markers=await root.evaluate(inspectElements,{origin:session.origin,rowSelector:params.empty_state.selector});
if(markers.length!==1 || markers[0].readonly_root!==true || !markers[0].fully_in_viewport || markers[0].busy || markers[0].text!==params.empty_state.text.trim()) {
throw new WorkerError('未观察到唯一、完整可见且文本精确一致的普通空态','EMPTY_STATE_UNCONFIRMED');
}
if(redactText(markers[0].text)!==markers[0].text) throw new WorkerError('空态证据不能包含敏感信息','SENSITIVE_OUTPUT');
};
await proof();
await page.waitForTimeout(180);
await proof();
guardRequests();
return {items:[],count:0,truncated:false,source_url:redactUrl(sourceUrl),coverage:'empty',
...(params.complete_only===true ? {skipped_incomplete_count:0} : {}),
empty_state:{confirmed:true,selector:params.empty_state.selector,text:params.empty_state.text.trim()}};
}
const selection=selectExtractRows(rows,params);
return {items:selection.items,count:selection.items.length,truncated:selection.truncated,source_url:redactUrl(sourceUrl),
coverage:!selection.items.length ? 'empty' : selection.truncated && selection.items.length===params.limit ? 'limit_reached' : 'bounded',
...(params.complete_only===true ? {skipped_incomplete_count:selection.skippedIncomplete} : {})};
}
const seekStopReason=code=>({SCROLL_BOUNDARY_BEFORE_TARGET:'boundary',SCROLL_BUDGET_EXHAUSTED:'scroll_budget',SCROLL_TARGET_TIMEOUT:'time_budget',NON_UNIQUE_TARGET:'ambiguous',TARGET_OBSCURED:'obscured',STALE_TARGET:'target_changed',UNOBSERVED_CURRENT_SELECTOR:'target_changed',SCROLL_SOURCE_CHANGED:'source_changed',SENSITIVE_OUTPUT:'unsafe_target',UNSAFE_OUTPUT:'unsafe_target',WRITE_ACTION_BLOCKED:'unsafe_target',ACTION_MISMATCH:'unsafe_target',READ_LIMIT:'unsafe_target',NOTE_BINDING_MISMATCH:'binding_mismatch',WRITE_REQUEST_BLOCKED:'blocked_request'}[code] || 'session_unavailable');
export const snapshotSeekResults=results=>results.map(item=>({...item,movements:item.movements.map(movement=>({...movement}))}));
const validViewport=value=>Boolean(value && Number.isFinite(value.width) && value.width>0 && Number.isFinite(value.height) && value.height>0 &&
Number.isFinite(value.scroll_x) && Number.isFinite(value.scroll_y) && value.scroll_y>=0);
async function seekScroll(session,params,{observed,currentObserved,observedViewport,currentViewport,trace,enableReadonlyPolicy,initialBlockedWrites,noteBinding}) {
const page=session.page,source=page.url(),rootLocator=page.locator(params.selector),deadline=Date.now()+params.timeout_ms;
const viewport=params.scroll_scope==='viewport';
const summary=trace.seek_result={scrolls:0,movements:[],target_reached:false,stop_reason:'time_budget'};
if(!observed || observed.sensitive || observed.unique!==true || !currentObserved || currentObserved.sensitive || currentObserved.unique!==true || currentObserved.tag!==observed.tag ||
['html','body'].includes(currentObserved.tag) || (viewport ? observed.kind!=='read' || currentObserved.kind!=='read' || !validViewport(observedViewport) || !validViewport(currentViewport) :
typeof observed.scroll_y?.can_scroll!=='boolean' || typeof currentObserved.scroll_y?.can_scroll!=='boolean')) {
throw new WorkerError('有界目标滚动必须使用已观察的唯一普通容器及对应滚动元信息','UNOBSERVED_CURRENT_SELECTOR');
}
if(typeof params.until_target.value!=='string' || !params.until_target.value.trim()) throw new WorkerError('有界目标尚未提供实际匹配值','MISSING_PARAMETER');
const root=await rootLocator.elementHandle({timeout:TIMEOUT});
if(!root) throw new WorkerError('滚动容器已离开页面','STALE_TARGET');
const scroller=viewport ? (await page.evaluateHandle(()=>document.scrollingElement)).asElement() : null;
if(viewport && !scroller) throw new WorkerError('页面没有可固定的实际视口滚动对象','ACTION_MISMATCH');
const checkViewport=async()=>{
const valid=await scroller.evaluate(element=>element.isConnected && element.ownerDocument===document && document.scrollingElement===element &&
(element===document.documentElement || element===document.body) && Number.isFinite(innerWidth) && innerWidth>0 && Number.isFinite(innerHeight) && innerHeight>0 &&
Number.isFinite(scrollX) && Number.isFinite(scrollY) && scrollY>=0 && Number.isFinite(element.scrollTop) && element.scrollTop>=0 &&
Number.isFinite(element.scrollHeight) && Number.isFinite(element.clientHeight) && element.clientHeight>0 && element.scrollHeight>=element.clientHeight);
if(!valid) throw new WorkerError('视口滚动对象或实际元信息已变化','STALE_TARGET');
};
const check=async()=>{
if(session.page!==page || page.url()!==source) throw new WorkerError('有界滚动过程中页面来源已变化','SCROLL_SOURCE_CHANGED');
await refreshLoginDetection(session);
sessionFor(session.taskId);
requireBoundNotePage(session,noteBinding);
if(session.blockedWrites>initialBlockedWrites) throw new WorkerError('页面尝试产生未知写请求,停止本次有界定位','WRITE_REQUEST_BLOCKED');
const same=await root.evaluate((element,selector)=>element.isConnected && document.querySelectorAll(selector).length===1 && document.querySelector(selector)===element,params.selector);
if(!same) throw new WorkerError('滚动容器对象已经变化','STALE_TARGET');
if(viewport) await checkViewport();
const roots=await root.evaluate(inspectElements,{origin:session.origin,nativeProfile:isNativeUiProfile(session.executionProfile)});
if(roots.length!==1 || roots[0].readonly_root!==true) throw new WorkerError('滚动根不是普通读取区域','SENSITIVE_OUTPUT');
if(isNativeUiProfile(session.executionProfile) && !nativeReadRootsValid(roots)) throw new WorkerError('新模式不滚动可交互或可编辑区域','SENSITIVE_OUTPUT');
requireOperable(roots[0],{data:true});
if(!viewport && !await root.evaluate(element=>/^(?:auto|scroll)$/.test(getComputedStyle(element).overflowY))) throw new WorkerError('有界定位必须使用实际滚动容器','ACTION_MISMATCH');
enableReadonlyPolicy(roots);
const candidates=await root.evaluate(inspectElements,{origin:session.origin,rowSelector:params.until_target.selector,target:params.until_target});
if(candidates.some(state=>state.row_limit)) throw new WorkerError('有界目标候选超过500个','READ_LIMIT');
const matches=candidates.filter(state=>typeof state.match_value==='string' && state.match_value===params.until_target.value.trim());
if(matches.length>1) throw new WorkerError('有界目标当前存在多个精确匹配,停止定位','NON_UNIQUE_TARGET');
if(!matches.length) return false;
const target=matches[0];
if(target.sensitive || target.forbidden) throw new WorkerError('有界目标不是普通可操作元素','SENSITIVE_OUTPUT');
if(!['a','button'].includes(target.tag) && target.role!=='button') throw new WorkerError('有界目标不是链接或读取按钮','ACTION_MISMATCH');
if(target.href && typeof target.href!=='string') throw new WorkerError('有界目标来源不是允许的同站链接','UNSAFE_OUTPUT');
if(DANGEROUS.test(target.label) || DANGEROUS.test(target.href || '') || (target.tag!=='a' && (!READ_ONLY_BUTTON.test(target.label) || target.form_method.toLowerCase()!=='get'))) {
throw new WorkerError('有界定位仅支持普通同站链接或明确读取按钮','WRITE_ACTION_BLOCKED');
}
if(noteBinding){
const actual=boundNoteSource(target.href,session.origin);
if(target.tag!=='a' || !actual || actual.note_id!==noteBinding.note_id) throw new WorkerError('有界定位目标与已选笔记编号不一致','NOTE_BINDING_MISMATCH');
}
if(!target.fully_in_viewport) return false;
requireOperable(target,{data:true,obstruction:true});
return true;
};
const poll=async(ms)=>{
const until=Math.min(deadline,Date.now()+ms);
do {
if(await check()) return true;
if(Date.now()>=until) return false;
await page.waitForTimeout(Math.min(100,Math.max(1,until-Date.now())));
}while(true);
};
let reached=await check();
for(let index=0;!reached && index<params.max_scrolls;index+=1){
if(Date.now()>=deadline) throw new WorkerError('有界定位超过本轮时间预算','SCROLL_TARGET_TIMEOUT');
// Recheck the pinned root and candidate immediately before each fixed movement.
if(await check()){reached=true;break;}
const movement=viewport ? await scroller.evaluate((element,{root,selector,direction,pixels})=>{
if(!element.isConnected || document.scrollingElement!==element || !root.isConnected || document.querySelectorAll(selector).length!==1 || document.querySelector(selector)!==root) return {error:true};
const before=element.scrollTop;
window.scrollBy({top:direction==='down' ? pixels : -pixels,behavior:'instant'});
return {before,after:element.scrollTop,scroll_height:element.scrollHeight,client_height:element.clientHeight};
},{root,selector:params.selector,direction:params.direction,pixels:params.pixels}) : await root.evaluate((element,{direction,pixels})=>{
const before=element.scrollTop;
element.scrollBy({top:direction==='down' ? pixels : -pixels,behavior:'instant'});
return {before,after:element.scrollTop,scroll_height:element.scrollHeight,client_height:element.clientHeight};
},params);
if(movement.error) throw new WorkerError('滚动前视口或普通目标根已经变化','STALE_TARGET');
summary.movements.push(movement);summary.scrolls=summary.movements.length;
await page.waitForTimeout(Math.min(80,Math.max(1,deadline-Date.now())));
reached=await check();
if(!reached && movement.before===movement.after){
reached=await poll(1000);
if(!reached) throw new WorkerError('已到滚动边界,限定等待后目标仍未完整可操作','SCROLL_BOUNDARY_BEFORE_TARGET');
}
}
if(!reached) reached=await poll(1000);
if(!reached) throw new WorkerError('有界定位尚未找到完整目标',Date.now()>=deadline ? 'SCROLL_TARGET_TIMEOUT' : 'SCROLL_BUDGET_EXHAUSTED');
summary.target_reached=true;summary.stop_reason='target_reached';
return {direction:params.direction,pixels:params.pixels,requested_max_scrolls:params.max_scrolls,scrolls:summary.scrolls,movements:summary.movements,
brought_into_view:false,target_reached:true,stop_reason:'target_reached',timeout_ms:params.timeout_ms,source_url:redactUrl(page.url())};
}
// Preflight the already existing policy before a wait can enable automation.
// A missing observed collection is a learning precondition, not permission to
// carry search authority into another step or to widen network exceptions.
export function preflightReadonlyWait(context) {
if(context.kind!=='wait' || context.sessionOrigin!=='https://www.xiaohongshu.com') return null;
if(Array.isArray(context.actualRoots) && context.actualRoots.some(root=>root?.sensitive || root?.forbidden)) {
throw new WorkerError('不能等待敏感元素或表单区域','SENSITIVE_OUTPUT');
}
const policy=beginReadonlyRequestPolicy(context);
if(!policy) throw new WorkerError('等待目标尚未满足已观察普通根与只读策略前置条件,请先观察并学习加载过渡','READONLY_WAIT_NOT_READY');
return policy;
}
async function prepareScopedWait(session,params,previous,noteBinding) {
const page=session.page,source=page.url(),selector=params.wait_scope;
const observed=previous.elements.find(element=>element.selector===selector);
if(!observed || observed.kind!=='read' || observed.unique!==true || observed.sensitive || !observed.in_viewport || /^(?:body|html)$/i.test(observed.tag || '') ||
await page.locator(selector).count()!==1) throw new WorkerError('范围等待须使用最新观察中唯一可见的普通容器','UNOBSERVED_CURRENT_SELECTOR');
const current=await describeCurrentSelector(page,selector,observed);
if(!current || current.unique!==true || current.kind!=='read' || current.tag!==observed.tag) throw new WorkerError('范围等待根已变化,请重新观察','UNOBSERVED_CURRENT_SELECTOR');
const root=await page.locator(selector).elementHandle({timeout:TIMEOUT});
const check=async()=>{
sessionFor(session.taskId);requireBoundNotePage(session,noteBinding);
if(session.page!==page || page.isClosed() || page.url()!==source) throw new WorkerError('范围等待页面来源已变化','SOURCE_CHANGED');
if(!root || !await root.evaluate((element,css)=>element.isConnected && document.querySelectorAll(css).length===1 && document.querySelector(css)===element,selector)) {
throw new WorkerError('范围等待的固定容器已替换','SOURCE_CHANGED');
}
const states=await root.evaluate(inspectElements,{origin:session.origin,fixedRootSelector:selector,fixedSourceUrl:source,nativeProfile:isNativeUiProfile(session.executionProfile)});
if(states.some(state=>state.scope_changed) || session.page!==page || page.url()!==source) throw new WorkerError('范围等待的固定容器或来源已变化','SOURCE_CHANGED');
requireOperable(states[0],{data:true});
if(states[0].readonly_root!==true) throw new WorkerError('范围等待根不能是表单或交互区域','SENSITIVE_OUTPUT');
if(isNativeUiProfile(session.executionProfile) && !nativeReadRootsValid(states)) throw new WorkerError('新模式等待根不能是可交互或可编辑区域','SENSITIVE_OUTPUT');
return states[0];
};
await check();
return {observed,check,async rows(){
const rows=await root.evaluate(inspectElements,{origin:session.origin,rowSelector:params.selector,fixedRootSelector:selector,fixedSourceUrl:source});
if(rows.some(row=>row.scope_changed)) throw new WorkerError('范围等待的固定容器或来源已变化','SOURCE_CHANGED');
if(rows.some(row=>row.row_limit) || rows.length>100) throw new WorkerError('范围等待最多核对100个集合对象','READ_LIMIT');
if(rows.some(row=>row.sensitive || row.forbidden || row.readonly_root!==true)) throw new WorkerError('范围等待集合包含敏感或交互对象','SENSITIVE_OUTPUT');
return rows;
}};
}
async function waitInScope(session,params,scope,{trace,initialBlockedWrites,noteBinding}) {
const startedMs=Date.now(),policyEnd=session.platformRequestPolicy?.expiresAt;
const budgetMs=Math.max(1,Math.min(params.timeout_ms,Number.isFinite(policyEnd) ? policyEnd-startedMs : params.timeout_ms));
const guard=()=>{if(session.blockedWrites>initialBlockedWrites) throw new WorkerError('范围等待期间已阻止未知请求','WRITE_REQUEST_BLOCKED');};
return withWaitDeadline(async control=>{
for(;;){
control.check();await scope.check();
await refreshLoginDetection(session);sessionFor(session.taskId);
const rows=await scope.rows();
await scope.check();guard();control.check();
if(rows.filter(row=>row.visible && row.in_viewport && !row.busy).length>=params.min_count){
return {satisfied:true,state:'visible',selector:params.selector,wait_scope:params.wait_scope,min_count:params.min_count,timeout_ms:params.timeout_ms};
}
await session.page.waitForTimeout(80);
}
},{budgetMs,startedMs,cleanupReserveMs:Math.min(2_000,Math.max(1,Math.floor(budgetMs/4))),onTimeout:()=>{
trace.wait_timeout=true;trace.wait_request_blocked=session.blockedWrites>initialBlockedWrites;
session.closed=true;session.ready=false;session.lastCapture=null;
endPlatformRequestPolicy(session.platformRequestPolicy);
session.platformRequestPolicy=null;session.platformRequestPage=null;session.automating=false;
const closeStart=performance.now();
session.context.once('close',()=>{trace.wait_context_closed=true;});
return closeContext(session).finally(()=>{trace.wait_close_ms=Math.max(0,Math.round(performance.now()-closeStart));});
}});
}
async function performActionSession(session, params, { captureBefore = true, trace, noteBinding, batchBudgetMs=15_000 } = {}) {
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
const kind = params.kind;
validateAction(params);
requireBoundNotePage(session,noteBinding);
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 (kind === 'wait' && params.state === 'hidden' && selector !== 'body' && !session.observedSelectors.has(selector)) {
throw new WorkerError('消失等待需要本会话已采集过的普通元素', 'UNOBSERVED_SELECTOR');
}
const flexible = ['wait', 'extract'].includes(kind) || Boolean(params.target) || (kind === 'scroll' && selector === 'body');
if (!flexible && (!observed || observed.sensitive)) throw new WorkerError('定位必须来自最近一次页面采集的可用元素', 'UNOBSERVED_SELECTOR');
if (kind === 'fill' && observed?.kind !== 'fill') throw new WorkerError('该元素不是可填写的普通输入框', 'ACTION_MISMATCH');
if (kind === 'press' && (observed?.kind !== 'fill' || !observed.search_input)) throw new WorkerError('Enter 仅适用于已观察的普通搜索输入框,不能提交评论或消息', 'WRITE_ACTION_BLOCKED');
if (kind === 'click' && !params.target && observed?.kind !== 'click') throw new WorkerError('该元素不是已观察到的链接或按钮', 'ACTION_MISMATCH');
let locator = session.page.locator(selector);
const count = params.wait_scope ? 0 : await locator.count();
if (kind !== 'wait' && ((!count && kind !== 'extract') || (!['read', 'extract'].includes(kind) && !params.target && count !== 1))) {
throw new WorkerError(`定位当前匹配 ${count} 个元素,操作要求唯一目标`, 'NON_UNIQUE_TARGET');
}
if (kind === 'read' && count > 200) throw new WorkerError('单次读取最多 200 个对象,请缩小定位范围', 'READ_LIMIT');
if (count > 500) throw new WorkerError('单次最多检查 500 个候选对象,请缩小范围', 'READ_LIMIT');
trace.phase = 'inspect_current';
const currentObserved = params.wait_scope ? null : await describeCurrentSelector(session.page,selector,observed);
const currentViewport=kind==='scroll' && params.scroll_scope==='viewport' ? await session.page.evaluate(()=>({width:innerWidth,height:innerHeight,scroll_x:scrollX,scroll_y:scrollY})) : null;
if (!flexible && !currentObserved) throw new WorkerError('本次页面采集未返回该定位,请重新采集并核对当前控件', 'UNOBSERVED_CURRENT_SELECTOR');
const searchAction = ['fill','press'].includes(kind) && observed?.search_input===true;
const sameStableSearch = searchAction && currentObserved?.search_input===true && observed.unique===true && currentObserved.unique===true &&
currentObserved.tag===observed.tag && typeof observed.stable_label==='string' && observed.stable_label.length>0 &&
currentObserved.stable_label===observed.stable_label;
if (!flexible && ((!['read', 'scroll'].includes(kind) && (currentObserved.label !== observed.label || currentObserved.tag !== observed.tag) && !sameStableSearch) ||
(searchAction && observed.stable_label && !sameStableSearch))) {
throw new WorkerError('目标语义已变化,请重新采集后确认', 'STALE_TARGET');
}
if(captureBefore) trace.phase = 'capture_before';
const before = trace.before = captureBefore ? await captureSession(session) : previous;
trace.phase = 'resolve_target';
requireBoundNotePage(session,noteBinding);
let states = params.wait_scope ? [] : resolveTarget(await inspectLocator(session, selector, params.row_selector ? {...params,kind:'read'} : params), params.target);
requireBoundNotePage(session,noteBinding);
if (searchAction && observed.stable_label && (states.length!==1 || states[0].search_input!==true || states[0].stable_label!==observed.stable_label)) {
throw new WorkerError('搜索控件的固定语义已变化,请重新采集并核对', 'STALE_TARGET');
}
if (params.target) locator = locator.nth(states[0].index);
let objectHandle;
let targetResolvedUnique = false;
if (['fill', 'press', 'click'].includes(kind) || (kind === 'read' && params.target)) {
// Pin the object itself: resolving an nth locator again during a rerender
// must never silently redirect a click to a different row.
objectHandle = await locator.elementHandle({ timeout: TIMEOUT });
if (!objectHandle) throw new WorkerError('目标对象已离开页面,请重新采集', 'STALE_TARGET');
const pinned = await objectHandle.evaluate(inspectElements, { origin: session.origin, target: params.target,nativeProfile:isNativeUiProfile(session.executionProfile),navigationProfile:session.executionProfile===NAVIGATION_UI_PROFILE });
if (params.target) {
resolveTarget(pinned, params.target);
targetResolvedUnique = states.length === 1 && pinned.length === 1;
}
else if (pinned[0].tag !== states[0].tag || pinned[0].label !== states[0].label ||
(searchAction && pinned[0].stable_label!==states[0].stable_label)) throw new WorkerError('目标在执行前发生变化', 'STALE_TARGET');
states = pinned;
}
if (['fill', 'press', 'click'].includes(kind)) requireOperable(states[0], { obstruction: true });
if (kind === 'press' && !states[0].search_input) throw new WorkerError('当前目标已不再是普通搜索输入框', 'WRITE_ACTION_BLOCKED');
if (kind === 'click' && !['a', 'button'].includes(states[0].tag)) {
const role = await locator.getAttribute('role');
if (role !== 'button') throw new WorkerError('动态目标不是链接或按钮', 'ACTION_MISMATCH');
}
if (kind === 'extract') states.forEach(state => {
if (state.sensitive || state.forbidden) throw new WorkerError('结构化行不能是敏感元素、脚本或表单控件', 'SENSITIVE_OUTPUT');
});
sessionFor(session.taskId);
const scopedWait=params.wait_scope ? await prepareScopedWait(session,params,previous,noteBinding) : null;
const nativeProfile=isNativeUiProfile(session.executionProfile);
let nativeContext;
if(nativeProfile){
const nativeRoots=['fill','press','click'].includes(kind) ? states : scopedWait ? [await scopedWait.check()] :
await inspectLocator(session,selector,{kind:'read'});
nativeContext={executionProfile:session.executionProfile,sessionOrigin:session.origin,targetResolvedUnique,pageUrl:session.page.url(),kind,selector,key:params.key,observed,
actualRoots:nativeRoots,matchCount:params.target ? 1 : count,href:kind==='click' ? states[0]?.href : undefined,
noteId:noteBinding?.note_id,keyword:kind==='fill' ? params.value : undefined};
if(kind==='press'){
// Check the exact ordinary search control before reading its business
// search value; never inspect arbitrary form inputs for this purpose.
assertNativeUiAction({...nativeContext,keyword:'ordinary_search_precheck'});
nativeContext.keyword=await objectHandle.inputValue({timeout:TIMEOUT});
}
assertNativeUiAction(nativeContext);
}
const initialBlockedWrites = session.blockedWrites;
const readonlyContext = roots=>({sessionOrigin:session.origin,pageUrl:session.page.url(),kind,selector:params.wait_scope || selector,
observed:scopedWait?.observed || observed,scrollScope:params.scroll_scope,untilTarget:params.until_target,
selectorObserved:scopedWait ? true : Boolean(observed && !observed.sensitive) || (kind==='wait' && params.state==='hidden' && session.observedSelectors.has(selector)),actualRoots:roots});
const enableReadonlyPolicy = roots => {
if(nativeProfile) assertNativeUiAction({...nativeContext,actualRoots:roots});
if (session.platformRequestPolicy) return;
session.platformRequestPolicy = nativeProfile ? beginNativeUiRequestPolicy({...nativeContext,actualRoots:roots}) : beginReadonlyRequestPolicy(readonlyContext(roots));
if(nativeProfile && !session.platformRequestPolicy) throw new WorkerError('当前动作未满足有界网页操作合同','NATIVE_UI_POLICY_NOT_READY');
session.platformRequestPage = session.platformRequestPolicy ? session.page : null;
};
if(nativeProfile || kind==='wait'){
// Authorize the already checked action before enabling automation. There
// must be no interval in which normal page traffic sees a missing policy.
session.platformRequestPolicy=nativeProfile ? beginNativeUiRequestPolicy(nativeContext) : preflightReadonlyWait(readonlyContext(scopedWait ? [await scopedWait.check()] : states));
if(nativeProfile && !session.platformRequestPolicy) throw new WorkerError('当前动作未满足有界网页操作合同','NATIVE_UI_POLICY_NOT_READY');
session.platformRequestPage=session.platformRequestPolicy ? session.page : null;
}
session.automating = true;
let result;
try {
trace.phase = 'execute';
requireBoundNotePage(session,noteBinding);
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');
}
if(!nativeProfile) session.platformRequestPolicy = beginSearchRequestPolicy({ sessionOrigin: session.origin, pageUrl: session.page.url(),
kind, observed, actual: states[0], matchCount: count });
session.platformRequestPage = session.platformRequestPolicy ? session.page : null;
await objectHandle.fill(params.value, { timeout: TIMEOUT });
result = { filled: true, selector };
} else if (kind === 'press') {
if(!nativeProfile) session.platformRequestPolicy = beginSearchRequestPolicy({ sessionOrigin: session.origin, pageUrl: session.page.url(),
kind, key: params.key, observed, actual: states[0], matchCount: count });
session.platformRequestPage = session.platformRequestPolicy ? session.page : null;
await objectHandle.press('Enter', { timeout: TIMEOUT });
trace.phase = 'settle';
await settle(session.page);
await settlePopups(session);
requireOrigin(session, session.page.url());
if(nativeProfile){
const deadline=Date.now()+TIMEOUT;
while(nativeSearchKeyword(session.page.url())!==nativeContext.keyword.trim()){
sessionFor(session.taskId);
if(session.blockedWrites>initialBlockedWrites) throw new WorkerError('搜索过渡期间有请求超出当前动作范围','WRITE_REQUEST_BLOCKED');
if(Date.now()>=deadline) throw new WorkerError('搜索未在预算内进入对应关键词结果页','SEARCH_TRANSITION_NOT_READY');
await session.page.waitForTimeout(80);
}
}
result = { pressed: true, key: 'Enter', selector, url: redactUrl(session.page.url()) };
} else if (kind === 'click') {
const target = await objectHandle.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(nativeProfile){
const pinned=await objectHandle.evaluate(inspectElements,{origin:session.origin,nativeProfile:true,navigationProfile:session.executionProfile===NAVIGATION_UI_PROFILE});
assertNativeUiAction({...nativeContext,actualRoots:pinned,href:target.href});
if(target.href!==nativeContext.href) throw new WorkerError('实际笔记链接在点击前已变化','STALE_TARGET');
}
if(noteBinding){
const actual=target.tag==='a' ? boundNoteSource(target.href,session.origin) : null;
if(!actual || actual.note_id!==noteBinding.note_id) throw new WorkerError('唯一标题当前链接不是本次已选笔记,未执行点击','NOTE_BINDING_MISMATCH');
noteBinding.href_checked=true;
}
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');
}
session.popupPermission = { opener: session.page, expectedUrl: target.tag === 'a' ? target.href : null, adopted: false,
...(session.executionProfile===NAVIGATION_UI_PROFILE ? {sourceUrl:session.page.url()} : {}) };
if(!nativeProfile) session.platformRequestPolicy = beginNoteOpenRequestPolicy({ sessionOrigin: session.origin, pageUrl: session.page.url(), kind,
observed, actual: states[0], matchCount: targetResolvedUnique ? 1 : count, href: target.href, targetResolvedUnique });
session.platformRequestPage = session.platformRequestPolicy ? session.page : null;
await objectHandle.click({ timeout: TIMEOUT });
trace.phase = 'settle';
await settle(session.page);
await settlePopups(session);
await settle(session.page);
requireOrigin(session, session.page.url());
if(noteBinding){noteBinding.opened=true;requireBoundNotePage(session,noteBinding);}
result = { clicked: true, selector, url: redactUrl(session.page.url()) };
} else if (kind === 'read') {
result = states.map(state => {
requireOperable(state, { data:true, viewport:Boolean(params.target) });
if (state.href && typeof state.href !== 'string') throw new WorkerError('读取来源不是允许的同源链接', 'UNSAFE_OUTPUT');
return { text:redactText(state.text || ''), ...(state.href ? {href:redactUrl(state.href)} : {}) };
});
enableReadonlyPolicy(states);
} else if (kind === 'scroll' && params.until_target) {
result=await seekScroll(session,params,{observed,currentObserved,observedViewport:previous.viewport,currentViewport,trace,enableReadonlyPolicy,initialBlockedWrites,noteBinding});
} else if (kind === 'scroll') {
if(params.until_selector && states.some(state=>state.until?.error)) {
const invalid=states.find(state=>state.until?.error);
throw new WorkerError('滚动目标不是可读取的普通有界元素',invalid.until.error);
}
if (selector !== 'body') {
requireOperable(states[0], { viewport: false, data: true });
enableReadonlyPolicy(states);
await locator.scrollIntoViewIfNeeded({ timeout: TIMEOUT });
} else enableReadonlyPolicy(states);
const movements = [];
const untilReached=async(pollMs=0)=>{
const deadline=Date.now()+pollMs;
do {
const current=await inspectLocator(session,selector,params);
if(current.length!==1) throw new WorkerError('滚动容器已变化,必须保持唯一','STALE_TARGET');
if(current[0].readonly_root!==true) throw new WorkerError('滚动容器已变为敏感或表单区域','SENSITIVE_OUTPUT');
const until=current[0].until;
if(until?.error) throw new WorkerError('滚动目标不是可读取的普通有界元素',until.error);
if(until?.complete_count>0) return true;
if(Date.now()>=deadline) return false;
await session.page.waitForTimeout(Math.min(100,Math.max(1,deadline-Date.now())));
} while(true);
};
let reached=params.until_selector ? await untilReached() : false;
for (let index = 0; index < params.max_scrolls && !reached; index += 1) {
const movement = await locator.evaluate((element, { direction, pixels, pageScroll }) => {
const root = pageScroll ? document.scrollingElement : element;
const before = root.scrollTop;
root.scrollBy({ top: direction === 'down' ? pixels : -pixels, behavior: 'instant' });
return { before, after: root.scrollTop, scroll_height: root.scrollHeight, client_height: root.clientHeight };
}, { ...params, pageScroll: selector === 'body' });
movements.push(movement);
await session.page.waitForTimeout(80);
if(params.until_selector) reached=await untilReached();
if (movement.before === movement.after) {
if(params.until_selector && !reached) reached=await untilReached(1000);
if(params.until_selector && !reached) throw new WorkerError('已到滚动边界,限定等待后目标仍未完整可见','SCROLL_BOUNDARY_BEFORE_TARGET');
break;
}
}
if(params.until_selector && !reached) reached=await untilReached(1000);
if(params.until_selector && !reached) throw new WorkerError('本次滚动预算已耗尽,目标尚未完整可见','SCROLL_BUDGET_EXHAUSTED');
result = { direction: params.direction, pixels: params.pixels, requested_max_scrolls: params.max_scrolls,
scrolls: movements.length, movements, brought_into_view: selector !== 'body' && !states[0].in_viewport,
...(params.until_selector ? {until_selector:params.until_selector,until_reached:reached} : {}),
source_url: redactUrl(session.page.url()) };
} else if (kind === 'wait' && scopedWait) {
result=await waitInScope(session,params,scopedWait,{trace,initialBlockedWrites,noteBinding});
} else if (kind === 'wait') {
if (states.some(state=>state.sensitive || state.forbidden)) throw new WorkerError('不能观察敏感元素或表单值', 'SENSITIVE_OUTPUT');
if(params.min_count!==undefined && states.length>100) throw new WorkerError('集合等待最多核对100个候选对象','READ_LIMIT');
if (params.state!=='hidden' && params.min_count===undefined && states.length>1) throw new WorkerError('条件等待必须定位唯一对象', 'NON_UNIQUE_TARGET');
enableReadonlyPolicy(states);
const deadline = Date.now() + params.timeout_ms;
let satisfied = false;
do {
const currentStates = await inspectLocator(session, selector, params);
if (currentStates.length > 500) throw new WorkerError('等待目标范围过大', 'READ_LIMIT');
if (currentStates.some(state => state.sensitive || state.forbidden)) throw new WorkerError('不能观察敏感元素或表单值', 'SENSITIVE_OUTPUT');
if (params.state === 'hidden') satisfied = currentStates.length === 0 || currentStates.every(state => !state.visible);
else {
if(params.min_count!==undefined){
if(currentStates.length>100) throw new WorkerError('集合等待最多核对100个候选对象','READ_LIMIT');
satisfied=currentStates.filter(state=>state.visible && state.in_viewport).length>=params.min_count;
}else {
if (currentStates.length > 1) throw new WorkerError('条件等待必须定位唯一对象', 'NON_UNIQUE_TARGET');
const state = currentStates[0];
satisfied = Boolean(state?.visible && (params.state !== 'text' || state.text?.includes(params.text)));
}
}
enableReadonlyPolicy(currentStates);
if (satisfied) break;
if (Date.now() >= deadline) throw new WorkerError('条件等待超时,当前页面尚未达到预期状态', 'WAIT_TIMEOUT');
await session.page.waitForTimeout(Math.min(100, Math.max(1, deadline - Date.now())));
} while (true);
result = { satisfied, state: params.state, selector, timeout_ms: params.timeout_ms,
...(params.min_count!==undefined ? {min_count:params.min_count} : {}) };
} else if (kind === 'extract') {
if(params.row_selector){
result=await extractInContainer(session,params,{observed,currentObserved,enableReadonlyPolicy,trace,initialBlockedWrites,noteBinding,batchBudgetMs});
}else {
const visibleStates = states.filter(state => state.visible && state.in_viewport);
if (params.target && !visibleStates.length) throw new WorkerError('动态行当前不在视口,请先显式滚动', 'TARGET_NOT_IN_VIEWPORT');
const selected=[];
let skippedIncomplete=0;
for (const state of visibleStates) {
if(selected.length>=params.limit) break;
requireOperable(state, { data: true });
if (state.error) throw new WorkerError(`结构化字段读取失败:${state.error}`, state.error);
if(params.complete_only===true && state.incomplete_fields){skippedIncomplete+=1;continue;}
selected.push(state);
}
const items = selected.map(state => Object.fromEntries(Object.entries(state.values).map(([key, value]) =>
[key,renderExtractValue(params.fields.find(field=>field.name===key),value)])));
enableReadonlyPolicy(selected.length ? selected : visibleStates);
const truncated = states.length > selected.length || items.some(item=>params.fields.some(field=>field.source==='content' && item[field.name]?.media_truncated));
result = { items, count: items.length, truncated, source_url: redactUrl(session.page.url()),
coverage: !items.length ? 'empty' : truncated && items.length === params.limit ? 'limit_reached' : 'bounded',
...(params.complete_only===true ? {skipped_incomplete_count:skippedIncomplete} : {}) };
}
}
if(noteBinding?.opened){
requireBoundNotePage(session,noteBinding);
if(result && typeof result==='object' && Object.hasOwn(result,'source_url')) result.source_url=noteBinding.source_url;
if(kind==='extract' && params.output_name==='note_detail'){
noteBinding.title=typeof result.items?.[0]?.title==='string' ? result.items[0].title : null;
if(noteBinding.title?.trim()!==noteBinding.note_title) throw new WorkerError('实际读取笔记标题与本次已选标题不一致,停止后续评论读取','NOTE_BINDING_MISMATCH');
}
}
trace.phase = 'settle';
await session.page.waitForTimeout(120);
await settlePopups(session);
requireBoundNotePage(session,noteBinding);
if(nativeProfile) assertNativeUiResultLocation(nativeContext,session.page.url());
trace.phase = 'request_guard';
if (session.blockedWrites > initialBlockedWrites) throw new WorkerError('页面尝试产生写请求,已阻止并停止本次只读步骤', 'WRITE_REQUEST_BLOCKED');
} finally {
endPlatformRequestPolicy(session.platformRequestPolicy);
session.platformRequestPolicy = null;
session.platformRequestPage = null;
session.automating = false;
}
trace.phase = 'capture_after';
const after = trace.after = await captureSession(session);
requireBoundNotePage(session,noteBinding);
if(nativeProfile) assertNativeUiResultLocation(nativeContext,session.page.url());
if(scopedWait){
trace.phase='inspect_current';
await scopedWait.check();
const rows=await scopedWait.rows();
if(rows.filter(row=>row.visible && row.in_viewport && !row.busy).length<params.min_count) {
throw new WorkerError('等待条件在后置取证期间已变化,请重新观察','WAIT_CONDITION_CHANGED');
}
}
return { result, before, after,...executionMetadata(session.executionProfile),...(trace.batch_evidence ? {batch_evidence:trace.batch_evidence} : {}) };
}
async function actionSession(session, params, options = {}) {
const trace = actionTrace();
options.onTrace?.(trace);
const diagnostics = { phase: 'validate', blocked_requests: [], blocked_request_count: 0 };
trace.diagnostics=diagnostics;
session.actionDiagnostics = diagnostics;
try {
return await performActionSession(session, params, { ...options, trace });
} catch (cause) {
const error = cause instanceof WorkerError || cause instanceof ActionInputError ? cause :
new WorkerError('浏览器动作未完成,请核对失败阶段与页面证据', 'BROWSER_ERROR');
if(error.code==='NAVIGATION_BINDING_MISMATCH' && error.navigation_comparison) diagnostics.navigation_comparison=error.navigation_comparison;
diagnostics.phase = trace.phase;
if (!shuttingDown && !session.closed && !session.page.isClosed()) {
// Failed requests may still have changed the page. Keep that actual state
// without claiming the action completed or replaying it.
trace.phase='failure_capture';
trace.after = await captureSession(session, '失败后的实际页面状态').catch(() => null);
}
if (trace.before) error.before = trace.before;
if (trace.after) error.after = trace.after;
if(trace.batch_evidence) error.batch_evidence=trace.batch_evidence;
if(trace.batch_context_closed===true || trace.wait_context_closed===true) error.session_closed=true;
if(trace.batch_request_blocked===true || trace.wait_request_blocked===true) error.error_codes=['WRITE_REQUEST_BLOCKED'];
if(trace.seek_result){
error.seek_result={...trace.seek_result,target_reached:false,stop_reason:seekStopReason(error.code)};
}
error.diagnostics = structuredClone(diagnostics);
throw error;
} finally {
endPlatformRequestPolicy(session.platformRequestPolicy);
session.platformRequestPolicy = null;
session.platformRequestPage = null;
session.automating = false;
session.popupPermission = null;
session.actionDiagnostics = null;
if (session.page.isClosed() && !session.closed) {
session.closed = true;
session.ready = false;
session.lastCapture = null;
void closeContext(session);
}
}
}
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;
}
const emptyExpectation=value=>value && typeof value==='object' && !Array.isArray(value) && Object.keys(value).length===1 && value.state==='empty';
const nonemptyExpectation=value=>value && typeof value==='object' && !Array.isArray(value) && Object.keys(value).length===1 && value.state==='nonempty';
export function validContentValue(field,value){
if(!value || typeof value!=='object' || Array.isArray(value) || Object.keys(value).length!==4 || Object.keys(value).some(key=>!['kind','text','media','media_truncated'].includes(key)) ||
!['text','media','mixed'].includes(value.kind) || (value.text!==null && (typeof value.text!=='string' || !value.text.trim())) ||
!Array.isArray(value.media) || value.media.length>(field.max_media ?? 4) || typeof value.media_truncated!=='boolean') return false;
if(value.kind!==(value.text ? value.media.length ? 'mixed' : 'text' : 'media') || (!value.text && !value.media.length) || (value.media_truncated && value.media.length!==(field.max_media ?? 4))) return false;
const selectors=new Set();
for(const media of value.media){
if(!media || typeof media!=='object' || Array.isArray(media) || Object.keys(media).length!==2 || !['image','video'].includes(media.kind) || !Object.hasOwn(media,'selector') || selectors.has(media.selector)) return false;
try{validateSelector(media.selector,{relative:true});}catch{return false;}
selectors.add(media.selector);
}
return true;
}
// This checks the extraction contract and result structure only. It deliberately
// makes no claim that a title, author or comment matches a business requirement.
export function validNonemptyExtractOutput(step,output){
if(!step || step.kind!=='extract') return false;
try {validateAction(step);}catch{return false;}
if(!output || typeof output!=='object' || Array.isArray(output)) return false;
const keys=['items','count','truncated','source_url','coverage',...(step.complete_only===true ? ['skipped_incomplete_count'] : []),...(step.batch ? ['batch_summary'] : [])];
if(Object.keys(output).length!==keys.length || Object.keys(output).some(key=>!keys.includes(key)) || !Array.isArray(output.items) ||
!Number.isInteger(output.count) || output.count<1 || output.count!==output.items.length || output.count>step.limit || typeof output.truncated!=='boolean' ||
!['bounded','limit_reached'].includes(output.coverage)) return false;
try {httpUrl(output.source_url);}catch{return false;}
if(output.coverage==='limit_reached' && (!output.truncated || output.count!==step.limit)) return false;
if(step.complete_only===true && (!Number.isInteger(output.skipped_incomplete_count) || output.skipped_incomplete_count<0 || (output.skipped_incomplete_count>0 && !output.truncated))) return false;
const names=step.fields.map(field=>field.name);
if(output.items.some(item=>!item || typeof item!=='object' || Array.isArray(item) || Object.keys(item).length!==names.length || Object.keys(item).some(key=>!names.includes(key)) ||
step.fields.some(field=>field.source==='content' ? !validContentValue(field,item[field.name]) :
(item[field.name]!==null && typeof item[field.name]!=='string') || (field.required && (typeof item[field.name]!=='string' || !item[field.name].trim()))))) return false;
if(output.truncated!==true && output.items.some(item=>step.fields.some(field=>field.source==='content' && item[field.name]?.media_truncated))) return false;
if(step.batch){
const summary=output.batch_summary;
if(output.truncated!==true || !summary || typeof summary!=='object' || Array.isArray(summary) || Object.keys(summary).length!==3 ||
Object.keys(summary).some(key=>!['batches_read','duplicates_removed','stop_reason'].includes(key)) ||
!Number.isInteger(summary.batches_read) || summary.batches_read<1 || summary.batches_read>step.batch.max_batches ||
!Number.isInteger(summary.duplicates_removed) || summary.duplicates_removed<0 ||
!['limit_reached','boundary','batch_budget','time_budget'].includes(summary.stop_reason) ||
(summary.stop_reason==='limit_reached' && output.count!==step.limit)) return false;
const identities=output.items.map(item=>item[step.batch.dedupe_field]);
if(identities.some(value=>typeof value!=='string' || !value.trim()) || new Set(identities).size!==identities.length) return false;
}
return true;
}
function assertExpected(expected, outputs, steps = []) {
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)) {
if(nonemptyExpectation(value)){
const step=steps.find(step=>step.kind==='extract' && step.output_name===name);
if(!validNonemptyExtractOutput(step,outputs[name])) errors.push(`输出 ${name} 没有符合提取契约的非空结构化结果`);
continue;
}
if(emptyExpectation(value)){
const step=steps.find(step=>step.kind==='extract' && step.output_name===name),output=outputs[name];
if(!step || step.allow_empty!==true || !step.row_selector || !output || output.count!==0 || output.items?.length!==0 || output.coverage!=='empty' || output.truncated!==false ||
output.empty_state?.confirmed!==true || output.empty_state.selector!==step.empty_state?.selector || output.empty_state.text!==step.empty_state?.text?.trim()) {
errors.push(`输出 ${name} 没有当前可见页面的明确空态证据`);
}
continue;
}
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 output = outputs[name];
// URLs never satisfy content assertions, including structured extraction.
const fields = steps.find(step => step.kind === 'extract' && step.output_name === name)?.fields || [];
const contentFields = fields.filter(field => field.source !== 'href').map(field => field.name);
const actual = Array.isArray(output) ? output.map(item => item.text || '').join('\n') :
output?.items ? output.items.flatMap(item => contentFields.map(name => fields.find(field=>field.name===name)?.source==='content' ? item[name]?.text || '' : item[name] || '')).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;
}
// Only module callers can shorten this deadline for isolated regression tests;
// the JSONL RPC exposes neither a budget override nor a cleanup configuration.
async function withDeadline(run,{budgetMs,startedMs=Date.now(),cleanupReserveMs=2000,onTimeout,errorCode}) {
const reserve=Math.min(cleanupReserveMs,Math.max(1,budgetMs-1));
const cutoff=startedMs+budgetMs-reserve;
const control={expired:false,check(){
if(this.expired || Date.now()>=cutoff) throw budgetError();
}};
let timer,cleanupTimer,cleanupPromise;
const budgetError=()=>new WorkerError(errorCode==='BATCH_BUDGET' ? '评论批次已达到时间预算,已中断会话并保留已完成批次证据' : errorCode==='WAIT_TIMEOUT' ? '范围等待已达到时间预算,已中断会话并保留已取得证据' : '试跑已达到本轮总时间预算,已中断当前会话并保留已取得证据',errorCode);
const expire=()=>{
control.expired=true;
if(!cleanupPromise){
const remaining=Math.max(1,startedMs+budgetMs-Date.now());
cleanupPromise=Promise.race([Promise.resolve().then(()=>onTimeout?.()),new Promise(resolve=>{cleanupTimer=setTimeout(resolve,remaining);})]).catch(()=>{});
}
return cleanupPromise;
};
const execution=Promise.resolve().then(()=>run(control));
const expiration=new Promise((_,reject)=>{
timer=setTimeout(()=>{
expire().then(()=>reject(budgetError()));
},Math.max(0,cutoff-Date.now()));
});
try{const value=await Promise.race([execution,expiration]);control.check();return value;}
catch(error){
if(control.expired || error.code===errorCode){await expire();throw budgetError();}
throw error;
}
finally{clearTimeout(timer);clearTimeout(cleanupTimer);}
}
export const withVerifyDeadline=(run,options)=>withDeadline(run,{...options,errorCode:'VERIFY_BUDGET'});
export const withBatchDeadline=(run,options)=>withDeadline(run,{...options,errorCode:'BATCH_BUDGET'});
export const withWaitDeadline=(run,options)=>withDeadline(run,{...options,errorCode:'WAIT_TIMEOUT'});
export async function verify(params,{budgetMs:internalBudgetMs,cleanupReserveMs=2000,batchBudgetMs=15_000}={}) {
if(!Number.isInteger(batchBudgetMs) || batchBudgetMs<1 || batchBudgetMs>15_000) throw new WorkerError('内部批次测试预算只能缩短既有上限','INVALID_INPUT');
const startedMs=Date.now();
const startedAt = new Date().toISOString();
const session = sessionFor(params.task_id);
resolveExecutionProfile(params.execution_profile);
if(params.execution_profile!==session.executionProfile ||
(isNativeUiProfile(session.executionProfile) ? !executionContractMatches(params.execution_contract,session.executionProfile) : params.execution_contract!==undefined))
throw new WorkerError('候选执行模式与当前浏览器会话不一致,未导航或回放','EXECUTION_PROFILE_MISMATCH');
const outputs = Object.create(null), evidence = [], errors = [],errorCodes=[],seekResults=[],stepTimings=[];
let currentStepPosition=0;
const expected = params.expected_outputs;
// Validate assertions before any browser movement.
assertExpected(expected, {});
for (const value of Object.values(expected)) {
if(emptyExpectation(value) || nonemptyExpectation(value)) continue;
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 noteBinding=params.note_binding===undefined ? null : makeNoteBinding(params.note_binding,entry,params.steps,parameters);
const verifyBudgetMs=90_000+(noteBinding?.seek_timeout_ms || 0);
const effectiveBudget=Number.isInteger(internalBudgetMs) && internalBudgetMs>0 ? Math.min(verifyBudgetMs,internalBudgetMs) : verifyBudgetMs;
let currentTrace=null,failureDiagnostics=null,budgetExpired=false,budgetContextClosed=false,batchContextClosed=false,batchCloseMs=null,entryFinished=null,closeStarted=null,closeMs=null;
const retainFailure=(code,diagnostics=currentTrace?.diagnostics,phase=diagnostics?.phase)=>{
const kind=params.steps[currentStepPosition-1]?.kind;
if(!currentTrace || !diagnostics || !ACTION_KINDS.includes(kind) || typeof code!=='string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(code)) return;
failureDiagnostics=structuredClone({step_position:currentStepPosition,kind,code,
diagnostics:{...diagnostics,phase:phase || currentTrace.phase}});
};
const retainTiming=status=>{
if(currentTrace && !stepTimings.some(item=>item.step_position===currentStepPosition)) stepTimings.push(currentTrace.snapshot(currentStepPosition,params.steps[currentStepPosition-1].kind,status));
};
const retainTrace=trace=>{
for(const snapshot of [trace?.before,...(trace?.batch_evidence || []).map(item=>item.capture),trace?.after]) {
if(snapshot && !evidence.some(item=>item.capture_id===snapshot.capture_id)) evidence.push(snapshot);
}
};
try {
await withVerifyDeadline(async control=>{
try {
await session.page.goto(entry.href, { waitUntil: 'domcontentloaded', timeout: 20_000 });
control.check();
await settle(session.page);
control.check();
evidence.push(await captureSession(session, '变参试跑入口'));
entryFinished=Date.now();
control.check();
for (let index = 0; index < params.steps.length; index += 1) {
currentStepPosition=index+1;
if (shuttingDown) throw new WorkerError('学习工作进程正在退出', 'WORKER_CLOSING');
control.check();
const step = params.steps[index];
if (!step || typeof step !== 'object' || !ACTION_KINDS.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.target?.parameter) {
if (!Object.hasOwn(parameters, step.target.parameter)) throw new WorkerError(`缺少动态目标参数 ${step.target.parameter}`, 'MISSING_PARAMETER');
actionParams.target = { ...step.target, value: requireString(parameters[step.target.parameter], '动态目标参数', 2_000) };
}
if(step.until_target?.parameter){
if(!Object.hasOwn(parameters,step.until_target.parameter)) throw new WorkerError(`缺少有界目标参数 ${step.until_target.parameter}`,'MISSING_PARAMETER');
actionParams.until_target={...step.until_target,value:requireString(parameters[step.until_target.parameter],'有界目标参数',2_000)};
delete actionParams.until_target.parameter;
}
if (['read', 'extract'].includes(step.kind)) requireString(step.output_name, 'output_name', 80);
const result = await actionSession(session, actionParams, { captureBefore: false,noteBinding,batchBudgetMs,onTrace:trace=>{currentTrace=trace;} });
control.check();
if(step.until_target) seekResults.push({step_position:index+1,scrolls:result.result.scrolls,movements:result.result.movements,target_reached:result.result.target_reached,stop_reason:result.result.stop_reason});
for(const batch of result.batch_evidence || []) if(!evidence.some(item=>item.capture_id===batch.capture.capture_id)) evidence.push(batch.capture);
evidence.push(result.after);
if (['read', 'extract'].includes(step.kind)) outputs[step.output_name] = result.result;
retainTiming('passed');
currentTrace=null;
}
errors.push(...assertExpected(expected, outputs, params.steps));
requireBoundNotePage(session,noteBinding);
} catch (error) {
if(control.expired || error.code==='VERIFY_BUDGET') throw error;
retainFailure(error.code,error.diagnostics || currentTrace?.diagnostics);
if(error.code==='BATCH_BUDGET' && currentTrace?.batch_timeout){
const step=params.steps[currentStepPosition-1],partial=currentTrace.batch_partial_result;
if(currentTrace.batch_request_blocked!==true && step?.kind==='extract' && partial?.count>0 && validNonemptyExtractOutput(step,partial)) outputs[step.output_name]=structuredClone(partial);
if(currentTrace.batch_request_blocked===true){errors.push('批次截止前已阻止未知请求,不提供该步部分输出');errorCodes.push('WRITE_REQUEST_BLOCKED');}
batchContextClosed=currentTrace.batch_context_closed===true;batchCloseMs=currentTrace.batch_close_ms ?? null;
}
if(error.code==='WAIT_TIMEOUT' && currentTrace?.wait_timeout){
batchContextClosed=currentTrace.wait_context_closed===true;batchCloseMs=currentTrace.wait_close_ms ?? null;
if(currentTrace.wait_request_blocked===true){errors.push('范围等待截止前已阻止未知请求');errorCodes.push('WRITE_REQUEST_BLOCKED');}
}
if(error.seek_result) seekResults.push({step_position:currentStepPosition,...error.seek_result});
errors.push(redactText(error.message));
if(typeof error.code==='string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(error.code)) errorCodes.push(error.code);
for (const snapshot of [error.before, ...(error.batch_evidence || []).map(item=>item.capture),error.after]) {
if (snapshot && !evidence.some(item => item.capture_id === snapshot.capture_id)) evidence.push(snapshot);
}
if (!session.closed && !session.page.isClosed()) {
if(currentTrace) currentTrace.phase='failure_capture';
await captureSession(session, '失败后页面状态').then(snapshot => evidence.push(snapshot)).catch(() => {});
}
if(control.expired) throw new WorkerError('总预算期间已中断取证','VERIFY_BUDGET');
retainTiming('failed');
}
},{budgetMs:effectiveBudget,startedMs,cleanupReserveMs,onTimeout:()=>{
budgetExpired=true;
retainFailure('VERIFY_BUDGET',currentTrace?.diagnostics,currentTrace?.phase);
if(entryFinished===null) entryFinished=Date.now();
retainTiming('interrupted');
retainTrace(currentTrace);
if(currentTrace?.seek_result && !seekResults.some(item=>item.step_position===currentStepPosition)) {
seekResults.push({step_position:currentStepPosition,...currentTrace.seek_result,target_reached:false,stop_reason:'time_budget'});
}
// Closing the context cancels pending DOM inspections, screenshots,
// navigation and action waits. Merely racing them would leave late work
// running after a timeout response and could append a false success.
session.closed=true;session.ready=false;
closeStarted=performance.now();
session.context.once('close',()=>{budgetContextClosed=true;});
const last=session.lastCapture;
if(last && Date.parse(last.captured_at)>=startedMs && !evidence.some(item=>item.capture_id===last.capture_id)) evidence.push(last);
session.lastCapture=null;
endPlatformRequestPolicy(session.platformRequestPolicy);
session.platformRequestPolicy=null;session.platformRequestPage=null;session.automating=false;
return closeContext(session).finally(()=>{closeMs=Math.max(0,Math.round(performance.now()-closeStarted));});
}});
} catch(error) {
if(error.code!=='VERIFY_BUDGET') throw error;
retainTrace(currentTrace);
if(!errorCodes.includes('VERIFY_BUDGET')) {errors.push(redactText(error.message));errorCodes.push('VERIFY_BUDGET');}
}
return {
...executionMetadata(session.executionProfile),
passed: errors.length === 0,
outputs, errors, evidence,error_codes:errorCodes,
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,
...(params.steps.some(step=>step?.until_target) ? {seek_results:snapshotSeekResults(seekResults)} : {}),
...(noteBinding?.seek_timeout_ms ? {verify_budget_ms:verifyBudgetMs} : {}),
...((budgetExpired && budgetContextClosed) || batchContextClosed ? {session_closed:true} : {}),
...(noteBinding ? {note_binding_result:bindingResult(noteBinding)} : {}),
...(failureDiagnostics ? {failure_diagnostics:structuredClone(failureDiagnostics)} : {}),
step_timings:stepTimings.map(item=>({...item,phase_ms:{...item.phase_ms}})),
verify_timing:{duration_ms:Math.max(0,Date.now()-startedMs),entry_ms:Math.max(0,(entryFinished ?? Date.now())-startedMs),
close_ms:closeStarted===null ? batchCloseMs : closeMs ?? Math.max(0,Math.round(performance.now()-closeStarted))},
};
}
const methods = { open, ready, capture, action, close, verify,
status: async params => {
const session = sessions.get(requireString(params.task_id, 'task_id', 128));
if (session && !session.closed && !session.page.isClosed()) await refreshLoginDetection(session);
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,
...(error.before ? { before: error.before } : {}), ...(error.after ? { after: error.after } : {}),
...(error.batch_evidence ? {batch_evidence:error.batch_evidence} : {}),
...(error.session_closed===true ? {session_closed:true} : {}),
...(error.error_codes ? {error_codes:error.error_codes} : {}),
...(error.diagnostics ? { diagnostics: error.diagnostics } : {}) } });
}
}
} 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;
});
}