assistant/tools/learning/action-contract.mjs

132 lines
9.6 KiB
JavaScript
Raw Permalink 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.

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