@jackwener/opencli 1.5.6 → 1.5.7
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/CHANGELOG.md +26 -0
- package/README.md +4 -2
- package/README.zh-CN.md +4 -1
- package/SKILL.md +879 -0
- package/dist/browser/cdp.d.ts +1 -0
- package/dist/browser/cdp.js +30 -27
- package/dist/browser/daemon-client.d.ts +7 -1
- package/dist/browser/daemon-client.js +3 -0
- package/dist/browser/dom-helpers.js +1 -0
- package/dist/browser/dom-helpers.test.js +14 -1
- package/dist/browser/mcp.js +18 -13
- package/dist/browser/page.js +22 -2
- package/dist/browser/page.test.d.ts +1 -0
- package/dist/browser/page.test.js +44 -0
- package/dist/browser/stealth.js +198 -0
- package/dist/browser/stealth.test.d.ts +1 -0
- package/dist/browser/stealth.test.js +134 -0
- package/dist/browser.test.js +1 -1
- package/dist/build-manifest.d.ts +1 -0
- package/dist/build-manifest.js +5 -1
- package/dist/build-manifest.test.js +2 -0
- package/dist/cli-manifest.json +544 -137
- package/dist/cli.js +20 -3
- package/dist/clis/antigravity/serve.d.ts +1 -1
- package/dist/clis/antigravity/serve.js +5 -8
- package/dist/clis/bilibili/subtitle.js +4 -0
- package/dist/clis/bilibili/subtitle.test.d.ts +1 -0
- package/dist/clis/bilibili/subtitle.test.js +48 -0
- package/dist/clis/chatwise/ask.js +0 -2
- package/dist/clis/chatwise/export.js +0 -2
- package/dist/clis/chatwise/history.js +0 -2
- package/dist/clis/chatwise/model.js +0 -2
- package/dist/clis/chatwise/new.js +1 -2
- package/dist/clis/chatwise/read.js +0 -2
- package/dist/clis/chatwise/screenshot.js +1 -2
- package/dist/clis/chatwise/send.js +0 -2
- package/dist/clis/chatwise/status.js +1 -2
- package/dist/clis/ctrip/search.d.ts +13 -0
- package/dist/clis/ctrip/search.js +73 -48
- package/dist/clis/ctrip/search.test.d.ts +1 -0
- package/dist/clis/ctrip/search.test.js +64 -0
- package/dist/clis/douyin/_shared/sts2.js +8 -2
- package/dist/clis/douyin/_shared/sts2.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/sts2.test.js +27 -0
- package/dist/clis/douyin/activities.js +4 -2
- package/dist/clis/douyin/activities.test.js +34 -1
- package/dist/clis/douyin/collections.js +1 -1
- package/dist/clis/douyin/collections.test.js +24 -2
- package/dist/clis/douyin/draft.d.ts +8 -11
- package/dist/clis/douyin/draft.js +302 -185
- package/dist/clis/douyin/draft.test.d.ts +1 -1
- package/dist/clis/douyin/draft.test.js +357 -2
- package/dist/clis/douyin/hashtag.js +9 -2
- package/dist/clis/douyin/hashtag.test.js +35 -2
- package/dist/clis/douyin/profile.js +1 -1
- package/dist/clis/douyin/profile.test.js +36 -1
- package/dist/clis/douyin/videos.js +22 -5
- package/dist/clis/douyin/videos.test.js +45 -2
- package/dist/clis/facebook/search.test.d.ts +5 -0
- package/dist/clis/facebook/search.test.js +60 -0
- package/dist/clis/facebook/search.yaml +4 -3
- package/dist/clis/instagram/download.d.ts +16 -0
- package/dist/clis/instagram/download.js +225 -0
- package/dist/clis/instagram/download.test.d.ts +1 -0
- package/dist/clis/instagram/download.test.js +118 -0
- package/dist/clis/notebooklm/bind-current.d.ts +1 -0
- package/dist/clis/notebooklm/bind-current.js +29 -0
- package/dist/clis/notebooklm/bind-current.test.d.ts +1 -0
- package/dist/clis/notebooklm/bind-current.test.js +35 -0
- package/dist/clis/notebooklm/binding.test.d.ts +1 -0
- package/dist/clis/notebooklm/binding.test.js +44 -0
- package/dist/clis/notebooklm/compat.test.d.ts +3 -0
- package/dist/clis/notebooklm/compat.test.js +16 -0
- package/dist/clis/notebooklm/current.d.ts +1 -0
- package/dist/clis/notebooklm/current.js +28 -0
- package/dist/clis/notebooklm/get.d.ts +1 -0
- package/dist/clis/notebooklm/get.js +37 -0
- package/dist/clis/notebooklm/history.d.ts +1 -0
- package/dist/clis/notebooklm/history.js +25 -0
- package/dist/clis/notebooklm/history.test.d.ts +1 -0
- package/dist/clis/notebooklm/history.test.js +58 -0
- package/dist/clis/notebooklm/list.d.ts +1 -0
- package/dist/clis/notebooklm/list.js +35 -0
- package/dist/clis/notebooklm/note-list.d.ts +1 -0
- package/dist/clis/notebooklm/note-list.js +28 -0
- package/dist/clis/notebooklm/note-list.test.d.ts +1 -0
- package/dist/clis/notebooklm/note-list.test.js +56 -0
- package/dist/clis/notebooklm/notes-get.d.ts +1 -0
- package/dist/clis/notebooklm/notes-get.js +47 -0
- package/dist/clis/notebooklm/notes-get.test.d.ts +1 -0
- package/dist/clis/notebooklm/notes-get.test.js +72 -0
- package/dist/clis/notebooklm/rpc.d.ts +36 -0
- package/dist/clis/notebooklm/rpc.js +189 -0
- package/dist/clis/notebooklm/rpc.test.d.ts +1 -0
- package/dist/clis/notebooklm/rpc.test.js +105 -0
- package/dist/clis/notebooklm/shared.d.ts +87 -0
- package/dist/clis/notebooklm/shared.js +3 -0
- package/dist/clis/notebooklm/source-fulltext.d.ts +1 -0
- package/dist/clis/notebooklm/source-fulltext.js +44 -0
- package/dist/clis/notebooklm/source-fulltext.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-fulltext.test.js +106 -0
- package/dist/clis/notebooklm/source-get.d.ts +1 -0
- package/dist/clis/notebooklm/source-get.js +40 -0
- package/dist/clis/notebooklm/source-get.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-get.test.js +84 -0
- package/dist/clis/notebooklm/source-guide.d.ts +1 -0
- package/dist/clis/notebooklm/source-guide.js +44 -0
- package/dist/clis/notebooklm/source-guide.test.d.ts +1 -0
- package/dist/clis/notebooklm/source-guide.test.js +104 -0
- package/dist/clis/notebooklm/source-list.d.ts +1 -0
- package/dist/clis/notebooklm/source-list.js +30 -0
- package/dist/clis/notebooklm/status.d.ts +1 -0
- package/dist/clis/notebooklm/status.js +31 -0
- package/dist/clis/notebooklm/summary.d.ts +1 -0
- package/dist/clis/notebooklm/summary.js +30 -0
- package/dist/clis/notebooklm/summary.test.d.ts +1 -0
- package/dist/clis/notebooklm/summary.test.js +78 -0
- package/dist/clis/notebooklm/utils.d.ts +37 -0
- package/dist/clis/notebooklm/utils.js +739 -0
- package/dist/clis/notebooklm/utils.test.d.ts +1 -0
- package/dist/clis/notebooklm/utils.test.js +390 -0
- package/dist/clis/substack/utils.d.ts +4 -0
- package/dist/clis/substack/utils.js +8 -2
- package/dist/clis/substack/utils.test.d.ts +1 -0
- package/dist/clis/substack/utils.test.js +46 -0
- package/dist/clis/v2ex/hot.yaml +4 -1
- package/dist/clis/v2ex/latest.yaml +4 -1
- package/dist/clis/v2ex/topic.yaml +6 -1
- package/dist/clis/weixin/download.d.ts +9 -0
- package/dist/clis/weixin/download.js +76 -6
- package/dist/clis/weread/book.js +108 -2
- package/dist/clis/weread/commands.test.js +262 -152
- package/dist/clis/weread/utils.d.ts +10 -0
- package/dist/clis/weread/utils.js +27 -7
- package/dist/clis/xiaohongshu/comments.d.ts +3 -0
- package/dist/clis/xiaohongshu/comments.js +76 -17
- package/dist/clis/xiaohongshu/comments.test.js +70 -9
- package/dist/clis/xiaohongshu/download.d.ts +4 -1
- package/dist/clis/xiaohongshu/download.js +83 -22
- package/dist/clis/xiaohongshu/download.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/download.test.js +75 -0
- package/dist/clis/xiaohongshu/note-helpers.d.ts +12 -0
- package/dist/clis/xiaohongshu/note-helpers.js +23 -0
- package/dist/clis/xiaohongshu/note.d.ts +7 -0
- package/dist/clis/xiaohongshu/note.js +76 -0
- package/dist/clis/xiaohongshu/note.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/note.test.js +136 -0
- package/dist/clis/xiaohongshu/search.js +9 -0
- package/dist/clis/xiaohongshu/search.test.js +10 -4
- package/dist/clis/youtube/search.js +57 -17
- package/dist/clis/zhihu/question.js +19 -17
- package/dist/clis/zhihu/question.test.d.ts +1 -0
- package/dist/clis/zhihu/question.test.js +54 -0
- package/dist/commanderAdapter.js +9 -0
- package/dist/commanderAdapter.test.js +25 -0
- package/dist/commands/daemon.d.ts +9 -0
- package/dist/commands/daemon.js +124 -0
- package/dist/commands/daemon.test.d.ts +1 -0
- package/dist/commands/daemon.test.js +185 -0
- package/dist/completion.js +3 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/daemon.d.ts +1 -1
- package/dist/daemon.js +25 -14
- package/dist/daemon.test.d.ts +1 -0
- package/dist/daemon.test.js +65 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.js +47 -2
- package/dist/electron-apps.d.ts +29 -0
- package/dist/electron-apps.js +65 -0
- package/dist/electron-apps.test.d.ts +1 -0
- package/dist/electron-apps.test.js +43 -0
- package/dist/engine.test.js +41 -9
- package/dist/execution.js +20 -16
- package/dist/idle-manager.d.ts +19 -0
- package/dist/idle-manager.js +54 -0
- package/dist/launcher.d.ts +36 -0
- package/dist/launcher.js +152 -0
- package/dist/launcher.test.d.ts +1 -0
- package/dist/launcher.test.js +57 -0
- package/dist/main.js +3 -3
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +31 -3
- package/dist/registry.test.js +13 -0
- package/dist/runtime.d.ts +5 -3
- package/dist/runtime.js +12 -5
- package/dist/serialization.d.ts +1 -0
- package/dist/serialization.js +3 -0
- package/dist/serialization.test.js +17 -1
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +52 -0
- package/dist/tui.test.d.ts +1 -0
- package/dist/tui.test.js +19 -0
- package/dist/weixin-download.test.js +14 -0
- package/docs/.vitepress/config.mts +1 -0
- package/docs/adapters/browser/notebooklm.md +69 -0
- package/docs/adapters/browser/xiaohongshu.md +19 -10
- package/docs/adapters/index.md +67 -66
- package/docs/guide/browser-bridge.md +12 -0
- package/docs/guide/troubleshooting.md +9 -4
- package/docs/superpowers/plans/2026-03-31-daemon-lifecycle-redesign.md +857 -0
- package/docs/superpowers/specs/2026-03-31-daemon-lifecycle-redesign.md +208 -0
- package/docs/zh/guide/browser-bridge.md +12 -0
- package/extension/dist/background.js +794 -513
- package/extension/src/background.test.ts +202 -2
- package/extension/src/background.ts +174 -10
- package/extension/src/cdp.ts +12 -0
- package/extension/src/protocol.ts +7 -5
- package/package.json +1 -1
- package/src/browser/cdp.ts +24 -17
- package/src/browser/daemon-client.ts +7 -1
- package/src/browser/dom-helpers.test.ts +15 -1
- package/src/browser/dom-helpers.ts +1 -0
- package/src/browser/mcp.ts +18 -13
- package/src/browser/page.test.ts +58 -0
- package/src/browser/page.ts +18 -2
- package/src/browser/stealth.test.ts +153 -0
- package/src/browser/stealth.ts +198 -0
- package/src/browser.test.ts +1 -1
- package/src/build-manifest.test.ts +2 -0
- package/src/build-manifest.ts +6 -1
- package/src/cli.ts +21 -3
- package/src/clis/antigravity/SKILL.md +3 -12
- package/src/clis/antigravity/serve.ts +5 -10
- package/src/clis/bilibili/subtitle.test.ts +60 -0
- package/src/clis/bilibili/subtitle.ts +4 -0
- package/src/clis/chatwise/ask.ts +0 -2
- package/src/clis/chatwise/export.ts +0 -2
- package/src/clis/chatwise/history.ts +0 -2
- package/src/clis/chatwise/model.ts +0 -2
- package/src/clis/chatwise/new.ts +1 -2
- package/src/clis/chatwise/read.ts +0 -2
- package/src/clis/chatwise/screenshot.ts +1 -2
- package/src/clis/chatwise/send.ts +0 -2
- package/src/clis/chatwise/status.ts +1 -2
- package/src/clis/ctrip/search.test.ts +73 -0
- package/src/clis/ctrip/search.ts +97 -47
- package/src/clis/douyin/_shared/sts2.test.ts +31 -0
- package/src/clis/douyin/_shared/sts2.ts +11 -3
- package/src/clis/douyin/activities.test.ts +41 -1
- package/src/clis/douyin/activities.ts +12 -3
- package/src/clis/douyin/collections.test.ts +35 -2
- package/src/clis/douyin/collections.ts +1 -1
- package/src/clis/douyin/draft.test.ts +444 -2
- package/src/clis/douyin/draft.ts +382 -218
- package/src/clis/douyin/hashtag.test.ts +42 -2
- package/src/clis/douyin/hashtag.ts +11 -3
- package/src/clis/douyin/profile.test.ts +43 -1
- package/src/clis/douyin/profile.ts +9 -2
- package/src/clis/douyin/videos.test.ts +52 -2
- package/src/clis/douyin/videos.ts +49 -15
- package/src/clis/facebook/search.test.ts +70 -0
- package/src/clis/facebook/search.yaml +4 -3
- package/src/clis/instagram/download.test.ts +159 -0
- package/src/clis/instagram/download.ts +286 -0
- package/src/clis/notebooklm/bind-current.test.ts +43 -0
- package/src/clis/notebooklm/bind-current.ts +36 -0
- package/src/clis/notebooklm/binding.test.ts +53 -0
- package/src/clis/notebooklm/compat.test.ts +19 -0
- package/src/clis/notebooklm/current.ts +38 -0
- package/src/clis/notebooklm/get.ts +53 -0
- package/src/clis/notebooklm/history.test.ts +70 -0
- package/src/clis/notebooklm/history.ts +36 -0
- package/src/clis/notebooklm/list.ts +40 -0
- package/src/clis/notebooklm/note-list.test.ts +64 -0
- package/src/clis/notebooklm/note-list.ts +42 -0
- package/src/clis/notebooklm/notes-get.test.ts +88 -0
- package/src/clis/notebooklm/notes-get.ts +67 -0
- package/src/clis/notebooklm/rpc.test.ts +126 -0
- package/src/clis/notebooklm/rpc.ts +286 -0
- package/src/clis/notebooklm/shared.ts +98 -0
- package/src/clis/notebooklm/source-fulltext.test.ts +123 -0
- package/src/clis/notebooklm/source-fulltext.ts +69 -0
- package/src/clis/notebooklm/source-get.test.ts +100 -0
- package/src/clis/notebooklm/source-get.ts +60 -0
- package/src/clis/notebooklm/source-guide.test.ts +121 -0
- package/src/clis/notebooklm/source-guide.ts +69 -0
- package/src/clis/notebooklm/source-list.ts +45 -0
- package/src/clis/notebooklm/status.ts +34 -0
- package/src/clis/notebooklm/summary.test.ts +94 -0
- package/src/clis/notebooklm/summary.ts +45 -0
- package/src/clis/notebooklm/utils.test.ts +446 -0
- package/src/clis/notebooklm/utils.ts +893 -0
- package/src/clis/substack/utils.test.ts +54 -0
- package/src/clis/substack/utils.ts +10 -2
- package/src/clis/v2ex/hot.yaml +4 -1
- package/src/clis/v2ex/latest.yaml +4 -1
- package/src/clis/v2ex/topic.yaml +6 -1
- package/src/clis/weixin/download.ts +95 -6
- package/src/clis/weread/book.ts +142 -2
- package/src/clis/weread/commands.test.ts +314 -154
- package/src/clis/weread/utils.ts +33 -4
- package/src/clis/xiaohongshu/comments.test.ts +85 -9
- package/src/clis/xiaohongshu/comments.ts +76 -17
- package/src/clis/xiaohongshu/download.test.ts +96 -0
- package/src/clis/xiaohongshu/download.ts +83 -22
- package/src/clis/xiaohongshu/note-helpers.ts +25 -0
- package/src/clis/xiaohongshu/note.test.ts +164 -0
- package/src/clis/xiaohongshu/note.ts +86 -0
- package/src/clis/xiaohongshu/search.test.ts +11 -4
- package/src/clis/xiaohongshu/search.ts +13 -0
- package/src/clis/youtube/search.ts +57 -17
- package/src/clis/zhihu/question.test.ts +71 -0
- package/src/clis/zhihu/question.ts +27 -15
- package/src/commanderAdapter.test.ts +30 -0
- package/src/commanderAdapter.ts +7 -0
- package/src/commands/daemon.test.ts +238 -0
- package/src/commands/daemon.ts +135 -0
- package/src/completion.ts +2 -1
- package/src/constants.ts +3 -0
- package/src/daemon.test.ts +88 -0
- package/src/daemon.ts +26 -14
- package/src/discovery.ts +52 -2
- package/src/electron-apps.test.ts +50 -0
- package/src/electron-apps.ts +89 -0
- package/src/engine.test.ts +45 -9
- package/src/execution.ts +24 -19
- package/src/idle-manager.ts +60 -0
- package/src/launcher.test.ts +67 -0
- package/src/launcher.ts +185 -0
- package/src/main.ts +3 -2
- package/src/registry.test.ts +15 -0
- package/src/registry.ts +32 -3
- package/src/runtime.ts +13 -7
- package/src/serialization.test.ts +19 -1
- package/src/serialization.ts +2 -0
- package/src/tui.test.ts +23 -0
- package/src/tui.ts +65 -0
- package/src/weixin-download.test.ts +27 -0
- package/tests/e2e/browser-public-extended.test.ts +6 -2
- package/chatwise-opencli.ps1 +0 -82
- package/dist/clis/chatwise/shared.d.ts +0 -2
- package/dist/clis/chatwise/shared.js +0 -6
- package/src/clis/chatwise/shared.ts +0 -8
|
@@ -270,13 +270,28 @@ async function waitForTrustedWebShelfSnapshot(page, snapshot, currentVid) {
|
|
|
270
270
|
* shelf cache order with the visible shelf links rendered on the page.
|
|
271
271
|
*/
|
|
272
272
|
export async function resolveShelfReaderUrl(page, bookId) {
|
|
273
|
+
const resolution = await resolveShelfReader(page, bookId);
|
|
274
|
+
return resolution.readerUrl;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Resolve the current reader URL for a shelf entry and return the parsed shelf
|
|
278
|
+
* snapshot used during resolution, so callers can reuse cached title/author
|
|
279
|
+
* metadata without loading the shelf page twice.
|
|
280
|
+
*/
|
|
281
|
+
export async function resolveShelfReader(page, bookId) {
|
|
273
282
|
const { snapshot: initialSnapshot, currentVid } = await loadWebShelfSnapshotWithVid(page);
|
|
274
283
|
const snapshot = await waitForTrustedWebShelfSnapshot(page, initialSnapshot, currentVid);
|
|
275
|
-
if (!snapshot.cacheFound)
|
|
276
|
-
return null;
|
|
284
|
+
if (!snapshot.cacheFound) {
|
|
285
|
+
return { snapshot, readerUrl: null };
|
|
286
|
+
}
|
|
287
|
+
const rawBookIds = getUniqueRawBookIds(snapshot);
|
|
277
288
|
const trustedIndexedBookIds = getTrustedIndexedBookIds(snapshot);
|
|
278
|
-
|
|
279
|
-
|
|
289
|
+
const canUseRawOrderFallback = trustedIndexedBookIds.length === 0
|
|
290
|
+
&& rawBookIds.length > 0
|
|
291
|
+
&& snapshot.shelfIndexes.length === 0;
|
|
292
|
+
if (trustedIndexedBookIds.length === 0 && !canUseRawOrderFallback) {
|
|
293
|
+
return { snapshot, readerUrl: null };
|
|
294
|
+
}
|
|
280
295
|
const readerUrls = await page.evaluate(`
|
|
281
296
|
(() => Array.from(document.querySelectorAll('a.shelfBook[href]'))
|
|
282
297
|
.map((anchor) => {
|
|
@@ -285,11 +300,16 @@ export async function resolveShelfReaderUrl(page, bookId) {
|
|
|
285
300
|
})
|
|
286
301
|
.filter(Boolean))
|
|
287
302
|
`);
|
|
288
|
-
|
|
289
|
-
|
|
303
|
+
const expectedEntryCount = trustedIndexedBookIds.length > 0 ? trustedIndexedBookIds.length : rawBookIds.length;
|
|
304
|
+
if (readerUrls.length !== expectedEntryCount) {
|
|
305
|
+
return { snapshot, readerUrl: null };
|
|
306
|
+
}
|
|
290
307
|
const entries = buildWebShelfEntries(snapshot, readerUrls);
|
|
291
308
|
const entry = entries.find((candidate) => candidate.bookId === bookId);
|
|
292
|
-
return
|
|
309
|
+
return {
|
|
310
|
+
snapshot,
|
|
311
|
+
readerUrl: entry?.readerUrl || null,
|
|
312
|
+
};
|
|
293
313
|
}
|
|
294
314
|
/** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */
|
|
295
315
|
export function formatDate(ts) {
|
|
@@ -1,32 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Xiaohongshu comments — DOM extraction from note detail page.
|
|
3
3
|
* XHS API requires signed requests, so we scrape the rendered DOM instead.
|
|
4
|
+
*
|
|
5
|
+
* Supports both top-level comments and nested replies (楼中楼) via
|
|
6
|
+
* the --with-replies flag.
|
|
4
7
|
*/
|
|
5
8
|
import { cli, Strategy } from '../../registry.js';
|
|
6
9
|
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
|
10
|
+
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
11
|
+
function parseCommentLimit(raw, fallback = 20) {
|
|
12
|
+
const n = Number(raw);
|
|
13
|
+
if (!Number.isFinite(n))
|
|
14
|
+
return fallback;
|
|
15
|
+
return Math.max(1, Math.min(Math.floor(n), 50));
|
|
16
|
+
}
|
|
7
17
|
cli({
|
|
8
18
|
site: 'xiaohongshu',
|
|
9
19
|
name: 'comments',
|
|
10
|
-
description: '
|
|
20
|
+
description: '获取小红书笔记评论(支持楼中楼子回复)',
|
|
11
21
|
domain: 'www.xiaohongshu.com',
|
|
12
22
|
strategy: Strategy.COOKIE,
|
|
13
23
|
args: [
|
|
14
|
-
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full
|
|
15
|
-
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
|
|
24
|
+
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
|
|
25
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
|
|
26
|
+
{ name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
|
|
16
27
|
],
|
|
17
|
-
columns: ['rank', 'author', 'text', 'likes', 'time'],
|
|
28
|
+
columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
|
|
18
29
|
func: async (page, kwargs) => {
|
|
19
|
-
const limit =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
noteId = urlMatch[1];
|
|
25
|
-
await page.goto(`https://www.xiaohongshu.com/explore/${noteId}`);
|
|
30
|
+
const limit = parseCommentLimit(kwargs.limit);
|
|
31
|
+
const withReplies = Boolean(kwargs['with-replies']);
|
|
32
|
+
const raw = String(kwargs['note-id']);
|
|
33
|
+
const noteId = parseNoteId(raw);
|
|
34
|
+
await page.goto(buildNoteUrl(raw));
|
|
26
35
|
await page.wait(3);
|
|
27
36
|
const data = await page.evaluate(`
|
|
28
37
|
(async () => {
|
|
29
38
|
const wait = (ms) => new Promise(r => setTimeout(r, ms))
|
|
39
|
+
const withReplies = ${withReplies}
|
|
30
40
|
|
|
31
41
|
// Check login state
|
|
32
42
|
const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
|
|
@@ -41,6 +51,31 @@ cli({
|
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
54
|
+
const parseLikes = (el) => {
|
|
55
|
+
const raw = clean(el)
|
|
56
|
+
return /^\\d+$/.test(raw) ? Number(raw) : 0
|
|
57
|
+
}
|
|
58
|
+
const expandReplyThreads = async (root) => {
|
|
59
|
+
if (!withReplies || !root) return
|
|
60
|
+
const clickedTexts = new Set()
|
|
61
|
+
for (let round = 0; round < 3; round++) {
|
|
62
|
+
const expanders = Array.from(root.querySelectorAll('button, [role="button"], span, div')).filter(el => {
|
|
63
|
+
if (!(el instanceof HTMLElement)) return false
|
|
64
|
+
const text = clean(el)
|
|
65
|
+
if (!text || text.length > 24) return false
|
|
66
|
+
if (!/(展开|更多回复|全部回复|查看.*回复|共\\d+条回复)/.test(text)) return false
|
|
67
|
+
if (clickedTexts.has(text)) return false
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
if (!expanders.length) break
|
|
71
|
+
for (const el of expanders) {
|
|
72
|
+
const text = clean(el)
|
|
73
|
+
el.click()
|
|
74
|
+
clickedTexts.add(text)
|
|
75
|
+
await wait(300)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
44
79
|
|
|
45
80
|
const results = []
|
|
46
81
|
const parents = document.querySelectorAll('.parent-comment')
|
|
@@ -50,13 +85,24 @@ cli({
|
|
|
50
85
|
|
|
51
86
|
const author = clean(item.querySelector('.author-wrapper .name, .user-name'))
|
|
52
87
|
const text = clean(item.querySelector('.content, .note-text'))
|
|
53
|
-
|
|
54
|
-
const likesRaw = clean(item.querySelector('.count'))
|
|
55
|
-
const likes = /^\\d+$/.test(likesRaw) ? Number(likesRaw) : 0
|
|
88
|
+
const likes = parseLikes(item.querySelector('.count'))
|
|
56
89
|
const time = clean(item.querySelector('.date, .time'))
|
|
57
90
|
|
|
58
91
|
if (!text) continue
|
|
59
|
-
results.push({ author, text, likes, time })
|
|
92
|
+
results.push({ author, text, likes, time, is_reply: false, reply_to: '' })
|
|
93
|
+
|
|
94
|
+
// Extract nested replies (楼中楼)
|
|
95
|
+
if (withReplies) {
|
|
96
|
+
await expandReplyThreads(p)
|
|
97
|
+
p.querySelectorAll('.reply-container .comment-item-sub, .sub-comment-list .comment-item').forEach(sub => {
|
|
98
|
+
const sAuthor = clean(sub.querySelector('.name, .user-name'))
|
|
99
|
+
const sText = clean(sub.querySelector('.content, .note-text'))
|
|
100
|
+
const sLikes = parseLikes(sub.querySelector('.count'))
|
|
101
|
+
const sTime = clean(sub.querySelector('.date, .time'))
|
|
102
|
+
if (!sText) return
|
|
103
|
+
results.push({ author: sAuthor, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author })
|
|
104
|
+
})
|
|
105
|
+
}
|
|
60
106
|
}
|
|
61
107
|
|
|
62
108
|
return { loginWall, results }
|
|
@@ -68,7 +114,20 @@ cli({
|
|
|
68
114
|
if (data.loginWall) {
|
|
69
115
|
throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
|
|
70
116
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
117
|
+
const all = data.results ?? [];
|
|
118
|
+
// When limiting, count only top-level comments; their replies are included for free
|
|
119
|
+
if (withReplies) {
|
|
120
|
+
const limited = [];
|
|
121
|
+
let topCount = 0;
|
|
122
|
+
for (const c of all) {
|
|
123
|
+
if (!c.is_reply)
|
|
124
|
+
topCount++;
|
|
125
|
+
if (topCount > limit)
|
|
126
|
+
break;
|
|
127
|
+
limited.push(c);
|
|
128
|
+
}
|
|
129
|
+
return limited.map((c, i) => ({ rank: i + 1, ...c }));
|
|
130
|
+
}
|
|
131
|
+
return all.slice(0, limit).map((c, i) => ({ rank: i + 1, ...c }));
|
|
73
132
|
},
|
|
74
133
|
});
|
|
@@ -33,22 +33,20 @@ describe('xiaohongshu comments', () => {
|
|
|
33
33
|
const page = createPageMock({
|
|
34
34
|
loginWall: false,
|
|
35
35
|
results: [
|
|
36
|
-
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
|
|
37
|
-
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
|
|
36
|
+
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' },
|
|
37
|
+
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
|
|
38
38
|
],
|
|
39
39
|
});
|
|
40
40
|
const result = (await command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 }));
|
|
41
41
|
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
|
|
42
|
-
expect(result).
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
]);
|
|
46
|
-
expect(result[0]).not.toHaveProperty('loginWall');
|
|
42
|
+
expect(result).toHaveLength(2);
|
|
43
|
+
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 });
|
|
44
|
+
expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 });
|
|
47
45
|
});
|
|
48
46
|
it('strips /explore/ prefix from full URL input', async () => {
|
|
49
47
|
const page = createPageMock({
|
|
50
48
|
loginWall: false,
|
|
51
|
-
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
|
|
49
|
+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
|
|
52
50
|
});
|
|
53
51
|
await command.func(page, {
|
|
54
52
|
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
|
|
@@ -56,6 +54,15 @@ describe('xiaohongshu comments', () => {
|
|
|
56
54
|
});
|
|
57
55
|
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
|
|
58
56
|
});
|
|
57
|
+
it('preserves full search_result URL with xsec_token for navigation', async () => {
|
|
58
|
+
const page = createPageMock({
|
|
59
|
+
loginWall: false,
|
|
60
|
+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
|
|
61
|
+
});
|
|
62
|
+
const fullUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search';
|
|
63
|
+
await command.func(page, { 'note-id': fullUrl, limit: 5 });
|
|
64
|
+
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
65
|
+
});
|
|
59
66
|
it('throws AuthRequiredError when login wall is detected', async () => {
|
|
60
67
|
const page = createPageMock({ loginWall: true, results: [] });
|
|
61
68
|
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
|
|
@@ -64,12 +71,14 @@ describe('xiaohongshu comments', () => {
|
|
|
64
71
|
const page = createPageMock({ loginWall: false, results: [] });
|
|
65
72
|
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
|
|
66
73
|
});
|
|
67
|
-
it('respects the limit', async () => {
|
|
74
|
+
it('respects the limit for top-level comments', async () => {
|
|
68
75
|
const manyComments = Array.from({ length: 10 }, (_, i) => ({
|
|
69
76
|
author: `User${i}`,
|
|
70
77
|
text: `Comment ${i}`,
|
|
71
78
|
likes: i,
|
|
72
79
|
time: '2024-01-01',
|
|
80
|
+
is_reply: false,
|
|
81
|
+
reply_to: '',
|
|
73
82
|
}));
|
|
74
83
|
const page = createPageMock({ loginWall: false, results: manyComments });
|
|
75
84
|
const result = (await command.func(page, { 'note-id': 'abc123', limit: 3 }));
|
|
@@ -77,4 +86,56 @@ describe('xiaohongshu comments', () => {
|
|
|
77
86
|
expect(result[0].rank).toBe(1);
|
|
78
87
|
expect(result[2].rank).toBe(3);
|
|
79
88
|
});
|
|
89
|
+
it('clamps invalid negative limits to a safe minimum', async () => {
|
|
90
|
+
const page = createPageMock({
|
|
91
|
+
loginWall: false,
|
|
92
|
+
results: [
|
|
93
|
+
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' },
|
|
94
|
+
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const result = (await command.func(page, { 'note-id': 'abc123', limit: -3 }));
|
|
98
|
+
expect(result).toHaveLength(1);
|
|
99
|
+
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
|
|
100
|
+
});
|
|
101
|
+
describe('--with-replies', () => {
|
|
102
|
+
it('includes reply rows with is_reply=true and reply_to set', async () => {
|
|
103
|
+
const page = createPageMock({
|
|
104
|
+
loginWall: false,
|
|
105
|
+
results: [
|
|
106
|
+
{ author: 'Alice', text: 'Main comment', likes: 10, time: '03-25', is_reply: false, reply_to: '' },
|
|
107
|
+
{ author: 'Bob', text: 'Reply to Alice', likes: 3, time: '03-25', is_reply: true, reply_to: 'Alice' },
|
|
108
|
+
{ author: 'Carol', text: 'Another top', likes: 5, time: '03-26', is_reply: false, reply_to: '' },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
const result = (await command.func(page, {
|
|
112
|
+
'note-id': 'abc123', limit: 50, 'with-replies': true,
|
|
113
|
+
}));
|
|
114
|
+
expect(result).toHaveLength(3);
|
|
115
|
+
expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
|
|
116
|
+
expect(result[1]).toMatchObject({ author: 'Bob', is_reply: true, reply_to: 'Alice' });
|
|
117
|
+
expect(result[2]).toMatchObject({ author: 'Carol', is_reply: false, reply_to: '' });
|
|
118
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
119
|
+
expect(script).toContain('共\\d+条回复');
|
|
120
|
+
expect(script).toContain('el.click()');
|
|
121
|
+
});
|
|
122
|
+
it('limits by top-level count, keeping attached replies', async () => {
|
|
123
|
+
const page = createPageMock({
|
|
124
|
+
loginWall: false,
|
|
125
|
+
results: [
|
|
126
|
+
{ author: 'A', text: 'Top 1', likes: 0, time: '', is_reply: false, reply_to: '' },
|
|
127
|
+
{ author: 'A1', text: 'Reply 1', likes: 0, time: '', is_reply: true, reply_to: 'A' },
|
|
128
|
+
{ author: 'A2', text: 'Reply 2', likes: 0, time: '', is_reply: true, reply_to: 'A' },
|
|
129
|
+
{ author: 'B', text: 'Top 2', likes: 0, time: '', is_reply: false, reply_to: '' },
|
|
130
|
+
{ author: 'C', text: 'Top 3', likes: 0, time: '', is_reply: false, reply_to: '' },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
// Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
|
|
134
|
+
const result = (await command.func(page, {
|
|
135
|
+
'note-id': 'abc123', limit: 2, 'with-replies': true,
|
|
136
|
+
}));
|
|
137
|
+
expect(result).toHaveLength(4);
|
|
138
|
+
expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
80
141
|
});
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Xiaohongshu download — download images and videos from a note.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* opencli xiaohongshu download
|
|
5
|
+
* opencli xiaohongshu download <note-id-or-url> --output ./xhs
|
|
6
|
+
*
|
|
7
|
+
* Accepts a bare note ID, a full xiaohongshu.com URL (with xsec_token),
|
|
8
|
+
* or a short link (http://xhslink.com/...).
|
|
6
9
|
*/
|
|
7
10
|
export {};
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* Xiaohongshu download — download images and videos from a note.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* opencli xiaohongshu download
|
|
5
|
+
* opencli xiaohongshu download <note-id-or-url> --output ./xhs
|
|
6
|
+
*
|
|
7
|
+
* Accepts a bare note ID, a full xiaohongshu.com URL (with xsec_token),
|
|
8
|
+
* or a short link (http://xhslink.com/...).
|
|
6
9
|
*/
|
|
7
10
|
import { cli, Strategy } from '../../registry.js';
|
|
8
11
|
import { formatCookieHeader } from '../../download/index.js';
|
|
9
12
|
import { downloadMedia } from '../../download/media-download.js';
|
|
13
|
+
import { buildNoteUrl, parseNoteId } from './note-helpers.js';
|
|
10
14
|
cli({
|
|
11
15
|
site: 'xiaohongshu',
|
|
12
16
|
name: 'download',
|
|
@@ -14,15 +18,15 @@ cli({
|
|
|
14
18
|
domain: 'www.xiaohongshu.com',
|
|
15
19
|
strategy: Strategy.COOKIE,
|
|
16
20
|
args: [
|
|
17
|
-
{ name: 'note-id', positional: true, required: true, help: 'Note ID
|
|
21
|
+
{ name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' },
|
|
18
22
|
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
|
|
19
23
|
],
|
|
20
24
|
columns: ['index', 'type', 'status', 'size'],
|
|
21
25
|
func: async (page, kwargs) => {
|
|
22
|
-
const
|
|
26
|
+
const rawInput = String(kwargs['note-id']);
|
|
23
27
|
const output = kwargs.output;
|
|
24
|
-
|
|
25
|
-
await page.goto(
|
|
28
|
+
const noteId = parseNoteId(rawInput);
|
|
29
|
+
await page.goto(buildNoteUrl(rawInput));
|
|
26
30
|
// Extract note info and media URLs
|
|
27
31
|
const data = await page.evaluate(`
|
|
28
32
|
(() => {
|
|
@@ -32,6 +36,18 @@ cli({
|
|
|
32
36
|
author: '',
|
|
33
37
|
media: []
|
|
34
38
|
};
|
|
39
|
+
const seenMedia = new Set();
|
|
40
|
+
const pushMedia = (type, url) => {
|
|
41
|
+
if (!url) return;
|
|
42
|
+
const key = type + ':' + url;
|
|
43
|
+
if (seenMedia.has(key)) return;
|
|
44
|
+
seenMedia.add(key);
|
|
45
|
+
result.media.push({ type, url });
|
|
46
|
+
};
|
|
47
|
+
const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)/i);
|
|
48
|
+
if (locationMatch) {
|
|
49
|
+
result.noteId = locationMatch[1];
|
|
50
|
+
}
|
|
35
51
|
|
|
36
52
|
// Get title
|
|
37
53
|
const titleEl = document.querySelector('.title, #detail-title, .note-content .title');
|
|
@@ -57,7 +73,6 @@ cli({
|
|
|
57
73
|
document.querySelectorAll(selector).forEach(img => {
|
|
58
74
|
let src = img.src || img.getAttribute('data-src') || '';
|
|
59
75
|
if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
|
|
60
|
-
// Convert to high quality URL (remove resize parameters)
|
|
61
76
|
src = src.split('?')[0];
|
|
62
77
|
src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
|
|
63
78
|
imageUrls.add(src);
|
|
@@ -65,26 +80,69 @@ cli({
|
|
|
65
80
|
});
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
// Get video
|
|
69
|
-
const videoSelectors = [
|
|
70
|
-
'video source',
|
|
71
|
-
'video[src]',
|
|
72
|
-
'.player video',
|
|
73
|
-
'.video-player video'
|
|
74
|
-
];
|
|
83
|
+
// Get video — prefer real URL from page state over blob: URLs
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
// Method 1: Extract from __INITIAL_STATE__ (SSR hydration data)
|
|
86
|
+
try {
|
|
87
|
+
const state = window.__INITIAL_STATE__;
|
|
88
|
+
if (state) {
|
|
89
|
+
const noteData = state.note?.noteDetailMap || state.note?.note || {};
|
|
90
|
+
for (const key of Object.keys(noteData)) {
|
|
91
|
+
const note = noteData[key]?.note || noteData[key];
|
|
92
|
+
const video = note?.video;
|
|
93
|
+
if (video) {
|
|
94
|
+
const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
|
|
95
|
+
if (vUrl) {
|
|
96
|
+
const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
|
|
97
|
+
pushMedia('video', fullUrl);
|
|
98
|
+
}
|
|
99
|
+
const streams = video.media?.stream?.h264 || [];
|
|
100
|
+
for (const stream of streams) {
|
|
101
|
+
if (stream.masterUrl) pushMedia('video', stream.masterUrl);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
81
104
|
}
|
|
82
|
-
}
|
|
105
|
+
}
|
|
106
|
+
} catch(e) {}
|
|
107
|
+
|
|
108
|
+
// Method 2: Extract video URLs from inline script JSON
|
|
109
|
+
if (result.media.filter(m => m.type === 'video').length === 0) {
|
|
110
|
+
try {
|
|
111
|
+
const scripts = document.querySelectorAll('script');
|
|
112
|
+
for (const s of scripts) {
|
|
113
|
+
const text = s.textContent || '';
|
|
114
|
+
const videoMatches = text.match(/https?:\\/\\/sns-video[^"'\\s]+\\.mp4[^"'\\s]*/g)
|
|
115
|
+
|| text.match(/https?:\\/\\/[^"'\\s]*xhscdn[^"'\\s]*\\.mp4[^"'\\s]*/g);
|
|
116
|
+
if (videoMatches) {
|
|
117
|
+
videoMatches.forEach(url => {
|
|
118
|
+
pushMedia('video', url.replace(/\\\\u002F/g, '/'));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch(e) {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Method 3: Fallback to DOM video elements, skip blob: URLs
|
|
126
|
+
if (result.media.filter(m => m.type === 'video').length === 0) {
|
|
127
|
+
const videoSelectors = [
|
|
128
|
+
'video source',
|
|
129
|
+
'video[src]',
|
|
130
|
+
'.player video',
|
|
131
|
+
'.video-player video'
|
|
132
|
+
];
|
|
133
|
+
for (const selector of videoSelectors) {
|
|
134
|
+
document.querySelectorAll(selector).forEach(v => {
|
|
135
|
+
const src = v.src || v.getAttribute('src') || '';
|
|
136
|
+
if (src && !src.startsWith('blob:')) {
|
|
137
|
+
pushMedia('video', src);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
83
141
|
}
|
|
84
142
|
|
|
85
143
|
// Add images to media
|
|
86
144
|
imageUrls.forEach(url => {
|
|
87
|
-
|
|
145
|
+
pushMedia('image', url);
|
|
88
146
|
});
|
|
89
147
|
|
|
90
148
|
return result;
|
|
@@ -95,11 +153,14 @@ cli({
|
|
|
95
153
|
}
|
|
96
154
|
// Extract cookies for authenticated downloads
|
|
97
155
|
const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
|
|
156
|
+
const resolvedNoteId = typeof data.noteId === 'string' && data.noteId.trim()
|
|
157
|
+
? data.noteId.trim()
|
|
158
|
+
: noteId;
|
|
98
159
|
return downloadMedia(data.media, {
|
|
99
160
|
output,
|
|
100
|
-
subdir:
|
|
161
|
+
subdir: resolvedNoteId,
|
|
101
162
|
cookies,
|
|
102
|
-
filenamePrefix:
|
|
163
|
+
filenamePrefix: resolvedNoteId,
|
|
103
164
|
timeout: 60000,
|
|
104
165
|
});
|
|
105
166
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './download.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
const { mockDownloadMedia, mockFormatCookieHeader } = vi.hoisted(() => ({
|
|
3
|
+
mockDownloadMedia: vi.fn(),
|
|
4
|
+
mockFormatCookieHeader: vi.fn(() => 'a=b'),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../../download/media-download.js', () => ({
|
|
7
|
+
downloadMedia: mockDownloadMedia,
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('../../download/index.js', () => ({
|
|
10
|
+
formatCookieHeader: mockFormatCookieHeader,
|
|
11
|
+
}));
|
|
12
|
+
import { getRegistry } from '../../registry.js';
|
|
13
|
+
import './download.js';
|
|
14
|
+
function createPageMock(evaluateResult) {
|
|
15
|
+
return {
|
|
16
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
18
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
24
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
26
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
30
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
31
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
35
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: '.xiaohongshu.com' }]),
|
|
36
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
37
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
describe('xiaohongshu download', () => {
|
|
41
|
+
const command = getRegistry().get('xiaohongshu/download');
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
mockDownloadMedia.mockReset();
|
|
44
|
+
mockFormatCookieHeader.mockClear();
|
|
45
|
+
mockDownloadMedia.mockResolvedValue([{ index: 1, type: 'video', status: 'success', size: '1 MB' }]);
|
|
46
|
+
});
|
|
47
|
+
it('preserves short links for navigation but uses canonical note id for output naming', async () => {
|
|
48
|
+
const page = createPageMock({
|
|
49
|
+
noteId: '69bc166f000000001a02069a',
|
|
50
|
+
media: [{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }],
|
|
51
|
+
});
|
|
52
|
+
const shortUrl = 'http://xhslink.com/o/4MKEjsZnhCz';
|
|
53
|
+
await command.func(page, { 'note-id': shortUrl, output: './out' });
|
|
54
|
+
expect(page.goto.mock.calls[0][0]).toBe(shortUrl);
|
|
55
|
+
expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }], expect.objectContaining({
|
|
56
|
+
output: './out',
|
|
57
|
+
subdir: '69bc166f000000001a02069a',
|
|
58
|
+
filenamePrefix: '69bc166f000000001a02069a',
|
|
59
|
+
cookies: 'a=b',
|
|
60
|
+
}));
|
|
61
|
+
});
|
|
62
|
+
it('preserves full note URL with xsec_token for navigation', async () => {
|
|
63
|
+
const page = createPageMock({
|
|
64
|
+
noteId: '69bc166f000000001a02069a',
|
|
65
|
+
media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
|
|
66
|
+
});
|
|
67
|
+
const fullUrl = 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search';
|
|
68
|
+
await command.func(page, { 'note-id': fullUrl, output: './out' });
|
|
69
|
+
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
70
|
+
expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({
|
|
71
|
+
subdir: '69bc166f000000001a02069a',
|
|
72
|
+
filenamePrefix: '69bc166f000000001a02069a',
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
|
|
2
|
+
/** Extract a bare note ID from a full URL or raw ID string. */
|
|
3
|
+
export declare function parseNoteId(input: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Build the best navigation URL for a note.
|
|
6
|
+
*
|
|
7
|
+
* XHS blocks direct `/explore/<id>` access without a valid `xsec_token`.
|
|
8
|
+
* When the user passes a full URL (from search results), we preserve it
|
|
9
|
+
* so the browser navigates with the token intact. For bare IDs we fall
|
|
10
|
+
* back to the `/explore/<id>` path (works when cookies carry enough context).
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildNoteUrl(input: string): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
|
|
2
|
+
/** Extract a bare note ID from a full URL or raw ID string. */
|
|
3
|
+
export function parseNoteId(input) {
|
|
4
|
+
const trimmed = input.trim();
|
|
5
|
+
const match = trimmed.match(/\/(?:explore|note|search_result)\/([a-f0-9]+)/);
|
|
6
|
+
return match ? match[1] : trimmed;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build the best navigation URL for a note.
|
|
10
|
+
*
|
|
11
|
+
* XHS blocks direct `/explore/<id>` access without a valid `xsec_token`.
|
|
12
|
+
* When the user passes a full URL (from search results), we preserve it
|
|
13
|
+
* so the browser navigates with the token intact. For bare IDs we fall
|
|
14
|
+
* back to the `/explore/<id>` path (works when cookies carry enough context).
|
|
15
|
+
*/
|
|
16
|
+
export function buildNoteUrl(input) {
|
|
17
|
+
const trimmed = input.trim();
|
|
18
|
+
if (/^https?:\/\//.test(trimmed)) {
|
|
19
|
+
// Full URL — navigate as-is; the browser will follow any redirects
|
|
20
|
+
return trimmed;
|
|
21
|
+
}
|
|
22
|
+
return `https://www.xiaohongshu.com/explore/${trimmed}`;
|
|
23
|
+
}
|