191 lines
12 KiB
JavaScript
191 lines
12 KiB
JavaScript
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)}});
|