@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.
- package/README.md +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-remove.js +1 -1
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- 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 =
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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
|
})()
|