132 lines
9.6 KiB
JavaScript
132 lines
9.6 KiB
JavaScript
// 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;
|
||
}
|