Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

427 lines
16 KiB
JavaScript

/**
* Xiaohongshu 点点 ask.
*
* Strategy note:
* 点点 uses Xiaohongshu's signed in-page axios + longlink/HTTP fallback stack.
* This adapter intentionally calls the site's own webpack conversation store
* from an authenticated tab instead of replaying private signatures.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
const XHS_WEB_HOST = 'www.xiaohongshu.com';
const ASK_COLUMNS = [
'query',
'answer',
'source_count',
'source_total_text',
'sources_summary',
'sources',
'warning',
'message_id',
'conversation_id',
];
export function unwrapEvaluateResult(payload) {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
function parseBoundedInteger(raw, defaultValue, min, max, label) {
const value = raw ?? defaultValue;
let parsed;
if (typeof value === 'number') {
parsed = value;
} else if (typeof value === 'string' && /^\d+$/.test(value)) {
parsed = Number(value);
} else {
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(raw)}`);
}
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new ArgumentError(`${label} must be an integer between ${min} and ${max}, got ${JSON.stringify(raw)}`);
}
return parsed;
}
export function parseAskTimeout(raw) {
return parseBoundedInteger(raw, 90, 1, 180, '--timeout');
}
export function parseAskLimit(raw) {
return parseBoundedInteger(raw, 10, 1, 50, '--source-limit');
}
function cleanText(value) {
return String(value ?? '')
.replace(/<[^>]+>/g, '')
.replace(/\u200b/g, '')
.replace(/\r\n/g, '\n')
.trim();
}
function compactSingleLine(value) {
return cleanText(value).replace(/\s+/g, ' ').trim();
}
function firstNonEmpty(...values) {
for (const value of values) {
const text = compactSingleLine(value);
if (text) return text;
}
return '';
}
function parseCount(value) {
if (value === null || value === undefined || value === '') return null;
const toSafeCount = (number) => (
Number.isSafeInteger(number) && number >= 0 ? number : null
);
if (typeof value === 'number') return toSafeCount(value);
const text = String(value).trim();
const compact = text.match(/^(\d+(?:\.\d+)?)\s*(万|[wW]|亿)\+?$/);
if (compact) {
const number = Number(compact[1]);
const multiplier = compact[2] === '亿' ? 1e8 : 1e4;
return Number.isFinite(number) ? toSafeCount(Math.round(number * multiplier)) : null;
}
const plain = text.replace(/,/g, '').replace(/\+$/, '');
if (/^\d+$/.test(plain) && (text === plain || text === `${plain}+` || /^\d{1,3}(?:,\d{3})+\+?$/.test(text))) {
return toSafeCount(Number(plain));
}
return null;
}
function parseUrlMaybe(value) {
if (!value) return null;
try {
return new URL(String(value));
} catch {
return null;
}
}
function parseTrustedSourceLink(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const xhsDiscoverMatch = raw.match(/^xhsdiscover:\/\/item\/([0-9a-f]{24})(?=[?#/]|$)/i);
if (xhsDiscoverMatch) {
const url = parseUrlMaybe(raw);
return {
noteId: xhsDiscoverMatch[1],
xsecToken: url?.searchParams.get('xsec_token') || url?.searchParams.get('xsecToken') || '',
href: raw,
};
}
let url = parseUrlMaybe(raw);
if (!url && raw.startsWith('/')) {
url = parseUrlMaybe(`https://${XHS_WEB_HOST}${raw}`);
}
const host = url?.hostname.toLowerCase();
if (
!url
|| url.protocol !== 'https:'
|| (host !== XHS_WEB_HOST && host !== 'xiaohongshu.com')
) {
return null;
}
const match = url.pathname.match(/^\/(?:explore|search_result|note)\/([0-9a-f]{24})\/?$/i);
if (!match) return null;
return {
noteId: match[1],
xsecToken: url.searchParams.get('xsec_token') || url.searchParams.get('xsecToken') || '',
href: url.toString(),
};
}
function extractNoteId(source) {
const directId = compactSingleLine(source?.id || source?.noteId || source?.note_id);
if (/^[0-9a-f]{24}$/i.test(directId)) return directId;
const candidates = [source?.textLink, source?.link, source?.url];
for (const candidate of candidates) {
const parsed = parseTrustedSourceLink(candidate);
if (parsed?.noteId) return parsed.noteId;
}
return '';
}
export function buildNoteUrl(noteId, xsecToken) {
if (!/^[0-9a-f]{24}$/i.test(String(noteId || ''))) return '';
const url = new URL(`https://${XHS_WEB_HOST}/explore/${noteId}`);
if (xsecToken) {
url.searchParams.set('xsec_token', xsecToken);
url.searchParams.set('xsec_source', '');
}
return url.toString();
}
function extractQuote(source) {
const content = cleanText(source?.content);
if (!content) return '';
const origin = Array.isArray(source?.originLocation) ? source.originLocation : [];
const start = Number(origin[0]);
const length = Number(origin[1]);
if (Number.isInteger(start) && start >= 0 && Number.isInteger(length) && length > 0) {
return compactSingleLine(content.slice(start, start + length)).slice(0, 300);
}
return compactSingleLine(content).slice(0, 300);
}
export function normalizeAskSource(source, index) {
const trustedLink = [source?.textLink, source?.link, source?.url]
.map(parseTrustedSourceLink)
.find(Boolean);
const noteId = extractNoteId(source);
const xsecToken = trustedLink?.xsecToken || '';
const normalized = {
rank: index + 1,
type: 'note',
title: firstNonEmpty(source?.title, source?.displayTitle, source?.name),
url: buildNoteUrl(noteId, xsecToken),
note_id: noteId,
xsec_token: xsecToken,
author: firstNonEmpty(source?.nickName, source?.nickname, source?.author, source?.userName),
};
const noteType = firstNonEmpty(source?.noteType);
if (noteType) normalized.note_type = noteType;
const likeCount = parseCount(source?.like);
if (likeCount !== null) normalized.like_count = likeCount;
const userId = compactSingleLine(source?.userId);
if (userId) normalized.user_id = userId;
const publishedAt = compactSingleLine(source?.time);
if (publishedAt) normalized.published_at = publishedAt;
const quote = extractQuote(source);
if (quote) normalized.quote = quote;
if (trustedLink?.href) normalized.deeplink = trustedLink.href;
return normalized;
}
function buildSourcesSummary(sources) {
return sources
.slice(0, 5)
.map((source) => `${source.rank}. ${source.title || source.note_id}${source.author ? ` - ${source.author}` : ''}`)
.join('\n');
}
export function buildAskResult(raw) {
const sourceItems = Array.isArray(raw?.sources) ? raw.sources : [];
const sources = sourceItems
.map((source, index) => normalizeAskSource(source, index))
.filter((source) => source.note_id || source.title || source.url);
const answer = cleanText(raw?.answer || raw?.base_info?.text || '');
const sourceIssue = compactSingleLine(raw?.warning);
const warning = sources.length === 0 && answer
? `Xiaohongshu 点点 returned an answer but no citation sources.${sourceIssue ? ` ${sourceIssue}` : ''}`
: sourceIssue;
return {
query: compactSingleLine(raw?.query),
answer,
source_count: sources.length,
source_total_text: compactSingleLine(raw?.source_total_text),
sources_summary: buildSourcesSummary(sources),
sources,
warning,
message_id: compactSingleLine(raw?.message_id),
conversation_id: compactSingleLine(raw?.conversation_id),
};
}
export function buildAskEvaluateJs(query, timeoutSeconds, sourceLimit) {
const prompt = String(query);
return `
(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const timeoutMs = ${Number(timeoutSeconds)} * 1000;
const sourceLimit = ${Number(sourceLimit)};
const requestSourceSize = Math.max(sourceLimit, 10);
const prompt = ${JSON.stringify(prompt)};
const simplifyText = (value, max = 6000) => {
const text = String(value || '');
return text.length > max ? text.slice(0, max) : text;
};
const latestRound = (store, scenes, msgId) => {
const rounds = typeof store.getSceneRounds === 'function'
? store.getSceneRounds(scenes.AiChat)
: (Array.isArray(store.rounds) ? store.rounds : []);
return [...rounds].reverse().find((round) => round?.aiMessage?.msgId === msgId) || null;
};
const slimSource = (item) => ({
id: item?.id || item?.noteId || item?.note_id || '',
title: item?.title || item?.displayTitle || '',
type: item?.type || '',
noteType: item?.noteType || '',
userId: item?.userId || '',
nickName: item?.nickName || item?.nickname || '',
content: simplifyText(item?.content || '', 2000),
originLocation: item?.originLocation || null,
textLink: item?.textLink || '',
link: item?.link || item?.imageList?.[0]?.link || '',
url: item?.url || '',
like: item?.like ?? null,
time: item?.time || '',
});
try {
if (!window.webpackChunkxhs_pc_web) {
return { ok: false, error: 'webpack_runtime_missing', page_url: location.href };
}
let webpackRequire;
window.webpackChunkxhs_pc_web.push([[Date.now()], {}, (req) => { webpackRequire = req; }]);
if (!webpackRequire) return { ok: false, error: 'webpack_require_unavailable', page_url: location.href };
const mod = webpackRequire(6404);
const useConversationStore = mod?.t;
const scenes = mod?.G || { AiChat: 'aiChat' };
if (typeof useConversationStore !== 'function') {
return { ok: false, error: 'conversation_store_missing', page_url: location.href };
}
const store = useConversationStore();
if (typeof store.sendMessage !== 'function' || typeof store.createConversation !== 'function') {
return { ok: false, error: 'conversation_api_missing', page_url: location.href };
}
if (typeof store.switchScene === 'function') store.switchScene(scenes.AiChat);
if (typeof store.clearConversation === 'function') {
await Promise.resolve(store.clearConversation(scenes.AiChat));
}
const conversationId = crypto.randomUUID();
store.createConversation(conversationId, scenes.AiChat);
const msgId = await store.sendMessage(conversationId, prompt, {
skipImmediateUI: true,
skipHistory: true,
visible: false,
});
if (!msgId) {
return { ok: false, error: 'send_message_failed', page_url: location.href };
}
const deadline = Date.now() + timeoutMs;
let round = null;
let finished = false;
while (Date.now() < deadline) {
await sleep(1000);
round = latestRound(store, scenes, msgId);
const answer = round?.aiMessage?.text || '';
if (round?.aiMessage?.isFinished && answer) {
finished = true;
break;
}
}
round = round || latestRound(store, scenes, msgId);
const aiMessage = round?.aiMessage || {};
const answer = aiMessage.text || (Array.isArray(aiMessage.dataFragments)
? aiMessage.dataFragments.map((fragment) => fragment?.text || '').join('')
: '');
if (!finished || !answer) {
return { ok: false, error: 'answer_timeout', timeout_seconds: ${Number(timeoutSeconds)}, message_id: msgId, conversation_id: conversationId };
}
let detail = null;
let sourceError = '';
try {
if (store.agent?.getResponseReferences) {
for (let attempt = 0; attempt < 5; attempt++) {
detail = await store.agent.getResponseReferences({
query: prompt,
message_id: msgId,
page: 0,
size: requestSourceSize,
version: 0,
result_version: 0,
id: '',
});
if (Array.isArray(detail?.items) && detail.items.length > 0) break;
await sleep(1000);
}
}
} catch (err) {
sourceError = String(err?.message || err || '');
}
const rawSources = Array.isArray(detail?.items) ? detail.items.slice(0, sourceLimit).map(slimSource) : [];
return {
ok: true,
query: prompt,
answer,
source_total_text: detail?.baseInfo?.totalCnt || aiMessage?.querySource?.text || aiMessage?.querySource?.oneboxText || '',
sources: rawSources,
warning: sourceError,
message_id: msgId,
conversation_id: conversationId,
};
} catch (err) {
return {
ok: false,
error: String(err?.message || err || 'unknown_error'),
stack: String(err?.stack || '').slice(0, 1500),
page_url: location.href,
};
}
})()
`;
}
function requirePrompt(query) {
const prompt = String(query || '').trim();
if (!prompt) throw new ArgumentError('query is required');
return prompt;
}
function mapAskError(raw, timeoutSeconds) {
const error = compactSingleLine(raw?.error);
if (error === 'answer_timeout') {
throw new TimeoutError('xiaohongshu ask', timeoutSeconds, '点点没有在超时时间内返回答案;可以重试或提高 --timeout。');
}
if (error === 'send_message_failed') {
throw new AuthRequiredError(XHS_WEB_HOST, 'Xiaohongshu 点点 did not accept the query. Check login status for www.xiaohongshu.com.');
}
throw new CommandExecutionError(
`xiaohongshu ask failed: ${error || 'unknown error'}`,
raw?.page_url ? `Page URL: ${raw.page_url}` : undefined,
);
}
function requireAskPayload(raw) {
if (!raw || typeof raw !== 'object') {
throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload');
}
const answer = cleanText(raw.answer || raw.base_info?.text || '');
if (!answer) {
throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload: missing answer');
}
if (!compactSingleLine(raw.message_id) || !compactSingleLine(raw.conversation_id)) {
throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload: missing message identity');
}
}
export const command = cli({
site: 'xiaohongshu',
name: 'ask',
access: 'write',
description: 'Ask 小红书点点 and return the answer with citation sources.',
domain: XHS_WEB_HOST,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'query', positional: true, required: true, help: 'Question for 点点' },
{ name: 'timeout', type: 'int', default: 90, help: 'Seconds to wait for the 点点 answer' },
{ name: 'source-limit', type: 'int', default: 10, help: 'Maximum citation sources to return' },
],
columns: ASK_COLUMNS,
func: async (page, kwargs) => {
const query = requirePrompt(kwargs?.query);
const timeout = parseAskTimeout(kwargs?.timeout);
const sourceLimit = parseAskLimit(kwargs?.['source-limit']);
const keyword = encodeURIComponent(query);
await page.goto(`https://${XHS_WEB_HOST}/search_result?keyword=${keyword}&source=web_search_result_notes`);
await page.wait?.(1);
const raw = unwrapEvaluateResult(await page.evaluate(buildAskEvaluateJs(query, timeout, sourceLimit)));
if (!raw || typeof raw !== 'object') {
throw new CommandExecutionError('xiaohongshu ask returned a malformed page payload');
}
if (raw.ok === false) mapAskError(raw, timeout);
requireAskPayload(raw);
return buildAskResult(raw);
},
});