@jackwener/opencli 1.5.6 → 1.5.8
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 +34 -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/extension-manifest-regression.test.js +1 -0
- 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 +250 -11
- package/extension/manifest.json +2 -1
- package/extension/src/background.test.ts +202 -2
- package/extension/src/background.ts +175 -10
- package/extension/src/cdp.test.ts +75 -0
- package/extension/src/cdp.ts +89 -3
- 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/extension-manifest-regression.test.ts +1 -0
- 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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { getRegistry } from '../../registry.js';
|
|
4
|
+
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
5
|
+
import './note.js';
|
|
6
|
+
|
|
7
|
+
function createPageMock(evaluateResult: any): IPage {
|
|
8
|
+
return {
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
11
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
17
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
19
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
23
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
24
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
28
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
29
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
30
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('parseNoteId', () => {
|
|
35
|
+
it('extracts ID from /explore/ URL', () => {
|
|
36
|
+
expect(parseNoteId('https://www.xiaohongshu.com/explore/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('extracts ID from /search_result/ URL with query params', () => {
|
|
40
|
+
expect(parseNoteId('https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc')).toBe('69c131c9000000002800be4c');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('extracts ID from /note/ URL', () => {
|
|
44
|
+
expect(parseNoteId('https://www.xiaohongshu.com/note/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns raw string when no URL pattern matches', () => {
|
|
48
|
+
expect(parseNoteId('69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('trims whitespace', () => {
|
|
52
|
+
expect(parseNoteId(' 69c131c9000000002800be4c ')).toBe('69c131c9000000002800be4c');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('buildNoteUrl', () => {
|
|
57
|
+
it('returns full URL as-is when given https URL', () => {
|
|
58
|
+
const url = 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok';
|
|
59
|
+
expect(buildNoteUrl(url)).toBe(url);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('constructs /explore/ URL for bare note ID', () => {
|
|
63
|
+
expect(buildNoteUrl('abc123')).toBe('https://www.xiaohongshu.com/explore/abc123');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('xiaohongshu note', () => {
|
|
68
|
+
const command = getRegistry().get('xiaohongshu/note');
|
|
69
|
+
|
|
70
|
+
it('is registered', () => {
|
|
71
|
+
expect(command).toBeDefined();
|
|
72
|
+
expect(command!.func).toBeTypeOf('function');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns note content as field/value rows', async () => {
|
|
76
|
+
const page = createPageMock({
|
|
77
|
+
loginWall: false,
|
|
78
|
+
notFound: false,
|
|
79
|
+
title: '尚界Z7实车体验',
|
|
80
|
+
desc: '今天去看了实车,外观很帅',
|
|
81
|
+
author: '小红薯用户',
|
|
82
|
+
likes: '257',
|
|
83
|
+
collects: '98',
|
|
84
|
+
comments: '45',
|
|
85
|
+
tags: ['#尚界Z7', '#鸿蒙智行'],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = (await command!.func!(page, { 'note-id': '69c131c9000000002800be4c' })) as any[];
|
|
89
|
+
|
|
90
|
+
expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69c131c9000000002800be4c');
|
|
91
|
+
expect(result).toEqual([
|
|
92
|
+
{ field: 'title', value: '尚界Z7实车体验' },
|
|
93
|
+
{ field: 'author', value: '小红薯用户' },
|
|
94
|
+
{ field: 'content', value: '今天去看了实车,外观很帅' },
|
|
95
|
+
{ field: 'likes', value: '257' },
|
|
96
|
+
{ field: 'collects', value: '98' },
|
|
97
|
+
{ field: 'comments', value: '45' },
|
|
98
|
+
{ field: 'tags', value: '#尚界Z7, #鸿蒙智行' },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('parses note ID from full /explore/ URL', async () => {
|
|
103
|
+
const page = createPageMock({
|
|
104
|
+
loginWall: false, notFound: false,
|
|
105
|
+
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await command!.func!(page, {
|
|
109
|
+
'note-id': 'https://www.xiaohongshu.com/explore/69c131c9000000002800be4c?xsec_token=abc',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69c131c9000000002800be4c');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('preserves full search_result URL with xsec_token for navigation', async () => {
|
|
116
|
+
const page = createPageMock({
|
|
117
|
+
loginWall: false, notFound: false,
|
|
118
|
+
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const fullUrl = 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc';
|
|
122
|
+
await command!.func!(page, { 'note-id': fullUrl });
|
|
123
|
+
|
|
124
|
+
// Should navigate to the full URL as-is, not strip the token
|
|
125
|
+
expect((page.goto as any).mock.calls[0][0]).toBe(fullUrl);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('throws AuthRequiredError on login wall', async () => {
|
|
129
|
+
const page = createPageMock({ loginWall: true, notFound: false });
|
|
130
|
+
|
|
131
|
+
await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws EmptyResultError when note is not found', async () => {
|
|
135
|
+
const page = createPageMock({ loginWall: false, notFound: true });
|
|
136
|
+
|
|
137
|
+
await expect(command!.func!(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('normalizes placeholder text to 0 for zero-count metrics', async () => {
|
|
141
|
+
const page = createPageMock({
|
|
142
|
+
loginWall: false, notFound: false,
|
|
143
|
+
title: 'New note', desc: 'Just posted', author: 'Author',
|
|
144
|
+
likes: '赞', collects: '收藏', comments: '评论', tags: [],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const result = (await command!.func!(page, { 'note-id': 'abc123' })) as any[];
|
|
148
|
+
expect(result.find((r: any) => r.field === 'likes')!.value).toBe('0');
|
|
149
|
+
expect(result.find((r: any) => r.field === 'collects')!.value).toBe('0');
|
|
150
|
+
expect(result.find((r: any) => r.field === 'comments')!.value).toBe('0');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('omits tags row when no tags present', async () => {
|
|
154
|
+
const page = createPageMock({
|
|
155
|
+
loginWall: false, notFound: false,
|
|
156
|
+
title: 'No tags', desc: 'Content', author: 'Author',
|
|
157
|
+
likes: '1', collects: '2', comments: '3', tags: [],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = (await command!.func!(page, { 'note-id': 'abc123' })) as any[];
|
|
161
|
+
expect(result.find((r: any) => r.field === 'tags')).toBeUndefined();
|
|
162
|
+
expect(result).toHaveLength(6);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu note — read full note content from a public note page.
|
|
3
|
+
*
|
|
4
|
+
* Extracts title, author, description text, and engagement metrics
|
|
5
|
+
* (likes, collects, comment count) via DOM extraction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { cli, Strategy } from '../../registry.js';
|
|
9
|
+
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
|
10
|
+
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
11
|
+
|
|
12
|
+
cli({
|
|
13
|
+
site: 'xiaohongshu',
|
|
14
|
+
name: 'note',
|
|
15
|
+
description: '获取小红书笔记正文和互动数据',
|
|
16
|
+
domain: 'www.xiaohongshu.com',
|
|
17
|
+
strategy: Strategy.COOKIE,
|
|
18
|
+
args: [
|
|
19
|
+
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['field', 'value'],
|
|
22
|
+
func: async (page, kwargs) => {
|
|
23
|
+
const raw = String(kwargs['note-id']);
|
|
24
|
+
const noteId = parseNoteId(raw);
|
|
25
|
+
const url = buildNoteUrl(raw);
|
|
26
|
+
|
|
27
|
+
await page.goto(url);
|
|
28
|
+
await page.wait(3);
|
|
29
|
+
|
|
30
|
+
const data = await page.evaluate(`
|
|
31
|
+
(() => {
|
|
32
|
+
const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
|
|
33
|
+
const notFound = /页面不见了|笔记不存在|无法浏览/.test(document.body.innerText || '')
|
|
34
|
+
|
|
35
|
+
const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
36
|
+
|
|
37
|
+
const title = clean(document.querySelector('#detail-title, .title'))
|
|
38
|
+
const desc = clean(document.querySelector('#detail-desc, .desc, .note-text'))
|
|
39
|
+
const author = clean(document.querySelector('.username, .author-wrapper .name'))
|
|
40
|
+
const likes = clean(document.querySelector('.like-wrapper .count'))
|
|
41
|
+
const collects = clean(document.querySelector('.collect-wrapper .count'))
|
|
42
|
+
const comments = clean(document.querySelector('.chat-wrapper .count'))
|
|
43
|
+
|
|
44
|
+
// Try to extract tags/topics
|
|
45
|
+
const tags = []
|
|
46
|
+
document.querySelectorAll('#detail-desc a.tag, #detail-desc a[href*="search_result"]').forEach(el => {
|
|
47
|
+
const t = (el.textContent || '').trim()
|
|
48
|
+
if (t) tags.push(t)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return { loginWall, notFound, title, desc, author, likes, collects, comments, tags }
|
|
52
|
+
})()
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
if (!data || typeof data !== 'object') {
|
|
56
|
+
throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ((data as any).loginWall) {
|
|
60
|
+
throw new AuthRequiredError('www.xiaohongshu.com', 'Note content requires login');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if ((data as any).notFound) {
|
|
64
|
+
throw new EmptyResultError('xiaohongshu/note', `Note ${noteId} not found or unavailable — it may have been deleted or restricted`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const d = data as any;
|
|
68
|
+
// XHS renders placeholder text like "赞"/"收藏"/"评论" when count is 0;
|
|
69
|
+
// normalize to '0' unless the value looks numeric.
|
|
70
|
+
const numOrZero = (v: string) => /^\d+/.test(v) ? v : '0';
|
|
71
|
+
const rows = [
|
|
72
|
+
{ field: 'title', value: d.title || '' },
|
|
73
|
+
{ field: 'author', value: d.author || '' },
|
|
74
|
+
{ field: 'content', value: d.desc || '' },
|
|
75
|
+
{ field: 'likes', value: numOrZero(d.likes || '') },
|
|
76
|
+
{ field: 'collects', value: numOrZero(d.collects || '') },
|
|
77
|
+
{ field: 'comments', value: numOrZero(d.comments || '') },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (d.tags?.length) {
|
|
81
|
+
rows.push({ field: 'tags', value: d.tags.join(', ') });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return rows;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
@@ -41,15 +41,16 @@ describe('xiaohongshu search', () => {
|
|
|
41
41
|
expect(cmd?.func).toBeTypeOf('function');
|
|
42
42
|
|
|
43
43
|
const page = createPageMock([
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
results: [],
|
|
47
|
-
},
|
|
44
|
+
// First evaluate: early login-wall check (returns true)
|
|
45
|
+
true,
|
|
48
46
|
]);
|
|
49
47
|
|
|
50
48
|
await expect(cmd!.func!(page, { query: '特斯拉', limit: 5 })).rejects.toThrow(
|
|
51
49
|
'Xiaohongshu search results are blocked behind a login wall'
|
|
52
50
|
);
|
|
51
|
+
|
|
52
|
+
// autoScroll must NOT be called when a login wall is detected early
|
|
53
|
+
expect(page.autoScroll).not.toHaveBeenCalled();
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
it('returns ranked results with search_result url and author_url preserved', async () => {
|
|
@@ -62,6 +63,9 @@ describe('xiaohongshu search', () => {
|
|
|
62
63
|
'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
|
|
63
64
|
|
|
64
65
|
const page = createPageMock([
|
|
66
|
+
// First evaluate: early login-wall check (returns false → no wall)
|
|
67
|
+
false,
|
|
68
|
+
// Second evaluate: main DOM extraction
|
|
65
69
|
{
|
|
66
70
|
loginWall: false,
|
|
67
71
|
results: [
|
|
@@ -99,6 +103,9 @@ describe('xiaohongshu search', () => {
|
|
|
99
103
|
expect(cmd?.func).toBeTypeOf('function');
|
|
100
104
|
|
|
101
105
|
const page = createPageMock([
|
|
106
|
+
// First evaluate: early login-wall check (returns false → no wall)
|
|
107
|
+
false,
|
|
108
|
+
// Second evaluate: main DOM extraction
|
|
102
109
|
{
|
|
103
110
|
loginWall: false,
|
|
104
111
|
results: [
|
|
@@ -44,6 +44,19 @@ cli({
|
|
|
44
44
|
);
|
|
45
45
|
await page.wait(3);
|
|
46
46
|
|
|
47
|
+
// Early login-wall detection: XHS may show a login gate instead of
|
|
48
|
+
// results. Check *before* autoScroll to avoid crashing on a page
|
|
49
|
+
// that has no meaningful content to scroll through.
|
|
50
|
+
const loginCheck = await page.evaluate(`
|
|
51
|
+
(() => /登录后查看搜索结果/.test(document.body?.innerText || ''))()
|
|
52
|
+
`);
|
|
53
|
+
if (loginCheck) {
|
|
54
|
+
throw new AuthRequiredError(
|
|
55
|
+
'www.xiaohongshu.com',
|
|
56
|
+
'Xiaohongshu search results are blocked behind a login wall',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
// Scroll a couple of times to load more results
|
|
48
61
|
await page.autoScroll({ times: 2 });
|
|
49
62
|
|
|
@@ -12,32 +12,60 @@ cli({
|
|
|
12
12
|
args: [
|
|
13
13
|
{ name: 'query', required: true, positional: true, help: 'Search query' },
|
|
14
14
|
{ name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
|
|
15
|
+
{ name: 'type', default: '', help: 'Filter type: shorts, video, channel, playlist' },
|
|
16
|
+
{ name: 'upload', default: '', help: 'Upload date: hour, today, week, month, year' },
|
|
17
|
+
{ name: 'sort', default: '', help: 'Sort by: relevance, date, views, rating' },
|
|
15
18
|
],
|
|
16
|
-
columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'],
|
|
19
|
+
columns: ['rank', 'title', 'channel', 'views', 'duration', 'published', 'url'],
|
|
17
20
|
func: async (page, kwargs) => {
|
|
18
21
|
const limit = Math.min(kwargs.limit || 20, 50);
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
const query = encodeURIComponent(kwargs.query);
|
|
23
|
+
|
|
24
|
+
// Build search URL with filter params
|
|
25
|
+
// YouTube uses sp= parameter for filters — we use the URL approach for reliability
|
|
26
|
+
const spMap: Record<string, string> = {
|
|
27
|
+
// type filters
|
|
28
|
+
'shorts': 'EgIQCQ%3D%3D', // Shorts (type=9)
|
|
29
|
+
'video': 'EgIQAQ%3D%3D',
|
|
30
|
+
'channel': 'EgIQAg%3D%3D',
|
|
31
|
+
'playlist': 'EgIQAw%3D%3D',
|
|
32
|
+
// upload date filters (can be combined with type via URL)
|
|
33
|
+
'hour': 'EgIIAQ%3D%3D',
|
|
34
|
+
'today': 'EgIIAg%3D%3D',
|
|
35
|
+
'week': 'EgIIAw%3D%3D',
|
|
36
|
+
'month': 'EgIIBA%3D%3D',
|
|
37
|
+
'year': 'EgIIBQ%3D%3D',
|
|
38
|
+
};
|
|
39
|
+
const sortMap: Record<string, string> = {
|
|
40
|
+
'date': 'CAI%3D',
|
|
41
|
+
'views': 'CAM%3D',
|
|
42
|
+
'rating': 'CAE%3D',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// YouTube only supports a single sp= parameter — pick the most specific filter.
|
|
46
|
+
// Priority: type > upload > sort (type is the most common use case)
|
|
47
|
+
let sp = '';
|
|
48
|
+
if (kwargs.type && spMap[kwargs.type]) sp = spMap[kwargs.type];
|
|
49
|
+
else if (kwargs.upload && spMap[kwargs.upload]) sp = spMap[kwargs.upload];
|
|
50
|
+
else if (kwargs.sort && sortMap[kwargs.sort]) sp = sortMap[kwargs.sort];
|
|
51
|
+
|
|
52
|
+
let url = `https://www.youtube.com/results?search_query=${query}`;
|
|
53
|
+
if (sp) url += `&sp=${sp}`;
|
|
54
|
+
|
|
55
|
+
await page.goto(url);
|
|
56
|
+
await page.wait(3);
|
|
21
57
|
const data = await page.evaluate(`
|
|
22
58
|
(async () => {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const context = cfg.INNERTUBE_CONTEXT;
|
|
26
|
-
if (!apiKey || !context) return {error: 'YouTube config not found'};
|
|
59
|
+
const data = window.ytInitialData;
|
|
60
|
+
if (!data) return {error: 'YouTube data not found'};
|
|
27
61
|
|
|
28
|
-
const resp = await fetch('/youtubei/v1/search?key=' + apiKey + '&prettyPrint=false', {
|
|
29
|
-
method: 'POST', credentials: 'include',
|
|
30
|
-
headers: {'Content-Type': 'application/json'},
|
|
31
|
-
body: JSON.stringify({context, query: '${kwargs.query.replace(/'/g, "\\'")}'})
|
|
32
|
-
});
|
|
33
|
-
if (!resp.ok) return {error: 'HTTP ' + resp.status};
|
|
34
|
-
|
|
35
|
-
const data = await resp.json();
|
|
36
62
|
const contents = data.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents || [];
|
|
37
63
|
const videos = [];
|
|
38
64
|
for (const section of contents) {
|
|
39
|
-
|
|
40
|
-
|
|
65
|
+
const items = section.itemSectionRenderer?.contents || section.reelShelfRenderer?.items || [];
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
if (videos.length >= ${limit}) break;
|
|
68
|
+
if (item.videoRenderer) {
|
|
41
69
|
const v = item.videoRenderer;
|
|
42
70
|
videos.push({
|
|
43
71
|
rank: videos.length + 1,
|
|
@@ -45,8 +73,20 @@ cli({
|
|
|
45
73
|
channel: v.ownerText?.runs?.[0]?.text || '',
|
|
46
74
|
views: v.viewCountText?.simpleText || v.shortViewCountText?.simpleText || '',
|
|
47
75
|
duration: v.lengthText?.simpleText || 'LIVE',
|
|
76
|
+
published: v.publishedTimeText?.simpleText || '',
|
|
48
77
|
url: 'https://www.youtube.com/watch?v=' + v.videoId
|
|
49
78
|
});
|
|
79
|
+
} else if (item.reelItemRenderer) {
|
|
80
|
+
const r = item.reelItemRenderer;
|
|
81
|
+
videos.push({
|
|
82
|
+
rank: videos.length + 1,
|
|
83
|
+
title: r.headline?.simpleText || '',
|
|
84
|
+
channel: r.navigationEndpoint?.reelWatchEndpoint?.overlay?.reelPlayerOverlayRenderer?.reelPlayerHeaderSupportedRenderers?.reelPlayerHeaderRenderer?.channelTitleText?.runs?.[0]?.text || '',
|
|
85
|
+
views: r.viewCountText?.simpleText || '',
|
|
86
|
+
duration: 'SHORT',
|
|
87
|
+
published: r.publishedTimeText?.simpleText || '',
|
|
88
|
+
url: 'https://www.youtube.com/shorts/' + r.videoId
|
|
89
|
+
});
|
|
50
90
|
}
|
|
51
91
|
}
|
|
52
92
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import { AuthRequiredError } from '../../errors.js';
|
|
4
|
+
import './question.js';
|
|
5
|
+
|
|
6
|
+
describe('zhihu question', () => {
|
|
7
|
+
it('returns answers even when the unused question detail request fails', async () => {
|
|
8
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
9
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
10
|
+
|
|
11
|
+
const evaluate = vi.fn().mockImplementation(async (_fn: unknown, args: { questionId: string; answerLimit: number }) => {
|
|
12
|
+
expect(args).toEqual({ questionId: '2021881398772981878', answerLimit: 3 });
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
answers: [
|
|
16
|
+
{
|
|
17
|
+
author: { name: 'alice' },
|
|
18
|
+
voteup_count: 12,
|
|
19
|
+
content: '<p>Hello <b>Zhihu</b></p>',
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const page = {
|
|
26
|
+
evaluate,
|
|
27
|
+
} as any;
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
|
|
31
|
+
).resolves.toEqual([
|
|
32
|
+
{
|
|
33
|
+
rank: 1,
|
|
34
|
+
author: 'alice',
|
|
35
|
+
votes: 12,
|
|
36
|
+
content: 'Hello Zhihu',
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
expect(evaluate).toHaveBeenCalledTimes(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('maps auth-like answer failures to AuthRequiredError', async () => {
|
|
44
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
45
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
46
|
+
|
|
47
|
+
const page = {
|
|
48
|
+
evaluate: vi.fn().mockResolvedValue({ ok: false, status: 403 }),
|
|
49
|
+
} as any;
|
|
50
|
+
|
|
51
|
+
await expect(
|
|
52
|
+
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
|
|
53
|
+
).rejects.toBeInstanceOf(AuthRequiredError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('preserves non-auth fetch failures as CliError instead of login errors', async () => {
|
|
57
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
58
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
59
|
+
|
|
60
|
+
const page = {
|
|
61
|
+
evaluate: vi.fn().mockResolvedValue({ ok: false, status: 500 }),
|
|
62
|
+
} as any;
|
|
63
|
+
|
|
64
|
+
await expect(
|
|
65
|
+
cmd!.func!(page, { id: '2021881398772981878', limit: 3 }),
|
|
66
|
+
).rejects.toMatchObject({
|
|
67
|
+
code: 'FETCH_ERROR',
|
|
68
|
+
message: 'Zhihu question answers request failed with HTTP 500',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
-
import { AuthRequiredError } from '../../errors.js';
|
|
2
|
+
import { AuthRequiredError, CliError } from '../../errors.js';
|
|
3
3
|
|
|
4
4
|
cli({
|
|
5
5
|
site: 'zhihu',
|
|
@@ -14,27 +14,39 @@ cli({
|
|
|
14
14
|
columns: ['rank', 'author', 'votes', 'content'],
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
16
|
const { id, limit = 5 } = kwargs;
|
|
17
|
+
const answerLimit = Number(limit);
|
|
17
18
|
|
|
18
19
|
const stripHtml = (html: string) =>
|
|
19
20
|
(html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
// Only fetch answers here. The question detail endpoint is not used by the
|
|
23
|
+
// current CLI output and can fail independently, which would incorrectly
|
|
24
|
+
// turn a successful answers response into a login error.
|
|
25
|
+
const result = await (page as any).evaluate(
|
|
26
|
+
async ({ questionId, answerLimit }: { questionId: string; answerLimit: number }) => {
|
|
27
|
+
const aResp = await fetch(
|
|
28
|
+
`https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`,
|
|
29
|
+
{ credentials: 'include' },
|
|
30
|
+
);
|
|
31
|
+
if (!aResp.ok) return { ok: false as const, status: aResp.status };
|
|
30
32
|
const a = await aResp.json();
|
|
31
|
-
return {
|
|
32
|
-
}
|
|
33
|
-
|
|
33
|
+
return { ok: true as const, answers: Array.isArray(a?.data) ? a.data : [] };
|
|
34
|
+
},
|
|
35
|
+
{ questionId: String(id), answerLimit },
|
|
36
|
+
);
|
|
34
37
|
|
|
35
|
-
if (!result
|
|
38
|
+
if (!result?.ok) {
|
|
39
|
+
if (result?.status === 401 || result?.status === 403) {
|
|
40
|
+
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
|
|
41
|
+
}
|
|
42
|
+
throw new CliError(
|
|
43
|
+
'FETCH_ERROR',
|
|
44
|
+
`Zhihu question answers request failed with HTTP ${result?.status ?? 'unknown'}`,
|
|
45
|
+
'Try again later or rerun with -v for more detail',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
36
48
|
|
|
37
|
-
const answers =
|
|
49
|
+
const answers = result.answers.slice(0, answerLimit).map((a: any, i: number) => ({
|
|
38
50
|
rank: i + 1,
|
|
39
51
|
author: a.author?.name ?? 'anonymous',
|
|
40
52
|
votes: a.voteup_count ?? 0,
|
|
@@ -123,3 +123,33 @@ describe('commanderAdapter boolean alias support', () => {
|
|
|
123
123
|
expect(kwargs.undo).toBe(false);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('commanderAdapter command aliases', () => {
|
|
128
|
+
const cmd: CliCommand = {
|
|
129
|
+
site: 'notebooklm',
|
|
130
|
+
name: 'get',
|
|
131
|
+
aliases: ['metadata'],
|
|
132
|
+
description: 'Get notebook metadata',
|
|
133
|
+
browser: false,
|
|
134
|
+
args: [],
|
|
135
|
+
func: vi.fn(),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
mockExecuteCommand.mockReset();
|
|
140
|
+
mockExecuteCommand.mockResolvedValue([]);
|
|
141
|
+
mockRenderOutput.mockReset();
|
|
142
|
+
delete process.env.OPENCLI_VERBOSE;
|
|
143
|
+
process.exitCode = undefined;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('registers aliases with Commander so compatibility names execute the same command', async () => {
|
|
147
|
+
const program = new Command();
|
|
148
|
+
const siteCmd = program.command('notebooklm');
|
|
149
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
150
|
+
|
|
151
|
+
await program.parseAsync(['node', 'opencli', 'notebooklm', 'metadata']);
|
|
152
|
+
|
|
153
|
+
expect(mockExecuteCommand).toHaveBeenCalledWith(cmd, {}, false);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/commanderAdapter.ts
CHANGED
|
@@ -52,6 +52,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
|
|
52
52
|
|
|
53
53
|
const deprecatedSuffix = cmd.deprecated ? ' [deprecated]' : '';
|
|
54
54
|
const subCmd = siteCmd.command(cmd.name).description(`${cmd.description}${deprecatedSuffix}`);
|
|
55
|
+
if (cmd.aliases?.length) subCmd.aliases(cmd.aliases);
|
|
55
56
|
|
|
56
57
|
// Register positional args first, then named options
|
|
57
58
|
const positionalArgs: typeof cmd.args = [];
|
|
@@ -103,6 +104,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
const result = await executeCommand(cmd, kwargs, verbose);
|
|
107
|
+
if (result === null || result === undefined) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
106
110
|
|
|
107
111
|
if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
|
|
108
112
|
console.error(chalk.yellow('[Verbose] Warning: Command returned an empty result.'));
|
|
@@ -293,7 +297,10 @@ export function registerAllCommands(
|
|
|
293
297
|
program: Command,
|
|
294
298
|
siteGroups: Map<string, Command>,
|
|
295
299
|
): void {
|
|
300
|
+
const seen = new Set<CliCommand>();
|
|
296
301
|
for (const [, cmd] of getRegistry()) {
|
|
302
|
+
if (seen.has(cmd)) continue;
|
|
303
|
+
seen.add(cmd);
|
|
297
304
|
let siteCmd = siteGroups.get(cmd.site);
|
|
298
305
|
if (!siteCmd) {
|
|
299
306
|
siteCmd = program.command(cmd.site).description(`${cmd.site} commands`);
|