@jackwener/opencli 1.7.21 → 1.8.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/README.md +31 -148
- package/README.zh-CN.md +38 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/boss/utils.js +17 -1
- package/clis/boss/utils.test.js +34 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/comments.js +3 -4
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/envelope.test.js +85 -0
- package/clis/weibo/favorites.js +4 -4
- package/clis/weibo/feed.js +3 -5
- package/clis/weibo/hot.js +3 -4
- package/clis/weibo/me.js +3 -5
- package/clis/weibo/post.js +3 -4
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/search.js +4 -3
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weibo/user.js +3 -4
- package/clis/weibo/utils.js +34 -5
- package/clis/weibo/utils.test.js +36 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +20 -0
- package/dist/src/external.d.ts +6 -1
- package/dist/src/external.test.js +19 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
package/dist/src/errors.js
CHANGED
|
@@ -97,6 +97,28 @@ export class PluginError extends CliError {
|
|
|
97
97
|
super('PLUGIN', message, hint, EXIT_CODES.GENERIC_ERROR);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Thrown when a JSON endpoint returns HTML instead of JSON — typically a login
|
|
102
|
+
* wall, rate-limit page, or WAF challenge. Surfaced as a structured error so
|
|
103
|
+
* callers can show "re-login or wait out the rate limit" guidance instead of
|
|
104
|
+
* the cryptic `SyntaxError: Unexpected token '<', "<!DOCTYPE "...` that a naive
|
|
105
|
+
* JSON.parse on an HTML body produces.
|
|
106
|
+
*
|
|
107
|
+
* `bodyPreview` is the first 100 chars of the response body (after trimming
|
|
108
|
+
* leading whitespace) — useful for logs / debugging without dumping the full
|
|
109
|
+
* page.
|
|
110
|
+
*/
|
|
111
|
+
export class LoginWallError extends CliError {
|
|
112
|
+
status;
|
|
113
|
+
url;
|
|
114
|
+
bodyPreview;
|
|
115
|
+
constructor(message, status, url, bodyPreview, hint) {
|
|
116
|
+
super('LOGIN_WALL', message, hint ?? 'The server returned an HTML page instead of JSON — likely a login wall, rate limit, or WAF challenge. Try re-logging in via your browser, or wait a few minutes before retrying.', EXIT_CODES.NOPERM);
|
|
117
|
+
this.status = status;
|
|
118
|
+
this.url = url;
|
|
119
|
+
this.bodyPreview = bodyPreview;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
100
122
|
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
101
123
|
/** Extract a human-readable message from an unknown caught value. */
|
|
102
124
|
export function getErrorMessage(error) {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
- name: ntn
|
|
18
18
|
binary: ntn
|
|
19
|
+
package: notion
|
|
19
20
|
description: "Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments"
|
|
20
21
|
homepage: "https://ntn.dev"
|
|
21
22
|
tags: [notion, notes, knowledge, productivity]
|
|
@@ -36,8 +37,18 @@
|
|
|
36
37
|
install:
|
|
37
38
|
default: "npm install -g @larksuite/cli"
|
|
38
39
|
|
|
40
|
+
- name: longbridge
|
|
41
|
+
binary: longbridge
|
|
42
|
+
description: "Longbridge CLI — AI-native market data, account management and trading commands for Longbridge OpenAPI"
|
|
43
|
+
homepage: "https://open.longbridge.com/zh-CN/docs/cli/"
|
|
44
|
+
tags: [longbridge, finance, trading, market-data, openapi, ai-agent]
|
|
45
|
+
install:
|
|
46
|
+
mac: "brew install --cask longbridge/tap/longbridge-terminal"
|
|
47
|
+
windows: "scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json"
|
|
48
|
+
|
|
39
49
|
- name: dws
|
|
40
50
|
binary: dws
|
|
51
|
+
package: DingTalk Workspace
|
|
41
52
|
description: "DingTalk Workspace CLI — messages, docs, calendar, contacts and more for humans and AI agents"
|
|
42
53
|
homepage: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
|
43
54
|
tags: [dingtalk, collaboration, productivity, ai-agent]
|
|
@@ -47,6 +58,7 @@
|
|
|
47
58
|
|
|
48
59
|
- name: wecom-cli
|
|
49
60
|
binary: wecom-cli
|
|
61
|
+
package: 企业微信
|
|
50
62
|
description: "WeCom/企业微信 CLI — contacts, todos, meetings, messages, calendar, docs and smart sheets for AI agents"
|
|
51
63
|
homepage: "https://github.com/WecomTeam/wecom-cli"
|
|
52
64
|
tags: [wecom, wechat-work, collaboration, productivity, ai-agent]
|
|
@@ -87,3 +99,11 @@
|
|
|
87
99
|
tags: [wechat, messaging, search, export, ai-agent]
|
|
88
100
|
install:
|
|
89
101
|
default: "npm install -g @jackwener/wx-cli"
|
|
102
|
+
|
|
103
|
+
- name: wrangler
|
|
104
|
+
binary: wrangler
|
|
105
|
+
description: "Cloudflare Wrangler — deploy Workers, manage R2/D1/KV, publish Pages"
|
|
106
|
+
homepage: "https://developers.cloudflare.com/workers/wrangler/"
|
|
107
|
+
tags: [cloudflare, workers, serverless, edge, deployment]
|
|
108
|
+
install:
|
|
109
|
+
default: "npm install -g wrangler"
|
package/dist/src/external.d.ts
CHANGED
|
@@ -8,7 +8,12 @@ export interface ExternalCliConfig {
|
|
|
8
8
|
/** User-facing OpenCLI subcommand and, by default, the executable name. */
|
|
9
9
|
name: string;
|
|
10
10
|
binary: string;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Display alias rendered alongside `name` in help/listing as `name(package)`.
|
|
13
|
+
* Use either the upstream distribution/project name (e.g. `tg-cli`, `discord-cli`)
|
|
14
|
+
* or a human-readable brand label (e.g. `notion`, `企业微信`) when the bare
|
|
15
|
+
* executable name is ambiguous.
|
|
16
|
+
*/
|
|
12
17
|
package?: string;
|
|
13
18
|
description?: string;
|
|
14
19
|
homepage?: string;
|
|
@@ -44,6 +44,21 @@ describe('parseCommand', () => {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
|
+
it('registers Longbridge with safe package-manager installers only', () => {
|
|
48
|
+
const raw = fs.readFileSync(path.join(__dirname, 'external-clis.yaml'), 'utf8');
|
|
49
|
+
const entries = (yaml.load(raw) || []);
|
|
50
|
+
const longbridge = entries.find((entry) => entry.name === 'longbridge');
|
|
51
|
+
expect(longbridge).toMatchObject({
|
|
52
|
+
binary: 'longbridge',
|
|
53
|
+
homepage: 'https://open.longbridge.com/zh-CN/docs/cli/',
|
|
54
|
+
install: {
|
|
55
|
+
mac: 'brew install --cask longbridge/tap/longbridge-terminal',
|
|
56
|
+
windows: 'scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(longbridge?.install?.linux).toBeUndefined();
|
|
60
|
+
expect(longbridge?.install?.default).toBeUndefined();
|
|
61
|
+
});
|
|
47
62
|
});
|
|
48
63
|
describe('formatExternalCliLabel', () => {
|
|
49
64
|
it('shows the package name when the executable name differs', () => {
|
|
@@ -52,6 +67,10 @@ describe('formatExternalCliLabel', () => {
|
|
|
52
67
|
it('keeps the label compact when package and name match', () => {
|
|
53
68
|
expect(formatExternalCliLabel({ name: 'docker', binary: 'docker', package: 'docker' })).toBe('docker');
|
|
54
69
|
});
|
|
70
|
+
it('renders a human-readable brand alias for ambiguous executable names', () => {
|
|
71
|
+
expect(formatExternalCliLabel({ name: 'ntn', binary: 'ntn', package: 'notion' })).toBe('ntn(notion)');
|
|
72
|
+
expect(formatExternalCliLabel({ name: 'wecom-cli', binary: 'wecom-cli', package: '企业微信' })).toBe('wecom-cli(企业微信)');
|
|
73
|
+
});
|
|
55
74
|
});
|
|
56
75
|
describe('installExternalCli', () => {
|
|
57
76
|
const cli = {
|
package/dist/src/main.js
CHANGED
|
@@ -143,9 +143,21 @@ if (getCompIdx !== -1) {
|
|
|
143
143
|
// can't combine a parent positional with subcommand dispatch) sees the internal
|
|
144
144
|
// `--session <name>` flag form. Also refuses the retired `opencli browser
|
|
145
145
|
// --session foo ...` user form with a friendly usage error.
|
|
146
|
-
const { rewriteBrowserArgv, BrowserSessionArgvError } = await import('./cli-argv-preprocess.js');
|
|
146
|
+
const { rewriteBrowserArgv, BrowserSessionArgvError, escapeLeadingDashPositional } = await import('./cli-argv-preprocess.js');
|
|
147
147
|
try {
|
|
148
|
-
|
|
148
|
+
let rewritten = rewriteBrowserArgv(process.argv.slice(2));
|
|
149
|
+
// Insert a `--` separator before a required positional whose value starts
|
|
150
|
+
// with `-` (e.g. BOSS 直聘 securityId tokens; #1160). Skipped when the
|
|
151
|
+
// manifest is unavailable so the user-cli / dev paths still work.
|
|
152
|
+
try {
|
|
153
|
+
const manifestPath = getCliManifestPath(BUILTIN_CLIS);
|
|
154
|
+
if (fs.existsSync(manifestPath)) {
|
|
155
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
156
|
+
if (Array.isArray(manifest))
|
|
157
|
+
rewritten = escapeLeadingDashPositional(rewritten, manifest);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* manifest unavailable; skip the dash escape */ }
|
|
149
161
|
process.argv.splice(2, process.argv.length - 2, ...rewritten);
|
|
150
162
|
}
|
|
151
163
|
catch (err) {
|
package/dist/src/utils.d.ts
CHANGED
|
@@ -12,3 +12,46 @@ export declare function sleep(ms: number): Promise<void>;
|
|
|
12
12
|
export declare function saveBase64ToFile(base64: string, filePath: string): Promise<void>;
|
|
13
13
|
export declare function createMarkdownConverter(configure?: (td: TurndownService) => void): TurndownService;
|
|
14
14
|
export declare function htmlToMarkdown(value: string, configure?: (td: TurndownService) => void): string;
|
|
15
|
+
/** Sentinel shape that browser-side `fetch` wrappers return when they detect an
|
|
16
|
+
* HTML response in place of JSON. Kept as a plain object so it survives the
|
|
17
|
+
* `page.evaluate` JSON round-trip. */
|
|
18
|
+
export interface LoginWallSignal {
|
|
19
|
+
__loginWall: true;
|
|
20
|
+
status: number;
|
|
21
|
+
url: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
bodyPreview: string;
|
|
24
|
+
}
|
|
25
|
+
/** Throw a `LoginWallError` if `value` is the sentinel returned by the
|
|
26
|
+
* browser-side sniffer; otherwise return `value` unchanged. Adapters that
|
|
27
|
+
* fetch from inside `page.evaluate` call this on the result before consuming
|
|
28
|
+
* it, so the Node-side gets a typed error instead of a JSON-parse stack
|
|
29
|
+
* trace. */
|
|
30
|
+
export declare function throwIfLoginWall<T>(value: T, opts?: {
|
|
31
|
+
url?: string;
|
|
32
|
+
}): T;
|
|
33
|
+
/** Parse a `Response` body as JSON, throwing `LoginWallError` if the server
|
|
34
|
+
* returned an HTML page (login wall / rate limit / WAF interception) instead
|
|
35
|
+
* of the expected JSON. Catches the common case of `<!DOCTYPE` or `<html`
|
|
36
|
+
* leading the body \u2014 naive `JSON.parse` on these gives a cryptic
|
|
37
|
+
* `SyntaxError` that callers can't distinguish from "real" malformed JSON.
|
|
38
|
+
*
|
|
39
|
+
* On real (non-HTML) JSON-parse failures, throws a regular `Error` with a
|
|
40
|
+
* body preview attached so debugging doesn't require a packet capture. */
|
|
41
|
+
export declare function parseJsonOrThrowLoginWall(response: Response, opts?: {
|
|
42
|
+
url?: string;
|
|
43
|
+
}): Promise<unknown>;
|
|
44
|
+
/** Browser-side JS source fragment (as a string) that performs a `fetch` and
|
|
45
|
+
* either returns the parsed JSON body or a `LoginWallSignal` sentinel when
|
|
46
|
+
* the response is HTML. Intended to be embedded inside an adapter's
|
|
47
|
+
* `page.evaluate` block.
|
|
48
|
+
*
|
|
49
|
+
* Usage from inside a `page.evaluate` IIFE:
|
|
50
|
+
*
|
|
51
|
+
* ${BROWSER_JSON_SNIFF_FN}
|
|
52
|
+
* const res = await fetchJsonOrLoginWall('/some/path.json', { credentials: 'include' });
|
|
53
|
+
* // res is the parsed JSON object, OR { __loginWall: true, status, url, contentType, bodyPreview }
|
|
54
|
+
* return res;
|
|
55
|
+
*
|
|
56
|
+
* The Node side then calls `throwIfLoginWall(res, { url })` on the result. */
|
|
57
|
+
export declare const BROWSER_JSON_SNIFF_FN = "\nasync function fetchJsonOrLoginWall(input, init) {\n const r = await fetch(input, init);\n const contentType = r.headers.get('content-type') || '';\n const text = await r.text();\n const trimmed = text.replace(/^\\s+/, '');\n const looksLikeHtml =\n contentType.toLowerCase().includes('text/html')\n || trimmed.startsWith('<!DOCTYPE')\n || trimmed.startsWith('<!doctype')\n || trimmed.startsWith('<html')\n || trimmed.startsWith('<HTML');\n if (looksLikeHtml) {\n return {\n __loginWall: true,\n status: r.status,\n url: r.url || (typeof input === 'string' ? input : ''),\n contentType,\n bodyPreview: trimmed.slice(0, 100),\n };\n }\n if (!r.ok) {\n return { error: r.status };\n }\n try {\n return JSON.parse(text);\n } catch (err) {\n throw new Error(\n 'JSON parse failed (status=' + r.status + ', body[0..50]=' + JSON.stringify(trimmed.slice(0, 50)) + '): '\n + (err && err.message ? err.message : String(err))\n );\n }\n}\n";
|
package/dist/src/utils.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as fs from 'node:fs';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import TurndownService from 'turndown';
|
|
7
|
+
import { LoginWallError } from './errors.js';
|
|
7
8
|
/** Type guard: checks if a value is a non-null, non-array object. */
|
|
8
9
|
export function isRecord(value) {
|
|
9
10
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -53,3 +54,99 @@ export function htmlToMarkdown(value, configure) {
|
|
|
53
54
|
.replace(/[ \t]+$/gm, '')
|
|
54
55
|
.trim();
|
|
55
56
|
}
|
|
57
|
+
function isLoginWallSignal(v) {
|
|
58
|
+
return (typeof v === 'object'
|
|
59
|
+
&& v !== null
|
|
60
|
+
&& v.__loginWall === true
|
|
61
|
+
&& typeof v.status === 'number');
|
|
62
|
+
}
|
|
63
|
+
/** Throw a `LoginWallError` if `value` is the sentinel returned by the
|
|
64
|
+
* browser-side sniffer; otherwise return `value` unchanged. Adapters that
|
|
65
|
+
* fetch from inside `page.evaluate` call this on the result before consuming
|
|
66
|
+
* it, so the Node-side gets a typed error instead of a JSON-parse stack
|
|
67
|
+
* trace. */
|
|
68
|
+
export function throwIfLoginWall(value, opts = {}) {
|
|
69
|
+
if (isLoginWallSignal(value)) {
|
|
70
|
+
throw new LoginWallError(`Server returned HTML instead of JSON (status=${value.status}). `
|
|
71
|
+
+ `Likely a login wall, rate limit, or WAF challenge.`, value.status, opts.url || value.url || '', value.bodyPreview);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
/** Parse a `Response` body as JSON, throwing `LoginWallError` if the server
|
|
76
|
+
* returned an HTML page (login wall / rate limit / WAF interception) instead
|
|
77
|
+
* of the expected JSON. Catches the common case of `<!DOCTYPE` or `<html`
|
|
78
|
+
* leading the body \u2014 naive `JSON.parse` on these gives a cryptic
|
|
79
|
+
* `SyntaxError` that callers can't distinguish from "real" malformed JSON.
|
|
80
|
+
*
|
|
81
|
+
* On real (non-HTML) JSON-parse failures, throws a regular `Error` with a
|
|
82
|
+
* body preview attached so debugging doesn't require a packet capture. */
|
|
83
|
+
export async function parseJsonOrThrowLoginWall(response, opts = {}) {
|
|
84
|
+
const contentType = response.headers.get('content-type') || '';
|
|
85
|
+
const text = await response.text();
|
|
86
|
+
const trimmed = text.trimStart();
|
|
87
|
+
const looksLikeHtml = contentType.toLowerCase().includes('text/html')
|
|
88
|
+
|| trimmed.startsWith('<!DOCTYPE')
|
|
89
|
+
|| trimmed.startsWith('<!doctype')
|
|
90
|
+
|| trimmed.startsWith('<html')
|
|
91
|
+
|| trimmed.startsWith('<HTML');
|
|
92
|
+
if (looksLikeHtml) {
|
|
93
|
+
throw new LoginWallError(`Server returned HTML instead of JSON (status=${response.status}). `
|
|
94
|
+
+ `Likely a login wall, rate limit, or WAF challenge.`, response.status, opts.url || response.url || '', trimmed.slice(0, 100));
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(text);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
// Real malformed JSON \u2014 surface body preview alongside the parser message
|
|
101
|
+
// so we don't have to repro to know what came back.
|
|
102
|
+
throw new Error(`JSON parse failed (status=${response.status}, body[0..50]=${JSON.stringify(trimmed.slice(0, 50))}): `
|
|
103
|
+
+ (err instanceof Error ? err.message : String(err)));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Browser-side JS source fragment (as a string) that performs a `fetch` and
|
|
107
|
+
* either returns the parsed JSON body or a `LoginWallSignal` sentinel when
|
|
108
|
+
* the response is HTML. Intended to be embedded inside an adapter's
|
|
109
|
+
* `page.evaluate` block.
|
|
110
|
+
*
|
|
111
|
+
* Usage from inside a `page.evaluate` IIFE:
|
|
112
|
+
*
|
|
113
|
+
* ${BROWSER_JSON_SNIFF_FN}
|
|
114
|
+
* const res = await fetchJsonOrLoginWall('/some/path.json', { credentials: 'include' });
|
|
115
|
+
* // res is the parsed JSON object, OR { __loginWall: true, status, url, contentType, bodyPreview }
|
|
116
|
+
* return res;
|
|
117
|
+
*
|
|
118
|
+
* The Node side then calls `throwIfLoginWall(res, { url })` on the result. */
|
|
119
|
+
export const BROWSER_JSON_SNIFF_FN = `
|
|
120
|
+
async function fetchJsonOrLoginWall(input, init) {
|
|
121
|
+
const r = await fetch(input, init);
|
|
122
|
+
const contentType = r.headers.get('content-type') || '';
|
|
123
|
+
const text = await r.text();
|
|
124
|
+
const trimmed = text.replace(/^\\s+/, '');
|
|
125
|
+
const looksLikeHtml =
|
|
126
|
+
contentType.toLowerCase().includes('text/html')
|
|
127
|
+
|| trimmed.startsWith('<!DOCTYPE')
|
|
128
|
+
|| trimmed.startsWith('<!doctype')
|
|
129
|
+
|| trimmed.startsWith('<html')
|
|
130
|
+
|| trimmed.startsWith('<HTML');
|
|
131
|
+
if (looksLikeHtml) {
|
|
132
|
+
return {
|
|
133
|
+
__loginWall: true,
|
|
134
|
+
status: r.status,
|
|
135
|
+
url: r.url || (typeof input === 'string' ? input : ''),
|
|
136
|
+
contentType,
|
|
137
|
+
bodyPreview: trimmed.slice(0, 100),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (!r.ok) {
|
|
141
|
+
return { error: r.status };
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(text);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'JSON parse failed (status=' + r.status + ', body[0..50]=' + JSON.stringify(trimmed.slice(0, 50)) + '): '
|
|
148
|
+
+ (err && err.message ? err.message : String(err))
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseJsonOrThrowLoginWall, throwIfLoginWall, BROWSER_JSON_SNIFF_FN, } from './utils.js';
|
|
3
|
+
import { LoginWallError } from './errors.js';
|
|
4
|
+
function makeResponse(body, opts = {}) {
|
|
5
|
+
return new Response(body, {
|
|
6
|
+
status: opts.status ?? 200,
|
|
7
|
+
headers: { 'content-type': opts.contentType ?? 'application/json' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
describe('parseJsonOrThrowLoginWall', () => {
|
|
11
|
+
it('returns parsed JSON on a normal application/json response', async () => {
|
|
12
|
+
const res = makeResponse(JSON.stringify({ hello: 'world', n: 42 }));
|
|
13
|
+
const parsed = await parseJsonOrThrowLoginWall(res);
|
|
14
|
+
expect(parsed).toEqual({ hello: 'world', n: 42 });
|
|
15
|
+
});
|
|
16
|
+
it('throws LoginWallError when content-type is text/html', async () => {
|
|
17
|
+
const res = makeResponse('<html><body>Please log in</body></html>', {
|
|
18
|
+
status: 401,
|
|
19
|
+
contentType: 'text/html; charset=utf-8',
|
|
20
|
+
});
|
|
21
|
+
await expect(parseJsonOrThrowLoginWall(res, { url: 'https://example.com/api' })).rejects.toMatchObject({
|
|
22
|
+
name: 'LoginWallError',
|
|
23
|
+
code: 'LOGIN_WALL',
|
|
24
|
+
status: 401,
|
|
25
|
+
url: 'https://example.com/api',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('throws LoginWallError when body starts with <!DOCTYPE even if content-type is missing', async () => {
|
|
29
|
+
// application/json content-type but body is actually HTML — WAFs sometimes do this
|
|
30
|
+
const res = new Response('<!DOCTYPE html><html><head></head><body>blocked</body></html>', {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: { 'content-type': 'application/json' },
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
await parseJsonOrThrowLoginWall(res, { url: 'https://x.com/api/list' });
|
|
36
|
+
throw new Error('expected throw');
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
expect(err).toBeInstanceOf(LoginWallError);
|
|
40
|
+
const e = err;
|
|
41
|
+
expect(e.status).toBe(200);
|
|
42
|
+
expect(e.url).toBe('https://x.com/api/list');
|
|
43
|
+
expect(e.bodyPreview.startsWith('<!DOCTYPE')).toBe(true);
|
|
44
|
+
expect(e.exitCode).toBe(77); // NOPERM
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it('throws LoginWallError when body starts with <html (no DOCTYPE)', async () => {
|
|
48
|
+
const res = new Response('<html lang="en"><body>nope</body></html>', {
|
|
49
|
+
status: 429,
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
});
|
|
52
|
+
await expect(parseJsonOrThrowLoginWall(res)).rejects.toBeInstanceOf(LoginWallError);
|
|
53
|
+
});
|
|
54
|
+
it('throws LoginWallError when body has leading whitespace before <!DOCTYPE', async () => {
|
|
55
|
+
const res = new Response(' \n\n<!DOCTYPE html><html></html>', {
|
|
56
|
+
status: 200,
|
|
57
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
58
|
+
});
|
|
59
|
+
await expect(parseJsonOrThrowLoginWall(res)).rejects.toBeInstanceOf(LoginWallError);
|
|
60
|
+
});
|
|
61
|
+
it('throws a regular Error (NOT LoginWallError) on real malformed JSON', async () => {
|
|
62
|
+
const res = makeResponse('{not really json,');
|
|
63
|
+
try {
|
|
64
|
+
await parseJsonOrThrowLoginWall(res);
|
|
65
|
+
throw new Error('expected throw');
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
expect(err).toBeInstanceOf(Error);
|
|
69
|
+
expect(err).not.toBeInstanceOf(LoginWallError);
|
|
70
|
+
const msg = err.message;
|
|
71
|
+
expect(msg).toContain('JSON parse failed');
|
|
72
|
+
expect(msg).toContain('body[0..50]=');
|
|
73
|
+
// body preview must be present so debugging doesn't require a repro
|
|
74
|
+
expect(msg).toContain('not really json');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
it('preserves first 100 chars of body in bodyPreview', async () => {
|
|
78
|
+
const longHtml = '<!DOCTYPE html><html><head><title>' + 'x'.repeat(500) + '</title></head></html>';
|
|
79
|
+
const res = new Response(longHtml, { status: 403, headers: { 'content-type': 'text/html' } });
|
|
80
|
+
try {
|
|
81
|
+
await parseJsonOrThrowLoginWall(res);
|
|
82
|
+
throw new Error('expected throw');
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const e = err;
|
|
86
|
+
expect(e.bodyPreview.length).toBe(100);
|
|
87
|
+
expect(e.bodyPreview.startsWith('<!DOCTYPE')).toBe(true);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('throwIfLoginWall', () => {
|
|
92
|
+
it('returns the value unchanged when it is not a login-wall sentinel', () => {
|
|
93
|
+
const data = { data: { foo: 'bar' } };
|
|
94
|
+
expect(throwIfLoginWall(data)).toBe(data);
|
|
95
|
+
expect(throwIfLoginWall('hello')).toBe('hello');
|
|
96
|
+
expect(throwIfLoginWall(null)).toBe(null);
|
|
97
|
+
expect(throwIfLoginWall(undefined)).toBe(undefined);
|
|
98
|
+
expect(throwIfLoginWall(42)).toBe(42);
|
|
99
|
+
expect(throwIfLoginWall([1, 2, 3])).toEqual([1, 2, 3]);
|
|
100
|
+
});
|
|
101
|
+
it('returns objects that happen to have unrelated __loginWall-ish keys unchanged', () => {
|
|
102
|
+
// Must NOT trigger on partial matches — sentinel needs __loginWall === true AND numeric status
|
|
103
|
+
expect(throwIfLoginWall({ __loginWall: false })).toEqual({ __loginWall: false });
|
|
104
|
+
expect(throwIfLoginWall({ __loginWall: true })).toEqual({ __loginWall: true }); // missing status → not a real sentinel
|
|
105
|
+
});
|
|
106
|
+
it('throws LoginWallError when value is the browser-side sentinel', () => {
|
|
107
|
+
const signal = {
|
|
108
|
+
__loginWall: true,
|
|
109
|
+
status: 403,
|
|
110
|
+
url: 'https://x.com/i/api/graphql/...',
|
|
111
|
+
contentType: 'text/html',
|
|
112
|
+
bodyPreview: '<!DOCTYPE html><html><head><title>Login</title>',
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
throwIfLoginWall(signal);
|
|
116
|
+
throw new Error('expected throw');
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
expect(err).toBeInstanceOf(LoginWallError);
|
|
120
|
+
const e = err;
|
|
121
|
+
expect(e.status).toBe(403);
|
|
122
|
+
expect(e.url).toBe('https://x.com/i/api/graphql/...');
|
|
123
|
+
expect(e.bodyPreview).toContain('<!DOCTYPE');
|
|
124
|
+
expect(e.code).toBe('LOGIN_WALL');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
it('opts.url overrides the URL embedded in the sentinel', () => {
|
|
128
|
+
const signal = {
|
|
129
|
+
__loginWall: true,
|
|
130
|
+
status: 401,
|
|
131
|
+
url: '',
|
|
132
|
+
contentType: 'text/html',
|
|
133
|
+
bodyPreview: '<html>',
|
|
134
|
+
};
|
|
135
|
+
try {
|
|
136
|
+
throwIfLoginWall(signal, { url: 'https://override.example.com/api' });
|
|
137
|
+
throw new Error('expected throw');
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
expect(err.url).toBe('https://override.example.com/api');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
describe('BROWSER_JSON_SNIFF_FN', () => {
|
|
145
|
+
it('is a non-empty string that defines fetchJsonOrLoginWall', () => {
|
|
146
|
+
expect(typeof BROWSER_JSON_SNIFF_FN).toBe('string');
|
|
147
|
+
expect(BROWSER_JSON_SNIFF_FN).toContain('fetchJsonOrLoginWall');
|
|
148
|
+
expect(BROWSER_JSON_SNIFF_FN).toContain('__loginWall');
|
|
149
|
+
});
|
|
150
|
+
it('can be evaluated as JS without syntax errors', () => {
|
|
151
|
+
// We can't run the actual fetch path here (no Response polyfill loop), but
|
|
152
|
+
// we CAN confirm the fragment parses cleanly when embedded inside an async IIFE.
|
|
153
|
+
expect(() => new Function(`(async () => { ${BROWSER_JSON_SNIFF_FN} })`)).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jackwener/opencli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"dev": "tsx src/main.ts",
|
|
43
43
|
"dev:bun": "bun src/main.ts",
|
|
44
44
|
"build": "npm run clean-dist && npm run copy-yaml && npm run build-manifest",
|
|
45
|
-
"prebuild-manifest": "tsc --build",
|
|
45
|
+
"prebuild-manifest": "tsc --build && node -e \"require('fs').chmodSync('dist/src/main.js', 0o755)\"",
|
|
46
46
|
"build-manifest": "tsx src/build-manifest.ts",
|
|
47
47
|
"clean-dist": "node scripts/clean-dist.cjs",
|
|
48
48
|
"copy-yaml": "node scripts/copy-yaml.cjs",
|
|
@@ -98,5 +98,11 @@
|
|
|
98
98
|
"typescript": "^6.0.2",
|
|
99
99
|
"vitepress": "^1.6.4",
|
|
100
100
|
"vitest": "^4.1.0"
|
|
101
|
+
},
|
|
102
|
+
"overrides": {
|
|
103
|
+
"postcss": "^8.5.10",
|
|
104
|
+
"vitepress": {
|
|
105
|
+
"vite": "6.4.2"
|
|
106
|
+
}
|
|
101
107
|
}
|
|
102
108
|
}
|
|
@@ -582,13 +582,6 @@
|
|
|
582
582
|
"voteCandidates"
|
|
583
583
|
]
|
|
584
584
|
},
|
|
585
|
-
{
|
|
586
|
-
"command": "reddit/popular",
|
|
587
|
-
"file": "clis/reddit/popular.js",
|
|
588
|
-
"missing": [
|
|
589
|
-
"author"
|
|
590
|
-
]
|
|
591
|
-
},
|
|
592
585
|
{
|
|
593
586
|
"command": "tieba/search",
|
|
594
587
|
"file": "clis/tieba/search.js",
|
|
@@ -612,31 +605,6 @@
|
|
|
612
605
|
"name"
|
|
613
606
|
]
|
|
614
607
|
},
|
|
615
|
-
{
|
|
616
|
-
"command": "twitter/download",
|
|
617
|
-
"file": "clis/twitter/download.js",
|
|
618
|
-
"missing": [
|
|
619
|
-
"poster",
|
|
620
|
-
"url"
|
|
621
|
-
]
|
|
622
|
-
},
|
|
623
|
-
{
|
|
624
|
-
"command": "twitter/download",
|
|
625
|
-
"file": "clis/twitter/download.js",
|
|
626
|
-
"missing": [
|
|
627
|
-
"url"
|
|
628
|
-
]
|
|
629
|
-
},
|
|
630
|
-
{
|
|
631
|
-
"command": "twitter/list-add",
|
|
632
|
-
"file": "clis/twitter/list-add.js",
|
|
633
|
-
"missing": [
|
|
634
|
-
"method",
|
|
635
|
-
"ts",
|
|
636
|
-
"url",
|
|
637
|
-
"via"
|
|
638
|
-
]
|
|
639
|
-
},
|
|
640
608
|
{
|
|
641
609
|
"command": "twitter/list-remove",
|
|
642
610
|
"file": "clis/twitter/list-remove.js",
|
|
@@ -773,19 +741,6 @@
|
|
|
773
741
|
"url"
|
|
774
742
|
]
|
|
775
743
|
},
|
|
776
|
-
{
|
|
777
|
-
"command": "xianyu/chat",
|
|
778
|
-
"file": "clis/xianyu/chat.js",
|
|
779
|
-
"missing": [
|
|
780
|
-
"can_input",
|
|
781
|
-
"can_send",
|
|
782
|
-
"item_url",
|
|
783
|
-
"peer_masked_id",
|
|
784
|
-
"requiresAuth",
|
|
785
|
-
"title",
|
|
786
|
-
"visible_messages"
|
|
787
|
-
]
|
|
788
|
-
},
|
|
789
744
|
{
|
|
790
745
|
"command": "xianyu/item",
|
|
791
746
|
"file": "clis/xianyu/item.js",
|
|
@@ -945,13 +900,6 @@
|
|
|
945
900
|
"url"
|
|
946
901
|
]
|
|
947
902
|
},
|
|
948
|
-
{
|
|
949
|
-
"command": "zhihu/search",
|
|
950
|
-
"file": "clis/zhihu/search.js",
|
|
951
|
-
"missing": [
|
|
952
|
-
"excerpt"
|
|
953
|
-
]
|
|
954
|
-
},
|
|
955
903
|
{
|
|
956
904
|
"command": "zsxq/groups",
|
|
957
905
|
"file": "clis/zsxq/groups.js",
|