@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
package/src/cli.ts
CHANGED
|
@@ -76,9 +76,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
76
76
|
for (const [site, cmds] of sites) {
|
|
77
77
|
console.log(chalk.bold.cyan(` ${site}`));
|
|
78
78
|
for (const cmd of cmds) {
|
|
79
|
-
const
|
|
79
|
+
const label = strategyLabel(cmd);
|
|
80
|
+
const tag = label === 'public'
|
|
80
81
|
? chalk.green('[public]')
|
|
81
|
-
: chalk.yellow(`[${
|
|
82
|
+
: chalk.yellow(`[${label}]`);
|
|
82
83
|
console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
|
|
83
84
|
}
|
|
84
85
|
console.log();
|
|
@@ -252,15 +253,23 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
252
253
|
|
|
253
254
|
pluginCmd
|
|
254
255
|
.command('install')
|
|
255
|
-
.description('Install a plugin from
|
|
256
|
+
.description('Install a plugin from a git repository')
|
|
256
257
|
.argument('<source>', 'Plugin source (e.g. github:user/repo)')
|
|
257
258
|
.action(async (source: string) => {
|
|
258
259
|
const { installPlugin } = await import('./plugin.js');
|
|
259
260
|
const { discoverPlugins } = await import('./discovery.js');
|
|
260
261
|
try {
|
|
261
|
-
const
|
|
262
|
+
const result = installPlugin(source);
|
|
262
263
|
await discoverPlugins();
|
|
263
|
-
|
|
264
|
+
if (Array.isArray(result)) {
|
|
265
|
+
if (result.length === 0) {
|
|
266
|
+
console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
|
|
267
|
+
} else {
|
|
268
|
+
console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
|
|
272
|
+
}
|
|
264
273
|
} catch (err) {
|
|
265
274
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
266
275
|
process.exitCode = 1;
|
|
@@ -368,17 +377,71 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
368
377
|
console.log();
|
|
369
378
|
console.log(chalk.bold(' Installed plugins'));
|
|
370
379
|
console.log();
|
|
380
|
+
|
|
381
|
+
// Group by monorepo
|
|
382
|
+
const standalone = plugins.filter((p) => !p.monorepoName);
|
|
383
|
+
const monoGroups = new Map<string, typeof plugins>();
|
|
371
384
|
for (const p of plugins) {
|
|
385
|
+
if (!p.monorepoName) continue;
|
|
386
|
+
const g = monoGroups.get(p.monorepoName) ?? [];
|
|
387
|
+
g.push(p);
|
|
388
|
+
monoGroups.set(p.monorepoName, g);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
for (const p of standalone) {
|
|
372
392
|
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
|
393
|
+
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
|
373
394
|
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
|
374
395
|
const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
|
|
375
|
-
console.log(` ${chalk.cyan(p.name)}${version}${cmds}${src}`);
|
|
396
|
+
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
|
|
376
397
|
}
|
|
398
|
+
|
|
399
|
+
for (const [mono, group] of monoGroups) {
|
|
400
|
+
console.log();
|
|
401
|
+
console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
|
|
402
|
+
for (const p of group) {
|
|
403
|
+
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
|
404
|
+
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
|
405
|
+
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
|
406
|
+
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
377
410
|
console.log();
|
|
378
411
|
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
|
|
379
412
|
console.log();
|
|
380
413
|
});
|
|
381
414
|
|
|
415
|
+
pluginCmd
|
|
416
|
+
.command('create')
|
|
417
|
+
.description('Create a new plugin scaffold')
|
|
418
|
+
.argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
|
|
419
|
+
.option('-d, --dir <path>', 'Output directory (default: ./<name>)')
|
|
420
|
+
.option('--description <text>', 'Plugin description')
|
|
421
|
+
.action(async (name: string, opts: { dir?: string; description?: string }) => {
|
|
422
|
+
const { createPluginScaffold } = await import('./plugin-scaffold.js');
|
|
423
|
+
try {
|
|
424
|
+
const result = createPluginScaffold(name, {
|
|
425
|
+
dir: opts.dir,
|
|
426
|
+
description: opts.description,
|
|
427
|
+
});
|
|
428
|
+
console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
|
|
429
|
+
console.log();
|
|
430
|
+
console.log(chalk.bold(' Files created:'));
|
|
431
|
+
for (const f of result.files) {
|
|
432
|
+
console.log(` ${chalk.cyan(f)}`);
|
|
433
|
+
}
|
|
434
|
+
console.log();
|
|
435
|
+
console.log(chalk.dim(' Next steps:'));
|
|
436
|
+
console.log(chalk.dim(` cd ${result.dir}`));
|
|
437
|
+
console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
|
|
438
|
+
console.log(chalk.dim(` opencli ${name} hello`));
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
382
445
|
// ── External CLIs ─────────────────────────────────────────────────────────
|
|
383
446
|
|
|
384
447
|
const externalClis = loadExternalClis();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 36kr article detail — INTERCEPT strategy.
|
|
3
|
+
*
|
|
4
|
+
* Fetches the full content of a 36kr article given its ID or URL.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
import { CliError } from '../../errors.js';
|
|
8
|
+
import type { IPage } from '../../types.js';
|
|
9
|
+
|
|
10
|
+
/** Extract article ID from a full URL or a bare numeric ID string */
|
|
11
|
+
function parseArticleId(input: string): string {
|
|
12
|
+
const m = input.match(/\/p\/(\d+)/);
|
|
13
|
+
return m ? m[1] : input.replace(/\D/g, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
cli({
|
|
17
|
+
site: '36kr',
|
|
18
|
+
name: 'article',
|
|
19
|
+
description: '获取36氪文章正文内容',
|
|
20
|
+
domain: 'www.36kr.com',
|
|
21
|
+
strategy: Strategy.INTERCEPT,
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
|
|
24
|
+
],
|
|
25
|
+
columns: ['field', 'value'],
|
|
26
|
+
func: async (page: IPage, args) => {
|
|
27
|
+
const articleId = parseArticleId(String(args.id ?? ''));
|
|
28
|
+
if (!articleId) {
|
|
29
|
+
throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await page.installInterceptor('36kr.com/api');
|
|
33
|
+
await page.goto(`https://www.36kr.com/p/${articleId}`);
|
|
34
|
+
await page.wait(5);
|
|
35
|
+
|
|
36
|
+
const data: any = await page.evaluate(`
|
|
37
|
+
(() => {
|
|
38
|
+
// Title: 36kr uses class "article-title" on h1
|
|
39
|
+
const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
|
|
40
|
+
// Author: second .author-name (first is empty nav link, second has real name)
|
|
41
|
+
const authorEls = document.querySelectorAll('.author-name');
|
|
42
|
+
const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
|
|
43
|
+
// Date: 36kr uses class "title-icon-item item-time" for the publish date
|
|
44
|
+
const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
|
|
45
|
+
const date = dateRaw.replace(/^[·\s]+/, '').trim();
|
|
46
|
+
// Article body paragraphs
|
|
47
|
+
const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
|
|
48
|
+
const body = Array.from(bodyEls)
|
|
49
|
+
.map(el => el.textContent?.trim())
|
|
50
|
+
.filter(t => t && t.length > 10)
|
|
51
|
+
.join(' ')
|
|
52
|
+
.slice(0, 800);
|
|
53
|
+
return { title, author, date, body };
|
|
54
|
+
})()
|
|
55
|
+
`);
|
|
56
|
+
|
|
57
|
+
if (!data?.title) {
|
|
58
|
+
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [
|
|
62
|
+
{ field: 'title', value: data.title },
|
|
63
|
+
{ field: 'author', value: data.author || '-' },
|
|
64
|
+
{ field: 'date', value: data.date || '-' },
|
|
65
|
+
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
|
|
66
|
+
{ field: 'body', value: data.body || '-' },
|
|
67
|
+
];
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildHotListUrl, getShanghaiDate } from './hot.js';
|
|
4
|
+
|
|
5
|
+
describe('36kr/hot date routing', () => {
|
|
6
|
+
it('formats dates in Asia/Shanghai instead of UTC', () => {
|
|
7
|
+
const date = new Date('2026-03-25T18:30:00.000Z');
|
|
8
|
+
expect(getShanghaiDate(date)).toBe('2026-03-26');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('builds dated hot-list routes with Shanghai-local date', () => {
|
|
12
|
+
const date = new Date('2026-03-25T18:30:00.000Z');
|
|
13
|
+
expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps catalog on the static route', () => {
|
|
17
|
+
expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 36kr hot-list — INTERCEPT strategy.
|
|
3
|
+
*
|
|
4
|
+
* Navigates to the 36kr hot-list page and scrapes rendered article links.
|
|
5
|
+
* Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
import { CliError } from '../../errors.js';
|
|
9
|
+
import type { IPage } from '../../types.js';
|
|
10
|
+
|
|
11
|
+
const TYPE_MAP: Record<string, string> = {
|
|
12
|
+
renqi: '人气榜',
|
|
13
|
+
zonghe: '综合榜',
|
|
14
|
+
shoucang: '收藏榜',
|
|
15
|
+
catalog: '热门资讯',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function getShanghaiDate(date = new Date()): string {
|
|
19
|
+
// Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
|
|
20
|
+
// and avoids the slow Intl timezone path that timed out on Windows CI.
|
|
21
|
+
return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildHotListUrl(listType: string, date = new Date()): string {
|
|
25
|
+
if (listType === 'catalog') {
|
|
26
|
+
return 'https://www.36kr.com/hot-list/catalog';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
cli({
|
|
33
|
+
site: '36kr',
|
|
34
|
+
name: 'hot',
|
|
35
|
+
description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
|
|
36
|
+
domain: 'www.36kr.com',
|
|
37
|
+
strategy: Strategy.INTERCEPT,
|
|
38
|
+
args: [
|
|
39
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
|
|
40
|
+
{
|
|
41
|
+
name: 'type',
|
|
42
|
+
type: 'string',
|
|
43
|
+
default: 'catalog',
|
|
44
|
+
help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
columns: ['rank', 'title', 'url'],
|
|
48
|
+
func: async (page: IPage, args) => {
|
|
49
|
+
const count = Math.min(Number(args.limit) || 20, 50);
|
|
50
|
+
const listType = String(args.type ?? 'catalog');
|
|
51
|
+
|
|
52
|
+
if (!TYPE_MAP[listType]) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
'INVALID_ARGUMENT',
|
|
55
|
+
`Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const url = buildHotListUrl(listType);
|
|
60
|
+
|
|
61
|
+
await page.installInterceptor('36kr.com/api');
|
|
62
|
+
await page.goto(url);
|
|
63
|
+
await page.wait(6);
|
|
64
|
+
|
|
65
|
+
// Scrape rendered article links from DOM (deduplicated)
|
|
66
|
+
const domItems: any = await page.evaluate(`
|
|
67
|
+
(() => {
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const results = [];
|
|
70
|
+
const links = document.querySelectorAll('a[href*="/p/"]');
|
|
71
|
+
for (const el of links) {
|
|
72
|
+
const href = el.getAttribute('href') || '';
|
|
73
|
+
const title = el.textContent?.trim() || '';
|
|
74
|
+
if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
|
|
75
|
+
seen.add(href);
|
|
76
|
+
seen.add(title);
|
|
77
|
+
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
})()
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
|
|
84
|
+
if (items.length === 0) {
|
|
85
|
+
throw new CliError(
|
|
86
|
+
'NO_DATA',
|
|
87
|
+
'Could not retrieve 36kr hot list',
|
|
88
|
+
'36kr may have changed its DOM structure',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return items.slice(0, count).map((item: any, i: number) => ({
|
|
93
|
+
rank: i + 1,
|
|
94
|
+
title: item.title,
|
|
95
|
+
url: item.url,
|
|
96
|
+
}));
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export { buildHotListUrl, getShanghaiDate };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
|
+
<rss version="2.0"><channel><title>36氪</title>
|
|
5
|
+
<item>
|
|
6
|
+
<title>红杉中国领投AI公司「示例」,金额近2亿元</title>
|
|
7
|
+
<link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
|
|
8
|
+
<pubDate>2026-03-26 10:00:00 +0800</pubDate>
|
|
9
|
+
</item>
|
|
10
|
+
<item>
|
|
11
|
+
<title>马斯克旗下xAI估值突破1000亿美元</title>
|
|
12
|
+
<link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
|
|
13
|
+
<pubDate>2026-03-26 09:00:00 +0800</pubDate>
|
|
14
|
+
</item>
|
|
15
|
+
<item>
|
|
16
|
+
<title>OpenAI发布GPT-5,多模态能力大幅提升</title>
|
|
17
|
+
<link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
|
|
18
|
+
<pubDate>2026-03-25 20:00:00 +0800</pubDate>
|
|
19
|
+
</item>
|
|
20
|
+
</channel></rss>`;
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
vi.restoreAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('36kr/news RSS parsing', () => {
|
|
27
|
+
it('parses RSS feed into ranked news items', async () => {
|
|
28
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
29
|
+
ok: true,
|
|
30
|
+
text: async () => SAMPLE_RSS,
|
|
31
|
+
} as Response);
|
|
32
|
+
|
|
33
|
+
// Direct RSS parse test using the same regex logic as news.ts
|
|
34
|
+
const xml = SAMPLE_RSS;
|
|
35
|
+
const items: { rank: number; title: string; date: string; url: string }[] = [];
|
|
36
|
+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = itemRegex.exec(xml)) && items.length < 10) {
|
|
39
|
+
const block = match[1];
|
|
40
|
+
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
41
|
+
const url =
|
|
42
|
+
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
|
|
43
|
+
block.match(/<link>(.*?)<\/link>/)?.[1] ??
|
|
44
|
+
'';
|
|
45
|
+
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
|
46
|
+
const date = pubDate.slice(0, 10);
|
|
47
|
+
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(items).toHaveLength(3);
|
|
51
|
+
expect(items[0].rank).toBe(1);
|
|
52
|
+
expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
|
|
53
|
+
expect(items[0].date).toBe('2026-03-26');
|
|
54
|
+
expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('respects limit — returns at most N items', async () => {
|
|
58
|
+
const xml = SAMPLE_RSS;
|
|
59
|
+
const limit = 2;
|
|
60
|
+
const items: { rank: number; title: string; date: string; url: string }[] = [];
|
|
61
|
+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
62
|
+
let match;
|
|
63
|
+
while ((match = itemRegex.exec(xml)) && items.length < limit) {
|
|
64
|
+
const block = match[1];
|
|
65
|
+
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
66
|
+
const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
|
|
67
|
+
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
|
68
|
+
const date = pubDate.slice(0, 10);
|
|
69
|
+
if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
|
|
70
|
+
}
|
|
71
|
+
expect(items).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('skips items with empty title', async () => {
|
|
75
|
+
const xml = `<rss><channel>
|
|
76
|
+
<item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
|
|
77
|
+
<item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
|
|
78
|
+
</channel></rss>`;
|
|
79
|
+
const items: any[] = [];
|
|
80
|
+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = itemRegex.exec(xml))) {
|
|
83
|
+
const block = match[1];
|
|
84
|
+
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
85
|
+
if (title) items.push({ title });
|
|
86
|
+
}
|
|
87
|
+
expect(items).toHaveLength(1);
|
|
88
|
+
expect(items[0].title).toBe('有标题的文章');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 36kr latest news — public RSS feed, no browser needed.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
|
|
6
|
+
cli({
|
|
7
|
+
site: '36kr',
|
|
8
|
+
name: 'news',
|
|
9
|
+
description: 'Latest tech/startup news from 36kr (36氪)',
|
|
10
|
+
domain: 'www.36kr.com',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'summary', 'date', 'url'],
|
|
16
|
+
func: async (_page, kwargs) => {
|
|
17
|
+
const count = Math.min(kwargs.limit || 20, 50);
|
|
18
|
+
const resp = await fetch('https://www.36kr.com/feed', {
|
|
19
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
|
|
20
|
+
});
|
|
21
|
+
if (!resp.ok) return [];
|
|
22
|
+
const xml = await resp.text();
|
|
23
|
+
|
|
24
|
+
const items: { rank: number; title: string; summary: string; date: string; url: string }[] = [];
|
|
25
|
+
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = itemRegex.exec(xml)) && items.length < count) {
|
|
28
|
+
const block = match[1];
|
|
29
|
+
const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
|
|
30
|
+
const url =
|
|
31
|
+
block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
|
|
32
|
+
block.match(/<link>(.*?)<\/link>/)?.[1] ??
|
|
33
|
+
'';
|
|
34
|
+
const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
|
|
35
|
+
const date = pubDate.slice(0, 10);
|
|
36
|
+
// Extract plain-text summary from HTML description (first ~120 chars)
|
|
37
|
+
const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
|
|
38
|
+
const summary = rawDesc
|
|
39
|
+
.replace(/<[^>]+>/g, ' ')
|
|
40
|
+
.replace(/ /g, ' ')
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/</g, '<')
|
|
43
|
+
.replace(/>/g, '>')
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.trim()
|
|
46
|
+
.slice(0, 120);
|
|
47
|
+
|
|
48
|
+
if (title) {
|
|
49
|
+
items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return items;
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 36kr article search — INTERCEPT strategy.
|
|
3
|
+
*
|
|
4
|
+
* Navigates to the 36kr search results page and scrapes rendered articles.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
import { CliError } from '../../errors.js';
|
|
8
|
+
import type { IPage } from '../../types.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: '36kr',
|
|
12
|
+
name: 'search',
|
|
13
|
+
description: '搜索36氪文章',
|
|
14
|
+
domain: 'www.36kr.com',
|
|
15
|
+
strategy: Strategy.INTERCEPT,
|
|
16
|
+
args: [
|
|
17
|
+
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
|
|
18
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
|
|
19
|
+
],
|
|
20
|
+
columns: ['rank', 'title', 'date', 'url'],
|
|
21
|
+
func: async (page: IPage, args) => {
|
|
22
|
+
const count = Math.min(Number(args.limit) || 20, 50);
|
|
23
|
+
const query = encodeURIComponent(String(args.query ?? ''));
|
|
24
|
+
|
|
25
|
+
await page.installInterceptor('36kr.com/api');
|
|
26
|
+
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
|
27
|
+
await page.wait(6);
|
|
28
|
+
|
|
29
|
+
const domItems: any = await page.evaluate(`
|
|
30
|
+
(() => {
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const results = [];
|
|
33
|
+
// article-item-title contains the clickable title link
|
|
34
|
+
const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
|
|
35
|
+
for (const el of titleEls) {
|
|
36
|
+
const href = el.getAttribute('href') || '';
|
|
37
|
+
const title = el.textContent?.trim() || '';
|
|
38
|
+
if (!title || seen.has(href)) continue;
|
|
39
|
+
seen.add(href);
|
|
40
|
+
// Look for date near the article item
|
|
41
|
+
const item = el.closest('[class*="article-item"]') || el.parentElement;
|
|
42
|
+
const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
|
|
43
|
+
const date = dateEl?.textContent?.trim() || '';
|
|
44
|
+
results.push({
|
|
45
|
+
title,
|
|
46
|
+
url: href.startsWith('http') ? href : 'https://36kr.com' + href,
|
|
47
|
+
date,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// Fallback: generic /p/ links with meaningful text
|
|
51
|
+
if (results.length === 0) {
|
|
52
|
+
const links = document.querySelectorAll('a[href*="/p/"]');
|
|
53
|
+
for (const el of links) {
|
|
54
|
+
const href = el.getAttribute('href') || '';
|
|
55
|
+
const title = el.textContent?.trim() || '';
|
|
56
|
+
if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
|
|
57
|
+
seen.add(href);
|
|
58
|
+
seen.add(title);
|
|
59
|
+
results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return results;
|
|
63
|
+
})()
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
const items = Array.isArray(domItems) ? (domItems as any[]) : [];
|
|
67
|
+
if (items.length === 0) {
|
|
68
|
+
throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return items.slice(0, count).map((item: any, i: number) => ({
|
|
72
|
+
rank: i + 1,
|
|
73
|
+
title: item.title,
|
|
74
|
+
date: item.date,
|
|
75
|
+
url: item.url,
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockApiGet } = vi.hoisted(() => ({
|
|
4
|
+
mockApiGet: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock('./utils.js', () => ({
|
|
8
|
+
apiGet: mockApiGet,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { getRegistry } from '../../registry.js';
|
|
12
|
+
import './comments.js';
|
|
13
|
+
|
|
14
|
+
describe('bilibili comments', () => {
|
|
15
|
+
const command = getRegistry().get('bilibili/comments');
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
mockApiGet.mockReset();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('resolves bvid to aid and fetches replies', async () => {
|
|
22
|
+
mockApiGet
|
|
23
|
+
.mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
|
|
24
|
+
.mockResolvedValueOnce({
|
|
25
|
+
data: {
|
|
26
|
+
replies: [
|
|
27
|
+
{
|
|
28
|
+
member: { uname: 'Alice' },
|
|
29
|
+
content: { message: 'Great video!' },
|
|
30
|
+
like: 42,
|
|
31
|
+
rcount: 3,
|
|
32
|
+
ctime: 1700000000,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });
|
|
39
|
+
|
|
40
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
|
|
41
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
|
|
42
|
+
params: { oid: 12345, type: 1, mode: 3, ps: 5 },
|
|
43
|
+
signed: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual([
|
|
47
|
+
{
|
|
48
|
+
rank: 1,
|
|
49
|
+
author: 'Alice',
|
|
50
|
+
text: 'Great video!',
|
|
51
|
+
likes: 42,
|
|
52
|
+
replies: 3,
|
|
53
|
+
time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws when aid cannot be resolved', async () => {
|
|
59
|
+
mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
|
|
60
|
+
|
|
61
|
+
await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
|
|
62
|
+
'Cannot resolve aid for bvid: BV_invalid',
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns empty array when replies is missing', async () => {
|
|
67
|
+
mockApiGet
|
|
68
|
+
.mockResolvedValueOnce({ data: { aid: 99 } })
|
|
69
|
+
.mockResolvedValueOnce({ data: {} }); // no replies key
|
|
70
|
+
|
|
71
|
+
const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
|
|
72
|
+
expect(result).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('caps limit at 50', async () => {
|
|
76
|
+
mockApiGet
|
|
77
|
+
.mockResolvedValueOnce({ data: { aid: 1 } })
|
|
78
|
+
.mockResolvedValueOnce({ data: { replies: [] } });
|
|
79
|
+
|
|
80
|
+
await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });
|
|
81
|
+
|
|
82
|
+
expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
|
|
83
|
+
params: { oid: 1, type: 1, mode: 3, ps: 50 },
|
|
84
|
+
signed: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('collapses newlines in comment text', async () => {
|
|
89
|
+
mockApiGet
|
|
90
|
+
.mockResolvedValueOnce({ data: { aid: 1 } })
|
|
91
|
+
.mockResolvedValueOnce({
|
|
92
|
+
data: {
|
|
93
|
+
replies: [
|
|
94
|
+
{ member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
|
|
100
|
+
expect(result[0].text).toBe('line1 line2 line3');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilibili comments — fetches top-level replies via the official API with WBI signing.
|
|
3
|
+
* Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cli, Strategy } from '../../registry.js';
|
|
7
|
+
import { apiGet } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'bilibili',
|
|
11
|
+
name: 'comments',
|
|
12
|
+
description: '获取 B站视频评论(使用官方 API + WBI 签名)',
|
|
13
|
+
domain: 'www.bilibili.com',
|
|
14
|
+
strategy: Strategy.COOKIE,
|
|
15
|
+
args: [
|
|
16
|
+
{ name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
|
|
17
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
|
|
18
|
+
],
|
|
19
|
+
columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
|
|
20
|
+
func: async (page, kwargs) => {
|
|
21
|
+
const bvid = String(kwargs.bvid).trim();
|
|
22
|
+
const limit = Math.min(Number(kwargs.limit) || 20, 50);
|
|
23
|
+
|
|
24
|
+
// Resolve bvid → aid (required by reply API)
|
|
25
|
+
const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
|
|
26
|
+
const aid = view?.data?.aid;
|
|
27
|
+
if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
|
|
28
|
+
|
|
29
|
+
const payload = await apiGet(page, '/x/v2/reply/main', {
|
|
30
|
+
params: { oid: aid, type: 1, mode: 3, ps: limit },
|
|
31
|
+
signed: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const replies: any[] = payload?.data?.replies ?? [];
|
|
35
|
+
return replies.slice(0, limit).map((r: any, i: number) => ({
|
|
36
|
+
rank: i + 1,
|
|
37
|
+
author: r.member?.uname ?? '',
|
|
38
|
+
text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
|
|
39
|
+
likes: r.like ?? 0,
|
|
40
|
+
replies: r.rcount ?? 0,
|
|
41
|
+
time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
|
|
42
|
+
}));
|
|
43
|
+
},
|
|
44
|
+
});
|