@sovovs/bycli 2.1.28 → 2.1.29

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/cli-manifest.json CHANGED
@@ -11320,6 +11320,221 @@
11320
11320
  "modulePath": "gitee/user.js",
11321
11321
  "sourceFile": "gitee/user.js"
11322
11322
  },
11323
+ {
11324
+ "site": "github",
11325
+ "name": "repo",
11326
+ "description": "Single-repository metadata including the true watch count",
11327
+ "access": "read",
11328
+ "domain": "api.github.com",
11329
+ "strategy": "public",
11330
+ "browser": false,
11331
+ "args": [
11332
+ {
11333
+ "name": "repo",
11334
+ "type": "str",
11335
+ "required": true,
11336
+ "positional": true,
11337
+ "help": "Repository as \"owner/repo\" (the full_name column from search) or its github.com URL"
11338
+ }
11339
+ ],
11340
+ "columns": [
11341
+ "full_name",
11342
+ "stars",
11343
+ "forks",
11344
+ "watchers",
11345
+ "open_issues",
11346
+ "language",
11347
+ "description",
11348
+ "license",
11349
+ "topics",
11350
+ "homepage",
11351
+ "default_branch",
11352
+ "archived",
11353
+ "is_fork",
11354
+ "size_kb",
11355
+ "created",
11356
+ "pushed",
11357
+ "url"
11358
+ ],
11359
+ "type": "js",
11360
+ "modulePath": "github/repo.js",
11361
+ "sourceFile": "github/repo.js"
11362
+ },
11363
+ {
11364
+ "site": "github",
11365
+ "name": "search",
11366
+ "description": "Search GitHub repositories by stars, forks, watchers, language, topic and more",
11367
+ "access": "read",
11368
+ "domain": "api.github.com",
11369
+ "strategy": "public",
11370
+ "browser": false,
11371
+ "args": [
11372
+ {
11373
+ "name": "query",
11374
+ "type": "str",
11375
+ "required": false,
11376
+ "positional": true,
11377
+ "help": "Free-text keyword; optional when at least one filter flag is given"
11378
+ },
11379
+ {
11380
+ "name": "stars",
11381
+ "type": "str",
11382
+ "required": false,
11383
+ "help": "Star count filter: \"5000\" (>=), \">10000\", \"<500\", \"1000..5000\""
11384
+ },
11385
+ {
11386
+ "name": "forks",
11387
+ "type": "str",
11388
+ "required": false,
11389
+ "help": "Fork count filter, same syntax as --stars"
11390
+ },
11391
+ {
11392
+ "name": "watchers",
11393
+ "type": "str",
11394
+ "required": false,
11395
+ "help": "Watch count filter, same syntax as --stars (client-side; costs 1 API call per scanned repo)"
11396
+ },
11397
+ {
11398
+ "name": "language",
11399
+ "type": "str",
11400
+ "required": false,
11401
+ "help": "Primary language, comma-separated for multiple (e.g. \"rust,go\")"
11402
+ },
11403
+ {
11404
+ "name": "topic",
11405
+ "type": "str",
11406
+ "required": false,
11407
+ "help": "Repository topic, comma-separated for multiple (e.g. \"cli,devtools\")"
11408
+ },
11409
+ {
11410
+ "name": "license",
11411
+ "type": "str",
11412
+ "required": false,
11413
+ "help": "License keyword (e.g. \"mit\", \"apache-2.0\")"
11414
+ },
11415
+ {
11416
+ "name": "size",
11417
+ "type": "str",
11418
+ "required": false,
11419
+ "help": "Repo size in KB: \"1000\" (>=), \"<500\", \"100..1000\""
11420
+ },
11421
+ {
11422
+ "name": "pushed",
11423
+ "type": "str",
11424
+ "required": false,
11425
+ "help": "Last push date: \"2026-01-01\" (>=), \">2026-01-01\", \"2025-01-01..2026-01-01\""
11426
+ },
11427
+ {
11428
+ "name": "created",
11429
+ "type": "str",
11430
+ "required": false,
11431
+ "help": "Creation date, same syntax as --pushed"
11432
+ },
11433
+ {
11434
+ "name": "issues",
11435
+ "type": "str",
11436
+ "required": false,
11437
+ "help": "Good-first-issue count filter, same syntax as --stars"
11438
+ },
11439
+ {
11440
+ "name": "owner",
11441
+ "type": "str",
11442
+ "required": false,
11443
+ "help": "Restrict to owners/orgs, comma-separated (e.g. \"facebook,vercel\")"
11444
+ },
11445
+ {
11446
+ "name": "in",
11447
+ "type": "str",
11448
+ "required": false,
11449
+ "help": "Restrict free-text match to: name, description, readme, topics (comma-separated)"
11450
+ },
11451
+ {
11452
+ "name": "include-forks",
11453
+ "type": "bool",
11454
+ "default": false,
11455
+ "required": false,
11456
+ "help": "Include forked repos (GitHub excludes them by default)"
11457
+ },
11458
+ {
11459
+ "name": "include-archived",
11460
+ "type": "bool",
11461
+ "default": false,
11462
+ "required": false,
11463
+ "help": "Include archived repos"
11464
+ },
11465
+ {
11466
+ "name": "sort",
11467
+ "type": "str",
11468
+ "default": "best-match",
11469
+ "required": false,
11470
+ "help": "Ranking dimension; \"watchers\" sorts client-side and enriches every scanned row",
11471
+ "choices": [
11472
+ "best-match",
11473
+ "stars",
11474
+ "forks",
11475
+ "watchers",
11476
+ "updated",
11477
+ "help-wanted-issues"
11478
+ ]
11479
+ },
11480
+ {
11481
+ "name": "order",
11482
+ "type": "str",
11483
+ "default": "desc",
11484
+ "required": false,
11485
+ "help": "Sort direction",
11486
+ "choices": [
11487
+ "desc",
11488
+ "asc"
11489
+ ]
11490
+ },
11491
+ {
11492
+ "name": "limit",
11493
+ "type": "int",
11494
+ "default": 20,
11495
+ "required": false,
11496
+ "help": "Max results (1-100)"
11497
+ },
11498
+ {
11499
+ "name": "page",
11500
+ "type": "int",
11501
+ "default": 1,
11502
+ "required": false,
11503
+ "help": "Result page; GitHub never serves past result 1000"
11504
+ },
11505
+ {
11506
+ "name": "scan",
11507
+ "type": "int",
11508
+ "default": 30,
11509
+ "required": false,
11510
+ "help": "Candidate pool size when filtering/sorting by watchers (1-100, one API call each)"
11511
+ },
11512
+ {
11513
+ "name": "with-watchers",
11514
+ "type": "bool",
11515
+ "default": false,
11516
+ "required": false,
11517
+ "help": "Add the real watch count column without filtering on it"
11518
+ }
11519
+ ],
11520
+ "columns": [
11521
+ "rank",
11522
+ "full_name",
11523
+ "stars",
11524
+ "forks",
11525
+ "watchers",
11526
+ "language",
11527
+ "description",
11528
+ "license",
11529
+ "topics",
11530
+ "open_issues",
11531
+ "pushed",
11532
+ "url"
11533
+ ],
11534
+ "type": "js",
11535
+ "modulePath": "github/search.js",
11536
+ "sourceFile": "github/search.js"
11537
+ },
11323
11538
  {
11324
11539
  "site": "google",
11325
11540
  "name": "news",
@@ -14863,6 +15078,87 @@
14863
15078
  "modulePath": "jira/search.js",
14864
15079
  "sourceFile": "jira/search.js"
14865
15080
  },
15081
+ {
15082
+ "site": "juejin",
15083
+ "name": "search",
15084
+ "description": "掘金搜索,支持综合/文章/课程/标签/用户维度与综合/最新/最热排序",
15085
+ "access": "read",
15086
+ "domain": "api.juejin.cn",
15087
+ "strategy": "public",
15088
+ "browser": false,
15089
+ "args": [
15090
+ {
15091
+ "name": "query",
15092
+ "type": "str",
15093
+ "required": true,
15094
+ "positional": true,
15095
+ "help": "搜索关键词"
15096
+ },
15097
+ {
15098
+ "name": "type",
15099
+ "type": "str",
15100
+ "default": "all",
15101
+ "required": false,
15102
+ "help": "结果类型:all(综合) / article(文章) / course(课程) / tag(标签) / user(用户)",
15103
+ "choices": [
15104
+ "all",
15105
+ "article",
15106
+ "course",
15107
+ "tag",
15108
+ "user"
15109
+ ]
15110
+ },
15111
+ {
15112
+ "name": "sort",
15113
+ "type": "str",
15114
+ "default": "relevance",
15115
+ "required": false,
15116
+ "help": "排序:relevance(综合) / newest(最新优先) / hottest(最热优先)",
15117
+ "choices": [
15118
+ "relevance",
15119
+ "newest",
15120
+ "hottest"
15121
+ ]
15122
+ },
15123
+ {
15124
+ "name": "period",
15125
+ "type": "str",
15126
+ "default": "all",
15127
+ "required": false,
15128
+ "help": "时间范围:all(不限) / day(最近一天) / week(最近一周) / month3(最近三月)",
15129
+ "choices": [
15130
+ "all",
15131
+ "day",
15132
+ "week",
15133
+ "month3"
15134
+ ]
15135
+ },
15136
+ {
15137
+ "name": "limit",
15138
+ "type": "int",
15139
+ "default": 20,
15140
+ "required": false,
15141
+ "help": "返回条数 (max 200)"
15142
+ }
15143
+ ],
15144
+ "columns": [
15145
+ "rank",
15146
+ "kind",
15147
+ "id",
15148
+ "title",
15149
+ "author",
15150
+ "views",
15151
+ "likes",
15152
+ "comments",
15153
+ "hot_index",
15154
+ "published_at",
15155
+ "url",
15156
+ "extra"
15157
+ ],
15158
+ "type": "js",
15159
+ "modulePath": "juejin/search.js",
15160
+ "sourceFile": "juejin/search.js"
15161
+ },
14866
15162
  {
14867
15163
  "site": "ke",
14868
15164
  "name": "chengjiao",
@@ -0,0 +1,47 @@
1
+ // github repo — single-repository metadata from `/repos/{owner}/{repo}`.
2
+ //
3
+ // Pairs with `github search`: the `full_name` column from a search row goes
4
+ // straight into this command's positional argument. This is also the only
5
+ // endpoint that reports a true watch count (`subscribers_count`); the search
6
+ // index does not carry it.
7
+ import { cli, Strategy } from '@sovovs/bycli/registry';
8
+ import { GITHUB_API, githubFetch, requireFullName } from './utils.js';
9
+
10
+ cli({
11
+ site: 'github',
12
+ name: 'repo',
13
+ access: 'read',
14
+ description: 'Single-repository metadata including the true watch count',
15
+ domain: 'api.github.com',
16
+ strategy: Strategy.PUBLIC,
17
+ browser: false,
18
+ args: [
19
+ { name: 'repo', positional: true, required: true, help: 'Repository as "owner/repo" (the full_name column from search) or its github.com URL' },
20
+ ],
21
+ columns: ['full_name', 'stars', 'forks', 'watchers', 'open_issues', 'language', 'description', 'license', 'topics', 'homepage', 'default_branch', 'archived', 'is_fork', 'size_kb', 'created', 'pushed', 'url'],
22
+ func: async (args) => {
23
+ const fullName = requireFullName(args.repo);
24
+ const r = await githubFetch(`${GITHUB_API}/repos/${fullName}`, `github repo ${fullName}`);
25
+ return [{
26
+ full_name: String(r?.full_name ?? fullName),
27
+ stars: r?.stargazers_count != null ? Number(r.stargazers_count) : null,
28
+ forks: r?.forks_count != null ? Number(r.forks_count) : null,
29
+ // `subscribers_count` is the watch count. `watchers_count` on this
30
+ // payload is a legacy alias for the star count — do not use it.
31
+ watchers: r?.subscribers_count != null ? Number(r.subscribers_count) : null,
32
+ open_issues: r?.open_issues_count != null ? Number(r.open_issues_count) : null,
33
+ language: String(r?.language ?? ''),
34
+ description: String(r?.description ?? '').trim(),
35
+ license: String(r?.license?.spdx_id ?? ''),
36
+ topics: Array.isArray(r?.topics) ? r.topics.join(', ') : '',
37
+ homepage: String(r?.homepage ?? ''),
38
+ default_branch: String(r?.default_branch ?? ''),
39
+ archived: Boolean(r?.archived),
40
+ is_fork: Boolean(r?.fork),
41
+ size_kb: r?.size != null ? Number(r.size) : null,
42
+ created: String(r?.created_at ?? '').slice(0, 10),
43
+ pushed: String(r?.pushed_at ?? '').slice(0, 10),
44
+ url: String(r?.html_url ?? `https://github.com/${fullName}`),
45
+ }];
46
+ },
47
+ });
@@ -0,0 +1,281 @@
1
+ // github search — multi-dimension repository search over the public GitHub
2
+ // REST API (`/search/repositories`).
3
+ //
4
+ // Dimensions map to GitHub search qualifiers server-side (stars, forks,
5
+ // language, topic, license, size, created, pushed, owner) so filtering happens
6
+ // before results are paged.
7
+ //
8
+ // The watch dimension is the exception. GitHub's search index has NO watcher
9
+ // qualifier — `watchers:`/`followers:` are aliases for the star count — and
10
+ // search rows report `watchers_count` as a mirror of `stargazers_count`. The
11
+ // real watch count only exists as `subscribers_count` on the repo endpoint, so
12
+ // `--watchers` / `--sort watchers` scan a candidate pool and enrich each row
13
+ // with one extra API call apiece. See `--scan`.
14
+ import { cli, Strategy } from '@sovovs/bycli/registry';
15
+ import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
16
+ import {
17
+ GITHUB_API,
18
+ buildDateQualifier,
19
+ buildRangeQualifier,
20
+ githubFetch,
21
+ requireBoundedInt,
22
+ } from './utils.js';
23
+
24
+ const MAX_LIMIT = 100; // GitHub caps per_page at 100 for search
25
+ const MAX_SCAN = 100;
26
+ const ENRICH_CONCURRENCY = 5;
27
+ // GitHub's search index never returns past result 1000, regardless of per_page.
28
+ const MAX_SEARCH_WINDOW = 1000;
29
+
30
+ /** Server-side sorts GitHub understands; `best-match` means "omit the param". */
31
+ const SERVER_SORTS = new Set(['stars', 'forks', 'updated', 'help-wanted-issues']);
32
+
33
+ /** Split a comma-separated arg into trimmed, non-empty terms. */
34
+ function splitTerms(value) {
35
+ return String(value ?? '')
36
+ .split(',')
37
+ .map((s) => s.trim())
38
+ .filter(Boolean);
39
+ }
40
+
41
+ /**
42
+ * Quote a qualifier value when it contains whitespace, so
43
+ * `--license "MIT License"` doesn't split into two qualifiers.
44
+ */
45
+ function quoteIfNeeded(value) {
46
+ return /\s/.test(value) ? `"${value}"` : value;
47
+ }
48
+
49
+ /**
50
+ * Turn a `--watchers` value into a local predicate.
51
+ *
52
+ * Reuses `buildRangeQualifier` for validation so the accepted syntax can't
53
+ * drift from the server-side range flags.
54
+ */
55
+ export function compileWatchersPredicate(value) {
56
+ const qualifier = buildRangeQualifier('watchers', value);
57
+ const expr = qualifier.slice('watchers:'.length);
58
+ let m;
59
+ if ((m = expr.match(/^>=(\d+)$/))) return (n) => n >= Number(m[1]);
60
+ if ((m = expr.match(/^>(\d+)$/))) return (n) => n > Number(m[1]);
61
+ if ((m = expr.match(/^<=(\d+)$/))) return (n) => n <= Number(m[1]);
62
+ if ((m = expr.match(/^<(\d+)$/))) return (n) => n < Number(m[1]);
63
+ if ((m = expr.match(/^(\d+)\.\.(\d+)$/))) return (n) => n >= Number(m[1]) && n <= Number(m[2]);
64
+ if ((m = expr.match(/^(\d+)\.\.\*$/))) return (n) => n >= Number(m[1]);
65
+ if ((m = expr.match(/^\*\.\.(\d+)$/))) return (n) => n <= Number(m[1]);
66
+ // buildRangeQualifier already rejected anything else.
67
+ throw new ArgumentError(`github --watchers value "${value}" is not a valid numeric filter`);
68
+ }
69
+
70
+ /** Assemble the `q=` search expression from free text plus every dimension. */
71
+ export function buildQuery(args = {}) {
72
+ const parts = [];
73
+ const freeText = String(args.query ?? '').trim();
74
+ if (freeText) parts.push(freeText);
75
+
76
+ for (const [flag, field] of [['stars', 'stars'], ['forks', 'forks'], ['issues', 'good-first-issues'], ['size', 'size']]) {
77
+ if (args[flag] != null && String(args[flag]).trim()) {
78
+ parts.push(buildRangeQualifier(field, args[flag]));
79
+ }
80
+ }
81
+ for (const [flag, field] of [['pushed', 'pushed'], ['created', 'created']]) {
82
+ if (args[flag] != null && String(args[flag]).trim()) {
83
+ parts.push(buildDateQualifier(field, args[flag]));
84
+ }
85
+ }
86
+ for (const [flag, field] of [['language', 'language'], ['topic', 'topic'], ['license', 'license']]) {
87
+ for (const term of splitTerms(args[flag])) {
88
+ parts.push(`${field}:${quoteIfNeeded(term)}`);
89
+ }
90
+ }
91
+ for (const term of splitTerms(args.owner)) {
92
+ parts.push(`user:${term}`);
93
+ }
94
+ if (args.in) {
95
+ const fields = splitTerms(args.in);
96
+ const allowed = new Set(['name', 'description', 'readme', 'topics']);
97
+ for (const f of fields) {
98
+ if (!allowed.has(f)) {
99
+ throw new ArgumentError(
100
+ `github --in value "${f}" is not searchable`,
101
+ 'GitHub restricts free-text scope to: name, description, readme, topics.',
102
+ );
103
+ }
104
+ }
105
+ if (fields.length) parts.push(`in:${fields.join(',')}`);
106
+ }
107
+ // GitHub excludes forks from search by default; archived repos are included.
108
+ if (args['include-forks']) parts.push('fork:true');
109
+ if (!args['include-archived']) parts.push('archived:false');
110
+
111
+ // `archived:false` alone is a valid GitHub query but would silently return
112
+ // "every non-archived repo", which is never what the caller meant.
113
+ const meaningful = parts.filter((p) => p !== 'archived:false' && p !== 'fork:true');
114
+ if (!meaningful.length) {
115
+ throw new ArgumentError(
116
+ 'github search needs a query or at least one filter',
117
+ 'Pass a keyword, or a dimension such as --stars ">10000" --language rust --topic cli.',
118
+ );
119
+ }
120
+ return parts.join(' ');
121
+ }
122
+
123
+ /** Run `mapper` over `items` with a bounded number of in-flight requests. */
124
+ async function mapWithConcurrency(items, concurrency, mapper) {
125
+ const out = new Array(items.length);
126
+ let cursor = 0;
127
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
128
+ while (cursor < items.length) {
129
+ const i = cursor++;
130
+ out[i] = await mapper(items[i], i);
131
+ }
132
+ });
133
+ await Promise.all(workers);
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Fetch the real watch count (`subscribers_count`) for each row.
139
+ *
140
+ * One core-API call per repo. Failures degrade to `null` rather than killing
141
+ * the whole search — a single deleted/renamed repo shouldn't lose 99 good rows.
142
+ */
143
+ async function enrichWatchers(rows) {
144
+ return mapWithConcurrency(rows, ENRICH_CONCURRENCY, async (row) => {
145
+ try {
146
+ const detail = await githubFetch(
147
+ `${GITHUB_API}/repos/${row.full_name}`,
148
+ 'github search (watch count)',
149
+ { allow404: false },
150
+ );
151
+ return { ...row, watchers: detail?.subscribers_count != null ? Number(detail.subscribers_count) : null };
152
+ }
153
+ catch {
154
+ return { ...row, watchers: null };
155
+ }
156
+ });
157
+ }
158
+
159
+ /** Shape one raw search item into an output row. */
160
+ function toRow(item) {
161
+ return {
162
+ rank: 0, // assigned after filtering/sorting
163
+ full_name: String(item?.full_name ?? ''),
164
+ stars: item?.stargazers_count != null ? Number(item.stargazers_count) : null,
165
+ forks: item?.forks_count != null ? Number(item.forks_count) : null,
166
+ // Deliberately null: the search payload's `watchers_count` is a copy of
167
+ // the star count, not a watch count. Only enrichment fills this in.
168
+ watchers: null,
169
+ language: String(item?.language ?? ''),
170
+ description: String(item?.description ?? '').trim(),
171
+ license: String(item?.license?.spdx_id ?? ''),
172
+ topics: Array.isArray(item?.topics) ? item.topics.join(', ') : '',
173
+ open_issues: item?.open_issues_count != null ? Number(item.open_issues_count) : null,
174
+ pushed: String(item?.pushed_at ?? '').slice(0, 10),
175
+ url: String(item?.html_url ?? (item?.full_name ? `https://github.com/${item.full_name}` : '')),
176
+ };
177
+ }
178
+
179
+ cli({
180
+ site: 'github',
181
+ name: 'search',
182
+ access: 'read',
183
+ description: 'Search GitHub repositories by stars, forks, watchers, language, topic and more',
184
+ domain: 'api.github.com',
185
+ strategy: Strategy.PUBLIC,
186
+ browser: false,
187
+ args: [
188
+ { name: 'query', positional: true, required: false, help: 'Free-text keyword; optional when at least one filter flag is given' },
189
+ { name: 'stars', help: 'Star count filter: "5000" (>=), ">10000", "<500", "1000..5000"' },
190
+ { name: 'forks', help: 'Fork count filter, same syntax as --stars' },
191
+ { name: 'watchers', help: 'Watch count filter, same syntax as --stars (client-side; costs 1 API call per scanned repo)' },
192
+ { name: 'language', help: 'Primary language, comma-separated for multiple (e.g. "rust,go")' },
193
+ { name: 'topic', help: 'Repository topic, comma-separated for multiple (e.g. "cli,devtools")' },
194
+ { name: 'license', help: 'License keyword (e.g. "mit", "apache-2.0")' },
195
+ { name: 'size', help: 'Repo size in KB: "1000" (>=), "<500", "100..1000"' },
196
+ { name: 'pushed', help: 'Last push date: "2026-01-01" (>=), ">2026-01-01", "2025-01-01..2026-01-01"' },
197
+ { name: 'created', help: 'Creation date, same syntax as --pushed' },
198
+ { name: 'issues', help: 'Good-first-issue count filter, same syntax as --stars' },
199
+ { name: 'owner', help: 'Restrict to owners/orgs, comma-separated (e.g. "facebook,vercel")' },
200
+ { name: 'in', help: 'Restrict free-text match to: name, description, readme, topics (comma-separated)' },
201
+ { name: 'include-forks', type: 'bool', default: false, help: 'Include forked repos (GitHub excludes them by default)' },
202
+ { name: 'include-archived', type: 'bool', default: false, help: 'Include archived repos' },
203
+ {
204
+ name: 'sort',
205
+ default: 'best-match',
206
+ choices: ['best-match', 'stars', 'forks', 'watchers', 'updated', 'help-wanted-issues'],
207
+ help: 'Ranking dimension; "watchers" sorts client-side and enriches every scanned row',
208
+ },
209
+ { name: 'order', default: 'desc', choices: ['desc', 'asc'], help: 'Sort direction' },
210
+ { name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
211
+ { name: 'page', type: 'int', default: 1, help: 'Result page; GitHub never serves past result 1000' },
212
+ { name: 'scan', type: 'int', default: 30, help: 'Candidate pool size when filtering/sorting by watchers (1-100, one API call each)' },
213
+ { name: 'with-watchers', type: 'bool', default: false, help: 'Add the real watch count column without filtering on it' },
214
+ ],
215
+ columns: ['rank', 'full_name', 'stars', 'forks', 'watchers', 'language', 'description', 'license', 'topics', 'open_issues', 'pushed', 'url'],
216
+ func: async (args) => {
217
+ const limit = requireBoundedInt(args.limit, 20, MAX_LIMIT);
218
+ const page = requireBoundedInt(args.page, 1, MAX_SEARCH_WINDOW);
219
+ const sort = String(args.sort ?? 'best-match');
220
+ const order = String(args.order ?? 'desc') === 'asc' ? 'asc' : 'desc';
221
+ const wantsWatchers = Boolean(args.watchers) || Boolean(args['with-watchers']) || sort === 'watchers';
222
+ const q = buildQuery(args);
223
+
224
+ // Watch-count work happens client-side, so pull a larger candidate pool
225
+ // to filter/sort down from. Everything else is filtered by GitHub.
226
+ const scan = wantsWatchers ? requireBoundedInt(args.scan, 30, MAX_SCAN, 'scan') : limit;
227
+ const perPage = wantsWatchers ? Math.max(scan, limit) : limit;
228
+
229
+ if (page * perPage > MAX_SEARCH_WINDOW) {
230
+ throw new ArgumentError(
231
+ `github search cannot reach page ${page} at ${perPage} results per page`,
232
+ `GitHub's search index stops at result ${MAX_SEARCH_WINDOW}; narrow the query with more filters instead.`,
233
+ );
234
+ }
235
+
236
+ const url = new URL(`${GITHUB_API}/search/repositories`);
237
+ url.searchParams.set('q', q);
238
+ url.searchParams.set('per_page', String(Math.min(perPage, MAX_LIMIT)));
239
+ url.searchParams.set('page', String(page));
240
+ // `best-match` is GitHub's default and is expressed by omitting `sort`.
241
+ if (SERVER_SORTS.has(sort)) {
242
+ url.searchParams.set('sort', sort);
243
+ url.searchParams.set('order', order);
244
+ }
245
+ else if (sort === 'watchers') {
246
+ // No server-side watch sort exists; rank the pool by stars so the
247
+ // scanned candidates are the plausible ones, then re-sort locally.
248
+ url.searchParams.set('sort', 'stars');
249
+ url.searchParams.set('order', 'desc');
250
+ }
251
+
252
+ const body = await githubFetch(url.toString(), 'github search');
253
+ const items = Array.isArray(body?.items) ? body.items : [];
254
+ if (!items.length) {
255
+ throw new EmptyResultError('github search', `No GitHub repositories matched "${q}".`);
256
+ }
257
+
258
+ let rows = items.map(toRow).filter((r) => r.full_name);
259
+ if (wantsWatchers) {
260
+ rows = await enrichWatchers(rows.slice(0, scan));
261
+ if (args.watchers != null && String(args.watchers).trim()) {
262
+ const pred = compileWatchersPredicate(args.watchers);
263
+ rows = rows.filter((r) => r.watchers != null && pred(r.watchers));
264
+ }
265
+ if (sort === 'watchers') {
266
+ rows.sort((a, b) => (order === 'asc'
267
+ ? (a.watchers ?? Infinity) - (b.watchers ?? Infinity)
268
+ : (b.watchers ?? -1) - (a.watchers ?? -1)));
269
+ }
270
+ }
271
+
272
+ rows = rows.slice(0, limit);
273
+ if (!rows.length) {
274
+ throw new EmptyResultError(
275
+ 'github search',
276
+ `No repositories in the top ${scan} results for "${q}" matched --watchers ${args.watchers}. Raise --scan or loosen the filter.`,
277
+ );
278
+ }
279
+ return rows.map((r, i) => ({ ...r, rank: i + 1 }));
280
+ },
281
+ });
@@ -0,0 +1,150 @@
1
+ // Shared helpers for the GitHub adapters that hit the public REST API
2
+ // (api.github.com). No browser, no cookies — `GITHUB_TOKEN` is optional and
3
+ // only raises the rate limit (60/hr → 5000/hr core, 10/min → 30/min search).
4
+ import { ArgumentError, CommandExecutionError, EmptyResultError, RateLimitedError } from '@sovovs/bycli/errors';
5
+
6
+ export const GITHUB_API = 'https://api.github.com';
7
+ const UA = 'bycli-github-adapter (+https://github.com/sovovs/byCLI)';
8
+
9
+ // owner/repo full names: owner is 1-39 chars of alnum/hyphen, repo adds ._-
10
+ const FULL_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9_.-]{1,100}$/;
11
+
12
+ export function requireString(value, label) {
13
+ const s = String(value ?? '').trim();
14
+ if (!s) throw new ArgumentError(`github ${label} cannot be empty`);
15
+ return s;
16
+ }
17
+
18
+ export function requireFullName(value) {
19
+ const s = String(value ?? '').trim().replace(/^https?:\/\/(?:www\.)?github\.com\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '');
20
+ if (!s) throw new ArgumentError('github repo is required (e.g. "facebook/react")');
21
+ if (!FULL_NAME.test(s)) {
22
+ throw new ArgumentError(
23
+ `github repo "${value}" is not a valid "owner/repo" name`,
24
+ 'Pass the full name from a search row, e.g. "facebook/react" (or its github.com URL).',
25
+ );
26
+ }
27
+ return s;
28
+ }
29
+
30
+ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
31
+ const raw = value ?? defaultValue;
32
+ const n = typeof raw === 'number' ? raw : Number(raw);
33
+ if (!Number.isInteger(n) || n <= 0) {
34
+ throw new ArgumentError(`github ${label} must be a positive integer`);
35
+ }
36
+ if (n > maxValue) {
37
+ throw new ArgumentError(`github ${label} must be <= ${maxValue}`);
38
+ }
39
+ return n;
40
+ }
41
+
42
+ function authHeaders() {
43
+ const token = String(process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '').trim();
44
+ return token ? { authorization: `Bearer ${token}` } : {};
45
+ }
46
+
47
+ /**
48
+ * Build a `>N` / `<N` / `>=N` / `A..B` numeric range qualifier from a raw arg.
49
+ *
50
+ * GitHub accepts `stars:>100`, `stars:>=100`, `stars:<50`, `stars:10..50` and
51
+ * `stars:100..*`. A bare number is treated as `>=N`, which is what people mean
52
+ * by "at least 1000 stars" — exact-match `stars:1000` is almost never wanted.
53
+ */
54
+ export function buildRangeQualifier(field, value) {
55
+ const raw = String(value ?? '').trim();
56
+ if (!raw) return '';
57
+ const compact = raw.replace(/\s+/g, '');
58
+ if (/^\d+$/.test(compact)) return `${field}:>=${compact}`;
59
+ if (/^(?:>=|<=|>|<)\d+$/.test(compact)) return `${field}:${compact}`;
60
+ if (/^\d+\.\.(?:\d+|\*)$/.test(compact)) return `${field}:${compact}`;
61
+ if (/^\*\.\.\d+$/.test(compact)) return `${field}:${compact}`;
62
+ throw new ArgumentError(
63
+ `github --${field} value "${raw}" is not a valid numeric filter`,
64
+ 'Use a bare number (>=N), a comparison (">100", ">=100", "<50"), or a range ("10..50", "100..*").',
65
+ );
66
+ }
67
+
68
+ /** Same idea for date fields (`pushed`, `created`): ISO date or comparison/range. */
69
+ export function buildDateQualifier(field, value) {
70
+ const raw = String(value ?? '').trim();
71
+ if (!raw) return '';
72
+ const compact = raw.replace(/\s+/g, '');
73
+ const D = '\\d{4}-\\d{2}-\\d{2}';
74
+ if (new RegExp(`^${D}$`).test(compact)) return `${field}:>=${compact}`;
75
+ if (new RegExp(`^(?:>=|<=|>|<)${D}$`).test(compact)) return `${field}:${compact}`;
76
+ if (new RegExp(`^${D}\\.\\.(?:${D}|\\*)$`).test(compact)) return `${field}:${compact}`;
77
+ if (new RegExp(`^\\*\\.\\.${D}$`).test(compact)) return `${field}:${compact}`;
78
+ throw new ArgumentError(
79
+ `github --${field} value "${raw}" is not a valid date filter`,
80
+ 'Use YYYY-MM-DD (>=date), a comparison (">2026-01-01"), or a range ("2025-01-01..2026-01-01").',
81
+ );
82
+ }
83
+
84
+ export async function githubFetch(url, label, { allow404 = true } = {}) {
85
+ let resp;
86
+ try {
87
+ resp = await fetch(url, {
88
+ headers: {
89
+ 'user-agent': UA,
90
+ accept: 'application/vnd.github+json',
91
+ 'x-github-api-version': '2022-11-28',
92
+ ...authHeaders(),
93
+ },
94
+ redirect: 'follow',
95
+ });
96
+ }
97
+ catch (err) {
98
+ throw new CommandExecutionError(
99
+ `${label} request failed: ${err?.message ?? err}`,
100
+ 'Check that api.github.com is reachable from this network.',
101
+ );
102
+ }
103
+ if (resp.status === 404 && allow404) {
104
+ throw new EmptyResultError(label, `GitHub API returned 404 for ${url}.`);
105
+ }
106
+ // GitHub signals both rate limiting and abuse detection with 403/429.
107
+ if (resp.status === 403 || resp.status === 429) {
108
+ const remaining = resp.headers.get('x-ratelimit-remaining');
109
+ const reset = Number(resp.headers.get('x-ratelimit-reset'));
110
+ const waitHint = Number.isFinite(reset) && reset > 0
111
+ ? ` Limit resets at ${new Date(reset * 1000).toISOString()}.`
112
+ : '';
113
+ if (remaining === '0' || resp.status === 429) {
114
+ throw new RateLimitedError(
115
+ `${label} hit the GitHub API rate limit (HTTP ${resp.status})`,
116
+ `Unauthenticated search allows 10 req/min.${waitHint} Set GITHUB_TOKEN to raise it to 30 req/min.`,
117
+ );
118
+ }
119
+ throw new CommandExecutionError(
120
+ `${label} was refused by GitHub (HTTP 403)`,
121
+ 'The API may require authentication for this resource; set GITHUB_TOKEN.',
122
+ );
123
+ }
124
+ if (resp.status === 422) {
125
+ let detail = '';
126
+ try {
127
+ const body = await resp.json();
128
+ const fields = Array.isArray(body?.errors)
129
+ ? body.errors.map((e) => e?.field ?? e?.message).filter(Boolean).join(', ')
130
+ : '';
131
+ detail = fields ? ` (${fields})` : (body?.message ? ` (${body.message})` : '');
132
+ }
133
+ catch { /* body already unreadable; fall through with no detail */ }
134
+ throw new ArgumentError(
135
+ `${label} was rejected by GitHub as an invalid query${detail}`,
136
+ 'Check the qualifier syntax; GitHub rejects malformed values like "stars:abc".',
137
+ );
138
+ }
139
+ if (!resp.ok) {
140
+ throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
141
+ }
142
+ let body;
143
+ try {
144
+ body = await resp.json();
145
+ }
146
+ catch (err) {
147
+ throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
148
+ }
149
+ return body;
150
+ }
@@ -0,0 +1,372 @@
1
+ // juejin search — 掘金站内搜索,覆盖页面上的三个筛选维度。
2
+ //
3
+ // 单一 endpoint `GET api.juejin.cn/search_api/v1/search` 同时驱动网页上的
4
+ // 三组筛选控件,参数名和 UI 标签对应关系(从 juejin web bundle 的枚举表读出,
5
+ // 见 xitu_juejin_web/fa804e1.js 的 `{left: [...], right: [...]}`):
6
+ //
7
+ // id_type → 顶部一级 tab:综合(0) / 文章(2) / 课程(12) / 标签(9) / 用户(1)
8
+ // sort_type → 排序 tab:综合排序(0) / 最新优先(1) / 最热优先(2)
9
+ // search_type → 时间范围下拉:时间不限(0) / 最近一天(1) / 最近一周(2) / 最近三月(3)
10
+ //
11
+ // `search_type` 的命名容易误读成"搜索类型",实际是时间窗(bundle 里这个参数由
12
+ // 名为 `period` 的变量传入),所以 CLI 侧暴露成 `--period` 而不是照搬 API 名。
13
+ //
14
+ // 响应是异构列表:每个 entry 带 `result_type` 决定 `result_model` 的形状
15
+ // (2=文章 / 1=用户 / 9=标签 / 12=课程小册)。综合 tab 会混排多种类型,
16
+ // 所以行结构做成一张统一表:identity 列 + 通用指标列,各类型特有的次要字段
17
+ // 折进 `extra`,避免列数爆炸又不丢信息。
18
+ import { cli, Strategy } from '@sovovs/bycli/registry';
19
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
20
+
21
+ const API = 'https://api.juejin.cn/search_api/v1/search';
22
+
23
+ // 每页固定 20 条:limit 参数被服务端忽略(传 5 或 50 都返回 20),
24
+ // 分页只认 cursor,所以自己按 cursor 翻页再截断到用户要的条数。
25
+ const PAGE_SIZE = 20;
26
+ const MAX_LIMIT = 200;
27
+ const MAX_PAGES = 30;
28
+
29
+ const TYPES = {
30
+ all: 0,
31
+ article: 2,
32
+ course: 12,
33
+ tag: 9,
34
+ user: 1,
35
+ };
36
+
37
+ const SORTS = {
38
+ relevance: 0,
39
+ newest: 1,
40
+ hottest: 2,
41
+ };
42
+
43
+ const PERIODS = {
44
+ all: 0,
45
+ day: 1,
46
+ week: 2,
47
+ month3: 3,
48
+ };
49
+
50
+ const RESULT_TYPES = {
51
+ 1: 'user',
52
+ 2: 'article',
53
+ 9: 'tag',
54
+ 12: 'course',
55
+ };
56
+
57
+ function requireQuery(value) {
58
+ const query = String(value ?? '').trim();
59
+ if (!query) {
60
+ throw new ArgumentError('juejin search query must not be empty', 'Example: bycli juejin search golang --sort hottest');
61
+ }
62
+ return query;
63
+ }
64
+
65
+ function requireChoice(value, table, flag, example) {
66
+ const key = String(value ?? '');
67
+ if (!Object.prototype.hasOwnProperty.call(table, key)) {
68
+ throw new ArgumentError(`juejin search --${flag} must be one of: ${Object.keys(table).join(', ')}`, example);
69
+ }
70
+ return table[key];
71
+ }
72
+
73
+ function requireLimit(value) {
74
+ const raw = value ?? 20;
75
+ const n = typeof raw === 'number' ? raw : Number(raw);
76
+ if (!Number.isInteger(n) || n <= 0) {
77
+ throw new ArgumentError('juejin search --limit must be a positive integer');
78
+ }
79
+ if (n > MAX_LIMIT) {
80
+ throw new ArgumentError(`juejin search --limit must be <= ${MAX_LIMIT}`, 'Deep pagination hits juejin rate limits; narrow the query instead');
81
+ }
82
+ return n;
83
+ }
84
+
85
+ function toIsoTime(seconds) {
86
+ const n = Number(seconds);
87
+ // 掘金对"没有时间"用 -62135596800(Go 零值 time.Time)而不是 0/null。
88
+ if (!Number.isFinite(n) || n <= 0) return null;
89
+ return new Date(n * 1000).toISOString();
90
+ }
91
+
92
+ // rtime(发布时间) 在**较新**的文章上是 Go 零值 -62135596800,只有老文章才填。
93
+ // 所以不能写 `rtime ?? ctime`(?? 只兜 null/undefined,兜不住这个哨兵值),
94
+ // 必须按"第一个能转出合法时间的字段"取,否则 --sort newest 整列时间全 null。
95
+ function firstIsoTime(...candidates) {
96
+ for (const candidate of candidates) {
97
+ const iso = toIsoTime(candidate);
98
+ if (iso) return iso;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function textOf(value) {
104
+ return String(value ?? '').trim();
105
+ }
106
+
107
+ function buildUrl({ query, idType, sortType, period, cursor }) {
108
+ const url = new URL(API);
109
+ url.searchParams.set('aid', '2608');
110
+ url.searchParams.set('spider', '0');
111
+ url.searchParams.set('version', '1');
112
+ url.searchParams.set('query', query);
113
+ url.searchParams.set('id_type', String(idType));
114
+ url.searchParams.set('sort_type', String(sortType));
115
+ url.searchParams.set('search_type', String(period));
116
+ url.searchParams.set('cursor', cursor);
117
+ url.searchParams.set('limit', String(PAGE_SIZE));
118
+ return url.toString();
119
+ }
120
+
121
+ async function fetchPage(url) {
122
+ let resp;
123
+ try {
124
+ resp = await fetch(url, {
125
+ headers: {
126
+ accept: 'application/json',
127
+ 'user-agent': 'Mozilla/5.0',
128
+ referer: 'https://juejin.cn/',
129
+ },
130
+ });
131
+ }
132
+ catch (err) {
133
+ throw new CommandExecutionError(
134
+ `juejin search request failed: ${err?.message ?? err}`,
135
+ 'Check that api.juejin.cn is reachable from this network.',
136
+ );
137
+ }
138
+ if (!resp.ok) {
139
+ throw new CommandExecutionError(`juejin search returned HTTP ${resp.status}`, `URL: ${url}`);
140
+ }
141
+ let payload;
142
+ try {
143
+ payload = await resp.json();
144
+ }
145
+ catch (err) {
146
+ throw new CommandExecutionError(`juejin search returned malformed JSON: ${err?.message ?? err}`);
147
+ }
148
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
149
+ throw new CommandExecutionError('juejin search returned malformed payload');
150
+ }
151
+ if (payload.err_no !== 0) {
152
+ throw new CommandExecutionError(`juejin search API error: ${textOf(payload.err_msg) || `err_no ${payload.err_no}`}`);
153
+ }
154
+ if (!Array.isArray(payload.data)) {
155
+ throw new CommandExecutionError('juejin search returned malformed data list', `URL: ${url}`);
156
+ }
157
+ return payload;
158
+ }
159
+
160
+ // 每个分支返回中间结构(identity/label/…),最后统一映射到 columns 命名,
161
+ // 避免中间 key 和 columns 重叠触发 silent-column-drop 误判。
162
+ function normalizeArticle(model) {
163
+ const info = model?.article_info;
164
+ if (!info || typeof info !== 'object') return null;
165
+ const identity = textOf(model.article_id ?? info.article_id);
166
+ const label = textOf(info.title);
167
+ if (!identity || !label) return null;
168
+ return {
169
+ identity,
170
+ label,
171
+ byline: textOf(model.author_user_info?.user_name),
172
+ viewTotal: Number(info.view_count ?? 0),
173
+ likeTotal: Number(info.digg_count ?? 0),
174
+ commentTotal: Number(info.comment_count ?? 0),
175
+ heat: Number(info.hot_index ?? 0),
176
+ stamp: firstIsoTime(info.rtime, info.ctime),
177
+ link: `https://juejin.cn/post/${identity}`,
178
+ aside: {
179
+ collect_count: Number(info.collect_count ?? 0),
180
+ category: textOf(model.category?.category_name) || null,
181
+ // 逗号连接而不是数组:row shape 门禁要求嵌套深度 <= 1(agent-native 行)。
182
+ tags: (Array.isArray(model.tags) ? model.tags : [])
183
+ .map((tag) => textOf(tag?.tag_name))
184
+ .filter(Boolean)
185
+ .join(',') || null,
186
+ brief: textOf(info.brief_content) || null,
187
+ is_original: info.is_original === 1,
188
+ },
189
+ };
190
+ }
191
+
192
+ function normalizeUser(model) {
193
+ const identity = textOf(model?.user_id);
194
+ const label = textOf(model?.user_name);
195
+ if (!identity || !label) return null;
196
+ return {
197
+ identity,
198
+ label,
199
+ // 用户自身就是作者,byline 放职位/公司当副标题更有信息量。
200
+ byline: [textOf(model.job_title), textOf(model.company)].filter(Boolean).join(' @ '),
201
+ viewTotal: Number(model.got_view_count ?? 0),
202
+ likeTotal: Number(model.got_digg_count ?? 0),
203
+ commentTotal: null,
204
+ heat: Number(model.follower_count ?? 0),
205
+ stamp: null,
206
+ link: `https://juejin.cn/user/${identity}`,
207
+ aside: {
208
+ level: Number(model.level ?? 0),
209
+ follower_count: Number(model.follower_count ?? 0),
210
+ post_article_count: Number(model.post_article_count ?? 0),
211
+ description: textOf(model.description) || null,
212
+ },
213
+ };
214
+ }
215
+
216
+ function normalizeTag(model) {
217
+ const tag = model?.tag;
218
+ const identity = textOf(model?.tag_id ?? tag?.tag_id);
219
+ const label = textOf(tag?.tag_name);
220
+ if (!identity || !label) return null;
221
+ return {
222
+ identity,
223
+ label,
224
+ byline: null,
225
+ viewTotal: null,
226
+ likeTotal: null,
227
+ commentTotal: null,
228
+ heat: Number(tag?.concern_user_count ?? 0),
229
+ stamp: toIsoTime(tag?.ctime),
230
+ link: `https://juejin.cn/tag/${encodeURIComponent(label)}`,
231
+ aside: {
232
+ post_article_count: Number(tag?.post_article_count ?? 0),
233
+ concern_user_count: Number(tag?.concern_user_count ?? 0),
234
+ },
235
+ };
236
+ }
237
+
238
+ function normalizeCourse(model) {
239
+ const base = model?.base_info;
240
+ const identity = textOf(model?.booklet_id ?? base?.booklet_id);
241
+ const label = textOf(base?.title);
242
+ if (!identity || !label) return null;
243
+ return {
244
+ identity,
245
+ label,
246
+ byline: textOf(model.user_info?.user_name),
247
+ viewTotal: Number(base?.read_time ?? 0),
248
+ likeTotal: null,
249
+ commentTotal: null,
250
+ heat: Number(base?.buy_count ?? 0),
251
+ stamp: firstIsoTime(base?.put_on_time, base?.ctime),
252
+ link: `https://juejin.cn/book/${identity}`,
253
+ aside: {
254
+ // price 是分,转成元避免下游误读成 2990 元。
255
+ price_yuan: Number.isFinite(Number(base?.price)) ? Number(base.price) / 100 : null,
256
+ section_count: Number(base?.section_count ?? 0),
257
+ buy_count: Number(base?.buy_count ?? 0),
258
+ summary: textOf(base?.summary) || null,
259
+ },
260
+ };
261
+ }
262
+
263
+ const NORMALIZERS = {
264
+ article: normalizeArticle,
265
+ user: normalizeUser,
266
+ tag: normalizeTag,
267
+ course: normalizeCourse,
268
+ };
269
+
270
+ function normalizeEntry(entry) {
271
+ if (!entry || typeof entry !== 'object') return null;
272
+ const kind = RESULT_TYPES[entry.result_type];
273
+ // 掘金以后可能加新的 result_type(沸点等)。未知类型静默跳过而不是抛错,
274
+ // 否则综合 tab 上线一个新卡片类型就会让整个命令挂掉。
275
+ if (!kind) return null;
276
+ const parsed = NORMALIZERS[kind](entry.result_model);
277
+ if (!parsed) return null;
278
+ return { resultKind: kind, parsed };
279
+ }
280
+
281
+ cli({
282
+ site: 'juejin',
283
+ name: 'search',
284
+ access: 'read',
285
+ description: '掘金搜索,支持综合/文章/课程/标签/用户维度与综合/最新/最热排序',
286
+ domain: 'api.juejin.cn',
287
+ strategy: Strategy.PUBLIC,
288
+ browser: false,
289
+ args: [
290
+ { name: 'query', required: true, positional: true, help: '搜索关键词' },
291
+ { name: 'type', default: 'all', choices: Object.keys(TYPES), help: '结果类型:all(综合) / article(文章) / course(课程) / tag(标签) / user(用户)' },
292
+ { name: 'sort', default: 'relevance', choices: Object.keys(SORTS), help: '排序:relevance(综合) / newest(最新优先) / hottest(最热优先)' },
293
+ { name: 'period', default: 'all', choices: Object.keys(PERIODS), help: '时间范围:all(不限) / day(最近一天) / week(最近一周) / month3(最近三月)' },
294
+ { name: 'limit', type: 'int', default: 20, help: `返回条数 (max ${MAX_LIMIT})` },
295
+ ],
296
+ columns: ['rank', 'kind', 'id', 'title', 'author', 'views', 'likes', 'comments', 'hot_index', 'published_at', 'url', 'extra'],
297
+ func: async (args) => {
298
+ const query = requireQuery(args.query);
299
+ const idType = requireChoice(args.type, TYPES, 'type', 'Example: bycli juejin search rust --type article');
300
+ const sortType = requireChoice(args.sort, SORTS, 'sort', 'Example: bycli juejin search rust --sort hottest');
301
+ const period = requireChoice(args.period, PERIODS, 'period', 'Example: bycli juejin search rust --period week');
302
+ const limit = requireLimit(args.limit);
303
+
304
+ const rows = [];
305
+ const seen = new Set();
306
+ let cursor = '0';
307
+ let pages = 0;
308
+
309
+ while (rows.length < limit && pages < MAX_PAGES) {
310
+ const url = buildUrl({ query, idType, sortType, period, cursor });
311
+ const payload = await fetchPage(url);
312
+ pages += 1;
313
+
314
+ for (const entry of payload.data) {
315
+ const normalized = normalizeEntry(entry);
316
+ if (!normalized) continue;
317
+ const { resultKind: kind, parsed } = normalized;
318
+ const dedupeKey = `${kind}:${parsed.identity}`;
319
+ // 服务端跨页会重复少量结果(实测 6 页 118 条里 6 条重复)。
320
+ if (seen.has(dedupeKey)) continue;
321
+ seen.add(dedupeKey);
322
+ rows.push({
323
+ rank: rows.length + 1,
324
+ kind,
325
+ id: parsed.identity,
326
+ title: parsed.label,
327
+ author: parsed.byline || null,
328
+ views: parsed.viewTotal,
329
+ likes: parsed.likeTotal,
330
+ comments: parsed.commentTotal,
331
+ hot_index: parsed.heat,
332
+ published_at: parsed.stamp,
333
+ url: parsed.link,
334
+ extra: parsed.aside,
335
+ });
336
+ if (rows.length >= limit) break;
337
+ }
338
+
339
+ if (rows.length >= limit) break;
340
+ const nextCursor = textOf(payload.cursor);
341
+ if (!payload.has_more || !nextCursor || nextCursor === cursor) break;
342
+ cursor = nextCursor;
343
+ }
344
+
345
+ if (rows.length === 0) {
346
+ throw new EmptyResultError('juejin search', `No ${args.type === 'all' ? '' : `${args.type} `}results for "${query}"`);
347
+ }
348
+ return rows;
349
+ },
350
+ });
351
+
352
+ export const __test__ = {
353
+ TYPES,
354
+ SORTS,
355
+ PERIODS,
356
+ RESULT_TYPES,
357
+ PAGE_SIZE,
358
+ MAX_LIMIT,
359
+ MAX_PAGES,
360
+ requireQuery,
361
+ requireChoice,
362
+ requireLimit,
363
+ toIsoTime,
364
+ firstIsoTime,
365
+ buildUrl,
366
+ fetchPage,
367
+ normalizeArticle,
368
+ normalizeUser,
369
+ normalizeTag,
370
+ normalizeCourse,
371
+ normalizeEntry,
372
+ };
@@ -62,7 +62,7 @@ async function getToken(page) {
62
62
  }
63
63
 
64
64
  async function navigateToEditor(page) {
65
- await page.goto(WEIXIN_HOME);
65
+ await page.goto(WEIXIN_HOME, { stealth: false });
66
66
  await page.wait(3);
67
67
  const token = await getToken(page);
68
68
  if (!token) {
@@ -71,8 +71,8 @@ async function navigateToEditor(page) {
71
71
  'Could not extract session token. Please log in to mp.weixin.qq.com',
72
72
  );
73
73
  }
74
- await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`);
75
- await page.wait(4);
74
+ await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?t=media/appmsg_edit_v2&action=edit&isNew=1&type=77&token=${token}&lang=zh_CN`, { stealth: false });
75
+ await page.wait(10);
76
76
  const hasTitle = await page.evaluate('!!document.querySelector("textarea#title")');
77
77
  if (!hasTitle) {
78
78
  throw new AuthRequiredError(
@@ -101,6 +101,30 @@ async function fillField(page, selector, value) {
101
101
  })()`);
102
102
  }
103
103
 
104
+ // WeChat's editor-integrity check can raise a "浏览器插件存在安全隐患" modal. It is a
105
+ // blocking overlay, so any later click (cover picker, save) would land on its mask.
106
+ // Dismiss it via its own 我知道了 button rather than removing the node, so the
107
+ // editor's own teardown runs.
108
+ async function dismissPluginWarning(page) {
109
+ return page.evaluate(`(() => {
110
+ var closed = 0;
111
+ document.querySelectorAll('.weui-desktop-dialog__wrp, .weui-desktop-dialog').forEach(function(dialog) {
112
+ if ((dialog.innerText || '').indexOf('\\u5b89\\u5168\\u9690\\u60a3') < 0) return;
113
+ var wrap = dialog.closest('.weui-desktop-dialog__wrp') || dialog;
114
+ if (window.getComputedStyle(wrap).display === 'none' || wrap.offsetHeight <= 0) return;
115
+ var buttons = wrap.querySelectorAll('button, a, .weui-desktop-btn');
116
+ for (var i = 0; i < buttons.length; i++) {
117
+ if ((buttons[i].textContent || '').trim() === '\\u6211\\u77e5\\u9053\\u4e86') {
118
+ buttons[i].click();
119
+ closed++;
120
+ return;
121
+ }
122
+ }
123
+ });
124
+ return { closed: closed };
125
+ })()`);
126
+ }
127
+
104
128
  async function fillContent(page, text) {
105
129
  var result = await page.evaluate(`(() => {
106
130
  var normalize = value => String(value ?? '').replace(/\\r\\n?/g, '\\n').trim();
@@ -116,33 +140,51 @@ async function fillContent(page, text) {
116
140
  return { ok: true, value: ueditorActual };
117
141
  }
118
142
  }
143
+ // The editor is a rich-text framework (ProseMirror) that owns its DOM, so
144
+ // only tag it here. Do NOT clear innerHTML, build a Range, or send a
145
+ // select-all chord: WeChat's editor-integrity check reads those as plugin
146
+ // tampering and shows a blocking "当前使用的浏览器插件存在安全隐患" modal.
147
+ // Once that mask is up every click lands on it and no text is ever typed.
148
+ // page.typeText() drives the editor through CDP DOM.focus + Input.insertText,
149
+ // which the editor accepts as genuine input.
119
150
  var editors = document.querySelectorAll('div[contenteditable="true"]');
120
151
  var editor = editors[editors.length - 1];
121
152
  if (!editor) return { ok: false, reason: 'content editor not found' };
122
- editor.focus();
123
- if (editor.querySelector('[contenteditable="false"]')) editor.innerHTML = '';
124
- var selection = window.getSelection();
125
- if (!selection) return { ok: false, reason: 'text selection is unavailable' };
126
- var range = document.createRange();
127
- range.selectNodeContents(editor);
128
- selection.removeAllRanges();
129
- selection.addRange(range);
153
+ document.querySelectorAll('[data-bycli-content-target]').forEach(element => {
154
+ element.removeAttribute('data-bycli-content-target');
155
+ });
156
+ editor.setAttribute('data-bycli-content-target', 'true');
130
157
  return { ok: false, nativeTargetFocused: true };
131
158
  })()`);
132
159
 
133
- if (!result?.nativeTargetFocused || typeof page.nativeType !== 'function') return result;
160
+ if (!result?.nativeTargetFocused) return result;
161
+
162
+ const editorTarget = 'div[contenteditable="true"][data-bycli-content-target="true"]';
163
+ if (typeof page.focusWindow === 'function') {
164
+ try { await page.focusWindow(); } catch { /* focus is best-effort */ }
165
+ }
134
166
 
167
+ if (typeof page.typeText !== 'function') {
168
+ return { ok: false, reason: 'page.typeText is unavailable for content entry' };
169
+ }
135
170
  try {
136
- await page.nativeType(text);
137
- } catch {
138
- return result;
171
+ await page.typeText(editorTarget, text);
172
+ } catch (err) {
173
+ return { ok: false, reason: `content typing failed: ${String(err).slice(0, 120)}` };
139
174
  }
175
+ await page.wait(1);
176
+ await dismissPluginWarning(page);
140
177
 
141
178
  return page.evaluate(`(() => {
142
- var normalize = value => String(value ?? '').replace(/\\r\\n?/g, '\\n').trim();
179
+ // The editor renders paragraph breaks as its own block structure, so its
180
+ // innerText carries extra blank lines the source text does not have.
181
+ // Compare on collapsed whitespace instead of exact line breaks.
182
+ var normalize = value => String(value ?? '')
183
+ .replace(/\\r\\n?/g, '\\n')
184
+ .replace(/[\\s\\u00a0\\u200b]+/g, ' ')
185
+ .trim();
143
186
  var expected = normalize(${JSON.stringify(text)});
144
- var editors = document.querySelectorAll('div[contenteditable="true"]');
145
- var editor = editors[editors.length - 1];
187
+ var editor = document.querySelector('${editorTarget}');
146
188
  if (!editor) return { ok: false, reason: 'content editor not found' };
147
189
  var actual = normalize(editor.innerText ?? editor.textContent ?? '');
148
190
  return actual === expected
@@ -307,6 +349,7 @@ export const createDraftCommand = cli({
307
349
  const args = normalizeCreateDraftArgs(kwargs);
308
350
  await navigateToEditor(page);
309
351
 
352
+
310
353
  const titleResult = await fillField(page, 'textarea#title', args.title);
311
354
  requirePageResult(titleResult, 'title');
312
355
 
@@ -315,6 +358,8 @@ export const createDraftCommand = cli({
315
358
  requirePageResult(authorResult, 'author');
316
359
  }
317
360
 
361
+ await page.wait(10);
362
+
318
363
  const contentResult = await fillContent(page, args.content);
319
364
  requirePageResult(contentResult, 'content');
320
365
 
@@ -32,6 +32,7 @@ export declare class Page extends BasePage {
32
32
  goto(url: string, options?: {
33
33
  waitUntil?: 'load' | 'none';
34
34
  settleMs?: number;
35
+ stealth?: boolean;
35
36
  }): Promise<void>;
36
37
  /** Get the active page identity (targetId) */
37
38
  getActivePage(): string | undefined;
@@ -106,10 +106,13 @@ export class Page extends BasePage {
106
106
  }
107
107
  this._lastUrl = url;
108
108
  // Inject stealth + settle in a single round-trip instead of two sequential exec calls.
109
- // The stealth guard flag prevents double-injection; settle uses DOM stability detection.
109
+ // Some security-sensitive editors reject runtime environment patches, so callers can
110
+ // retain the normal DOM-settle behavior while opting out of stealth injection.
111
+ const useStealth = options?.stealth !== false;
110
112
  if (options?.waitUntil !== 'none') {
111
113
  const maxMs = options?.settleMs ?? 1000;
112
- const combinedCode = `${generateStealthJs()};\n${waitForDomStableJs(maxMs, Math.min(500, maxMs))}`;
114
+ const settleCode = waitForDomStableJs(maxMs, Math.min(500, maxMs));
115
+ const combinedCode = useStealth ? `${generateStealthJs()};\n${settleCode}` : settleCode;
113
116
  const combinedOpts = {
114
117
  code: combinedCode,
115
118
  ...this._cmdOpts(),
@@ -134,7 +137,7 @@ export class Page extends BasePage {
134
137
  }
135
138
  }
136
139
  }
137
- else {
140
+ else if (useStealth) {
138
141
  // Even with waitUntil='none', still inject stealth (best-effort)
139
142
  try {
140
143
  await sendCommand('exec', {
@@ -70,6 +70,7 @@ export interface IPage {
70
70
  goto(url: string, options?: {
71
71
  waitUntil?: 'load' | 'none';
72
72
  settleMs?: number;
73
+ stealth?: boolean;
73
74
  }): Promise<void>;
74
75
  evaluate<T = any>(js: string): Promise<T>;
75
76
  evaluate<Args extends unknown[], T>(fn: BrowserEvaluateFunction<Args, T>, ...args: Args): Promise<Awaited<T>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.28",
3
+ "version": "2.1.29",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },