185 lines
12 KiB
JavaScript
185 lines
12 KiB
JavaScript
// Experimental exceptions for requests naturally made by finite official UI
|
|
// actions. This module never issues a request or inspects bodies/headers.
|
|
import { validateSelector } from './action-contract.mjs';
|
|
import {assertNativeUiAction,validNativePage,nativeNoteId,nativeSearchKeyword,NAVIGATION_UI_PROFILE,navigationTarget,validNavigationPage,navigationDestinationMatches} from './execution-profile.mjs';
|
|
|
|
export const SEARCH_POLICY_VERSION = 'xhs-search-experimental-v3';
|
|
export const NOTE_POLICY_VERSION = 'xhs-note-open-experimental-v2';
|
|
export const READONLY_POLICY_VERSION = 'xhs-readonly-ui-experimental-v1';
|
|
export const SEARCH_SCROLL_POLICY_VERSION = 'xhs-search-scroll-experimental-v1';
|
|
export const NATIVE_UI_POLICY_VERSION = 'xhs-page-native-v1';
|
|
export const NAVIGATION_UI_POLICY_VERSION = 'xhs-page-navigation-v1';
|
|
const OFFICIAL_ORIGIN = 'https://www.xiaohongshu.com';
|
|
const issued = new WeakSet();
|
|
const searchScrollPages = new WeakMap();
|
|
const nativeUiPages = new WeakMap();
|
|
const MAX_WINDOW_MS = 15_000;
|
|
const SEARCH_RULES = Object.freeze([
|
|
['collect', 'https://t2.xiaohongshu.com', '/api/v2/collect', 'xhr'],
|
|
['search_history', 'https://so.xiaohongshu.com', '/api/sns/web/search/history/sync', 'xhr'],
|
|
['search_notes', 'https://so.xiaohongshu.com', '/api/sns/web/v2/search/notes', 'xhr'],
|
|
['search_onebox', 'https://edith.xiaohongshu.com', '/api/sns/web/v1/search/onebox', 'xhr'],
|
|
['search_dqa', 'https://so.xiaohongshu.com', '/api/sns/web/v1/dqa/instant', 'xhr'],
|
|
['apm_data', 'https://apm-fe.xiaohongshu.com', '/api/data', 'fetch'],
|
|
['apm_data_xhr', 'https://apm-fe.xiaohongshu.com', '/api/data', 'xhr'],
|
|
].map(rule => Object.freeze(rule)));
|
|
const NOTE_RULES = Object.freeze([
|
|
['collect', 'https://t2.xiaohongshu.com', '/api/v2/collect', 'xhr'],
|
|
['note_share_code', 'https://edith.xiaohongshu.com', '/api/sns/web/share/code', 'xhr'],
|
|
['note_widgets', 'https://edith.xiaohongshu.com', '/api/sns/web/v2/widgets', 'xhr'],
|
|
['note_feed', 'https://edith.xiaohongshu.com', '/api/sns/web/v1/feed', 'xhr'],
|
|
['note_seo', 'https://edith.xiaohongshu.com', '/api/sns/web/worldcup/note/seo', 'xhr'],
|
|
['note_metrics', 'https://edith.xiaohongshu.com', '/api/sns/web/v1/note/metrics_report', 'xhr'],
|
|
['note_history_report', 'https://edith.xiaohongshu.com', '/api/sns/v1/history/report_web', 'xhr'],
|
|
['apm_data', 'https://apm-fe.xiaohongshu.com', '/api/data', 'fetch'],
|
|
].map(rule => Object.freeze(rule)));
|
|
const READONLY_RULES = Object.freeze([
|
|
['collect', 'https://t2.xiaohongshu.com', '/api/v2/collect', 'xhr'],
|
|
['apm_data', 'https://apm-fe.xiaohongshu.com', '/api/data', 'fetch'],
|
|
].map(rule => Object.freeze(rule)));
|
|
// Pagination is the existing exact search-notes rule. Other search-start
|
|
// requests (history, onebox, dqa) are deliberately outside this scope.
|
|
const SEARCH_SCROLL_RULES = Object.freeze([...READONLY_RULES, SEARCH_RULES.find(([name]) => name === 'search_notes')]);
|
|
|
|
function officialPage(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
return url.origin === OFFICIAL_ORIGIN && !url.username && !url.password;
|
|
} catch { return false; }
|
|
}
|
|
|
|
export function beginNativeUiRequestPolicy(context){
|
|
if(context.sessionOrigin!==OFFICIAL_ORIGIN || !Number.isFinite(context.now ?? Date.now())) return null;
|
|
try{assertNativeUiAction(context);}catch{return null;}
|
|
const now=context.now ?? Date.now();
|
|
const navigation=context.executionProfile===NAVIGATION_UI_PROFILE;
|
|
const policy=Object.freeze({version:navigation ? NAVIGATION_UI_POLICY_VERSION : NATIVE_UI_POLICY_VERSION,scope:'native_ui',expiresAt:now+MAX_WINDOW_MS});
|
|
issued.add(policy);
|
|
nativeUiPages.set(policy,Object.freeze({pageUrl:context.pageUrl,startsAt:now,kind:context.kind,navigation,
|
|
keyword:['fill','press'].includes(context.kind) ? context.keyword.trim() : null,
|
|
noteId:context.kind==='click' ? nativeNoteId(context.href) : null,
|
|
navigationTarget:navigation && context.kind==='click' ? navigationTarget(context.href) : null}));
|
|
return policy;
|
|
}
|
|
|
|
function nativePageMatches(policy,pageUrl,now){
|
|
const bound=nativeUiPages.get(policy);
|
|
if(!bound || !(bound.navigation ? validNavigationPage(pageUrl) : validNativePage(pageUrl))) return 'page_changed';
|
|
if(now<bound.startsAt) return 'policy_not_started';
|
|
if(now>=policy.expiresAt) return 'policy_expired';
|
|
if(pageUrl===bound.pageUrl) return null;
|
|
if(bound.kind==='press' && nativeSearchKeyword(pageUrl)===bound.keyword) return null;
|
|
if(bound.kind==='click'){
|
|
if(bound.navigation){
|
|
if(navigationDestinationMatches(bound.navigationTarget?.href,pageUrl)) return null;
|
|
}else if(nativeNoteId(pageUrl)===bound.noteId) return null;
|
|
}
|
|
return 'page_changed';
|
|
}
|
|
|
|
export function beginSearchRequestPolicy({ sessionOrigin, pageUrl, kind, key, observed, actual, matchCount, now = Date.now() }) {
|
|
if (sessionOrigin !== OFFICIAL_ORIGIN || !officialPage(pageUrl) ||
|
|
!(kind === 'fill' || (kind === 'press' && key === 'Enter')) || matchCount !== 1 ||
|
|
observed?.kind !== 'fill' || observed.unique !== true || observed.search_input !== true || observed.sensitive !== false ||
|
|
!['input', 'textarea'].includes(observed.tag) || actual?.tag !== observed.tag || actual.search_input !== true ||
|
|
actual.sensitive !== false || actual.visible !== true || actual.in_viewport !== true || actual.unobscured !== true ||
|
|
!Number.isFinite(now)) return null;
|
|
const policy = Object.freeze({ version: SEARCH_POLICY_VERSION, scope: 'search', expiresAt: now + MAX_WINDOW_MS });
|
|
issued.add(policy);
|
|
return policy;
|
|
}
|
|
|
|
export function beginNoteOpenRequestPolicy({ sessionOrigin, pageUrl, kind, observed, actual, matchCount, href, targetResolvedUnique = false, now = Date.now() }) {
|
|
if (sessionOrigin !== OFFICIAL_ORIGIN || !officialPage(pageUrl) || kind !== 'click' || matchCount !== 1 ||
|
|
observed?.kind !== 'click' || observed.tag !== 'a' || (observed.unique !== true && targetResolvedUnique !== true) || observed.sensitive !== false ||
|
|
actual?.tag !== 'a' || actual.sensitive !== false || actual.visible !== true || actual.in_viewport !== true ||
|
|
actual.unobscured !== true || actual.href !== href || !officialPage(href) || !Number.isFinite(now)) return null;
|
|
const url = new URL(href);
|
|
if (!/^\/(?:explore|search_result)\/[a-fA-F0-9]{24}$/.test(url.pathname)) return null;
|
|
const policy = Object.freeze({ version: NOTE_POLICY_VERSION, scope: 'note_open', expiresAt: now + MAX_WINDOW_MS });
|
|
issued.add(policy);
|
|
return policy;
|
|
}
|
|
|
|
function boundedSearchScroll({ pageUrl, kind, selector, observed, actualRoots, scrollScope, untilTarget }) {
|
|
if (kind !== 'scroll' || scrollScope !== 'viewport' || !untilTarget || typeof untilTarget !== 'object' || Array.isArray(untilTarget) ||
|
|
Object.keys(untilTarget).some(key => !['selector','field','value'].includes(key)) || untilTarget.field !== 'text' ||
|
|
typeof untilTarget.value !== 'string' || !untilTarget.value.trim() || untilTarget.value.length > 2000 ||
|
|
observed?.kind !== 'read' || observed.unique !== true || observed.sensitive !== false || actualRoots.length !== 1 ||
|
|
typeof observed.tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(observed.tag) ||
|
|
['body','html','a'].includes(observed.tag) || actualRoots[0].tag !== observed.tag ||
|
|
actualRoots[0].visible !== true || actualRoots[0].in_viewport !== true) return false;
|
|
try {
|
|
if (!['/search_result','/search_result_ai'].includes(new URL(pageUrl).pathname)) return false;
|
|
validateSelector(selector, { relative:true });
|
|
validateSelector(untilTarget.selector, { relative:true });
|
|
} catch { return false; }
|
|
return true;
|
|
}
|
|
|
|
export function beginReadonlyRequestPolicy({ sessionOrigin, pageUrl, kind, selector, selectorObserved, actualRoots, observed, scrollScope, untilTarget, now = Date.now() }) {
|
|
if (sessionOrigin !== OFFICIAL_ORIGIN || !officialPage(pageUrl) || !['read','extract','scroll','wait'].includes(kind) ||
|
|
typeof selector !== 'string' || !selector.trim() || selector.length>1000 ||
|
|
!Number.isFinite(now) || !Array.isArray(actualRoots) || !actualRoots.length || actualRoots.length > 500) return null;
|
|
const bodyException = selector === 'body' && ['scroll','wait'].includes(kind);
|
|
if ((selector === 'body' && !bodyException) || (!bodyException && selectorObserved !== true) ||
|
|
actualRoots.some(root => root?.readonly_root !== true || root.sensitive !== false || root.forbidden !== false ||
|
|
['input','textarea','select','button','form','script','style','noscript','iframe'].includes(root.tag) ||
|
|
(root.tag === 'body' && !bodyException) || (bodyException && root.tag !== 'body'))) return null;
|
|
const searchScroll = boundedSearchScroll({pageUrl,kind,selector,observed,actualRoots,scrollScope,untilTarget});
|
|
const policy = Object.freeze({ version:searchScroll ? SEARCH_SCROLL_POLICY_VERSION : READONLY_POLICY_VERSION,
|
|
scope:searchScroll ? 'search_scroll' : 'readonly_ui',expiresAt:now+MAX_WINDOW_MS });
|
|
issued.add(policy);
|
|
if (searchScroll) searchScrollPages.set(policy, Object.freeze({pageUrl,startsAt:now}));
|
|
return policy;
|
|
}
|
|
|
|
// Fixed diagnostic reasons explain the existing decision; they never authorize
|
|
// another request, replay a request, or inspect headers or bodies.
|
|
export function evaluatePlatformRequest({ policy, automating, sessionOrigin, pageUrl, activeMainFrame, method, resourceType, url, now = Date.now() }) {
|
|
const active=Boolean(policy && issued.has(policy));
|
|
const reject=reason=>({allowed:false,reason,...(active ? {policy_version:policy.version} : {})});
|
|
if(!policy) return reject('missing_policy');
|
|
if(!active) return reject('inactive_policy');
|
|
if(!Number.isFinite(now)) return reject('invalid_time');
|
|
if(now>policy.expiresAt) return reject('policy_expired');
|
|
if(automating!==true) return reject('automation_inactive');
|
|
if(sessionOrigin!==OFFICIAL_ORIGIN) return reject('session_out_of_scope');
|
|
if(!officialPage(pageUrl)) return reject('page_out_of_scope');
|
|
if(activeMainFrame!==true) return reject('not_main_frame');
|
|
if(method!=='POST') return reject('method_not_allowed');
|
|
if (policy.scope === 'search_scroll') {
|
|
const bound = searchScrollPages.get(policy);
|
|
if(!bound || pageUrl!==bound.pageUrl) return reject('page_changed');
|
|
if(now<bound.startsAt) return reject('policy_not_started');
|
|
if(now>=policy.expiresAt) return reject('policy_expired');
|
|
}
|
|
let parsed;
|
|
try { parsed = new URL(url); } catch { return reject('invalid_url'); }
|
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return reject('invalid_url');
|
|
if(policy.scope==='native_ui'){
|
|
const pageFailure=nativePageMatches(policy,pageUrl,now);
|
|
if(pageFailure) return reject(pageFailure);
|
|
// This permits the trusted site's own page traffic, not a tool/API call.
|
|
// It does not classify POST as read-only or suppress platform security.
|
|
if(parsed.port || !(parsed.hostname==='xiaohongshu.com' || parsed.hostname.endsWith('.xiaohongshu.com'))) return reject('request_out_of_scope');
|
|
if(!['xhr','fetch'].includes(resourceType)) return {...reject('resource_type_not_allowed'),expected_resource_types:['xhr','fetch']};
|
|
return {allowed:true,policy_version:policy.version,policy_rule:'native_page_traffic'};
|
|
}
|
|
const rules = policy.scope === 'search' ? SEARCH_RULES : policy.scope === 'note_open' ? NOTE_RULES : policy.scope === 'readonly_ui' ? READONLY_RULES : policy.scope === 'search_scroll' ? SEARCH_SCROLL_RULES : [];
|
|
const locationRules=rules.filter(([,origin,pathname])=>parsed.origin===origin && parsed.pathname===pathname);
|
|
if(!locationRules.length) return reject('request_out_of_scope');
|
|
const rule=locationRules.find(([, , , type])=>resourceType===type);
|
|
return rule ? {allowed:true,policy_version:policy.version,policy_rule:rule[0]} :
|
|
{...reject('resource_type_not_allowed'),expected_resource_types:[...new Set(locationRules.map(([, , , type])=>type))]};
|
|
}
|
|
|
|
export function matchPlatformRequest(context) {
|
|
const decision=evaluatePlatformRequest(context);
|
|
return decision.allowed ? {policy_version:decision.policy_version,policy_rule:decision.policy_rule} : null;
|
|
}
|
|
|
|
export function endPlatformRequestPolicy(policy) {
|
|
if (policy) { issued.delete(policy); searchScrollPages.delete(policy); nativeUiPages.delete(policy); }
|
|
}
|