@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
@@ -8,11 +8,14 @@
8
8
  * Requires: logged into creator.xiaohongshu.com in Chrome.
9
9
  */
10
10
  import { cli, Strategy } from '@jackwener/opencli/registry';
11
- import { EmptyResultError } from '@jackwener/opencli/errors';
11
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
12
12
  const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/;
13
13
  const METRIC_LINE_RE = /^\d+$/;
14
14
  const VISIBILITY_LINE_RE = /可见$/;
15
15
  const NOTE_ANALYZE_API_PATH = '/api/galaxy/creator/datacenter/note/analyze/list';
16
+ const NOTE_ANALYZE_PAGE_SIZE = 10;
17
+ const CAPTURE_POLL_ATTEMPTS = 20;
18
+ const CAPTURE_POLL_INTERVAL_S = 0.5;
16
19
  const NOTE_DETAIL_PAGE_URL = 'https://creator.xiaohongshu.com/statistics/note-detail';
17
20
  const NOTE_ID_HTML_RE = /"noteId":"([0-9a-f]{24})"/g;
18
21
  function buildNoteDetailUrl(noteId) {
@@ -105,6 +108,237 @@ function mapAnalyzeItems(items) {
105
108
  url: buildNoteDetailUrl(item.id),
106
109
  }));
107
110
  }
111
+ function unwrapEvaluateResult(payload) {
112
+ if (payload && typeof payload === 'object' && !Array.isArray(payload) && 'session' in payload && 'data' in payload) {
113
+ return payload.data;
114
+ }
115
+ return payload;
116
+ }
117
+ // Capture the dashboard's signed /api/galaxy/* responses on window.__xhsCapture
118
+ // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406.
119
+ async function installXhsFetchCaptureHook(page) {
120
+ await page.evaluate(`(() => {
121
+ window.__xhsCapture = {};
122
+ if (window.__xhsCaptureInstalled) return;
123
+ window.__xhsCaptureInstalled = true;
124
+ const origFetch = window.fetch;
125
+ window.fetch = async function(...args) {
126
+ const resp = await origFetch.apply(this, args);
127
+ try {
128
+ const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
129
+ if (url.includes('/api/galaxy/')) {
130
+ resp.clone().text().then((body) => {
131
+ try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {}
132
+ }).catch(() => {});
133
+ }
134
+ } catch (_) {}
135
+ return resp;
136
+ };
137
+ const OrigXHR = window.XMLHttpRequest;
138
+ function HookedXHR() {
139
+ const xhr = new OrigXHR();
140
+ const origOpen = xhr.open;
141
+ let capturedUrl = '';
142
+ xhr.open = function(method, url, ...rest) {
143
+ capturedUrl = url;
144
+ return origOpen.call(this, method, url, ...rest);
145
+ };
146
+ xhr.addEventListener('load', () => {
147
+ try {
148
+ if (capturedUrl.includes('/api/galaxy/')) {
149
+ window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText };
150
+ }
151
+ } catch (_) {}
152
+ });
153
+ return xhr;
154
+ }
155
+ HookedXHR.prototype = OrigXHR.prototype;
156
+ for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
157
+ if (key in OrigXHR) HookedXHR[key] = OrigXHR[key];
158
+ }
159
+ window.XMLHttpRequest = HookedXHR;
160
+ })()`);
161
+ }
162
+ function parseCaptureMapPayload(raw) {
163
+ const payload = unwrapEvaluateResult(raw);
164
+ if (typeof payload === 'string') {
165
+ try {
166
+ return JSON.parse(payload);
167
+ }
168
+ catch {
169
+ return {};
170
+ }
171
+ }
172
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
173
+ return payload;
174
+ }
175
+ return {};
176
+ }
177
+ function getAnalyzeListPageNumber(url) {
178
+ try {
179
+ const parsed = new URL(url, 'https://creator.xiaohongshu.com');
180
+ const pageNum = Number.parseInt(parsed.searchParams.get('page_num') || '', 10);
181
+ if (Number.isFinite(pageNum) && pageNum > 0)
182
+ return pageNum;
183
+ }
184
+ catch { }
185
+ const match = String(url || '').match(/[?&]page_num=(\d+)/);
186
+ const pageNum = Number.parseInt(match?.[1] || '', 10);
187
+ return Number.isFinite(pageNum) && pageNum > 0 ? pageNum : Number.MAX_SAFE_INTEGER;
188
+ }
189
+ function harvestAnalyzeListCaptures(captureMap) {
190
+ const items = [];
191
+ const seen = new Set();
192
+ let total = 0;
193
+ const entries = Object.entries(captureMap)
194
+ .filter(([url]) => url.includes('/note/analyze/list'))
195
+ .sort(([a], [b]) => getAnalyzeListPageNumber(a) - getAnalyzeListPageNumber(b));
196
+ for (const [url, capture] of entries) {
197
+ if (!capture?.ok) continue;
198
+ try {
199
+ const json = JSON.parse(capture.body);
200
+ const data = json?.data ?? {};
201
+ if (typeof data.total === 'number' && data.total > total) total = data.total;
202
+ for (const note of data.note_infos ?? []) {
203
+ if (!note?.id || seen.has(note.id)) continue;
204
+ seen.add(note.id);
205
+ items.push(note);
206
+ }
207
+ }
208
+ catch { }
209
+ }
210
+ return { items, total };
211
+ }
212
+ function isAnalyzeCaptureComplete(items, total, limit) {
213
+ if (total <= 0)
214
+ return true;
215
+ return items.length >= Math.min(total, limit);
216
+ }
217
+ async function pollCaptureMap(page) {
218
+ let captureMap = {};
219
+ for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) {
220
+ await page.wait(CAPTURE_POLL_INTERVAL_S);
221
+ const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
222
+ captureMap = parseCaptureMapPayload(raw);
223
+ if (Object.keys(captureMap).some((url) => url.includes('/note/analyze/list'))) break;
224
+ }
225
+ return captureMap;
226
+ }
227
+ // Fresh-published notes return title: "" from /note/analyze/list. Scrape the
228
+ // /new/note-manager card DOM (under its "全部笔记" tab, which surfaces every
229
+ // state including 审核中) so the rows the API leaves empty still get the
230
+ // derived title that the note-manager UI shows.
231
+ async function fetchNoteManagerTitleMap(page, neededCount) {
232
+ const map = new Map();
233
+ const scrapeCards = async () => {
234
+ const cards = unwrapEvaluateResult(await page.evaluate(`() => {
235
+ const noteIdRe = /"noteId":"([0-9a-f]{24})"/;
236
+ return Array.from(document.querySelectorAll('div.note[data-impression], div.note')).map((card) => {
237
+ const impression = card.getAttribute('data-impression') || '';
238
+ const id = impression.match(noteIdRe)?.[1] || '';
239
+ const title = (card.querySelector('.title, .raw')?.innerText || '').trim();
240
+ return { id, title };
241
+ }).filter((entry) => entry.id && entry.title);
242
+ }`));
243
+ for (const card of Array.isArray(cards) ? cards : []) {
244
+ if (!map.has(card.id)) map.set(card.id, card.title);
245
+ }
246
+ };
247
+ // Scroll the first scrollable ancestor of a note card to the bottom so
248
+ // the list lazy-loads the rest of its rows. Page-level scrollTo does not
249
+ // work because the cards live inside an inner overflow-auto container.
250
+ const scrollInnerListToBottom = async () => {
251
+ return unwrapEvaluateResult(await page.evaluate(`(() => {
252
+ const firstCard = document.querySelector('div.note[data-impression]');
253
+ let el = firstCard && firstCard.parentElement;
254
+ while (el) {
255
+ const s = window.getComputedStyle(el);
256
+ if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) {
257
+ el.scrollTop = el.scrollHeight;
258
+ return true;
259
+ }
260
+ el = el.parentElement;
261
+ }
262
+ return false;
263
+ })()`));
264
+ };
265
+ try {
266
+ await page.goto('https://creator.xiaohongshu.com/new/note-manager');
267
+ // Poll for the initial hydration batch and then scroll the inner list
268
+ // container to surface the rest of the rows. The all-notes tab is the
269
+ // default state so no tab click is needed here.
270
+ for (let i = 0; i < 12; i++) {
271
+ await page.wait(1);
272
+ await scrapeCards();
273
+ if (map.size >= neededCount) return map;
274
+ await scrollInnerListToBottom();
275
+ }
276
+ return map;
277
+ }
278
+ catch {
279
+ return map;
280
+ }
281
+ }
282
+ async function fetchCreatorNotesByCapture(page, limit) {
283
+ // Land on dashboard root before installing the hook so the data-analysis
284
+ // SPA navigation fires page_num=1's signed request UNDER the hook.
285
+ await page.goto('https://creator.xiaohongshu.com/statistics');
286
+ await installXhsFetchCaptureHook(page);
287
+ await page.evaluate(`(() => {
288
+ history.pushState({}, '', '/statistics/data-analysis?source=official');
289
+ window.dispatchEvent(new PopStateEvent('popstate'));
290
+ })()`);
291
+ let captureMap = await pollCaptureMap(page);
292
+ let { items, total } = harvestAnalyzeListCaptures(captureMap);
293
+ if (items.length === 0) return [];
294
+ const totalPages = total > 0 ? Math.ceil(total / NOTE_ANALYZE_PAGE_SIZE) : 1;
295
+ const neededPages = Math.min(totalPages, Math.ceil(limit / NOTE_ANALYZE_PAGE_SIZE));
296
+ for (let pageNum = 2; pageNum <= neededPages && items.length < limit; pageNum++) {
297
+ const clicked = unwrapEvaluateResult(await page.evaluate(`(() => {
298
+ const target = String(${pageNum});
299
+ // .d-pagination-page renders the page number doubled (a visible span +
300
+ // an accessibility span), so textContent for page 2 reads "22". Match
301
+ // both the raw digit and the doubled form to tolerate either render.
302
+ const btns = Array.from(document.querySelectorAll('.d-pagination-page'));
303
+ const match = btns.find((btn) => {
304
+ const text = (btn.textContent || '').trim();
305
+ return text === target || text === target + target;
306
+ });
307
+ if (match) { match.click(); return true; }
308
+ return false;
309
+ })()`));
310
+ if (!clicked) break;
311
+ const before = items.length;
312
+ let advanced = false;
313
+ for (let attempt = 0; attempt < CAPTURE_POLL_ATTEMPTS; attempt++) {
314
+ await page.wait(CAPTURE_POLL_INTERVAL_S);
315
+ const raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
316
+ captureMap = parseCaptureMapPayload(raw);
317
+ const harvested = harvestAnalyzeListCaptures(captureMap);
318
+ if (harvested.items.length > before) {
319
+ items = harvested.items;
320
+ total = Math.max(total, harvested.total);
321
+ advanced = true;
322
+ break;
323
+ }
324
+ }
325
+ if (!advanced) break;
326
+ }
327
+ if (!isAnalyzeCaptureComplete(items, total, limit)) {
328
+ throw new CommandExecutionError(`xiaohongshu creator-notes: captured ${items.length} of ${Math.min(total, limit)} expected analyze rows; refusing partial results`);
329
+ }
330
+ const notes = mapAnalyzeItems(items).slice(0, limit);
331
+ const missingTitles = notes.filter((note) => !note.title).length;
332
+ if (missingTitles > 0) {
333
+ const titleMap = await fetchNoteManagerTitleMap(page, notes.length);
334
+ for (const note of notes) {
335
+ if (!note.title && note.id && titleMap.has(note.id)) {
336
+ note.title = titleMap.get(note.id);
337
+ }
338
+ }
339
+ }
340
+ return notes;
341
+ }
108
342
  async function fetchCreatorNotesByApi(page, limit) {
109
343
  const pageSize = Math.min(Math.max(limit, 10), 20);
110
344
  const maxPages = Math.max(1, Math.ceil(limit / pageSize));
@@ -148,7 +382,16 @@ async function fetchCreatorNotesByApi(page, limit) {
148
382
  return notes.slice(0, limit);
149
383
  }
150
384
  export async function fetchCreatorNotes(page, limit) {
151
- let notes = await fetchCreatorNotesByApi(page, limit);
385
+ let notes = [];
386
+ try {
387
+ notes = await fetchCreatorNotesByCapture(page, limit);
388
+ }
389
+ catch (error) {
390
+ if (error instanceof CommandExecutionError) throw error;
391
+ }
392
+ if (notes.length === 0) {
393
+ notes = await fetchCreatorNotesByApi(page, limit);
394
+ }
152
395
  if (notes.length === 0) {
153
396
  await page.goto('https://creator.xiaohongshu.com/new/note-manager');
154
397
  const maxPageDowns = Math.max(0, Math.ceil(limit / 10) + 1);
@@ -228,3 +471,9 @@ cli({
228
471
  }));
229
472
  },
230
473
  });
474
+ export const __test__ = {
475
+ harvestAnalyzeListCaptures,
476
+ isAnalyzeCaptureComplete,
477
+ parseCaptureMapPayload,
478
+ unwrapEvaluateResult,
479
+ };
@@ -1,7 +1,7 @@
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
- import { parseCreatorNoteIdsFromHtml, parseCreatorNotesText } from './creator-notes.js';
4
+ import { __test__, parseCreatorNoteIdsFromHtml, parseCreatorNotesText } from './creator-notes.js';
5
5
  import './creator-notes.js';
6
6
  function createPageMock(evaluateResult, interceptedRequests = []) {
7
7
  const evaluate = Array.isArray(evaluateResult)
@@ -190,6 +190,83 @@ describe('xiaohongshu creator-notes', () => {
190
190
  'dddddddddddddddddddddddd',
191
191
  ]);
192
192
  });
193
+ it('harvests captured analyze pages in page order and dedupes note ids', () => {
194
+ const captureMap = {
195
+ '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=2': {
196
+ ok: true,
197
+ body: JSON.stringify({
198
+ data: {
199
+ total: 3,
200
+ note_infos: [
201
+ { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', title: 'page 2' },
202
+ { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'duplicate from page 2' },
203
+ ],
204
+ },
205
+ }),
206
+ },
207
+ '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=1': {
208
+ ok: true,
209
+ body: JSON.stringify({
210
+ data: {
211
+ total: 3,
212
+ note_infos: [
213
+ { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'page 1' },
214
+ ],
215
+ },
216
+ }),
217
+ },
218
+ };
219
+ expect(__test__.harvestAnalyzeListCaptures(captureMap)).toEqual({
220
+ total: 3,
221
+ items: [
222
+ { id: 'aaaaaaaaaaaaaaaaaaaaaaaa', title: 'page 1' },
223
+ { id: 'bbbbbbbbbbbbbbbbbbbbbbbb', title: 'page 2' },
224
+ ],
225
+ });
226
+ });
227
+ it('treats incomplete captured pagination as fallback-needed instead of partial success', () => {
228
+ const firstPageItems = Array.from({ length: 10 }, (_, index) => ({
229
+ id: String(index).padStart(24, '0'),
230
+ }));
231
+ expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 25, 20)).toBe(false);
232
+ expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 25, 10)).toBe(true);
233
+ expect(__test__.isAnalyzeCaptureComplete(firstPageItems, 0, 20)).toBe(true);
234
+ });
235
+ it('unwraps browser bridge capture-map envelopes', () => {
236
+ const captureMap = {
237
+ '/api/galaxy/creator/datacenter/note/analyze/list?page_num=1': {
238
+ ok: true,
239
+ body: '{"data":{"total":0,"note_infos":[]}}',
240
+ },
241
+ };
242
+ expect(__test__.parseCaptureMapPayload({ session: 'site:xiaohongshu', data: JSON.stringify(captureMap) })).toEqual(captureMap);
243
+ expect(__test__.parseCaptureMapPayload({ session: 'site:xiaohongshu', data: captureMap })).toEqual(captureMap);
244
+ });
245
+ it('does not fall back to partial DOM rows when captured total proves pagination is incomplete', async () => {
246
+ const cmd = getRegistry().get('xiaohongshu/creator-notes');
247
+ const captureMap = {
248
+ '/api/galaxy/creator/datacenter/note/analyze/list?type=0&page_size=10&page_num=1': {
249
+ ok: true,
250
+ body: JSON.stringify({
251
+ data: {
252
+ total: 25,
253
+ note_infos: Array.from({ length: 10 }, (_, index) => ({
254
+ id: String(index).padStart(24, '0'),
255
+ title: `note ${index}`,
256
+ })),
257
+ },
258
+ }),
259
+ },
260
+ };
261
+ const page = createPageMock(undefined);
262
+ page.evaluate = vi.fn()
263
+ .mockResolvedValueOnce(undefined)
264
+ .mockResolvedValueOnce(undefined)
265
+ .mockResolvedValueOnce(JSON.stringify(captureMap))
266
+ .mockResolvedValueOnce(false);
267
+
268
+ await expect(cmd.func(page, { limit: 20 })).rejects.toBeInstanceOf(CommandExecutionError);
269
+ });
193
270
  it('throws EmptyResultError when the creator account has no notes', async () => {
194
271
  const cmd = getRegistry().get('xiaohongshu/creator-notes');
195
272
  const page = createPageMock(undefined);
@@ -52,49 +52,108 @@ export function buildDownloadExtractJs(noteId) {
52
52
  const authorEl = document.querySelector('.username, .author-name, .name');
53
53
  result.author = authorEl?.textContent?.trim() || 'unknown';
54
54
 
55
- // Get images - try multiple selectors
56
- const imageSelectors = [
57
- '.swiper-slide img',
58
- '.carousel-image img',
59
- '.note-slider img',
60
- '.note-image img',
61
- '.image-wrapper img',
62
- '#noteContainer .media-container img[src*="xhscdn"]',
63
- 'img[src*="ci.xiaohongshu.com"]'
64
- ];
55
+ // Get images: prefer canonical carousel order from __INITIAL_STATE__
56
+ // so the saved order matches what the user sees on the platform (#1514).
57
+ // DOM extraction is used only as a fallback because multiple selectors,
58
+ // hidden / duplicated / preloaded slides, and lazy rendering can reorder
59
+ // the discovered nodes away from the platform's display order.
65
60
 
66
- const imageUrls = new Set();
67
- for (const selector of imageSelectors) {
68
- document.querySelectorAll(selector).forEach(img => {
69
- let src = img.src || img.getAttribute('data-src') || '';
70
- if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
71
- src = src.split('?')[0];
72
- src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
73
- imageUrls.add(src);
61
+ const normalizeImageUrl = (raw) => {
62
+ if (!raw || typeof raw !== 'string') return '';
63
+ let src = raw.split('?')[0];
64
+ src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
65
+ return src;
66
+ };
67
+ const orderedImageUrls = [];
68
+ const seenImageUrls = new Set();
69
+ const pushImage = (url) => {
70
+ if (!url || seenImageUrls.has(url)) return;
71
+ seenImageUrls.add(url);
72
+ orderedImageUrls.push(url);
73
+ };
74
+
75
+ const getStructuredNotes = () => {
76
+ const state = window.__INITIAL_STATE__;
77
+ const noteData = state?.note?.noteDetailMap || state?.note?.note || {};
78
+ if (!noteData || typeof noteData !== 'object') return [];
79
+ const currentIds = [...new Set([result.noteId, '${noteId}'].filter(Boolean))];
80
+ const notes = [];
81
+ for (const id of currentIds) {
82
+ const entry = noteData[id];
83
+ const note = entry?.note || entry;
84
+ if (note && typeof note === 'object') notes.push(note);
85
+ }
86
+ // Compatibility fallback for legacy single-note stores. Do not use this
87
+ // when keyed detail maps contain multiple notes, or carousel order can
88
+ // be polluted by preloaded/previous note entries.
89
+ const keys = Object.keys(noteData);
90
+ if (notes.length === 0 && keys.length === 1) {
91
+ const entry = noteData[keys[0]];
92
+ const note = entry?.note || entry;
93
+ if (note && typeof note === 'object') notes.push(note);
94
+ }
95
+ return notes;
96
+ };
97
+
98
+ // Method 1: walk __INITIAL_STATE__.note.noteDetailMap[id].note.imageList
99
+ // in array order. Each entry exposes urlDefault as the canonical CDN URL.
100
+ let imageInitialStateUsed = false;
101
+ try {
102
+ for (const note of getStructuredNotes()) {
103
+ const list = Array.isArray(note?.imageList) ? note.imageList : [];
104
+ for (const item of list) {
105
+ const candidate = item?.urlDefault || item?.urlPre || item?.url
106
+ || item?.infoList?.find(i => i?.imageScene === 'WB_DFT')?.url
107
+ || item?.infoList?.[0]?.url
108
+ || '';
109
+ const src = normalizeImageUrl(candidate);
110
+ if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
111
+ pushImage(src);
112
+ imageInitialStateUsed = true;
113
+ }
74
114
  }
75
- });
115
+ }
116
+ } catch(e) {}
117
+
118
+ // Method 2: fallback to DOM scraping when the structured state is missing
119
+ // (e.g. preview pages without full SSR hydration). Order may differ from
120
+ // the carousel; surface it anyway rather than returning zero images.
121
+ if (!imageInitialStateUsed) {
122
+ const imageSelectors = [
123
+ '.swiper-slide img',
124
+ '.carousel-image img',
125
+ '.note-slider img',
126
+ '.note-image img',
127
+ '.image-wrapper img',
128
+ '#noteContainer .media-container img[src*="xhscdn"]',
129
+ 'img[src*="ci.xiaohongshu.com"]'
130
+ ];
131
+ for (const selector of imageSelectors) {
132
+ document.querySelectorAll(selector).forEach(img => {
133
+ const raw = img.src || img.getAttribute('data-src') || '';
134
+ const src = normalizeImageUrl(raw);
135
+ if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
136
+ pushImage(src);
137
+ }
138
+ });
139
+ }
76
140
  }
77
141
 
78
142
  // Get video — prefer real URL from page state over blob: URLs
79
143
 
80
144
  // Method 1: Extract from __INITIAL_STATE__ (SSR hydration data)
81
145
  try {
82
- const state = window.__INITIAL_STATE__;
83
- if (state) {
84
- const noteData = state.note?.noteDetailMap || state.note?.note || {};
85
- for (const key of Object.keys(noteData)) {
86
- const note = noteData[key]?.note || noteData[key];
87
- const video = note?.video;
88
- if (video) {
89
- const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
90
- if (vUrl) {
91
- const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
92
- pushMedia('video', fullUrl);
93
- }
94
- const streams = video.media?.stream?.h264 || [];
95
- for (const stream of streams) {
96
- if (stream.masterUrl) pushMedia('video', stream.masterUrl);
97
- }
146
+ for (const note of getStructuredNotes()) {
147
+ const video = note?.video;
148
+ if (video) {
149
+ const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
150
+ if (vUrl) {
151
+ const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
152
+ pushMedia('video', fullUrl);
153
+ }
154
+ const streams = video.media?.stream?.h264 || [];
155
+ for (const stream of streams) {
156
+ if (stream.masterUrl) pushMedia('video', stream.masterUrl);
98
157
  }
99
158
  }
100
159
  }
@@ -135,10 +194,9 @@ export function buildDownloadExtractJs(noteId) {
135
194
  }
136
195
  }
137
196
 
138
- // Add images to media
139
- imageUrls.forEach(url => {
140
- pushMedia('image', url);
141
- });
197
+ // Preserve the pre-existing media type order (videos first, then images)
198
+ // while keeping image carousel order stable within the image batch.
199
+ orderedImageUrls.forEach(url => pushMedia('image', url));
142
200
 
143
201
  return result;
144
202
  })()