@jackwener/opencli 1.4.1 → 1.5.0
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/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/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 +1111 -112
- package/dist/cli.js +34 -3
- 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/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/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 +114 -18
- package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +119 -0
- 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 +8 -14
- package/dist/doctor.d.ts +1 -0
- package/dist/doctor.js +9 -2
- package/dist/download/index.js +63 -51
- package/dist/download/index.test.js +17 -4
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +15 -32
- package/dist/execution.d.ts +1 -3
- package/dist/execution.js +21 -1
- package/dist/hooks.js +2 -0
- package/dist/main.js +5 -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.d.ts +38 -5
- package/dist/plugin.js +267 -33
- package/dist/plugin.test.js +220 -3
- 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.js +1 -1
- package/dist/serialization.d.ts +2 -0
- package/dist/serialization.js +6 -0
- package/dist/types.d.ts +1 -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/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 +87 -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 +2 -1
- package/src/browser/discover.ts +8 -3
- package/src/browser/errors.ts +13 -14
- package/src/browser/mcp.ts +2 -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 +35 -3
- 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/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/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 +137 -0
- package/src/clis/xiaohongshu/publish.ts +129 -18
- package/src/commanderAdapter.test.ts +78 -0
- package/src/commanderAdapter.ts +188 -24
- package/src/daemon.ts +19 -1
- package/src/discovery.ts +8 -15
- package/src/doctor.ts +13 -2
- package/src/download/index.test.ts +14 -4
- package/src/download/index.ts +67 -55
- package/src/errors.ts +25 -66
- package/src/execution.ts +28 -3
- package/src/hooks.ts +1 -0
- package/src/main.ts +6 -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.test.ts +246 -2
- package/src/plugin.ts +338 -36
- 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 +1 -1
- package/src/serialization.ts +4 -0
- package/src/types.ts +1 -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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu comments — DOM extraction from note detail page.
|
|
3
|
+
* XHS API requires signed requests, so we scrape the rendered DOM instead.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
import { AuthRequiredError, EmptyResultError } from '../../errors.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'xiaohongshu',
|
|
11
|
+
name: 'comments',
|
|
12
|
+
description: '获取小红书笔记评论(仅主评论,不含楼中楼)',
|
|
13
|
+
domain: 'www.xiaohongshu.com',
|
|
14
|
+
strategy: Strategy.COOKIE,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'note-id', required: true, positional: true, help: 'Note ID or full /explore/<id> URL' },
|
|
17
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['rank', 'author', 'text', 'likes', 'time'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
const limit = Math.min(Number(kwargs.limit) || 20, 50);
|
|
22
|
+
let noteId = String(kwargs['note-id']).trim();
|
|
23
|
+
|
|
24
|
+
// Accept full URLs: /explore/<id> or /note/<id>
|
|
25
|
+
const urlMatch = noteId.match(/\/explore\/([a-f0-9]+)/) || noteId.match(/\/note\/([a-f0-9]+)/);
|
|
26
|
+
if (urlMatch) noteId = urlMatch[1];
|
|
27
|
+
|
|
28
|
+
await page.goto(`https://www.xiaohongshu.com/explore/${noteId}`);
|
|
29
|
+
await page.wait(3);
|
|
30
|
+
|
|
31
|
+
const data = await page.evaluate(`
|
|
32
|
+
(async () => {
|
|
33
|
+
const wait = (ms) => new Promise(r => setTimeout(r, ms))
|
|
34
|
+
|
|
35
|
+
// Check login state
|
|
36
|
+
const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
|
|
37
|
+
|
|
38
|
+
// Scroll the note container to trigger comment loading
|
|
39
|
+
const scroller = document.querySelector('.note-scroller') || document.querySelector('.container')
|
|
40
|
+
if (scroller) {
|
|
41
|
+
for (let i = 0; i < 3; i++) {
|
|
42
|
+
scroller.scrollTo(0, scroller.scrollHeight)
|
|
43
|
+
await wait(1000)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
48
|
+
|
|
49
|
+
const results = []
|
|
50
|
+
const parents = document.querySelectorAll('.parent-comment')
|
|
51
|
+
for (const p of parents) {
|
|
52
|
+
const item = p.querySelector('.comment-item')
|
|
53
|
+
if (!item) continue
|
|
54
|
+
|
|
55
|
+
const author = clean(item.querySelector('.author-wrapper .name, .user-name'))
|
|
56
|
+
const text = clean(item.querySelector('.content, .note-text'))
|
|
57
|
+
// XHS shows text "赞" when likes = 0; only shows a number when > 0
|
|
58
|
+
const likesRaw = clean(item.querySelector('.count'))
|
|
59
|
+
const likes = /^\\d+$/.test(likesRaw) ? Number(likesRaw) : 0
|
|
60
|
+
const time = clean(item.querySelector('.date, .time'))
|
|
61
|
+
|
|
62
|
+
if (!text) continue
|
|
63
|
+
results.push({ author, text, likes, time })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { loginWall, results }
|
|
67
|
+
})()
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
if (!data || typeof data !== 'object') {
|
|
71
|
+
throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if ((data as any).loginWall) {
|
|
75
|
+
throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const results: any[] = (data as any).results ?? [];
|
|
79
|
+
return results.slice(0, limit).map((c: any, i: number) => ({ rank: i + 1, ...c }));
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { getRegistry } from '../../registry.js';
|
|
8
|
+
import type { IPage } from '../../types.js';
|
|
9
|
+
import './publish.js';
|
|
10
|
+
|
|
11
|
+
function createPageMock(evaluateResults: any[]): IPage {
|
|
12
|
+
const evaluate = vi.fn();
|
|
13
|
+
for (const result of evaluateResults) {
|
|
14
|
+
evaluate.mockResolvedValueOnce(result);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
evaluate,
|
|
20
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
scrollTo: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
|
|
26
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
28
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
32
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
33
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
37
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
38
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('xiaohongshu publish', () => {
|
|
43
|
+
it('selects the image-text tab and publishes successfully', async () => {
|
|
44
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
45
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
46
|
+
|
|
47
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
|
|
48
|
+
const imagePath = path.join(tempDir, 'demo.jpg');
|
|
49
|
+
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
|
50
|
+
|
|
51
|
+
const page = createPageMock([
|
|
52
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
53
|
+
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
54
|
+
{ hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
55
|
+
{ ok: true, count: 1 },
|
|
56
|
+
false,
|
|
57
|
+
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
58
|
+
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
59
|
+
true,
|
|
60
|
+
'https://creator.xiaohongshu.com/publish/success',
|
|
61
|
+
'发布成功',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const result = await cmd!.func!(page, {
|
|
65
|
+
title: 'DeepSeek别乱问',
|
|
66
|
+
content: '一篇真实一点的小红书正文',
|
|
67
|
+
images: imagePath,
|
|
68
|
+
topics: '',
|
|
69
|
+
draft: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const evaluateCalls = (page.evaluate as any).mock.calls.map((args: any[]) => String(args[0]));
|
|
73
|
+
expect(evaluateCalls.some((code: string) => code.includes("const targets = ['上传图文', '图文', '图片']"))).toBe(true);
|
|
74
|
+
expect(evaluateCalls.some((code: string) => code.includes("No image file input found on page"))).toBe(true);
|
|
75
|
+
expect(result).toEqual([
|
|
76
|
+
{
|
|
77
|
+
status: '✅ 发布成功',
|
|
78
|
+
detail: '"DeepSeek别乱问" · 1张图片 · 发布成功',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fails early with a clear error when still on the video page', async () => {
|
|
84
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
85
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
86
|
+
|
|
87
|
+
const page = createPageMock([
|
|
88
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
89
|
+
{ ok: false, visibleTexts: ['上传视频', '上传图文'] },
|
|
90
|
+
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
91
|
+
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
92
|
+
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
93
|
+
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
await expect(cmd!.func!(page, {
|
|
97
|
+
title: 'DeepSeek别乱问',
|
|
98
|
+
content: '一篇真实一点的小红书正文',
|
|
99
|
+
topics: '',
|
|
100
|
+
draft: false,
|
|
101
|
+
})).rejects.toThrow('Still on the video publish page after trying to select 图文');
|
|
102
|
+
|
|
103
|
+
expect(page.screenshot).toHaveBeenCalledWith({ path: '/tmp/xhs_publish_tab_debug.png' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('waits for the image-text surface to appear after clicking the tab', async () => {
|
|
107
|
+
const cmd = getRegistry().get('xiaohongshu/publish');
|
|
108
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
109
|
+
|
|
110
|
+
const page = createPageMock([
|
|
111
|
+
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
|
|
112
|
+
{ ok: true, target: '上传图文', text: '上传图文' },
|
|
113
|
+
{ hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
|
|
114
|
+
{ hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
|
|
115
|
+
{ ok: true, sel: 'input[maxlength="20"]' },
|
|
116
|
+
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
|
|
117
|
+
true,
|
|
118
|
+
'https://creator.xiaohongshu.com/publish/success',
|
|
119
|
+
'发布成功',
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const result = await cmd!.func!(page, {
|
|
123
|
+
title: '延迟切换也能过',
|
|
124
|
+
content: '图文页切换慢一点也继续等',
|
|
125
|
+
topics: '',
|
|
126
|
+
draft: false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect((page.wait as any).mock.calls).toContainEqual([{ time: 0.5 }]);
|
|
130
|
+
expect(result).toEqual([
|
|
131
|
+
{
|
|
132
|
+
status: '✅ 发布成功',
|
|
133
|
+
detail: '"延迟切换也能过" · 无图 · 发布成功',
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -63,14 +63,22 @@ async function injectImages(page: IPage, images: ImagePayload[]): Promise<{ ok:
|
|
|
63
63
|
(async () => {
|
|
64
64
|
const images = ${payload};
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Only use image-capable file inputs. Do not fall back to a generic uploader,
|
|
67
|
+
// otherwise we can accidentally feed images into the video upload flow.
|
|
67
68
|
const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
|
|
68
69
|
const input = inputs.find(el => {
|
|
69
70
|
const accept = el.getAttribute('accept') || '';
|
|
70
|
-
return
|
|
71
|
-
|
|
71
|
+
return (
|
|
72
|
+
accept.includes('image') ||
|
|
73
|
+
accept.includes('.jpg') ||
|
|
74
|
+
accept.includes('.jpeg') ||
|
|
75
|
+
accept.includes('.png') ||
|
|
76
|
+
accept.includes('.gif') ||
|
|
77
|
+
accept.includes('.webp')
|
|
78
|
+
);
|
|
79
|
+
});
|
|
72
80
|
|
|
73
|
-
if (!input) return { ok: false, count: 0, error: 'No file input found on page' };
|
|
81
|
+
if (!input) return { ok: false, count: 0, error: 'No image file input found on page' };
|
|
74
82
|
|
|
75
83
|
const dt = new DataTransfer();
|
|
76
84
|
for (const img of images) {
|
|
@@ -151,6 +159,111 @@ async function fillField(page: IPage, selectors: string[], text: string, fieldNa
|
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
async function selectImageTextTab(
|
|
163
|
+
page: IPage,
|
|
164
|
+
): Promise<{ ok: boolean; target?: string; text?: string; visibleTexts?: string[] }> {
|
|
165
|
+
const result = await page.evaluate(`
|
|
166
|
+
() => {
|
|
167
|
+
const isVisible = (el) => {
|
|
168
|
+
if (!el || el.offsetParent === null) return false;
|
|
169
|
+
const rect = el.getBoundingClientRect();
|
|
170
|
+
return rect.width > 0 && rect.height > 0;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
174
|
+
const selector = 'button, [role="tab"], [role="button"], a, label, div, span, li';
|
|
175
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
176
|
+
const targets = ['上传图文', '图文', '图片'];
|
|
177
|
+
|
|
178
|
+
for (const target of targets) {
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
if (!isVisible(node)) continue;
|
|
181
|
+
const text = normalize(node.innerText || node.textContent || '');
|
|
182
|
+
if (!text || text.includes('视频')) continue;
|
|
183
|
+
if (text === target || text.startsWith(target) || text.includes(target)) {
|
|
184
|
+
const clickable = node.closest('button, [role="tab"], [role="button"], a, label') || node;
|
|
185
|
+
clickable.click();
|
|
186
|
+
return { ok: true, target, text };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const visibleTexts = [];
|
|
192
|
+
for (const node of nodes) {
|
|
193
|
+
if (!isVisible(node)) continue;
|
|
194
|
+
const text = normalize(node.innerText || node.textContent || '');
|
|
195
|
+
if (!text || text.length > 20) continue;
|
|
196
|
+
visibleTexts.push(text);
|
|
197
|
+
if (visibleTexts.length >= 20) break;
|
|
198
|
+
}
|
|
199
|
+
return { ok: false, visibleTexts };
|
|
200
|
+
}
|
|
201
|
+
`);
|
|
202
|
+
if (result?.ok) {
|
|
203
|
+
await page.wait({ time: 1 });
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function inspectPublishSurface(
|
|
209
|
+
page: IPage,
|
|
210
|
+
): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> {
|
|
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
|
+
return {
|
|
238
|
+
hasTitleInput,
|
|
239
|
+
hasImageInput,
|
|
240
|
+
hasVideoSurface: text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function waitForImageTextSurface(
|
|
247
|
+
page: IPage,
|
|
248
|
+
maxWaitMs = 5_000,
|
|
249
|
+
): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> {
|
|
250
|
+
const pollMs = 500;
|
|
251
|
+
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
|
|
252
|
+
let surface = await inspectPublishSurface(page);
|
|
253
|
+
|
|
254
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
255
|
+
if (surface.hasTitleInput || surface.hasImageInput || !surface.hasVideoSurface) {
|
|
256
|
+
return surface;
|
|
257
|
+
}
|
|
258
|
+
if (i < maxAttempts - 1) {
|
|
259
|
+
await page.wait({ time: pollMs / 1_000 });
|
|
260
|
+
surface = await inspectPublishSurface(page);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return surface;
|
|
265
|
+
}
|
|
266
|
+
|
|
154
267
|
cli({
|
|
155
268
|
site: 'xiaohongshu',
|
|
156
269
|
name: 'publish',
|
|
@@ -204,20 +317,18 @@ cli({
|
|
|
204
317
|
}
|
|
205
318
|
|
|
206
319
|
// ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`);
|
|
220
|
-
if (tabClicked) await page.wait({ time: 1 });
|
|
320
|
+
const tabResult = await selectImageTextTab(page);
|
|
321
|
+
const surface = await waitForImageTextSurface(page, tabResult?.ok ? 5_000 : 2_000);
|
|
322
|
+
if (!surface.hasTitleInput && !surface.hasImageInput && surface.hasVideoSurface) {
|
|
323
|
+
await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
|
|
324
|
+
const detail = tabResult?.ok
|
|
325
|
+
? `clicked "${tabResult.text}"`
|
|
326
|
+
: `visible candidates: ${(tabResult?.visibleTexts || []).join(' | ') || 'none'}`;
|
|
327
|
+
throw new Error(
|
|
328
|
+
'Still on the video publish page after trying to select 图文. ' +
|
|
329
|
+
`Details: ${detail}. Debug screenshot: /tmp/xhs_publish_tab_debug.png`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
221
332
|
|
|
222
333
|
// ── Step 3: Upload images ──────────────────────────────────────────────────
|
|
223
334
|
if (imageData.length > 0) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import type { CliCommand } from './registry.js';
|
|
4
|
+
|
|
5
|
+
const { mockExecuteCommand, mockRenderOutput } = vi.hoisted(() => ({
|
|
6
|
+
mockExecuteCommand: vi.fn(),
|
|
7
|
+
mockRenderOutput: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('./execution.js', () => ({
|
|
11
|
+
executeCommand: mockExecuteCommand,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock('./output.js', () => ({
|
|
15
|
+
render: mockRenderOutput,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { registerCommandToProgram } from './commanderAdapter.js';
|
|
19
|
+
|
|
20
|
+
describe('commanderAdapter arg passing', () => {
|
|
21
|
+
const cmd: CliCommand = {
|
|
22
|
+
site: 'paperreview',
|
|
23
|
+
name: 'submit',
|
|
24
|
+
description: 'Submit a PDF',
|
|
25
|
+
browser: false,
|
|
26
|
+
args: [
|
|
27
|
+
{ name: 'pdf', positional: true, required: true, help: 'Path to the paper PDF' },
|
|
28
|
+
{ name: 'dry-run', type: 'bool', default: false, help: 'Validate only' },
|
|
29
|
+
{ name: 'prepare-only', type: 'bool', default: false, help: 'Prepare only' },
|
|
30
|
+
],
|
|
31
|
+
func: vi.fn(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
mockExecuteCommand.mockReset();
|
|
36
|
+
mockExecuteCommand.mockResolvedValue([]);
|
|
37
|
+
mockRenderOutput.mockReset();
|
|
38
|
+
delete process.env.OPENCLI_VERBOSE;
|
|
39
|
+
process.exitCode = undefined;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('passes bool flag values through to executeCommand for coercion', async () => {
|
|
43
|
+
const program = new Command();
|
|
44
|
+
const siteCmd = program.command('paperreview');
|
|
45
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
46
|
+
|
|
47
|
+
await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'false']);
|
|
48
|
+
|
|
49
|
+
expect(mockExecuteCommand).toHaveBeenCalled();
|
|
50
|
+
const kwargs = mockExecuteCommand.mock.calls[0][1];
|
|
51
|
+
expect(kwargs.pdf).toBe('./paper.pdf');
|
|
52
|
+
expect(kwargs).toHaveProperty('dry-run');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('passes valueless bool flags as true to executeCommand', async () => {
|
|
56
|
+
const program = new Command();
|
|
57
|
+
const siteCmd = program.command('paperreview');
|
|
58
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
59
|
+
|
|
60
|
+
await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--prepare-only']);
|
|
61
|
+
|
|
62
|
+
expect(mockExecuteCommand).toHaveBeenCalled();
|
|
63
|
+
const kwargs = mockExecuteCommand.mock.calls[0][1];
|
|
64
|
+
expect(kwargs.pdf).toBe('./paper.pdf');
|
|
65
|
+
expect(kwargs['prepare-only']).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects invalid bool values before calling executeCommand', async () => {
|
|
69
|
+
const program = new Command();
|
|
70
|
+
const siteCmd = program.command('paperreview');
|
|
71
|
+
registerCommandToProgram(siteCmd, cmd);
|
|
72
|
+
|
|
73
|
+
await program.parseAsync(['node', 'opencli', 'paperreview', 'submit', './paper.pdf', '--dry-run', 'maybe']);
|
|
74
|
+
|
|
75
|
+
// normalizeArgValue validates bools eagerly; executeCommand should not be reached
|
|
76
|
+
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|