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