@jackwener/opencli 1.4.1 → 1.5.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/.github/workflows/build-extension.yml +2 -6
- package/.github/workflows/ci.yml +21 -1
- package/README.md +35 -6
- package/README.zh-CN.md +12 -5
- package/SKILL.md +2 -0
- package/dist/browser/cdp.d.ts +2 -1
- package/dist/browser/cdp.js +5 -0
- package/dist/browser/discover.d.ts +4 -1
- package/dist/browser/discover.js +6 -2
- package/dist/browser/errors.d.ts +2 -2
- package/dist/browser/errors.js +4 -12
- package/dist/browser/mcp.d.ts +2 -1
- package/dist/browser/page.d.ts +3 -0
- package/dist/browser/page.js +24 -1
- package/dist/build-manifest.d.ts +2 -0
- package/dist/build-manifest.js +39 -14
- package/dist/build-manifest.test.js +21 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +2 -1
- package/dist/cli-manifest.json +1567 -108
- package/dist/cli.js +68 -6
- package/dist/clis/36kr/article.d.ts +1 -0
- package/dist/clis/36kr/article.js +62 -0
- package/dist/clis/36kr/hot.d.ts +3 -0
- package/dist/clis/36kr/hot.js +80 -0
- package/dist/clis/36kr/hot.test.d.ts +1 -0
- package/dist/clis/36kr/hot.test.js +15 -0
- package/dist/clis/36kr/news.d.ts +1 -0
- package/dist/clis/36kr/news.js +51 -0
- package/dist/clis/36kr/news.test.d.ts +1 -0
- package/dist/clis/36kr/news.test.js +85 -0
- package/dist/clis/36kr/search.d.ts +1 -0
- package/dist/clis/36kr/search.js +72 -0
- package/dist/clis/bilibili/comments.d.ts +5 -0
- package/dist/clis/bilibili/comments.js +40 -0
- package/dist/clis/bilibili/comments.test.d.ts +1 -0
- package/dist/clis/bilibili/comments.test.js +82 -0
- package/dist/clis/bluesky/feeds.yaml +29 -0
- package/dist/clis/bluesky/followers.yaml +33 -0
- package/dist/clis/bluesky/following.yaml +33 -0
- package/dist/clis/bluesky/profile.yaml +27 -0
- package/dist/clis/bluesky/search.yaml +34 -0
- package/dist/clis/bluesky/starter-packs.yaml +34 -0
- package/dist/clis/bluesky/thread.yaml +32 -0
- package/dist/clis/bluesky/trending.yaml +27 -0
- package/dist/clis/bluesky/user.yaml +34 -0
- package/dist/clis/chatgpt/ask.js +29 -14
- package/dist/clis/chatgpt/ax.d.ts +6 -0
- package/dist/clis/chatgpt/ax.js +172 -1
- package/dist/clis/chatgpt/model.d.ts +1 -0
- package/dist/clis/chatgpt/model.js +24 -0
- package/dist/clis/chatgpt/send.js +12 -3
- package/dist/clis/douban/download.d.ts +1 -0
- package/dist/clis/douban/download.js +67 -0
- package/dist/clis/douban/download.test.d.ts +1 -0
- package/dist/clis/douban/download.test.js +170 -0
- package/dist/clis/douban/photos.d.ts +1 -0
- package/dist/clis/douban/photos.js +34 -0
- package/dist/clis/douban/utils.d.ts +25 -0
- package/dist/clis/douban/utils.js +190 -1
- package/dist/clis/douban/utils.test.d.ts +1 -0
- package/dist/clis/douban/utils.test.js +64 -0
- package/dist/clis/imdb/person.d.ts +1 -0
- package/dist/clis/imdb/person.js +203 -0
- package/dist/clis/imdb/reviews.d.ts +1 -0
- package/dist/clis/imdb/reviews.js +88 -0
- package/dist/clis/imdb/search.d.ts +1 -0
- package/dist/clis/imdb/search.js +161 -0
- package/dist/clis/imdb/title.d.ts +1 -0
- package/dist/clis/imdb/title.js +93 -0
- package/dist/clis/imdb/top.d.ts +1 -0
- package/dist/clis/imdb/top.js +53 -0
- package/dist/clis/imdb/trending.d.ts +1 -0
- package/dist/clis/imdb/trending.js +52 -0
- package/dist/clis/imdb/utils.d.ts +46 -0
- package/dist/clis/imdb/utils.js +285 -0
- package/dist/clis/imdb/utils.test.d.ts +1 -0
- package/dist/clis/imdb/utils.test.js +88 -0
- package/dist/clis/jd/item.d.ts +4 -0
- package/dist/clis/jd/item.js +16 -15
- package/dist/clis/jd/item.test.js +16 -1
- package/dist/clis/linux-do/categories.yaml +38 -9
- package/dist/clis/linux-do/category.d.ts +1 -0
- package/dist/clis/linux-do/category.js +36 -0
- package/dist/clis/linux-do/feed.d.ts +45 -0
- package/dist/clis/linux-do/feed.js +397 -0
- package/dist/clis/linux-do/feed.test.d.ts +1 -0
- package/dist/clis/linux-do/feed.test.js +118 -0
- package/dist/clis/linux-do/hot.d.ts +1 -0
- package/dist/clis/linux-do/hot.js +25 -0
- package/dist/clis/linux-do/latest.d.ts +1 -0
- package/dist/clis/linux-do/latest.js +18 -0
- package/dist/clis/linux-do/tags.yaml +41 -0
- package/dist/clis/linux-do/topic.yaml +41 -3
- package/dist/clis/linux-do/user-posts.yaml +67 -0
- package/dist/clis/linux-do/user-topics.yaml +54 -0
- package/dist/clis/paperreview/commands.test.d.ts +3 -0
- package/dist/clis/paperreview/commands.test.js +243 -0
- package/dist/clis/paperreview/feedback.d.ts +1 -0
- package/dist/clis/paperreview/feedback.js +52 -0
- package/dist/clis/paperreview/review.d.ts +1 -0
- package/dist/clis/paperreview/review.js +37 -0
- package/dist/clis/paperreview/submit.d.ts +1 -0
- package/dist/clis/paperreview/submit.js +85 -0
- package/dist/clis/paperreview/utils.d.ts +46 -0
- package/dist/clis/paperreview/utils.js +197 -0
- package/dist/clis/paperreview/utils.test.d.ts +1 -0
- package/dist/clis/paperreview/utils.test.js +49 -0
- package/dist/clis/producthunt/browse.d.ts +1 -0
- package/dist/clis/producthunt/browse.js +99 -0
- package/dist/clis/producthunt/hot.d.ts +1 -0
- package/dist/clis/producthunt/hot.js +110 -0
- package/dist/clis/producthunt/posts.d.ts +1 -0
- package/dist/clis/producthunt/posts.js +28 -0
- package/dist/clis/producthunt/today.d.ts +1 -0
- package/dist/clis/producthunt/today.js +35 -0
- package/dist/clis/producthunt/utils.d.ts +29 -0
- package/dist/clis/producthunt/utils.js +99 -0
- package/dist/clis/producthunt/utils.test.d.ts +1 -0
- package/dist/clis/producthunt/utils.test.js +64 -0
- package/dist/clis/twitter/article.js +4 -28
- package/dist/clis/twitter/likes.d.ts +24 -0
- package/dist/clis/twitter/likes.js +217 -0
- package/dist/clis/twitter/likes.test.d.ts +1 -0
- package/dist/clis/twitter/likes.test.js +85 -0
- package/dist/clis/twitter/profile.js +4 -28
- package/dist/clis/twitter/search.js +2 -1
- package/dist/clis/twitter/search.test.js +2 -0
- package/dist/clis/twitter/shared.d.ts +6 -0
- package/dist/clis/twitter/shared.js +35 -0
- package/dist/clis/twitter/timeline.js +2 -13
- package/dist/clis/twitter/trending.js +29 -61
- package/dist/clis/v2ex/hot.yaml +17 -3
- package/dist/clis/weixin/download.d.ts +17 -0
- package/dist/clis/weixin/download.js +88 -20
- package/dist/clis/weread/book.js +2 -2
- package/dist/clis/weread/commands.test.d.ts +3 -0
- package/dist/clis/weread/commands.test.js +43 -0
- package/dist/clis/weread/highlights.js +2 -2
- package/dist/clis/weread/notebooks.js +2 -2
- package/dist/clis/weread/notes.js +3 -3
- package/dist/clis/weread/shelf.js +2 -2
- package/dist/clis/weread/utils.d.ts +4 -4
- package/dist/clis/weread/utils.js +32 -14
- package/dist/clis/weread/utils.test.js +1 -28
- package/dist/clis/xiaohongshu/comments.d.ts +5 -0
- package/dist/clis/xiaohongshu/comments.js +74 -0
- package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/comments.test.js +79 -0
- package/dist/clis/xiaohongshu/publish.js +179 -47
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +131 -0
- package/dist/clis/xiaohongshu/search.d.ts +8 -1
- package/dist/clis/xiaohongshu/search.js +20 -1
- package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
- package/dist/clis/xiaohongshu/search.test.js +32 -1
- package/dist/commanderAdapter.d.ts +1 -0
- package/dist/commanderAdapter.js +176 -29
- package/dist/commanderAdapter.test.d.ts +1 -0
- package/dist/commanderAdapter.test.js +62 -0
- package/dist/daemon.js +17 -1
- package/dist/discovery.js +48 -42
- package/dist/doctor.d.ts +2 -2
- package/dist/doctor.js +11 -4
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/engine.test.js +42 -0
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +17 -34
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +66 -8
- package/dist/execution.test.d.ts +1 -0
- package/dist/execution.test.js +40 -0
- package/dist/external.js +6 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +6 -0
- package/dist/output.js +5 -1
- package/dist/pipeline/executor.js +3 -4
- package/dist/plugin-manifest.d.ts +70 -0
- package/dist/plugin-manifest.js +160 -0
- package/dist/plugin-manifest.test.d.ts +4 -0
- package/dist/plugin-manifest.test.js +179 -0
- package/dist/plugin-scaffold.d.ts +28 -0
- package/dist/plugin-scaffold.js +142 -0
- package/dist/plugin-scaffold.test.d.ts +4 -0
- package/dist/plugin-scaffold.test.js +83 -0
- package/dist/plugin.d.ts +82 -11
- package/dist/plugin.js +870 -84
- package/dist/plugin.test.js +1032 -17
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +2 -0
- package/dist/runtime-detect.d.ts +21 -0
- package/dist/runtime-detect.js +32 -0
- package/dist/runtime-detect.test.d.ts +1 -0
- package/dist/runtime-detect.test.js +27 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +2 -2
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +3 -0
- package/dist/update-check.d.ts +22 -0
- package/dist/update-check.js +112 -0
- package/dist/weixin-download.test.d.ts +1 -0
- package/dist/weixin-download.test.js +30 -0
- package/dist/weread-private-api-regression.test.d.ts +1 -0
- package/dist/weread-private-api-regression.test.js +122 -0
- package/dist/yaml-schema.d.ts +3 -0
- package/dist/yaml-schema.js +18 -1
- package/docs/.vitepress/config.mts +4 -0
- package/docs/adapters/browser/36kr.md +47 -0
- package/docs/adapters/browser/bluesky.md +53 -0
- package/docs/adapters/browser/douban.md +14 -0
- package/docs/adapters/browser/imdb.md +47 -0
- package/docs/adapters/browser/jd.md +2 -2
- package/docs/adapters/browser/linux-do.md +181 -20
- package/docs/adapters/browser/paperreview.md +43 -0
- package/docs/adapters/browser/producthunt.md +49 -0
- package/docs/adapters/desktop/chatgpt.md +5 -0
- package/docs/adapters/index.md +6 -2
- package/docs/advanced/download.md +4 -0
- package/docs/advanced/rate-limiter-plugin.md +99 -0
- package/docs/guide/electron-app-cli.md +200 -0
- package/docs/guide/getting-started.md +1 -0
- package/docs/guide/plugins.md +97 -0
- package/docs/zh/guide/electron-app-cli.md +188 -0
- package/docs/zh/guide/getting-started.md +1 -0
- package/docs/zh/guide/plugins.md +65 -0
- package/extension/package.json +1 -0
- package/extension/scripts/package-release.mjs +179 -0
- package/extension/src/background.ts +2 -0
- package/package.json +4 -1
- package/scripts/postinstall.js +10 -0
- package/src/browser/cdp.ts +8 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -1
- package/src/browser/page.ts +24 -1
- package/src/build-manifest.test.ts +23 -0
- package/src/build-manifest.ts +40 -15
- package/src/capabilityRouting.ts +2 -1
- package/src/cli.ts +69 -6
- package/src/clis/36kr/article.ts +69 -0
- package/src/clis/36kr/hot.test.ts +19 -0
- package/src/clis/36kr/hot.ts +100 -0
- package/src/clis/36kr/news.test.ts +90 -0
- package/src/clis/36kr/news.ts +54 -0
- package/src/clis/36kr/search.ts +78 -0
- package/src/clis/bilibili/comments.test.ts +102 -0
- package/src/clis/bilibili/comments.ts +44 -0
- package/src/clis/bluesky/feeds.yaml +29 -0
- package/src/clis/bluesky/followers.yaml +33 -0
- package/src/clis/bluesky/following.yaml +33 -0
- package/src/clis/bluesky/profile.yaml +27 -0
- package/src/clis/bluesky/search.yaml +34 -0
- package/src/clis/bluesky/starter-packs.yaml +34 -0
- package/src/clis/bluesky/thread.yaml +32 -0
- package/src/clis/bluesky/trending.yaml +27 -0
- package/src/clis/bluesky/user.yaml +34 -0
- package/src/clis/chatgpt/ask.ts +28 -14
- package/src/clis/chatgpt/ax.ts +180 -1
- package/src/clis/chatgpt/model.ts +27 -0
- package/src/clis/chatgpt/send.ts +16 -6
- package/src/clis/douban/download.test.ts +196 -0
- package/src/clis/douban/download.ts +78 -0
- package/src/clis/douban/photos.ts +36 -0
- package/src/clis/douban/utils.test.ts +97 -0
- package/src/clis/douban/utils.ts +232 -1
- package/src/clis/imdb/person.ts +232 -0
- package/src/clis/imdb/reviews.ts +111 -0
- package/src/clis/imdb/search.ts +179 -0
- package/src/clis/imdb/title.ts +121 -0
- package/src/clis/imdb/top.ts +67 -0
- package/src/clis/imdb/trending.ts +66 -0
- package/src/clis/imdb/utils.test.ts +117 -0
- package/src/clis/imdb/utils.ts +305 -0
- package/src/clis/jd/item.test.ts +18 -1
- package/src/clis/jd/item.ts +18 -15
- package/src/clis/linux-do/categories.yaml +38 -9
- package/src/clis/linux-do/category.ts +37 -0
- package/src/clis/linux-do/feed.test.ts +132 -0
- package/src/clis/linux-do/feed.ts +501 -0
- package/src/clis/linux-do/hot.ts +26 -0
- package/src/clis/linux-do/latest.ts +19 -0
- package/src/clis/linux-do/tags.yaml +41 -0
- package/src/clis/linux-do/topic.yaml +41 -3
- package/src/clis/linux-do/user-posts.yaml +67 -0
- package/src/clis/linux-do/user-topics.yaml +54 -0
- package/src/clis/paperreview/commands.test.ts +283 -0
- package/src/clis/paperreview/feedback.ts +64 -0
- package/src/clis/paperreview/review.ts +47 -0
- package/src/clis/paperreview/submit.ts +119 -0
- package/src/clis/paperreview/utils.test.ts +68 -0
- package/src/clis/paperreview/utils.ts +276 -0
- package/src/clis/producthunt/browse.ts +109 -0
- package/src/clis/producthunt/hot.ts +127 -0
- package/src/clis/producthunt/posts.ts +29 -0
- package/src/clis/producthunt/today.ts +37 -0
- package/src/clis/producthunt/utils.test.ts +72 -0
- package/src/clis/producthunt/utils.ts +122 -0
- package/src/clis/twitter/article.ts +5 -28
- package/src/clis/twitter/likes.test.ts +91 -0
- package/src/clis/twitter/likes.ts +256 -0
- package/src/clis/twitter/profile.ts +5 -28
- package/src/clis/twitter/search.test.ts +2 -0
- package/src/clis/twitter/search.ts +3 -1
- package/src/clis/twitter/shared.ts +45 -0
- package/src/clis/twitter/timeline.ts +2 -13
- package/src/clis/twitter/trending.ts +29 -77
- package/src/clis/v2ex/hot.yaml +17 -3
- package/src/clis/weixin/download.ts +114 -20
- package/src/clis/weread/book.ts +2 -2
- package/src/clis/weread/commands.test.ts +57 -0
- package/src/clis/weread/highlights.ts +2 -2
- package/src/clis/weread/notebooks.ts +2 -2
- package/src/clis/weread/notes.ts +3 -3
- package/src/clis/weread/shelf.ts +2 -2
- package/src/clis/weread/utils.test.ts +1 -32
- package/src/clis/weread/utils.ts +41 -16
- package/src/clis/xiaohongshu/comments.test.ts +96 -0
- package/src/clis/xiaohongshu/comments.ts +81 -0
- package/src/clis/xiaohongshu/publish.test.ts +151 -0
- package/src/clis/xiaohongshu/publish.ts +206 -54
- package/src/clis/xiaohongshu/search.test.ts +39 -1
- package/src/clis/xiaohongshu/search.ts +19 -1
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +49 -48
- package/src/doctor.ts +15 -5
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/engine.test.ts +38 -0
- package/src/errors.ts +26 -63
- package/src/execution.test.ts +47 -0
- package/src/execution.ts +67 -9
- package/src/external.ts +6 -1
- package/src/hooks.ts +1 -0
- package/src/main.ts +7 -0
- package/src/output.ts +3 -1
- package/src/pipeline/executor.ts +4 -6
- package/src/plugin-manifest.test.ts +223 -0
- package/src/plugin-manifest.ts +206 -0
- package/src/plugin-scaffold.test.ts +98 -0
- package/src/plugin-scaffold.ts +170 -0
- package/src/plugin.test.ts +1104 -17
- package/src/plugin.ts +1101 -86
- package/src/registry.ts +6 -1
- package/src/runtime-detect.test.ts +30 -0
- package/src/runtime-detect.ts +36 -0
- package/src/runtime.ts +3 -3
- package/src/serialization.ts +4 -0
- package/src/types.ts +3 -0
- package/src/update-check.ts +114 -0
- package/src/weixin-download.test.ts +64 -0
- package/src/weread-private-api-regression.test.ts +150 -0
- package/src/yaml-schema.ts +20 -0
- package/tests/e2e/browser-auth.test.ts +13 -9
- package/tests/e2e/browser-public-extended.test.ts +1 -1
- package/tests/e2e/browser-public.test.ts +62 -4
- package/tests/e2e/helpers.ts +2 -1
- package/tests/e2e/public-commands.test.ts +37 -3
- package/tests/smoke/api-health.test.ts +1 -1
- package/vitest.config.ts +10 -0
- package/dist/clis/linux-do/category.yaml +0 -51
- package/dist/clis/linux-do/hot.yaml +0 -50
- package/dist/clis/linux-do/latest.yaml +0 -40
- package/src/clis/linux-do/category.yaml +0 -51
- package/src/clis/linux-do/hot.yaml +0 -50
- package/src/clis/linux-do/latest.yaml +0 -40
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import './comments.js';
|
|
4
|
+
function createPageMock(evaluateResult) {
|
|
5
|
+
return {
|
|
6
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
7
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
8
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
14
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
16
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
20
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
21
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
25
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
26
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('xiaohongshu comments', () => {
|
|
30
|
+
const command = getRegistry().get('xiaohongshu/comments');
|
|
31
|
+
it('returns ranked comment rows', async () => {
|
|
32
|
+
const page = createPageMock({
|
|
33
|
+
loginWall: false,
|
|
34
|
+
results: [
|
|
35
|
+
{ author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
|
|
36
|
+
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const result = (await command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 }));
|
|
40
|
+
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
|
|
41
|
+
expect(result).toEqual([
|
|
42
|
+
{ rank: 1, author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
|
|
43
|
+
{ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
|
|
44
|
+
]);
|
|
45
|
+
expect(result[0]).not.toHaveProperty('loginWall');
|
|
46
|
+
});
|
|
47
|
+
it('strips /explore/ prefix from full URL input', async () => {
|
|
48
|
+
const page = createPageMock({
|
|
49
|
+
loginWall: false,
|
|
50
|
+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
|
|
51
|
+
});
|
|
52
|
+
await command.func(page, {
|
|
53
|
+
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
|
|
54
|
+
limit: 5,
|
|
55
|
+
});
|
|
56
|
+
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
|
|
57
|
+
});
|
|
58
|
+
it('throws AuthRequiredError when login wall is detected', async () => {
|
|
59
|
+
const page = createPageMock({ loginWall: true, results: [] });
|
|
60
|
+
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
|
|
61
|
+
});
|
|
62
|
+
it('returns empty array when no comments are found', async () => {
|
|
63
|
+
const page = createPageMock({ loginWall: false, results: [] });
|
|
64
|
+
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
it('respects the limit', async () => {
|
|
67
|
+
const manyComments = Array.from({ length: 10 }, (_, i) => ({
|
|
68
|
+
author: `User${i}`,
|
|
69
|
+
text: `Comment ${i}`,
|
|
70
|
+
likes: i,
|
|
71
|
+
time: '2024-01-01',
|
|
72
|
+
}));
|
|
73
|
+
const page = createPageMock({ loginWall: false, results: manyComments });
|
|
74
|
+
const result = (await command.func(page, { 'note-id': 'abc123', limit: 3 }));
|
|
75
|
+
expect(result).toHaveLength(3);
|
|
76
|
+
expect(result[0].rank).toBe(1);
|
|
77
|
+
expect(result[2].rank).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -22,6 +22,21 @@ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_l
|
|
|
22
22
|
const MAX_IMAGES = 9;
|
|
23
23
|
const MAX_TITLE_LEN = 20;
|
|
24
24
|
const UPLOAD_SETTLE_MS = 3000;
|
|
25
|
+
/** Selectors for the title field, ordered by priority (new UI first). */
|
|
26
|
+
const TITLE_SELECTORS = [
|
|
27
|
+
// New creator center (2026-03) uses contenteditable for the title field.
|
|
28
|
+
// Placeholder observed: "填写标题会有更多赞哦"
|
|
29
|
+
'[contenteditable="true"][placeholder*="标题"]',
|
|
30
|
+
'[contenteditable="true"][placeholder*="赞"]',
|
|
31
|
+
'[contenteditable="true"][class*="title"]',
|
|
32
|
+
'input[maxlength="20"]',
|
|
33
|
+
'input[class*="title"]',
|
|
34
|
+
'input[placeholder*="标题"]',
|
|
35
|
+
'input[placeholder*="title" i]',
|
|
36
|
+
'.title-input input',
|
|
37
|
+
'.note-title input',
|
|
38
|
+
'input[maxlength]',
|
|
39
|
+
];
|
|
25
40
|
/**
|
|
26
41
|
* Read a local image and return the name, MIME type, and base64 content.
|
|
27
42
|
* Throws if the file does not exist or the extension is unsupported.
|
|
@@ -57,14 +72,22 @@ async function injectImages(page, images) {
|
|
|
57
72
|
(async () => {
|
|
58
73
|
const images = ${payload};
|
|
59
74
|
|
|
60
|
-
//
|
|
75
|
+
// Only use image-capable file inputs. Do not fall back to a generic uploader,
|
|
76
|
+
// otherwise we can accidentally feed images into the video upload flow.
|
|
61
77
|
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
62
78
|
const input = inputs.find(el => {
|
|
63
79
|
const accept = el.getAttribute('accept') || '';
|
|
64
|
-
return
|
|
65
|
-
|
|
80
|
+
return (
|
|
81
|
+
accept.includes('image') ||
|
|
82
|
+
accept.includes('.jpg') ||
|
|
83
|
+
accept.includes('.jpeg') ||
|
|
84
|
+
accept.includes('.png') ||
|
|
85
|
+
accept.includes('.gif') ||
|
|
86
|
+
accept.includes('.webp')
|
|
87
|
+
);
|
|
88
|
+
});
|
|
66
89
|
|
|
67
|
-
if (!input) return { ok: false, count: 0, error: 'No file input found on page' };
|
|
90
|
+
if (!input) return { ok: false, count: 0, error: 'No image file input found on page' };
|
|
68
91
|
|
|
69
92
|
const dt = new DataTransfer();
|
|
70
93
|
for (const img of images) {
|
|
@@ -141,6 +164,121 @@ async function fillField(page, selectors, text, fieldName) {
|
|
|
141
164
|
throw new Error(`Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
|
|
142
165
|
}
|
|
143
166
|
}
|
|
167
|
+
async function selectImageTextTab(page) {
|
|
168
|
+
const result = await page.evaluate(`
|
|
169
|
+
() => {
|
|
170
|
+
const isVisible = (el) => {
|
|
171
|
+
if (!el || el.offsetParent === null) return false;
|
|
172
|
+
const rect = el.getBoundingClientRect();
|
|
173
|
+
return rect.width > 0 && rect.height > 0;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
177
|
+
const selector = 'button, [role="tab"], [role="button"], a, label, div, span, li';
|
|
178
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
179
|
+
const targets = ['上传图文', '图文', '图片'];
|
|
180
|
+
|
|
181
|
+
for (const target of targets) {
|
|
182
|
+
for (const node of nodes) {
|
|
183
|
+
if (!isVisible(node)) continue;
|
|
184
|
+
const text = normalize(node.innerText || node.textContent || '');
|
|
185
|
+
if (!text || text.includes('视频')) continue;
|
|
186
|
+
if (text === target || text.startsWith(target) || text.includes(target)) {
|
|
187
|
+
const clickable = node.closest('button, [role="tab"], [role="button"], a, label') || node;
|
|
188
|
+
clickable.click();
|
|
189
|
+
return { ok: true, target, text };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const visibleTexts = [];
|
|
195
|
+
for (const node of nodes) {
|
|
196
|
+
if (!isVisible(node)) continue;
|
|
197
|
+
const text = normalize(node.innerText || node.textContent || '');
|
|
198
|
+
if (!text || text.length > 20) continue;
|
|
199
|
+
visibleTexts.push(text);
|
|
200
|
+
if (visibleTexts.length >= 20) break;
|
|
201
|
+
}
|
|
202
|
+
return { ok: false, visibleTexts };
|
|
203
|
+
}
|
|
204
|
+
`);
|
|
205
|
+
if (result?.ok) {
|
|
206
|
+
await page.wait({ time: 1 });
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
async function inspectPublishSurfaceState(page) {
|
|
211
|
+
return page.evaluate(`
|
|
212
|
+
() => {
|
|
213
|
+
const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim();
|
|
214
|
+
const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => {
|
|
215
|
+
if (!el || el.offsetParent === null) return false;
|
|
216
|
+
const placeholder = (el.getAttribute('placeholder') || '').trim();
|
|
217
|
+
const cls = el.className ? String(el.className) : '';
|
|
218
|
+
const maxLength = Number(el.getAttribute('maxlength') || 0);
|
|
219
|
+
return (
|
|
220
|
+
placeholder.includes('标题') ||
|
|
221
|
+
/title/i.test(placeholder) ||
|
|
222
|
+
/title/i.test(cls) ||
|
|
223
|
+
maxLength === 20
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
const hasImageInput = !!Array.from(document.querySelectorAll('input[type="file"]')).find((el) => {
|
|
227
|
+
const accept = el.getAttribute('accept') || '';
|
|
228
|
+
return (
|
|
229
|
+
accept.includes('image') ||
|
|
230
|
+
accept.includes('.jpg') ||
|
|
231
|
+
accept.includes('.jpeg') ||
|
|
232
|
+
accept.includes('.png') ||
|
|
233
|
+
accept.includes('.gif') ||
|
|
234
|
+
accept.includes('.webp')
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频');
|
|
238
|
+
const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface';
|
|
239
|
+
return { state, hasTitleInput, hasImageInput, hasVideoSurface };
|
|
240
|
+
}
|
|
241
|
+
`);
|
|
242
|
+
}
|
|
243
|
+
async function waitForPublishSurfaceState(page, maxWaitMs = 5_000) {
|
|
244
|
+
const pollMs = 500;
|
|
245
|
+
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
|
|
246
|
+
let surface = await inspectPublishSurfaceState(page);
|
|
247
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
248
|
+
if (surface.state !== 'video_surface') {
|
|
249
|
+
return surface;
|
|
250
|
+
}
|
|
251
|
+
if (i < maxAttempts - 1) {
|
|
252
|
+
await page.wait({ time: pollMs / 1_000 });
|
|
253
|
+
surface = await inspectPublishSurfaceState(page);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return surface;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Poll until the title/content editing form appears on the page.
|
|
260
|
+
* The new creator center UI only renders the editor after images are uploaded.
|
|
261
|
+
*/
|
|
262
|
+
async function waitForEditForm(page, maxWaitMs = 10_000) {
|
|
263
|
+
const pollMs = 1_000;
|
|
264
|
+
const maxAttempts = Math.ceil(maxWaitMs / pollMs);
|
|
265
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
266
|
+
const found = await page.evaluate(`
|
|
267
|
+
(() => {
|
|
268
|
+
const sels = ${JSON.stringify(TITLE_SELECTORS)};
|
|
269
|
+
for (const sel of sels) {
|
|
270
|
+
const el = document.querySelector(sel);
|
|
271
|
+
if (el && el.offsetParent !== null) return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
})()`);
|
|
275
|
+
if (found)
|
|
276
|
+
return true;
|
|
277
|
+
if (i < maxAttempts - 1)
|
|
278
|
+
await page.wait({ time: pollMs / 1_000 });
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
144
282
|
cli({
|
|
145
283
|
site: 'xiaohongshu',
|
|
146
284
|
name: 'publish',
|
|
@@ -151,7 +289,7 @@ cli({
|
|
|
151
289
|
args: [
|
|
152
290
|
{ name: 'title', required: true, help: '笔记标题 (最多20字)' },
|
|
153
291
|
{ name: 'content', required: true, positional: true, help: '笔记正文' },
|
|
154
|
-
{ name: 'images', required:
|
|
292
|
+
{ name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
|
|
155
293
|
{ name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
|
|
156
294
|
{ name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
|
|
157
295
|
],
|
|
@@ -175,6 +313,8 @@ cli({
|
|
|
175
313
|
throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
|
|
176
314
|
if (!content)
|
|
177
315
|
throw new Error('Positional argument <content> is required');
|
|
316
|
+
if (imagePaths.length === 0)
|
|
317
|
+
throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.');
|
|
178
318
|
if (imagePaths.length > MAX_IMAGES)
|
|
179
319
|
throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
|
|
180
320
|
// Read images in Node.js context before navigating (fast-fail on bad paths)
|
|
@@ -189,43 +329,35 @@ cli({
|
|
|
189
329
|
'Re-capture browser login via: opencli xiaohongshu creator-profile');
|
|
190
330
|
}
|
|
191
331
|
// ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
332
|
+
const tabResult = await selectImageTextTab(page);
|
|
333
|
+
const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000);
|
|
334
|
+
if (surface.state === 'video_surface') {
|
|
335
|
+
await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
|
|
336
|
+
const detail = tabResult?.ok
|
|
337
|
+
? `clicked "${tabResult.text}"`
|
|
338
|
+
: `visible candidates: ${(tabResult?.visibleTexts || []).join(' | ') || 'none'}`;
|
|
339
|
+
throw new Error('Still on the video publish page after trying to select 图文. ' +
|
|
340
|
+
`Details: ${detail}. Debug screenshot: /tmp/xhs_publish_tab_debug.png`);
|
|
201
341
|
}
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
`);
|
|
205
|
-
if (tabClicked)
|
|
206
|
-
await page.wait({ time: 1 });
|
|
207
342
|
// ── Step 3: Upload images ──────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
343
|
+
const upload = await injectImages(page, imageData);
|
|
344
|
+
if (!upload.ok) {
|
|
345
|
+
await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
|
|
346
|
+
throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
|
|
347
|
+
'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
|
|
348
|
+
}
|
|
349
|
+
// Allow XHS to process and upload images to its CDN
|
|
350
|
+
await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
|
|
351
|
+
await waitForUploads(page);
|
|
352
|
+
// ── Step 3b: Wait for editor form to render ───────────────────────────────
|
|
353
|
+
const formReady = await waitForEditForm(page);
|
|
354
|
+
if (!formReady) {
|
|
355
|
+
await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' });
|
|
356
|
+
throw new Error('Editing form did not appear after image upload. The page layout may have changed. ' +
|
|
357
|
+
'Debug screenshot: /tmp/xhs_publish_form_debug.png');
|
|
218
358
|
}
|
|
219
359
|
// ── Step 4: Fill title ─────────────────────────────────────────────────────
|
|
220
|
-
await fillField(page,
|
|
221
|
-
'input[maxlength="20"]',
|
|
222
|
-
'input[class*="title"]',
|
|
223
|
-
'input[placeholder*="标题"]',
|
|
224
|
-
'input[placeholder*="title" i]',
|
|
225
|
-
'.title-input input',
|
|
226
|
-
'.note-title input',
|
|
227
|
-
'input[maxlength]',
|
|
228
|
-
], title, 'title');
|
|
360
|
+
await fillField(page, TITLE_SELECTORS, title, 'title');
|
|
229
361
|
await page.wait({ time: 0.5 });
|
|
230
362
|
// ── Step 5: Fill content / body ────────────────────────────────────────────
|
|
231
363
|
await fillField(page, [
|
|
@@ -237,7 +369,7 @@ cli({
|
|
|
237
369
|
'.note-content [contenteditable="true"]',
|
|
238
370
|
'.editor-content [contenteditable="true"]',
|
|
239
371
|
// Broad fallback — last resort; filter out any title contenteditable
|
|
240
|
-
'[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
|
|
372
|
+
'[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])',
|
|
241
373
|
], content, 'content');
|
|
242
374
|
await page.wait({ time: 0.5 });
|
|
243
375
|
// ── Step 6: Add topic hashtags ─────────────────────────────────────────────
|
|
@@ -294,14 +426,14 @@ cli({
|
|
|
294
426
|
await page.wait({ time: 0.5 });
|
|
295
427
|
}
|
|
296
428
|
// ── Step 7: Publish or save draft ─────────────────────────────────────────
|
|
297
|
-
const
|
|
429
|
+
const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
|
|
298
430
|
const btnClicked = await page.evaluate(`
|
|
299
|
-
(
|
|
431
|
+
(labels => {
|
|
300
432
|
const buttons = document.querySelectorAll('button, [role="button"]');
|
|
301
433
|
for (const btn of buttons) {
|
|
302
434
|
const text = (btn.innerText || btn.textContent || '').trim();
|
|
303
435
|
if (
|
|
304
|
-
(text ===
|
|
436
|
+
labels.some(l => text === l || text.includes(l)) &&
|
|
305
437
|
btn.offsetParent !== null &&
|
|
306
438
|
!btn.disabled
|
|
307
439
|
) {
|
|
@@ -310,11 +442,11 @@ cli({
|
|
|
310
442
|
}
|
|
311
443
|
}
|
|
312
444
|
return false;
|
|
313
|
-
})(${JSON.stringify(
|
|
445
|
+
})(${JSON.stringify(actionLabels)})
|
|
314
446
|
`);
|
|
315
447
|
if (!btnClicked) {
|
|
316
448
|
await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
|
|
317
|
-
throw new Error(`Could not find "${
|
|
449
|
+
throw new Error(`Could not find "${actionLabels[0]}" button. ` +
|
|
318
450
|
'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
|
|
319
451
|
}
|
|
320
452
|
// ── Step 8: Verify success ─────────────────────────────────────────────────
|
|
@@ -326,7 +458,7 @@ cli({
|
|
|
326
458
|
const text = (el.innerText || '').trim();
|
|
327
459
|
if (
|
|
328
460
|
el.children.length === 0 &&
|
|
329
|
-
(text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
|
|
461
|
+
(text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
|
|
330
462
|
) return text;
|
|
331
463
|
}
|
|
332
464
|
return '';
|
|
@@ -334,13 +466,13 @@ cli({
|
|
|
334
466
|
`);
|
|
335
467
|
const navigatedAway = !finalUrl.includes('/publish/publish');
|
|
336
468
|
const isSuccess = successMsg.length > 0 || navigatedAway;
|
|
337
|
-
const verb = isDraft ? '
|
|
469
|
+
const verb = isDraft ? '暂存成功' : '发布成功';
|
|
338
470
|
return [
|
|
339
471
|
{
|
|
340
472
|
status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
|
|
341
473
|
detail: [
|
|
342
474
|
`"${title}"`,
|
|
343
|
-
|
|
475
|
+
`${imageData.length}张图片`,
|
|
344
476
|
topics.length ? `话题: ${topics.join(' ')}` : '',
|
|
345
477
|
successMsg || finalUrl || '',
|
|
346
478
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './publish.js';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { getRegistry } from '../../registry.js';
|
|
6
|
+
import './publish.js';
|
|
7
|
+
function createPageMock(evaluateResults) {
|
|
8
|
+
const evaluate = vi.fn();
|
|
9
|
+
for (const result of evaluateResults) {
|
|
10
|
+
evaluate.mockResolvedValueOnce(result);
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
evaluate,
|
|
15
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
21
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
23
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
27
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
28
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
32
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
33
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
describe('xiaohongshu publish', () => {
|
|
37
|
+
it('selects the image-text tab and publishes successfully', async () => {
|
|
38
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
39
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
40
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
41
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
42
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
43
|
+
const page = createPageMock([
|
|
44
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
45
|
+
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
46
|
+
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
47
|
+
{ ok: true, count: 1 },
|
|
48
|
+
false,
|
|
49
|
+
true, // waitForEditForm: editor appeared
|
|
50
|
+
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
51
|
+
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
52
|
+
true,
|
|
53
|
+
'https://creator.xiaohongshu.com/publish/success',
|
|
54
|
+
'发布成功',
|
|
55
|
+
]);
|
|
56
|
+
const result = await cmd.func(page, {
|
|
57
|
+
title: 'DeepSeek别乱问',
|
|
58
|
+
content: '一篇真实一点的小红书正文',
|
|
59
|
+
images: imagePath,
|
|
60
|
+
topics: '',
|
|
61
|
+
draft: false,
|
|
62
|
+
});
|
|
63
|
+
const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0]));
|
|
64
|
+
expect(evaluateCalls.some((code) => code.includes("const targets = ['上传图文', '图文', '图片']"))).toBe(true);
|
|
65
|
+
expect(evaluateCalls.some((code) => code.includes("No image file input found on page"))).toBe(true);
|
|
66
|
+
expect(result).toEqual([
|
|
67
|
+
{
|
|
68
|
+
status: '✅ 发布成功',
|
|
69
|
+
detail: '"DeepSeek别乱问" · 1张图片 · 发布成功',
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
it('fails early with a clear error when still on the video page', async () => {
|
|
74
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
75
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
76
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
77
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
78
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
79
|
+
const page = createPageMock([
|
|
80
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
81
|
+
{ ok: false, visibleTexts: ['上传视频', '上传图文'] },
|
|
82
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
83
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
84
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
85
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
86
|
+
]);
|
|
87
|
+
await expect(cmd.func(page, {
|
|
88
|
+
title: 'DeepSeek别乱问',
|
|
89
|
+
content: '一篇真实一点的小红书正文',
|
|
90
|
+
images: imagePath,
|
|
91
|
+
topics: '',
|
|
92
|
+
draft: false,
|
|
93
|
+
})).rejects.toThrow('Still on the video publish page after trying to select 图文');
|
|
94
|
+
expect(page.screenshot).toHaveBeenCalledWith({ path: '/tmp/xhs_publish_tab_debug.png' });
|
|
95
|
+
});
|
|
96
|
+
it('waits for the image-text surface to appear after clicking the tab', async () => {
|
|
97
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
98
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
99
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
100
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
101
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
102
|
+
const page = createPageMock([
|
|
103
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
104
|
+
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
105
|
+
{ state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
106
|
+
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
107
|
+
{ ok: true, count: 1 }, // injectImages
|
|
108
|
+
false, // waitForUploads: no progress indicator
|
|
109
|
+
true, // waitForEditForm: editor appeared
|
|
110
|
+
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
111
|
+
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
112
|
+
true,
|
|
113
|
+
'https://creator.xiaohongshu.com/publish/success',
|
|
114
|
+
'发布成功',
|
|
115
|
+
]);
|
|
116
|
+
const result = await cmd.func(page, {
|
|
117
|
+
title: '延迟切换也能过',
|
|
118
|
+
content: '图文页切换慢一点也继续等',
|
|
119
|
+
images: imagePath,
|
|
120
|
+
topics: '',
|
|
121
|
+
draft: false,
|
|
122
|
+
});
|
|
123
|
+
expect(page.wait.mock.calls).toContainEqual([{ time: 0.5 }]);
|
|
124
|
+
expect(result).toEqual([
|
|
125
|
+
{
|
|
126
|
+
status: '✅ 发布成功',
|
|
127
|
+
detail: '"延迟切换也能过" · 1张图片 · 发布成功',
|
|
128
|
+
},
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -5,4 +5,11 @@
|
|
|
5
5
|
* the search results page and extracts data from rendered DOM elements.
|
|
6
6
|
* Ref: https://github.com/jackwener/opencli/issues/10
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Extract approximate publish date from a Xiaohongshu note URL.
|
|
10
|
+
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
|
11
|
+
* characters encode a Unix timestamp (the moment the ID was generated,
|
|
12
|
+
* which closely matches publish time but is not an official API field).
|
|
13
|
+
* e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
|
|
14
|
+
*/
|
|
15
|
+
export declare function noteIdToDate(url: string): string;
|
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { cli, Strategy } from '../../registry.js';
|
|
9
9
|
import { AuthRequiredError } from '../../errors.js';
|
|
10
|
+
/**
|
|
11
|
+
* Extract approximate publish date from a Xiaohongshu note URL.
|
|
12
|
+
* XHS note IDs follow MongoDB ObjectID format where the first 8 hex
|
|
13
|
+
* characters encode a Unix timestamp (the moment the ID was generated,
|
|
14
|
+
* which closely matches publish time but is not an official API field).
|
|
15
|
+
* e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
|
|
16
|
+
*/
|
|
17
|
+
export function noteIdToDate(url) {
|
|
18
|
+
const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
|
|
19
|
+
if (!match)
|
|
20
|
+
return '';
|
|
21
|
+
const hex = match[1].substring(0, 8);
|
|
22
|
+
const ts = parseInt(hex, 16);
|
|
23
|
+
if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000)
|
|
24
|
+
return '';
|
|
25
|
+
// Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
|
|
26
|
+
return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
10
28
|
cli({
|
|
11
29
|
site: 'xiaohongshu',
|
|
12
30
|
name: 'search',
|
|
@@ -17,7 +35,7 @@ cli({
|
|
|
17
35
|
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
18
36
|
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
19
37
|
],
|
|
20
|
-
columns: ['rank', 'title', 'author', 'likes', 'url'],
|
|
38
|
+
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
|
|
21
39
|
func: async (page, kwargs) => {
|
|
22
40
|
const keyword = encodeURIComponent(kwargs.query);
|
|
23
41
|
await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
@@ -89,6 +107,7 @@ cli({
|
|
|
89
107
|
.map((item, i) => ({
|
|
90
108
|
rank: i + 1,
|
|
91
109
|
...item,
|
|
110
|
+
published_at: noteIdToDate(item.url),
|
|
92
111
|
}));
|
|
93
112
|
},
|
|
94
113
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '../../registry.js';
|
|
3
|
-
import './search.js';
|
|
3
|
+
import { noteIdToDate } from './search.js';
|
|
4
4
|
function createPageMock(evaluateResults) {
|
|
5
5
|
const evaluate = vi.fn();
|
|
6
6
|
for (const result of evaluateResults) {
|
|
@@ -70,6 +70,7 @@ describe('xiaohongshu search', () => {
|
|
|
70
70
|
title: '某鱼买FSD被坑了4万',
|
|
71
71
|
author: '随风',
|
|
72
72
|
likes: '261',
|
|
73
|
+
published_at: '2025-10-10',
|
|
73
74
|
url: detailUrl,
|
|
74
75
|
author_url: authorUrl,
|
|
75
76
|
},
|
|
@@ -112,3 +113,33 @@ describe('xiaohongshu search', () => {
|
|
|
112
113
|
expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
|
|
113
114
|
});
|
|
114
115
|
});
|
|
116
|
+
describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
|
117
|
+
it('parses a known note ID to the correct China-timezone date', () => {
|
|
118
|
+
// 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
|
|
119
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
|
|
120
|
+
// 0x68e90be8 → 2025-10-10 in UTC+8
|
|
121
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
|
|
122
|
+
});
|
|
123
|
+
it('returns China date when UTC+8 crosses into the next day', () => {
|
|
124
|
+
// 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
|
|
125
|
+
// Without UTC+8 offset this would incorrectly return 2026-03-15
|
|
126
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
|
|
127
|
+
});
|
|
128
|
+
it('handles /note/ path variant', () => {
|
|
129
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
|
|
130
|
+
});
|
|
131
|
+
it('handles URL with query parameters', () => {
|
|
132
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
|
|
133
|
+
});
|
|
134
|
+
it('returns empty string for non-matching URLs', () => {
|
|
135
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
|
|
136
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
|
|
137
|
+
});
|
|
138
|
+
it('returns empty string for IDs shorter than 24 hex chars', () => {
|
|
139
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
|
|
140
|
+
});
|
|
141
|
+
it('returns empty string when timestamp is out of range', () => {
|
|
142
|
+
// All zeros → ts = 0
|
|
143
|
+
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { Command } from 'commander';
|
|
13
13
|
import { type CliCommand } from './registry.js';
|
|
14
|
+
export declare function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown;
|
|
14
15
|
/**
|
|
15
16
|
* Register a single CliCommand as a Commander subcommand.
|
|
16
17
|
*/
|