diff --git a/clis/xiaohongshu/creator-note-detail.js b/clis/xiaohongshu/creator-note-detail.js index 8b9dcd231..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: '基础数据' }, @@ -247,37 +247,170 @@ 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' }, ]; +const CAPTURE_POLL_ATTEMPTS = 20; +const CAPTURE_POLL_INTERVAL_S = 0.5; +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 = {}; + if (window.__xhsCaptureInstalled) return; + window.__xhsCaptureInstalled = true; + 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 (shouldCapture(url)) { + 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 (shouldCapture(capturedUrl)) { + window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText }; + } + } catch (_) {} + }); + 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; + })()`); +} async function captureNoteDetailPayload(page, noteId) { - 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}`; + 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); + let captureMap = {}; + for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) { + await page.wait(CAPTURE_POLL_INTERVAL_S); + let raw; 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; } + 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 (data && typeof data === 'string') { - try { - payload[key] = JSON.parse(data); - captured++; - } - catch { } - } + if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) { + throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer'); } - catch { } + const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix)); + if (captured.length === wantedSuffixes.length) + break; + } + const payload = {}; + for (const endpoint of DETAIL_API_ENDPOINTS) { + const matchUrl = findCapturedUrl(captureMap, endpoint.suffix); + if (!matchUrl) + continue; + payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint); } - return captured > 0 ? payload : null; + return Object.keys(payload).length > 0 ? payload : null; } async function captureNoteDetailDomData(page) { const result = await page.evaluate(`() => { @@ -308,14 +441,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); 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..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'; @@ -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,155 @@ 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('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');