@jackwener/opencli 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -245,6 +245,86 @@ describe('weread/search regression', () => {
245
245
  },
246
246
  ]);
247
247
  });
248
+ it('decodes named and astral HTML entities before matching search cards', async () => {
249
+ const command = getRegistry().get('weread/search');
250
+ expect(command?.func).toBeTypeOf('function');
251
+ const fetchMock = vi.fn()
252
+ .mockResolvedValueOnce({
253
+ ok: true,
254
+ json: () => Promise.resolve({
255
+ books: [
256
+ {
257
+ bookInfo: {
258
+ title: 'A <B> 😊',
259
+ author: "O'Neil & Co",
260
+ bookId: 'entity-book',
261
+ },
262
+ },
263
+ ],
264
+ }),
265
+ })
266
+ .mockResolvedValueOnce({
267
+ ok: true,
268
+ text: () => Promise.resolve(`
269
+ <ul class="search_bookDetail_list">
270
+ <li class="wr_bookList_item">
271
+ <a class="wr_bookList_item_link" href="/web/reader/entity-reader"></a>
272
+ <p class="wr_bookList_item_title">A &lt;B&gt; &#x1F60A;</p>
273
+ <p class="wr_bookList_item_author">O&apos;Neil &amp; Co</p>
274
+ </li>
275
+ </ul>
276
+ `),
277
+ });
278
+ vi.stubGlobal('fetch', fetchMock);
279
+ const result = await command.func({ query: 'entities', limit: 5 });
280
+ expect(result).toEqual([
281
+ {
282
+ rank: 1,
283
+ title: 'A <B> 😊',
284
+ author: "O'Neil & Co",
285
+ bookId: 'entity-book',
286
+ url: 'https://weread.qq.com/web/reader/entity-reader',
287
+ },
288
+ ]);
289
+ });
290
+ it('leaves invalid numeric HTML entities literal instead of throwing raw RangeError', async () => {
291
+ const command = getRegistry().get('weread/search');
292
+ expect(command?.func).toBeTypeOf('function');
293
+ const fetchMock = vi.fn()
294
+ .mockResolvedValueOnce({
295
+ ok: true,
296
+ json: () => Promise.resolve({
297
+ books: [
298
+ {
299
+ bookInfo: {
300
+ title: 'Bad &#xFFFFFFFF; Entity',
301
+ author: 'Tester',
302
+ bookId: 'bad-entity-book',
303
+ },
304
+ },
305
+ ],
306
+ }),
307
+ })
308
+ .mockResolvedValueOnce({
309
+ ok: true,
310
+ text: () => Promise.resolve(`
311
+ <ul class="search_bookDetail_list">
312
+ <li class="wr_bookList_item">
313
+ <a class="wr_bookList_item_link" href="/web/reader/bad-entity-reader"></a>
314
+ <p class="wr_bookList_item_title">Bad &#xFFFFFFFF; Entity</p>
315
+ <p class="wr_bookList_item_author">Tester</p>
316
+ </li>
317
+ </ul>
318
+ `),
319
+ });
320
+ vi.stubGlobal('fetch', fetchMock);
321
+ const result = await command.func({ query: 'bad entity', limit: 5 });
322
+ expect(result[0]).toMatchObject({
323
+ title: 'Bad &#xFFFFFFFF; Entity',
324
+ bookId: 'bad-entity-book',
325
+ url: 'https://weread.qq.com/web/reader/bad-entity-reader',
326
+ });
327
+ });
248
328
  it('leaves urls empty when same-title results are ambiguous and html cards have no author', async () => {
249
329
  const command = getRegistry().get('weread/search');
250
330
  expect(command?.func).toBeTypeOf('function');
@@ -1,14 +1,29 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
4
+ function decodeNumericHtmlEntity(raw, radix) {
5
+ const codePoint = parseInt(raw, radix);
6
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) {
7
+ return null;
8
+ }
9
+ try {
10
+ return String.fromCodePoint(codePoint);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
4
16
  function decodeHtmlText(value) {
5
17
  return value
6
18
  .replace(/<[^>]+>/g, '')
7
- .replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
8
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
19
+ .replace(/&#x([0-9a-fA-F]+);/gi, (entity, n) => decodeNumericHtmlEntity(n, 16) ?? entity)
20
+ .replace(/&#(\d+);/g, (entity, n) => decodeNumericHtmlEntity(n, 10) ?? entity)
9
21
  .replace(/&nbsp;/g, ' ')
10
22
  .replace(/&amp;/g, '&')
11
23
  .replace(/&quot;/g, '"')
24
+ .replace(/&apos;/g, "'")
25
+ .replace(/&lt;/g, '<')
26
+ .replace(/&gt;/g, '>')
12
27
  .trim();
13
28
  }
14
29
  function normalizeSearchTitle(value) {
@@ -9,7 +9,7 @@
9
9
  * Requires: logged into creator.xiaohongshu.com in Chrome.
10
10
  */
11
11
  import { cli, Strategy } from '@jackwener/opencli/registry';
12
- import { EmptyResultError } from '@jackwener/opencli/errors';
12
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
13
13
  const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
14
14
  const NOTE_DETAIL_METRICS = [
15
15
  { label: '曝光数', section: '基础数据' },
@@ -247,37 +247,170 @@ const DETAIL_API_ENDPOINTS = [
247
247
  { suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
248
248
  { suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
249
249
  { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
250
- { suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
250
+ { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' },
251
251
  ];
252
+ const CAPTURE_POLL_ATTEMPTS = 20;
253
+ const CAPTURE_POLL_INTERVAL_S = 0.5;
254
+ function detailApiEndpointForUrl(url) {
255
+ if (!url)
256
+ return null;
257
+ try {
258
+ const parsed = new URL(String(url), 'https://creator.xiaohongshu.com');
259
+ return DETAIL_API_ENDPOINTS.find((endpoint) => parsed.pathname === endpoint.suffix) ?? null;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ function findCapturedUrl(captureMap, suffix) {
266
+ return Object.keys(captureMap).find((url) => detailApiEndpointForUrl(url)?.suffix === suffix);
267
+ }
268
+ function isPlainObject(value) {
269
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
270
+ }
271
+ function assertOptionalArray(payload, key, suffix) {
272
+ if (key in payload && !Array.isArray(payload[key])) {
273
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
274
+ }
275
+ }
276
+ function assertOptionalPlainObject(payload, key, suffix) {
277
+ if (key in payload && !isPlainObject(payload[key])) {
278
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
279
+ }
280
+ }
281
+ function validateCapturedPayload(payload, endpoint) {
282
+ const suffix = endpoint.suffix;
283
+ if (!isPlainObject(payload)) {
284
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a malformed payload`);
285
+ }
286
+ if (endpoint.key === 'noteBase') {
287
+ assertOptionalPlainObject(payload, 'hour', suffix);
288
+ assertOptionalPlainObject(payload, 'day', suffix);
289
+ }
290
+ if (endpoint.key === 'audienceSource') {
291
+ assertOptionalArray(payload, 'source', suffix);
292
+ }
293
+ if (endpoint.key === 'audienceSourceDetail') {
294
+ for (const key of ['gender', 'age', 'city', 'interest']) {
295
+ assertOptionalArray(payload, key, suffix);
296
+ }
297
+ }
298
+ return payload;
299
+ }
300
+ function parseCapturedJson(capture, endpoint) {
301
+ const suffix = endpoint.suffix;
302
+ if (!capture || typeof capture !== 'object') {
303
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: malformed capture for ${suffix}`);
304
+ }
305
+ if (capture.ok !== true) {
306
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned HTTP ${capture.status ?? 'non-2xx'}`);
307
+ }
308
+ if (typeof capture.body !== 'string') {
309
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a non-text body`);
310
+ }
311
+ try {
312
+ const envelope = JSON.parse(capture.body);
313
+ const payload = isPlainObject(envelope) && Object.hasOwn(envelope, 'data') ? envelope.data : envelope;
314
+ return validateCapturedPayload(payload, endpoint);
315
+ }
316
+ catch {
317
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned invalid JSON or payload shape`);
318
+ }
319
+ }
320
+ // Capture the dashboard's signed datacenter/note responses on window.__xhsCapture
321
+ // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406.
322
+ async function installXhsFetchCaptureHook(page) {
323
+ await page.evaluate(`(() => {
324
+ const targetPaths = ${JSON.stringify(DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix))};
325
+ const shouldCapture = (url) => {
326
+ try {
327
+ return targetPaths.includes(new URL(String(url), window.location.origin).pathname);
328
+ } catch (_) {
329
+ return false;
330
+ }
331
+ };
332
+ // Reset the buffer every call so stale captures from a previous run on
333
+ // the same tab cannot leak into the current navigation's harvest.
334
+ window.__xhsCapture = {};
335
+ if (window.__xhsCaptureInstalled) return;
336
+ window.__xhsCaptureInstalled = true;
337
+ const origFetch = window.fetch;
338
+ window.fetch = async function(...args) {
339
+ const resp = await origFetch.apply(this, args);
340
+ try {
341
+ const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
342
+ if (shouldCapture(url)) {
343
+ resp.clone().text().then((body) => {
344
+ try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {}
345
+ }).catch(() => {});
346
+ }
347
+ } catch (_) {}
348
+ return resp;
349
+ };
350
+ const OrigXHR = window.XMLHttpRequest;
351
+ function HookedXHR() {
352
+ const xhr = new OrigXHR();
353
+ const origOpen = xhr.open;
354
+ let capturedUrl = '';
355
+ xhr.open = function(method, url, ...rest) {
356
+ capturedUrl = url;
357
+ return origOpen.call(this, method, url, ...rest);
358
+ };
359
+ xhr.addEventListener('load', () => {
360
+ try {
361
+ if (shouldCapture(capturedUrl)) {
362
+ window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText };
363
+ }
364
+ } catch (_) {}
365
+ });
366
+ return xhr;
367
+ }
368
+ HookedXHR.prototype = OrigXHR.prototype;
369
+ // Preserve readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE)
370
+ // since dashboard code may read XMLHttpRequest.DONE etc against the constructor.
371
+ for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
372
+ if (key in OrigXHR) HookedXHR[key] = OrigXHR[key];
373
+ }
374
+ window.XMLHttpRequest = HookedXHR;
375
+ })()`);
376
+ }
252
377
  async function captureNoteDetailPayload(page, noteId) {
253
- const payload = {};
254
- let captured = 0;
255
- // Try to fetch each API endpoint through the page context (uses the browser's cookies)
256
- for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
257
- await page.wait({ time: 0.5 + Math.random() });
258
- const apiUrl = `${suffix}?note_id=${noteId}`;
378
+ await installXhsFetchCaptureHook(page);
379
+ // SPA-navigate inside the dashboard so the React router re-fires the
380
+ // signed datacenter/note/* requests under our hook. A second page.goto
381
+ // would wipe the hook before the first auto-fetch can land.
382
+ await page.evaluate(`(() => {
383
+ const target = '/statistics/note-detail?noteId=' + ${JSON.stringify(noteId)};
384
+ history.pushState({}, '', target);
385
+ window.dispatchEvent(new PopStateEvent('popstate'));
386
+ })()`);
387
+ const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix);
388
+ let captureMap = {};
389
+ for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) {
390
+ await page.wait(CAPTURE_POLL_INTERVAL_S);
391
+ let raw;
259
392
  try {
260
- const data = await page.evaluate(`
261
- async () => {
262
- try {
263
- const resp = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' });
264
- if (!resp.ok) return null;
265
- const json = await resp.json();
266
- return JSON.stringify(json.data ?? {});
267
- } catch { return null; }
393
+ raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
394
+ captureMap = typeof raw === 'string' ? JSON.parse(raw) : {};
395
+ }
396
+ catch {
397
+ throw new CommandExecutionError('xiaohongshu creator-note-detail: failed to read signed datacenter/note capture buffer');
268
398
  }
269
- `);
270
- if (data && typeof data === 'string') {
271
- try {
272
- payload[key] = JSON.parse(data);
273
- captured++;
274
- }
275
- catch { }
276
- }
399
+ if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) {
400
+ throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer');
277
401
  }
278
- catch { }
402
+ const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix));
403
+ if (captured.length === wantedSuffixes.length)
404
+ break;
405
+ }
406
+ const payload = {};
407
+ for (const endpoint of DETAIL_API_ENDPOINTS) {
408
+ const matchUrl = findCapturedUrl(captureMap, endpoint.suffix);
409
+ if (!matchUrl)
410
+ continue;
411
+ payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint);
279
412
  }
280
- return captured > 0 ? payload : null;
413
+ return Object.keys(payload).length > 0 ? payload : null;
281
414
  }
282
415
  async function captureNoteDetailDomData(page) {
283
416
  const result = await page.evaluate(`() => {
@@ -308,14 +441,18 @@ async function captureNoteDetailDomData(page) {
308
441
  return result;
309
442
  }
310
443
  export async function fetchCreatorNoteDetailRows(page, noteId) {
311
- await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`);
444
+ // Land on the dashboard root first so the React app boots before the
445
+ // note-specific signed APIs fire. captureNoteDetailPayload then installs
446
+ // the fetch+XHR hook and SPA-navigates to /statistics/note-detail under
447
+ // it, which is what surfaces the audience / trend rows.
448
+ await page.goto('https://creator.xiaohongshu.com/statistics');
449
+ const apiPayload = await captureNoteDetailPayload(page, noteId);
312
450
  const domData = await captureNoteDetailDomData(page).catch(() => null);
313
451
  let rows = parseCreatorNoteDetailDomData(domData, noteId);
314
452
  if (rows.length === 0) {
315
453
  const bodyText = await page.evaluate('() => document.body.innerText');
316
454
  rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
317
455
  }
318
- const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
319
456
  appendTrendRows(rows, apiPayload ?? undefined);
320
457
  appendAudienceRows(rows, apiPayload ?? undefined);
321
458
  return rows;
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { getRegistry } from '@jackwener/opencli/registry';
4
4
  import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js';
5
5
  import './creator-note-detail.js';
@@ -208,40 +208,44 @@ describe('xiaohongshu creator-note-detail', () => {
208
208
  it('navigates to the note detail page and returns parsed rows', async () => {
209
209
  const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
210
210
  expect(cmd?.func).toBeTypeOf('function');
211
- const page = createPageMock([
212
- {
213
- title: '示例笔记',
214
- infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
215
- sections: [
216
- {
217
- title: '基础数据',
218
- metrics: [
219
- { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
220
- { label: '观看数', value: '50', extra: '粉丝占比 20%' },
221
- { label: '封面点击率', value: '12%', extra: '粉丝 11%' },
222
- { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
223
- { label: '涨粉数', value: '2', extra: '' },
224
- ],
225
- },
226
- {
227
- title: '互动数据',
228
- metrics: [
229
- { label: '点赞数', value: '8', extra: '粉丝占比 25%' },
230
- { label: '评论数', value: '1', extra: '粉丝占比 0%' },
231
- { label: '收藏数', value: '3', extra: '粉丝占比 50%' },
232
- { label: '分享数', value: '0', extra: '粉丝占比 0%' },
233
- ],
234
- },
235
- ],
236
- },
237
- null,
238
- null,
239
- null,
240
- null,
241
- ]);
211
+ const domData = {
212
+ title: '示例笔记',
213
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
214
+ sections: [
215
+ {
216
+ title: '基础数据',
217
+ metrics: [
218
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
219
+ { label: '观看数', value: '50', extra: '粉丝占比 20%' },
220
+ { label: '封面点击率', value: '12%', extra: '粉丝 11%' },
221
+ { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
222
+ { label: '涨粉数', value: '2', extra: '' },
223
+ ],
224
+ },
225
+ {
226
+ title: '互动数据',
227
+ metrics: [
228
+ { label: '点赞数', value: '8', extra: '粉丝占比 25%' },
229
+ { label: '评论数', value: '1', extra: '粉丝占比 0%' },
230
+ { label: '收藏数', value: '3', extra: '粉丝占比 50%' },
231
+ { label: '分享数', value: '0', extra: '粉丝占比 0%' },
232
+ ],
233
+ },
234
+ ],
235
+ };
236
+ const page = createPageMock(undefined);
237
+ page.evaluate = vi.fn(async (script) => {
238
+ const s = String(script);
239
+ if (s.includes('window.__xhsCapture =')) return undefined;
240
+ if (s.includes('history.pushState')) return undefined;
241
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
242
+ if (s.includes("document.querySelector('.note-title')")) return domData;
243
+ if (s.includes('document.body.innerText')) return '';
244
+ return undefined;
245
+ });
242
246
  const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
243
- expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id');
244
- expect(page.evaluate.mock.calls[0][0]).toContain("document.querySelector('.note-title')");
247
+ expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics');
248
+ expect(page.evaluate.mock.calls[0][0]).toContain('window.__xhsCapture =');
245
249
  expect(result).toEqual([
246
250
  { section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
247
251
  { section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
@@ -257,7 +261,7 @@ describe('xiaohongshu creator-note-detail', () => {
257
261
  { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
258
262
  ]);
259
263
  });
260
- it('waits between creator detail API fetches to avoid burst traffic', async () => {
264
+ it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => {
261
265
  const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
262
266
  const domData = {
263
267
  title: '示例笔记',
@@ -284,10 +288,155 @@ describe('xiaohongshu creator-note-detail', () => {
284
288
  },
285
289
  ],
286
290
  };
287
- const page = createPageMock([domData, null, null, null, null]);
291
+ const page = createPageMock(undefined);
292
+ page.evaluate = vi.fn(async (script) => {
293
+ const s = String(script);
294
+ if (s.includes('window.__xhsCapture =')) return undefined;
295
+ if (s.includes('history.pushState')) return undefined;
296
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
297
+ if (s.includes("document.querySelector('.note-title')")) return domData;
298
+ return undefined;
299
+ });
288
300
  await cmd.func(page, { 'note-id': 'demo-note-id' });
289
- expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
301
+ // Capture loop polls until the deadline expires (no hits with empty mock).
290
302
  expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
303
+ const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture'));
304
+ expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1);
305
+ });
306
+ it('matches signed API captures by exact pathname so source/detail cannot shadow source', async () => {
307
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
308
+ const domData = {
309
+ title: '示例笔记',
310
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
311
+ sections: [
312
+ {
313
+ title: '基础数据',
314
+ metrics: [
315
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
316
+ ],
317
+ },
318
+ ],
319
+ };
320
+ const detailCapture = [
321
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=demo-note-id',
322
+ {
323
+ status: 200,
324
+ ok: true,
325
+ body: JSON.stringify({ data: { gender: [{ title: '女性', value: 64 }] } }),
326
+ },
327
+ ];
328
+ const sourceCapture = [
329
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id',
330
+ {
331
+ status: 200,
332
+ ok: true,
333
+ body: JSON.stringify({
334
+ data: {
335
+ source: [
336
+ {
337
+ title: '首页推荐',
338
+ value_with_double: 88.8,
339
+ info: { imp_count: 1000, view_count: 400, interaction_count: 20 },
340
+ },
341
+ ],
342
+ },
343
+ }),
344
+ },
345
+ ];
346
+ const baseCapture = [
347
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id',
348
+ {
349
+ status: 200,
350
+ ok: true,
351
+ body: JSON.stringify({ data: { hour: { view_list: [{ date: new Date('2026-03-19T12:00:00+08:00').getTime(), count: 7 }] } } }),
352
+ },
353
+ ];
354
+ const trendCapture = [
355
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=demo-note-id',
356
+ {
357
+ status: 200,
358
+ ok: true,
359
+ body: JSON.stringify({ data: { no_data: false, no_data_tip_msg: '趋势可用' } }),
360
+ },
361
+ ];
362
+ for (const orderedCaptures of [
363
+ [detailCapture, sourceCapture, baseCapture, trendCapture],
364
+ [sourceCapture, detailCapture, baseCapture, trendCapture],
365
+ ]) {
366
+ const captureMap = Object.fromEntries(orderedCaptures);
367
+ const page = createPageMock(undefined);
368
+ page.evaluate = vi.fn(async (script) => {
369
+ const s = String(script);
370
+ if (s.includes('window.__xhsCapture =')) return undefined;
371
+ if (s.includes('history.pushState')) return undefined;
372
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
373
+ if (s.includes("document.querySelector('.note-title')")) return domData;
374
+ return undefined;
375
+ });
376
+ const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
377
+ expect(result).toEqual(expect.arrayContaining([
378
+ { section: '观看来源', metric: '首页推荐', value: '88.8%', extra: '曝光 1000 · 观看 400 · 互动 20' },
379
+ { section: '观众画像', metric: '性别/女性', value: '64%', extra: '' },
380
+ { section: '趋势数据', metric: '按小时/观看数', value: '1 points', extra: '03-19 12:00=7' },
381
+ ]));
382
+ }
383
+ });
384
+ it('throws a typed error when a captured signed API returns non-2xx', async () => {
385
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
386
+ const captureMap = {
387
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id': {
388
+ status: 406,
389
+ ok: false,
390
+ body: '{"msg":"not acceptable"}',
391
+ },
392
+ };
393
+ const page = createPageMock(undefined);
394
+ page.evaluate = vi.fn(async (script) => {
395
+ const s = String(script);
396
+ if (s.includes('window.__xhsCapture =')) return undefined;
397
+ if (s.includes('history.pushState')) return undefined;
398
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
399
+ return null;
400
+ });
401
+ await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
402
+ });
403
+ it('throws a typed error for wrong-shaped signed API payloads instead of falling back to DOM rows', async () => {
404
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
405
+ const domData = {
406
+ title: '示例笔记',
407
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
408
+ sections: [
409
+ {
410
+ title: '基础数据',
411
+ metrics: [
412
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
413
+ ],
414
+ },
415
+ ],
416
+ };
417
+ for (const body of [
418
+ JSON.stringify({ data: null }),
419
+ JSON.stringify({ data: [] }),
420
+ JSON.stringify({ data: { source: {} } }),
421
+ ]) {
422
+ const captureMap = {
423
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id': {
424
+ status: 200,
425
+ ok: true,
426
+ body,
427
+ },
428
+ };
429
+ const page = createPageMock(undefined);
430
+ page.evaluate = vi.fn(async (script) => {
431
+ const s = String(script);
432
+ if (s.includes('window.__xhsCapture =')) return undefined;
433
+ if (s.includes('history.pushState')) return undefined;
434
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
435
+ if (s.includes("document.querySelector('.note-title')")) return domData;
436
+ return null;
437
+ });
438
+ await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
439
+ }
291
440
  });
292
441
  it('throws EmptyResultError when the detail page exposes no metrics', async () => {
293
442
  const cmd = getRegistry().get('xiaohongshu/creator-note-detail');