// Pure validation shared by the restricted browser action entry points. export class ActionInputError extends Error { constructor(message, code = 'INVALID_INPUT') { super(message); this.code = code; } } export const ACTION_KINDS = ['fill', 'press', 'click', 'read', 'scroll', 'wait', 'extract']; const privateAttribute = /value|password|passwd|credential|token|secret|session|cookie|authorization|captcha|verification|phone|mobile|sms|code/i; const fail = message => { throw new ActionInputError(message); }; const boundedInteger = (value, name, min, max) => { if (!Number.isInteger(value) || value < min || value > max) fail(`${name} 必须为 ${min}–${max} 的整数`); }; const string = (value, name, max) => { if (typeof value !== 'string' || !value.trim() || value.length > max) fail(`${name} 必须为非空文本,最多 ${max} 字符`); }; export function validateSelector(value, { relative = false } = {}) { string(value, 'selector', 1_000); if (/>>|(?:^|\s)(?:xpath|text|css|id|role|nth|javascript)\s*=|javascript:|:has-text\s*\(|:text(?:-is|-matches)?\s*\(/i.test(value)) fail('仅支持 CSS 定位,不支持脚本、XPath 或自定义定位引擎'); if (relative && /(?:^|[\s,>+~])(?:html|body)(?:$|[\s,.#:[>+~])|:root/i.test(value)) fail('字段定位必须相对于当前行,不能指向整个页面'); } export function validateAttribute(value) { if (typeof value !== 'string' || privateAttribute.test(value) || !/^(?:id|title|role|datetime|aria-label|data-[a-z][a-z0-9_-]{0,63})$/.test(value)) { fail('仅允许普通业务标识、标题及标签属性,不读取表单值、凭据或执行属性'); } } export function validateAction(params) { if (!params || typeof params !== 'object' || !ACTION_KINDS.includes(params.kind)) { throw new ActionInputError('不支持的受限步骤类型', 'UNSUPPORTED_ACTION'); } validateSelector(params.selector); if(params.until_selector!==undefined && params.kind!=='scroll') fail('until_selector 仅适用于滚动'); if(params.complete_only!==undefined && params.kind!=='extract') fail('complete_only 仅适用于结构化提取'); for (const key of ['row_selector','batch','allow_empty','empty_state']) { if(params[key]!==undefined && params.kind!=='extract') fail(`${key} 仅适用于结构化提取`); } if(params.min_count!==undefined && params.kind!=='wait') fail('min_count 仅适用于可见等待'); if(params.wait_scope!==undefined){ if(params.kind!=='wait' || params.state!=='visible' || params.min_count===undefined) fail('wait_scope 仅适用于声明最少数量的可见集合等待'); validateSelector(params.wait_scope,{relative:true}); validateSelector(params.selector,{relative:true}); if(/:root\b/i.test(params.wait_scope) || /:root\b/i.test(params.selector)) fail('范围等待不能使用文档根'); } if(params.until_target!==undefined && params.kind!=='scroll') fail('until_target 仅适用于有界滚动定位'); if(params.scroll_scope!==undefined && (params.scroll_scope!=='viewport' || params.kind!=='scroll' || params.until_target===undefined)) fail('scroll_scope 仅允许用于有界目标滚动的 viewport 模式'); if (params.kind === 'press' && params.key !== 'Enter') fail('仅允许在已观察的普通搜索输入框按 Enter'); if (params.target !== undefined) { const target = params.target; if (!['click', 'read', 'extract'].includes(params.kind) || !target || typeof target !== 'object' || !['text', 'href', 'attribute'].includes(target.field)) fail('动态目标仅适用于 click、read、extract,匹配字段必须为 text、href 或 attribute'); if (target.field === 'attribute') validateAttribute(target.attribute); if (target.value !== undefined) string(target.value, 'target.value', 2_000); if (target.parameter !== undefined) string(target.parameter, 'target.parameter', 80); if (target.value === undefined && target.parameter === undefined) fail('动态目标需要 value 或 parameter'); } if (params.kind === 'scroll') { if (!['up', 'down'].includes(params.direction)) fail('direction 必须为 up 或 down'); boundedInteger(params.pixels, 'pixels', 1, 1_600); boundedInteger(params.max_scrolls, 'max_scrolls', 1, 10); if (params.until_selector !== undefined) validateSelector(params.until_selector, {relative:true}); if(params.until_target!==undefined){ const target=params.until_target; if(params.until_selector!==undefined || !target || typeof target!=='object' || Array.isArray(target) || Object.keys(target).some(key=>!['selector','field','parameter','value','attribute'].includes(key))) fail('until_target 不能与 until_selector 混用,且仅支持固定目标字段'); validateSelector(target.selector,{relative:true}); if(!['text','href','attribute'].includes(target.field) || (target.parameter!==undefined)===(target.value!==undefined)) fail('until_target 需要文本/来源/普通属性以及唯一参数或值'); if(target.field==='attribute') validateAttribute(target.attribute); else if(target.attribute!==undefined) fail('attribute 仅适用于普通属性匹配'); if(target.parameter!==undefined) string(target.parameter,'until_target.parameter',80); if(target.value!==undefined) string(target.value,'until_target.value',2_000); if(/^(?:body|html)$/i.test(params.selector.trim())) fail('有界目标滚动必须使用已观察的普通容器'); boundedInteger(params.timeout_ms,'timeout_ms',1,15_000); }else if(params.timeout_ms!==undefined) fail('滚动 timeout_ms 仅适用于 until_target'); } if (params.kind === 'wait') { if (!['visible', 'hidden', 'text'].includes(params.state)) fail('state 必须为 visible、hidden 或 text'); boundedInteger(params.timeout_ms, 'timeout_ms', 1, 15_000); if (params.state === 'text') string(params.text, 'text', 2_000); if(params.min_count!==undefined){ if(params.state!=='visible') fail('min_count 仅适用于可见等待'); boundedInteger(params.min_count,'min_count',1,100); } } if (params.kind === 'extract') { boundedInteger(params.limit, 'limit', 1, 100); string(params.output_name, 'output_name', 80); if (params.complete_only !== undefined && typeof params.complete_only !== 'boolean') fail('complete_only 必须为布尔值'); if (params.row_selector !== undefined) { validateSelector(params.row_selector,{relative:true}); if(params.target!==undefined) fail('容器内行提取不支持动态目标'); } if(params.allow_empty!==undefined && typeof params.allow_empty!=='boolean') fail('allow_empty 必须为布尔值'); if(params.empty_state!==undefined && params.allow_empty!==true) fail('empty_state 需要 allow_empty:true'); if(params.allow_empty===true){ if(!params.row_selector || !params.empty_state || typeof params.empty_state!=='object' || Array.isArray(params.empty_state) || Object.keys(params.empty_state).some(key=>!['selector','text'].includes(key))) fail('确认空态需要容器行定位和固定空态证据'); validateSelector(params.empty_state.selector,{relative:true}); string(params.empty_state.text,'empty_state.text',2_000); } if (!Array.isArray(params.fields) || !params.fields.length || params.fields.length > 12) fail('fields 必须包含 1–12 个字段'); const names = new Set(); for (const field of params.fields) { if (!field || typeof field !== 'object' || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(field.name || '') || ['constructor', 'prototype'].includes(field.name) || names.has(field.name)) fail('字段名称必须为不重复的普通 ASCII 标识符'); names.add(field.name); if (!['text', 'href', 'attribute','content'].includes(field.source)) fail('字段 source 必须为 text、href、attribute 或 content'); if (field.selector !== undefined && field.selector !== '') validateSelector(field.selector, { relative: true }); if (field.source === 'attribute') validateAttribute(field.attribute); if (field.required !== undefined && typeof field.required !== 'boolean') fail('required 必须为布尔值'); if(field.source==='content'){ if(field.required!==true || field.attribute!==undefined) fail('内容类型字段必须显式 required:true,不能混用 attribute'); validateSelector(field.text_selector,{relative:true});validateSelector(field.media_selector,{relative:true}); if(field.max_media!==undefined) boundedInteger(field.max_media,'max_media',1,4); }else if(['text_selector','media_selector','max_media'].some(key=>field[key]!==undefined)) fail('媒体配置仅适用于 source:content'); } if(params.batch!==undefined){ const batch=params.batch; if(!batch || typeof batch!=='object' || Array.isArray(batch) || Object.keys(batch).some(key=>!['scroll_selector','pixels','max_batches','dedupe_field'].includes(key)) || !params.row_selector || params.complete_only!==true || params.target!==undefined || params.allow_empty===true) fail('批次提取需要容器行定位和完整行模式,不能与动态目标或空态混用'); validateSelector(batch.scroll_selector); if(batch.scroll_selector==='body') fail('批次滚动必须限定到已观察的普通容器'); boundedInteger(batch.pixels,'batch.pixels',1,1_600); boundedInteger(batch.max_batches,'batch.max_batches',1,5); const id=params.fields.find(field=>field.name===batch.dedupe_field); if(!id || id.required!==true || id.source!=='attribute' || !/^(?:id|data-[a-z][a-z0-9_-]{0,63})$/.test(id.attribute || '')) fail('批次去重字段必须为 required 的普通 id/data-* 业务属性'); } } return params; }