assistant/tools/learning/execution-profile.mjs

191 lines
12 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 {ActionInputError} from './action-contract.mjs';
export const NATIVE_UI_PROFILE='playwright-xhs-native-ui-v1';
export const NATIVE_UI_CONTRACT=Object.freeze({engine:'playwright',action_policy:'xhs-bounded-readonly-v1',network_policy:'xhs-page-native-v1'});
export const NAVIGATION_UI_PROFILE='playwright-xhs-navigation-ui-v1';
export const NAVIGATION_UI_CONTRACT=Object.freeze({engine:'playwright',action_policy:'xhs-observed-navigation-readonly-v1',network_policy:'xhs-page-navigation-v1'});
const OFFICIAL='https://www.xiaohongshu.com';
const SEARCH_SELECTORS=new Set(['#search-input-in-feeds','#search-input-ai']);
const READ_KINDS=new Set(['read','extract','scroll','wait']);
const fail=(message,code='WRITE_ACTION_BLOCKED')=>{const error=new ActionInputError(message);error.code=code;throw error;};
export function resolveExecutionProfile(value){
if(value===undefined) return null;
if(value===NATIVE_UI_PROFILE) return NATIVE_UI_CONTRACT;
if(value===NAVIGATION_UI_PROFILE) return NAVIGATION_UI_CONTRACT;
fail('未知或不支持的执行模式','EXECUTION_PROFILE_INVALID');
}
export const isNativeUiProfile=value=>value===NATIVE_UI_PROFILE || value===NAVIGATION_UI_PROFILE;
export function executionContractMatches(value,profile=NATIVE_UI_PROFILE){
const contract=resolveExecutionProfile(profile);
return Boolean(value && typeof value==='object' && !Array.isArray(value) && Object.keys(value).length===3 &&
contract && Object.keys(contract).every(key=>Object.hasOwn(value,key) && value[key]===contract[key]));
}
/** Fixed public navigation destinations; no message, composer or account action route. */
export function navigationTarget(value){
try{
const url=new URL(value);
if(url.origin!==OFFICIAL || url.port || url.username || url.password) return null;
const note=/^\/(?:explore|search_result)\/([a-fA-F0-9]{24})$/.exec(url.pathname);
const profile=/^\/user\/profile\/([a-fA-F0-9]{24})$/.exec(url.pathname);
const kind=note ? 'note' : profile ? 'profile' : url.pathname==='/notification' ? 'notification' :
url.pathname==='/explore' ? 'explore' : ['/search_result','/search_result_ai'].includes(url.pathname) ? 'search' : null;
return kind ? {kind,object_id:(note?.[1] || profile?.[1] || null)?.toLowerCase() ?? null,href:url.href} : null;
}catch{return null;}
}
export const validNavigationPage=value=>navigationTarget(value)!==null;
function profileChannelOmissionMatches(expectedUrl,actualUrl){
const expected=new URL(expectedUrl),actual=new URL(actualUrl);
if(expected.origin!==actual.origin || expected.pathname!==actual.pathname || expected.hash!==actual.hash) return false;
const removable=['channel_type','parent_page_channel_type'],omitted=new Set();
for(const key of removable){
const before=expected.searchParams.getAll(key),after=actual.searchParams.getAll(key);
if(before.length>1 || after.length>1 || after.length>before.length || (after.length && after[0]!==before[0])) return false;
if(before.length===1 && after.length===0) omitted.add(key);
}
if(!omitted.size) return false;
// Only the two observed channel parameters may disappear. Every retained
// pair, including unknown keys and both xsec values, keeps its value/order.
const before=[...expected.searchParams.entries()].filter(([key])=>!omitted.has(key));
return JSON.stringify(before)===JSON.stringify([...actual.searchParams.entries()]);
}
export function navigationDestinationMatches(expectedUrl,actualUrl){
const expected=navigationTarget(expectedUrl),actual=navigationTarget(actualUrl);
if(!expected || !actual || expected.kind!==actual.kind || expected.object_id!==actual.object_id) return false;
// The site switches a clicked search-result note to its standard explore
// alias and changes tracking parameters. Identity remains the same public
// note. Public profile hydration may only omit the two observed channel
// parameters; all other destinations keep exact full-URL binding.
return expected.kind==='note' || expected.href===actual.href ||
(expected.kind==='profile' && profileChannelOmissionMatches(expected.href,actual.href));
}
export const navigationPopupMatches=(expectedUrl,actualUrl)=>navigationDestinationMatches(expectedUrl,actualUrl);
// Fixed diagnostics only: no URL, object identifier, arbitrary query key or
// query value leaves the process. These facts never authorize navigation.
export function navigationComparison(expectedUrl,actualUrl){
const expected=navigationTarget(expectedUrl),actual=navigationTarget(actualUrl);
let left=null,right=null;
try{left=new URL(expectedUrl);}catch{}
try{right=new URL(actualUrl);}catch{}
const entries=url=>url ? [...url.searchParams.entries()] : null;
const equal=(a,b)=>a!==null && b!==null && JSON.stringify(a)===JSON.stringify(b);
const tracking=key=>({expected_count:Math.min(1000,left?.searchParams.getAll(key).length ?? 0),
actual_count:Math.min(1000,right?.searchParams.getAll(key).length ?? 0),
values_equal:equal(left ? left.searchParams.getAll(key) : null,right ? right.searchParams.getAll(key) : null)});
const other=url=>entries(url)?.filter(([key])=>!['xsec_token','xsec_source'].includes(key)) ?? null;
return {expected_kind:expected?.kind || 'invalid',actual_kind:actual?.kind || 'invalid',
same_origin:Boolean(left && right && left.origin===right.origin),same_pathname:Boolean(left && right && left.pathname===right.pathname),
same_object_id:Boolean(expected?.object_id && actual?.object_id && expected.kind===actual.kind && expected.object_id===actual.object_id),
same_query:Boolean(left && right && left.search===right.search),same_query_pairs:equal(entries(left),entries(right)),
same_hash:Boolean(left && right && left.hash===right.hash),xsec_token:tracking('xsec_token'),xsec_source:tracking('xsec_source'),
other_query_equal:equal(other(left),other(right))};
}
export function validNativePage(value){
try{
const url=new URL(value);
return url.origin===OFFICIAL && !url.username && !url.password &&
(/^\/(?:explore|search_result|search_result_ai)$/.test(url.pathname) || /^\/(?:explore|search_result)\/[a-fA-F0-9]{24}$/.test(url.pathname));
}catch{return false;}
}
export function nativeNoteId(value){
try{
const url=new URL(value),match=/^\/(?:explore|search_result)\/([a-fA-F0-9]{24})$/.exec(url.pathname);
return url.origin===OFFICIAL && !url.username && !url.password && match ? match[1].toLowerCase() : null;
}catch{return null;}
}
export function nativeSearchKeyword(value){
try{
const url=new URL(value);
if(!validNativePage(value) || !['/search_result','/search_result_ai'].includes(url.pathname)) return null;
const values=url.searchParams.getAll('keyword');
if(values.length!==1) return null;
let keyword=values[0];
// The observed site currently encodes the search text twice. Decode at
// most once after URLSearchParams, never infer a different search term.
if(/%[a-f0-9]{2}/i.test(keyword)) try{keyword=decodeURIComponent(keyword);}catch{return null;}
return keyword.trim();
}catch{return null;}
}
export function nativeReadRootsValid(roots){
return Array.isArray(roots) && roots.length>0 && roots.length<=500 && roots.every(root=>
root?.native_context?.read_root===true && root.readonly_root===true && root.sensitive===false && root.forbidden===false);
}
export function assertNativeUiResultLocation(context,pageUrl){
if(context.executionProfile===NAVIGATION_UI_PROFILE){
const actual=navigationTarget(pageUrl);
if(!actual) fail('动作后的页面不在声明导航范围','SOURCE_CHANGED');
if(context.kind==='click'){
if(!navigationDestinationMatches(context.href,pageUrl)){
const error=new ActionInputError('点击后未进入本次固定的完整导航来源','NAVIGATION_BINDING_MISMATCH');
error.navigation_comparison=navigationComparison(context.href,pageUrl);
throw error;
}
return true;
}
if(context.kind==='press'){
if(nativeSearchKeyword(pageUrl)!==context.keyword?.trim()) fail('搜索提交尚未进入对应关键词结果页','SEARCH_TRANSITION_NOT_READY');
}else if(pageUrl!==context.pageUrl) fail('读取动作期间的完整页面来源已变化','SOURCE_CHANGED');
return true;
}
if(!validNativePage(pageUrl)) fail('动作后的页面不在声明范围','SOURCE_CHANGED');
if(context.kind==='press'){
if(nativeSearchKeyword(pageUrl)!==context.keyword?.trim()) fail('搜索提交尚未进入对应关键词结果页','SEARCH_TRANSITION_NOT_READY');
}else if(context.kind==='click'){
if(nativeNoteId(pageUrl)!==nativeNoteId(context.href)) fail('点击后未进入本次固定笔记来源','NOTE_BINDING_MISMATCH');
}else if(pageUrl!==context.pageUrl) fail('读取动作期间的完整页面来源已变化','SOURCE_CHANGED');
return true;
}
/** Metadata comes only from the worker's fixed inspection, never API input. */
export function assertNativeUiAction({kind,pageUrl,selector,key,observed,actualRoots,matchCount,href,keyword,noteId,executionProfile=NATIVE_UI_PROFILE,targetResolvedUnique=false}){
resolveExecutionProfile(executionProfile);
const navigation=executionProfile===NAVIGATION_UI_PROFILE;
if(!(navigation ? validNavigationPage(pageUrl) : validNativePage(pageUrl))) fail('当前页面不在已声明的小红书读取范围','SCOPE_BLOCKED');
if(['fill','press'].includes(kind)){
const actual=actualRoots?.[0];
if(!SEARCH_SELECTORS.has(selector) || matchCount!==1 || actualRoots?.length!==1 ||
observed?.kind!=='fill' || observed.unique!==true || observed.search_input!==true || observed.sensitive!==false ||
actual?.search_input!==true || actual.native_context?.search_input!==true || actual.sensitive!==false ||
!['input','textarea'].includes(actual.tag) || actual.tag!==observed.tag ||
actual.visible!==true || actual.in_viewport!==true || actual.unobscured!==true ||
(kind==='press' && key!=='Enter')) fail('此执行模式只允许已观察、唯一可用的普通搜索输入框');
if(typeof keyword!=='string' || !keyword.trim() || keyword.length>2000 || /(?:\+?86[-\s]?)?1[3-9]\d{9}|(?:验证码|密码|password)\s*[::]/i.test(keyword)) fail('搜索值须为已声明的普通业务关键词','SENSITIVE_INPUT');
return true;
}
if(kind==='click'){
if(navigation){
const actual=actualRoots?.[0],destination=navigationTarget(href),metadata=actual?.native_context;
const fixed=destination?.kind==='note' ? metadata?.note_link===true : destination?.kind==='profile' ? metadata?.profile_link===true : metadata?.navigation_link===true;
if(matchCount!==1 || actualRoots?.length!==1 || observed?.kind!=='click' || observed.tag!=='a' || observed.sensitive!==false ||
(observed.unique!==true && targetResolvedUnique!==true) || actual?.tag!=='a' || !fixed || actual.sensitive!==false ||
actual.visible!==true || actual.in_viewport!==true || actual.unobscured!==true || actual.href!==href || !destination ||
(noteId && (destination.kind!=='note' || destination.object_id!==noteId))) fail('导航只允许已观察、固定实际对象的官网读取链接');
return true;
}
const actual=actualRoots?.[0],id=nativeNoteId(href);
if(matchCount!==1 || actualRoots?.length!==1 || observed?.kind!=='click' || observed.tag!=='a' || observed.sensitive!==false ||
actual?.tag!=='a' || actual.native_context?.note_link!==true || actual.sensitive!==false || actual.visible!==true ||
actual.in_viewport!==true || actual.unobscured!==true || actual.href!==href || !id || (noteId && id!==noteId))
fail('此执行模式只允许固定实际对象的官网标准笔记链接');
return true;
}
if(!READ_KINDS.has(kind) || !nativeReadRootsValid(actualRoots)) fail('此执行模式只允许普通、非交互、不可编辑区域的有界读取','SENSITIVE_OUTPUT');
return true;
}
export const executionMetadata=profile=>profile===undefined ? {} :
({execution_profile:profile,execution_contract:{...resolveExecutionProfile(profile)}});