@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
package/src/browser/errors.ts
CHANGED
|
@@ -5,38 +5,37 @@
|
|
|
5
5
|
* The daemon architecture has a single failure mode: daemon not reachable or extension not connected.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { BrowserConnectError } from '../errors.js';
|
|
8
|
+
import { BrowserConnectError, type BrowserConnectKind } from '../errors.js';
|
|
9
9
|
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
10
10
|
|
|
11
|
-
export
|
|
11
|
+
// Re-export so callers don't need to import from two places
|
|
12
|
+
export type ConnectFailureKind = BrowserConnectKind;
|
|
12
13
|
|
|
13
14
|
export function formatBrowserConnectError(kind: ConnectFailureKind, detail?: string): BrowserConnectError {
|
|
14
15
|
switch (kind) {
|
|
15
16
|
case 'daemon-not-running':
|
|
16
17
|
return new BrowserConnectError(
|
|
17
|
-
'Cannot connect to opencli daemon.' +
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
' node dist/daemon.js\n' +
|
|
21
|
-
`Make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
|
18
|
+
'Cannot connect to opencli daemon.' + (detail ? `\n\n${detail}` : ''),
|
|
19
|
+
`The daemon should auto-start. If it keeps failing, make sure port ${DEFAULT_DAEMON_PORT} is available.`,
|
|
20
|
+
kind,
|
|
22
21
|
);
|
|
23
22
|
case 'extension-not-connected':
|
|
24
23
|
return new BrowserConnectError(
|
|
25
|
-
'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
' 1. Download from GitHub Releases\n' +
|
|
29
|
-
' 2. Open chrome://extensions/ → Enable Developer Mode\n' +
|
|
30
|
-
' 3. Click "Load unpacked" → select the extension folder\n' +
|
|
31
|
-
' 4. Make sure Chrome is running',
|
|
24
|
+
'Browser Bridge extension is not connected.' + (detail ? `\n\n${detail}` : ''),
|
|
25
|
+
'Install the extension from GitHub Releases, then reload.',
|
|
26
|
+
kind,
|
|
32
27
|
);
|
|
33
28
|
case 'command-failed':
|
|
34
29
|
return new BrowserConnectError(
|
|
35
30
|
`Browser command failed: ${detail ?? 'unknown error'}`,
|
|
31
|
+
undefined,
|
|
32
|
+
kind,
|
|
36
33
|
);
|
|
37
34
|
default:
|
|
38
35
|
return new BrowserConnectError(
|
|
39
36
|
detail ?? 'Failed to connect to browser',
|
|
37
|
+
undefined,
|
|
38
|
+
kind,
|
|
40
39
|
);
|
|
41
40
|
}
|
|
42
41
|
}
|
package/src/browser/mcp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import * as path from 'node:path';
|
|
8
8
|
import * as fs from 'node:fs';
|
|
9
9
|
import type { IPage } from '../types.js';
|
|
10
|
+
import type { IBrowserFactory } from '../runtime.js';
|
|
10
11
|
import { Page } from './page.js';
|
|
11
12
|
import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
|
12
13
|
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
@@ -18,7 +19,7 @@ export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing'
|
|
|
18
19
|
/**
|
|
19
20
|
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
|
20
21
|
*/
|
|
21
|
-
export class BrowserBridge {
|
|
22
|
+
export class BrowserBridge implements IBrowserFactory {
|
|
22
23
|
private _state: BrowserBridgeState = 'idle';
|
|
23
24
|
private _page: Page | null = null;
|
|
24
25
|
private _daemonProc: ChildProcess | null = null;
|
|
@@ -143,4 +143,27 @@ describe('manifest helper rules', () => {
|
|
|
143
143
|
modulePath: 'xueqiu/fund-holdings.js',
|
|
144
144
|
});
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
it('captures deprecated metadata for TS adapters', () => {
|
|
148
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-manifest-'));
|
|
149
|
+
tempDirs.push(dir);
|
|
150
|
+
const file = path.join(dir, 'legacy.ts');
|
|
151
|
+
fs.writeFileSync(file, `
|
|
152
|
+
import { cli } from '../../registry.js';
|
|
153
|
+
cli({
|
|
154
|
+
site: 'demo',
|
|
155
|
+
name: 'legacy',
|
|
156
|
+
description: 'legacy command',
|
|
157
|
+
deprecated: 'legacy is deprecated',
|
|
158
|
+
replacedBy: 'opencli demo new',
|
|
159
|
+
});
|
|
160
|
+
`);
|
|
161
|
+
|
|
162
|
+
expect(scanTs(file, 'demo')).toMatchObject({
|
|
163
|
+
site: 'demo',
|
|
164
|
+
name: 'legacy',
|
|
165
|
+
deprecated: 'legacy is deprecated',
|
|
166
|
+
replacedBy: 'opencli demo new',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
146
169
|
});
|
package/src/build-manifest.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface ManifestEntry {
|
|
|
38
38
|
columns?: string[];
|
|
39
39
|
pipeline?: Record<string, unknown>[];
|
|
40
40
|
timeout?: number;
|
|
41
|
+
deprecated?: boolean | string;
|
|
42
|
+
replacedBy?: string;
|
|
41
43
|
/** 'yaml' or 'ts' — determines how executeCommand loads the handler */
|
|
42
44
|
type: 'yaml' | 'ts';
|
|
43
45
|
/** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */
|
|
@@ -46,7 +48,7 @@ export interface ManifestEntry {
|
|
|
46
48
|
navigateBefore?: boolean | string;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
import type
|
|
51
|
+
import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
|
|
50
52
|
|
|
51
53
|
import { isRecord } from './utils.js';
|
|
52
54
|
|
|
@@ -173,20 +175,7 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
|
173
175
|
const strategy = strategyStr.toUpperCase();
|
|
174
176
|
const browser = cliDef.browser ?? (strategy !== 'PUBLIC');
|
|
175
177
|
|
|
176
|
-
const args
|
|
177
|
-
if (cliDef.args && typeof cliDef.args === 'object') {
|
|
178
|
-
for (const [argName, argDef] of Object.entries(cliDef.args)) {
|
|
179
|
-
args.push({
|
|
180
|
-
name: argName,
|
|
181
|
-
type: argDef?.type ?? 'str',
|
|
182
|
-
default: argDef?.default,
|
|
183
|
-
required: argDef?.required ?? false,
|
|
184
|
-
positional: argDef?.positional === true || undefined,
|
|
185
|
-
help: argDef?.description ?? argDef?.help ?? '',
|
|
186
|
-
choices: argDef?.choices,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
178
|
+
const args = parseYamlArgs(cliDef.args);
|
|
190
179
|
|
|
191
180
|
return {
|
|
192
181
|
site: cliDef.site ?? site,
|
|
@@ -199,6 +188,8 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
|
199
188
|
columns: cliDef.columns,
|
|
200
189
|
pipeline: cliDef.pipeline,
|
|
201
190
|
timeout: cliDef.timeout,
|
|
191
|
+
deprecated: (cliDef as Record<string, unknown>).deprecated as boolean | string | undefined,
|
|
192
|
+
replacedBy: (cliDef as Record<string, unknown>).replacedBy as string | undefined,
|
|
202
193
|
type: 'yaml',
|
|
203
194
|
navigateBefore: cliDef.navigateBefore,
|
|
204
195
|
};
|
|
@@ -269,6 +260,17 @@ export function scanTs(filePath: string, site: string): ManifestEntry | null {
|
|
|
269
260
|
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
|
|
270
261
|
}
|
|
271
262
|
|
|
263
|
+
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
|
|
264
|
+
if (deprecatedBoolMatch) {
|
|
265
|
+
entry.deprecated = deprecatedBoolMatch[1] === 'true';
|
|
266
|
+
} else {
|
|
267
|
+
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
268
|
+
if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
272
|
+
if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
|
|
273
|
+
|
|
272
274
|
return entry;
|
|
273
275
|
} catch (err) {
|
|
274
276
|
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
|
|
@@ -338,6 +340,29 @@ function main(): void {
|
|
|
338
340
|
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
|
|
339
341
|
const tsCount = manifest.filter(e => e.type === 'ts').length;
|
|
340
342
|
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
|
|
343
|
+
|
|
344
|
+
// Restore executable permissions on bin entries.
|
|
345
|
+
// tsc does not preserve the +x bit, so after a clean rebuild the CLI
|
|
346
|
+
// entry-point loses its executable permission, causing "Permission denied".
|
|
347
|
+
// See: https://github.com/jackwener/opencli/issues/446
|
|
348
|
+
if (process.platform !== 'win32') {
|
|
349
|
+
const pkgPath = path.resolve(__dirname, '..', 'package.json');
|
|
350
|
+
try {
|
|
351
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
352
|
+
const bins: Record<string, string> = typeof pkg.bin === 'string'
|
|
353
|
+
? { [pkg.name ?? 'cli']: pkg.bin }
|
|
354
|
+
: pkg.bin ?? {};
|
|
355
|
+
for (const binPath of Object.values(bins)) {
|
|
356
|
+
const abs = path.resolve(__dirname, '..', binPath);
|
|
357
|
+
if (fs.existsSync(abs)) {
|
|
358
|
+
fs.chmodSync(abs, 0o755);
|
|
359
|
+
console.log(`✅ Restored executable permission: ${binPath}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch {
|
|
363
|
+
// Best-effort; never break the build for a permission fix.
|
|
364
|
+
}
|
|
365
|
+
}
|
|
341
366
|
}
|
|
342
367
|
|
|
343
368
|
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
package/src/capabilityRouting.ts
CHANGED
package/src/cli.ts
CHANGED
|
@@ -258,9 +258,17 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
258
258
|
const { installPlugin } = await import('./plugin.js');
|
|
259
259
|
const { discoverPlugins } = await import('./discovery.js');
|
|
260
260
|
try {
|
|
261
|
-
const
|
|
261
|
+
const result = installPlugin(source);
|
|
262
262
|
await discoverPlugins();
|
|
263
|
-
|
|
263
|
+
if (Array.isArray(result)) {
|
|
264
|
+
if (result.length === 0) {
|
|
265
|
+
console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
|
|
266
|
+
} else {
|
|
267
|
+
console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
|
|
271
|
+
}
|
|
264
272
|
} catch (err) {
|
|
265
273
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
266
274
|
process.exitCode = 1;
|
|
@@ -368,12 +376,36 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
368
376
|
console.log();
|
|
369
377
|
console.log(chalk.bold(' Installed plugins'));
|
|
370
378
|
console.log();
|
|
379
|
+
|
|
380
|
+
// Group by monorepo
|
|
381
|
+
const standalone = plugins.filter((p) => !p.monorepoName);
|
|
382
|
+
const monoGroups = new Map<string, typeof plugins>();
|
|
371
383
|
for (const p of plugins) {
|
|
384
|
+
if (!p.monorepoName) continue;
|
|
385
|
+
const g = monoGroups.get(p.monorepoName) ?? [];
|
|
386
|
+
g.push(p);
|
|
387
|
+
monoGroups.set(p.monorepoName, g);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (const p of standalone) {
|
|
372
391
|
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
|
392
|
+
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
|
373
393
|
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
|
374
394
|
const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
|
|
375
|
-
console.log(` ${chalk.cyan(p.name)}${version}${cmds}${src}`);
|
|
395
|
+
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
|
|
376
396
|
}
|
|
397
|
+
|
|
398
|
+
for (const [mono, group] of monoGroups) {
|
|
399
|
+
console.log();
|
|
400
|
+
console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
|
|
401
|
+
for (const p of group) {
|
|
402
|
+
const version = p.version ? chalk.green(` @${p.version}`) : '';
|
|
403
|
+
const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
|
|
404
|
+
const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
|
|
405
|
+
console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
377
409
|
console.log();
|
|
378
410
|
console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
|
|
379
411
|
console.log();
|
|
@@ -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
|
+
});
|