From 867760fd9e97d5da455b83f22b2cf7c9b0285485 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Sat, 23 May 2026 18:31:23 +0900 Subject: [PATCH 1/5] fix(xiaohongshu): hook dashboard fetch to capture signed datacenter/note/* responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four /api/galaxy/creator/datacenter/note/* endpoints behind the creator-note-detail view require an x-s / x-t / x-s-common signing interceptor that the dashboard's own JS installs at page load. The previous in-page roundtrip called fetch() directly from page.evaluate, which bypasses the interceptor and gets HTTP 406, so 观看来源 / 观众画像 / 趋势数据 rows silently never landed even though the help string promised them. Instead of forging signatures, install a fetch + XHR capture hook on window.__xhsCapture, SPA-navigate to /statistics/note-detail via history.pushState + popstate (a hard page.goto would wipe the hook before the first auto-fetch fires), and harvest the dashboard's own signed responses out of the capture buffer. Also fix a 1-character endpoint name: /note/audience -> /note/audience/source. The old path returned 404 even when signed; the page actually fetches /note/audience/source for the 观看来源 panel. Confirmed against the live dashboard XHR list while logged in. Tests updated to mock the new install-hook + SPA-nav + poll-capture sequence at page.evaluate (the previous burst-wait-between-fetches assertion no longer applies). Closes #1728. Reporter diagnosis: @ppop123 traced the signing bypass + endpoint typo and verified the hook + SPA-nav workaround on 86 notes. --- clis/xiaohongshu/creator-note-detail.js | 106 ++++++++++++++----- clis/xiaohongshu/creator-note-detail.test.js | 86 ++++++++------- 2 files changed, 131 insertions(+), 61 deletions(-) diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index 8b9dcd231..ac260efa6 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -247,37 +247,89 @@ const DETAIL_API_ENDPOINTS = [ { suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' }, { suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' }, { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' }, - { suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' }, + { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' }, ]; +// Install a fetch + XHR capture hook on window.__xhsCapture so the +// dashboard's own signed requests (x-s / x-t / x-s-common) land in our +// observation buffer. A direct fetch() from page.evaluate bypasses the +// signing interceptor and returns HTTP 406, so prior to this the four +// datacenter/note/* calls silently surfaced no rows. +async function installXhsFetchCaptureHook(page) { + await page.evaluate(`(() => { + if (window.__xhsCapture) return; + window.__xhsCapture = {}; + const origFetch = window.fetch; + window.fetch = async function(...args) { + const resp = await origFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; + if (url.includes('/api/galaxy/')) { + resp.clone().text().then((body) => { + try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {} + }).catch(() => {}); + } + } catch (_) {} + return resp; + }; + const OrigXHR = window.XMLHttpRequest; + function HookedXHR() { + const xhr = new OrigXHR(); + const origOpen = xhr.open; + let capturedUrl = ''; + xhr.open = function(method, url, ...rest) { + capturedUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + xhr.addEventListener('load', () => { + try { + if (capturedUrl.includes('/api/galaxy/')) { + window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText }; + } + } catch (_) {} + }); + return xhr; + } + HookedXHR.prototype = OrigXHR.prototype; + window.XMLHttpRequest = HookedXHR; + })()`); +} async function captureNoteDetailPayload(page, noteId) { + await installXhsFetchCaptureHook(page); + // SPA-navigate inside the dashboard so the React router re-fires the + // signed datacenter/note/* requests under our hook. A second page.goto + // would wipe the hook before the first auto-fetch can land. + await page.evaluate(`(() => { + const target = '/statistics/note-detail?noteId=' + ${JSON.stringify(noteId)}; + history.pushState({}, '', target); + window.dispatchEvent(new PopStateEvent('popstate')); + })()`); + const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix); + // 20 iterations × 0.5s wait = 10s upper bound; the iteration cap also + // keeps the loop terminating quickly under a no-op page.wait mock. + let captureMap = {}; + for (let i = 0; i < 20; i++) { + await page.wait(0.5); + const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); + captureMap = typeof raw === 'string' ? JSON.parse(raw) : {}; + const captured = wantedSuffixes.filter((suffix) => Object.keys(captureMap).some((url) => url.includes(suffix))); + if (captured.length === wantedSuffixes.length) + break; + } const payload = {}; - let captured = 0; - // Try to fetch each API endpoint through the page context (uses the browser's cookies) for (const { suffix, key } of DETAIL_API_ENDPOINTS) { - await page.wait({ time: 0.5 + Math.random() }); - const apiUrl = `${suffix}?note_id=${noteId}`; + const matchUrl = Object.keys(captureMap).find((url) => url.includes(suffix)); + if (!matchUrl) + continue; + const capture = captureMap[matchUrl]; + if (!capture || !capture.ok) + continue; try { - const data = await page.evaluate(` - async () => { - try { - const resp = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' }); - if (!resp.ok) return null; - const json = await resp.json(); - return JSON.stringify(json.data ?? {}); - } catch { return null; } - } - `); - if (data && typeof data === 'string') { - try { - payload[key] = JSON.parse(data); - captured++; - } - catch { } - } + const json = JSON.parse(capture.body); + payload[key] = json.data ?? json; } catch { } } - return captured > 0 ? payload : null; + return Object.keys(payload).length > 0 ? payload : null; } async function captureNoteDetailDomData(page) { const result = await page.evaluate(`() => { @@ -308,14 +360,18 @@ async function captureNoteDetailDomData(page) { return result; } export async function fetchCreatorNoteDetailRows(page, noteId) { - await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`); + // Land on the dashboard root first so the React app boots before the + // note-specific signed APIs fire. captureNoteDetailPayload then installs + // the fetch+XHR hook and SPA-navigates to /statistics/note-detail under + // it, which is what surfaces the audience / trend rows. + await page.goto('https://creator.xiaohongshu.com/statistics'); + const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null); const domData = await captureNoteDetailDomData(page).catch(() => null); let rows = parseCreatorNoteDetailDomData(domData, noteId); if (rows.length === 0) { const bodyText = await page.evaluate('() => document.body.innerText'); rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId); } - const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null); appendTrendRows(rows, apiPayload ?? undefined); appendAudienceRows(rows, apiPayload ?? undefined); return rows; diff --git a/clis/xiaohongshu/creator-note-detail.test.js b/clis/xiaohongshu/creator-note-detail.test.js index 9ce050a16..89e9cf6fc 100644 --- a/clis/xiaohongshu/creator-note-detail.test.js +++ b/clis/xiaohongshu/creator-note-detail.test.js @@ -208,40 +208,44 @@ describe('xiaohongshu creator-note-detail', () => { it('navigates to the note detail page and returns parsed rows', async () => { const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); expect(cmd?.func).toBeTypeOf('function'); - const page = createPageMock([ - { - title: '示例笔记', - infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', - sections: [ - { - title: '基础数据', - metrics: [ - { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, - { label: '观看数', value: '50', extra: '粉丝占比 20%' }, - { label: '封面点击率', value: '12%', extra: '粉丝 11%' }, - { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' }, - { label: '涨粉数', value: '2', extra: '' }, - ], - }, - { - title: '互动数据', - metrics: [ - { label: '点赞数', value: '8', extra: '粉丝占比 25%' }, - { label: '评论数', value: '1', extra: '粉丝占比 0%' }, - { label: '收藏数', value: '3', extra: '粉丝占比 50%' }, - { label: '分享数', value: '0', extra: '粉丝占比 0%' }, - ], - }, - ], - }, - null, - null, - null, - null, - ]); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + { label: '观看数', value: '50', extra: '粉丝占比 20%' }, + { label: '封面点击率', value: '12%', extra: '粉丝 11%' }, + { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' }, + { label: '涨粉数', value: '2', extra: '' }, + ], + }, + { + title: '互动数据', + metrics: [ + { label: '点赞数', value: '8', extra: '粉丝占比 25%' }, + { label: '评论数', value: '1', extra: '粉丝占比 0%' }, + { label: '收藏数', value: '3', extra: '粉丝占比 50%' }, + { label: '分享数', value: '0', extra: '粉丝占比 0%' }, + ], + }, + ], + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({}); + if (s.includes("document.querySelector('.note-title')")) return domData; + if (s.includes('document.body.innerText')) return ''; + return undefined; + }); const result = await cmd.func(page, { 'note-id': 'demo-note-id' }); - expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id'); - expect(page.evaluate.mock.calls[0][0]).toContain("document.querySelector('.note-title')"); + expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics'); + expect(page.evaluate.mock.calls[0][0]).toContain('window.__xhsCapture ='); expect(result).toEqual([ { section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' }, { section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' }, @@ -257,7 +261,7 @@ describe('xiaohongshu creator-note-detail', () => { { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' }, ]); }); - it('waits between creator detail API fetches to avoid burst traffic', async () => { + it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => { const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); const domData = { title: '示例笔记', @@ -284,10 +288,20 @@ describe('xiaohongshu creator-note-detail', () => { }, ], }; - const page = createPageMock([domData, null, null, null, null]); + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({}); + if (s.includes("document.querySelector('.note-title')")) return domData; + return undefined; + }); await cmd.func(page, { 'note-id': 'demo-note-id' }); - expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) })); + // Capture loop polls until the deadline expires (no hits with empty mock). expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4); + const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture')); + expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1); }); it('throws EmptyResultError when the detail page exposes no metrics', async () => { const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); From 9c9db23adc8a435c40492d0a4762a72f0529cccb Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Sat, 23 May 2026 21:04:02 +0900 Subject: [PATCH 2/5] test(xiaohongshu): trim installXhsFetchCaptureHook comment to match sibling tone Sibling helper functions in creator-note-detail.js have no doc-comment block above the declaration; the 5-line WHY block on the new hook was out of style. Compress to two lines covering the same WHY (signed API bypass + 406) and let the rest of the context live in the commit body of the parent fix. --- clis/xiaohongshu/creator-note-detail.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index ac260efa6..ddca464b9 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -249,11 +249,8 @@ const DETAIL_API_ENDPOINTS = [ { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' }, { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' }, ]; -// Install a fetch + XHR capture hook on window.__xhsCapture so the -// dashboard's own signed requests (x-s / x-t / x-s-common) land in our -// observation buffer. A direct fetch() from page.evaluate bypasses the -// signing interceptor and returns HTTP 406, so prior to this the four -// datacenter/note/* calls silently surfaced no rows. +// Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture +// since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. async function installXhsFetchCaptureHook(page) { await page.evaluate(`(() => { if (window.__xhsCapture) return; From f636191d26ad92e74ff714fb7bb001d7dcf96a38 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Sat, 23 May 2026 21:07:44 +0900 Subject: [PATCH 3/5] test(xiaohongshu): name the creator-note-detail poll bounds Inline literals (20 iteration cap, 0.5s wait) drift from sibling convention in clis/xiaohongshu/delete-note.js where the same kind of post-write polling is named VERIFY_TIMEOUT_MS / VERIFY_POLL_MS. Promote the two values to CAPTURE_POLL_ATTEMPTS / CAPTURE_POLL_INTERVAL_S so the loop reads against an explicit budget and future tuning lands in one place. --- clis/xiaohongshu/creator-note-detail.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index ddca464b9..af371b1b8 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -249,6 +249,8 @@ const DETAIL_API_ENDPOINTS = [ { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' }, { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' }, ]; +const CAPTURE_POLL_ATTEMPTS = 20; +const CAPTURE_POLL_INTERVAL_S = 0.5; // Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. async function installXhsFetchCaptureHook(page) { @@ -301,11 +303,9 @@ async function captureNoteDetailPayload(page, noteId) { window.dispatchEvent(new PopStateEvent('popstate')); })()`); const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix); - // 20 iterations × 0.5s wait = 10s upper bound; the iteration cap also - // keeps the loop terminating quickly under a no-op page.wait mock. let captureMap = {}; - for (let i = 0; i < 20; i++) { - await page.wait(0.5); + for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) { + await page.wait(CAPTURE_POLL_INTERVAL_S); const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); captureMap = typeof raw === 'string' ? JSON.parse(raw) : {}; const captured = wantedSuffixes.filter((suffix) => Object.keys(captureMap).some((url) => url.includes(suffix))); From 85603ab19ed20df77debb42bb22d68489a8a47e8 Mon Sep 17 00:00:00 2001 From: Benjamin Liu Date: Sun, 24 May 2026 15:52:22 +0900 Subject: [PATCH 4/5] fix(xiaohongshu): address copilot review on creator-note-detail hook Two polish items from the Copilot review on #1732: - Buffer reset: window.__xhsCapture is now cleared on every install call so stale captures from a previous run on the same tab cannot leak into the current navigation's harvest. The wrapper-install guard moves to a separate __xhsCaptureInstalled flag so the fetch/XHR monkey-patches themselves are still installed exactly once per page lifetime. - XHR static constants: HookedXHR now copies the readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE) from the original constructor so dashboard code that reads XMLHttpRequest.DONE etc against the constructor keeps working. --- clis/xiaohongshu/creator-note-detail.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index af371b1b8..b4679d000 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -255,8 +255,11 @@ const CAPTURE_POLL_INTERVAL_S = 0.5; // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. async function installXhsFetchCaptureHook(page) { await page.evaluate(`(() => { - if (window.__xhsCapture) return; + // Reset the buffer every call so stale captures from a previous run on + // the same tab cannot leak into the current navigation's harvest. window.__xhsCapture = {}; + if (window.__xhsCaptureInstalled) return; + window.__xhsCaptureInstalled = true; const origFetch = window.fetch; window.fetch = async function(...args) { const resp = await origFetch.apply(this, args); @@ -289,6 +292,11 @@ async function installXhsFetchCaptureHook(page) { return xhr; } HookedXHR.prototype = OrigXHR.prototype; + // Preserve readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE) + // since dashboard code may read XMLHttpRequest.DONE etc against the constructor. + for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) { + if (key in OrigXHR) HookedXHR[key] = OrigXHR[key]; + } window.XMLHttpRequest = HookedXHR; })()`); } From b02e7e38a507a56c101dac9a77a542f3961dece2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Mon, 25 May 2026 14:13:20 +0800 Subject: [PATCH 5/5] fix(xhs): tighten note detail capture matching --- clis/xiaohongshu/creator-note-detail.js | 112 ++++++++++++--- clis/xiaohongshu/creator-note-detail.test.js | 137 ++++++++++++++++++- 2 files changed, 230 insertions(+), 19 deletions(-) diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index b4679d000..876e29603 100644 --- a/clis/xiaohongshu/creator-note-detail.js +++ b/clis/xiaohongshu/creator-note-detail.js @@ -9,7 +9,7 @@ * Requires: logged into creator.xiaohongshu.com in Chrome. */ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { EmptyResultError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/; const NOTE_DETAIL_METRICS = [ { label: '曝光数', section: '基础数据' }, @@ -251,10 +251,84 @@ const DETAIL_API_ENDPOINTS = [ ]; const CAPTURE_POLL_ATTEMPTS = 20; const CAPTURE_POLL_INTERVAL_S = 0.5; -// Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture +function detailApiEndpointForUrl(url) { + if (!url) + return null; + try { + const parsed = new URL(String(url), 'https://creator.xiaohongshu.com'); + return DETAIL_API_ENDPOINTS.find((endpoint) => parsed.pathname === endpoint.suffix) ?? null; + } + catch { + return null; + } +} +function findCapturedUrl(captureMap, suffix) { + return Object.keys(captureMap).find((url) => detailApiEndpointForUrl(url)?.suffix === suffix); +} +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +function assertOptionalArray(payload, key, suffix) { + if (key in payload && !Array.isArray(payload[key])) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`); + } +} +function assertOptionalPlainObject(payload, key, suffix) { + if (key in payload && !isPlainObject(payload[key])) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`); + } +} +function validateCapturedPayload(payload, endpoint) { + const suffix = endpoint.suffix; + if (!isPlainObject(payload)) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a malformed payload`); + } + if (endpoint.key === 'noteBase') { + assertOptionalPlainObject(payload, 'hour', suffix); + assertOptionalPlainObject(payload, 'day', suffix); + } + if (endpoint.key === 'audienceSource') { + assertOptionalArray(payload, 'source', suffix); + } + if (endpoint.key === 'audienceSourceDetail') { + for (const key of ['gender', 'age', 'city', 'interest']) { + assertOptionalArray(payload, key, suffix); + } + } + return payload; +} +function parseCapturedJson(capture, endpoint) { + const suffix = endpoint.suffix; + if (!capture || typeof capture !== 'object') { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: malformed capture for ${suffix}`); + } + if (capture.ok !== true) { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned HTTP ${capture.status ?? 'non-2xx'}`); + } + if (typeof capture.body !== 'string') { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a non-text body`); + } + try { + const envelope = JSON.parse(capture.body); + const payload = isPlainObject(envelope) && Object.hasOwn(envelope, 'data') ? envelope.data : envelope; + return validateCapturedPayload(payload, endpoint); + } + catch { + throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned invalid JSON or payload shape`); + } +} +// Capture the dashboard's signed datacenter/note responses on window.__xhsCapture // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406. async function installXhsFetchCaptureHook(page) { await page.evaluate(`(() => { + const targetPaths = ${JSON.stringify(DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix))}; + const shouldCapture = (url) => { + try { + return targetPaths.includes(new URL(String(url), window.location.origin).pathname); + } catch (_) { + return false; + } + }; // Reset the buffer every call so stale captures from a previous run on // the same tab cannot leak into the current navigation's harvest. window.__xhsCapture = {}; @@ -265,7 +339,7 @@ async function installXhsFetchCaptureHook(page) { const resp = await origFetch.apply(this, args); try { const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; - if (url.includes('/api/galaxy/')) { + if (shouldCapture(url)) { resp.clone().text().then((body) => { try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {} }).catch(() => {}); @@ -284,7 +358,7 @@ async function installXhsFetchCaptureHook(page) { }; xhr.addEventListener('load', () => { try { - if (capturedUrl.includes('/api/galaxy/')) { + if (shouldCapture(capturedUrl)) { window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText }; } } catch (_) {} @@ -314,25 +388,27 @@ async function captureNoteDetailPayload(page, noteId) { let captureMap = {}; for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) { await page.wait(CAPTURE_POLL_INTERVAL_S); - const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); - captureMap = typeof raw === 'string' ? JSON.parse(raw) : {}; - const captured = wantedSuffixes.filter((suffix) => Object.keys(captureMap).some((url) => url.includes(suffix))); + let raw; + try { + raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})'); + captureMap = typeof raw === 'string' ? JSON.parse(raw) : {}; + } + catch { + throw new CommandExecutionError('xiaohongshu creator-note-detail: failed to read signed datacenter/note capture buffer'); + } + if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) { + throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer'); + } + const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix)); if (captured.length === wantedSuffixes.length) break; } const payload = {}; - for (const { suffix, key } of DETAIL_API_ENDPOINTS) { - const matchUrl = Object.keys(captureMap).find((url) => url.includes(suffix)); + for (const endpoint of DETAIL_API_ENDPOINTS) { + const matchUrl = findCapturedUrl(captureMap, endpoint.suffix); if (!matchUrl) continue; - const capture = captureMap[matchUrl]; - if (!capture || !capture.ok) - continue; - try { - const json = JSON.parse(capture.body); - payload[key] = json.data ?? json; - } - catch { } + payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint); } return Object.keys(payload).length > 0 ? payload : null; } @@ -370,7 +446,7 @@ export async function fetchCreatorNoteDetailRows(page, noteId) { // the fetch+XHR hook and SPA-navigates to /statistics/note-detail under // it, which is what surfaces the audience / trend rows. await page.goto('https://creator.xiaohongshu.com/statistics'); - const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null); + const apiPayload = await captureNoteDetailPayload(page, noteId); const domData = await captureNoteDetailDomData(page).catch(() => null); let rows = parseCreatorNoteDetailDomData(domData, noteId); if (rows.length === 0) { diff --git a/clis/xiaohongshu/creator-note-detail.test.js b/clis/xiaohongshu/creator-note-detail.test.js index 89e9cf6fc..54f09c164 100644 --- a/clis/xiaohongshu/creator-note-detail.test.js +++ b/clis/xiaohongshu/creator-note-detail.test.js @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { EmptyResultError } from '@jackwener/opencli/errors'; +import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; import { getRegistry } from '@jackwener/opencli/registry'; import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js'; import './creator-note-detail.js'; @@ -303,6 +303,141 @@ describe('xiaohongshu creator-note-detail', () => { const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture')); expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1); }); + it('matches signed API captures by exact pathname so source/detail cannot shadow source', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + ], + }, + ], + }; + const detailCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { gender: [{ title: '女性', value: 64 }] } }), + }, + ]; + const sourceCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ + data: { + source: [ + { + title: '首页推荐', + value_with_double: 88.8, + info: { imp_count: 1000, view_count: 400, interaction_count: 20 }, + }, + ], + }, + }), + }, + ]; + const baseCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { hour: { view_list: [{ date: new Date('2026-03-19T12:00:00+08:00').getTime(), count: 7 }] } } }), + }, + ]; + const trendCapture = [ + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=demo-note-id', + { + status: 200, + ok: true, + body: JSON.stringify({ data: { no_data: false, no_data_tip_msg: '趋势可用' } }), + }, + ]; + for (const orderedCaptures of [ + [detailCapture, sourceCapture, baseCapture, trendCapture], + [sourceCapture, detailCapture, baseCapture, trendCapture], + ]) { + const captureMap = Object.fromEntries(orderedCaptures); + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + if (s.includes("document.querySelector('.note-title')")) return domData; + return undefined; + }); + const result = await cmd.func(page, { 'note-id': 'demo-note-id' }); + expect(result).toEqual(expect.arrayContaining([ + { section: '观看来源', metric: '首页推荐', value: '88.8%', extra: '曝光 1000 · 观看 400 · 互动 20' }, + { section: '观众画像', metric: '性别/女性', value: '64%', extra: '' }, + { section: '趋势数据', metric: '按小时/观看数', value: '1 points', extra: '03-19 12:00=7' }, + ])); + } + }); + it('throws a typed error when a captured signed API returns non-2xx', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const captureMap = { + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id': { + status: 406, + ok: false, + body: '{"msg":"not acceptable"}', + }, + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + return null; + }); + await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError); + }); + it('throws a typed error for wrong-shaped signed API payloads instead of falling back to DOM rows', async () => { + const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); + const domData = { + title: '示例笔记', + infoText: '示例笔记\n2026-03-19 12:00\n切换笔记', + sections: [ + { + title: '基础数据', + metrics: [ + { label: '曝光数', value: '100', extra: '粉丝占比 10%' }, + ], + }, + ], + }; + for (const body of [ + JSON.stringify({ data: null }), + JSON.stringify({ data: [] }), + JSON.stringify({ data: { source: {} } }), + ]) { + const captureMap = { + 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id': { + status: 200, + ok: true, + body, + }, + }; + const page = createPageMock(undefined); + page.evaluate = vi.fn(async (script) => { + const s = String(script); + if (s.includes('window.__xhsCapture =')) return undefined; + if (s.includes('history.pushState')) return undefined; + if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap); + if (s.includes("document.querySelector('.note-title')")) return domData; + return null; + }); + await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError); + } + }); it('throws EmptyResultError when the detail page exposes no metrics', async () => { const cmd = getRegistry().get('xiaohongshu/creator-note-detail'); const page = createPageMock(undefined);