@sovovs/bycli 2.1.28 → 2.1.30
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 +296 -0
- package/clis/github/repo.js +47 -0
- package/clis/github/search.js +281 -0
- package/clis/github/utils.js +150 -0
- package/clis/juejin/search.js +397 -0
- package/clis/weixin/create-draft.js +63 -18
- package/dist/src/browser/page.d.ts +1 -0
- package/dist/src/browser/page.js +6 -3
- package/dist/src/output.js +26 -5
- package/dist/src/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -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,397 @@
|
|
|
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
|
+
// 掘金对**非 0 的时间窗**(search_type=1/2/3)会间歇性返回空首页:同一 query
|
|
30
|
+
// 连打 6 次,2-5 次拿到 `data: []` 而 err_no 仍是 0/success,只有 cursor 里的
|
|
31
|
+
// 实例标识不同——像是部分后端分片对时间窗查询答不出来。实测 period=week 命中
|
|
32
|
+
// 率只有 1/6 ~ 2/6,但 4 次以内总能拿到数据。
|
|
33
|
+
//
|
|
34
|
+
// 真实无结果(如 query="鿃鿄鿅鿆")连打 8 次稳定是 0,所以重试只会救回抖动,
|
|
35
|
+
// 不会把空态变成假数据。只重试**首页且一行都没拿到**的情形:翻页途中的空页
|
|
36
|
+
// 本来就是正常的终止信号。
|
|
37
|
+
const EMPTY_RETRY_ATTEMPTS = 4;
|
|
38
|
+
const EMPTY_RETRY_DELAY_MS = 400;
|
|
39
|
+
|
|
40
|
+
function delay(ms) {
|
|
41
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TYPES = {
|
|
45
|
+
all: 0,
|
|
46
|
+
article: 2,
|
|
47
|
+
course: 12,
|
|
48
|
+
tag: 9,
|
|
49
|
+
user: 1,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const SORTS = {
|
|
53
|
+
relevance: 0,
|
|
54
|
+
newest: 1,
|
|
55
|
+
hottest: 2,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const PERIODS = {
|
|
59
|
+
all: 0,
|
|
60
|
+
day: 1,
|
|
61
|
+
week: 2,
|
|
62
|
+
month3: 3,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const RESULT_TYPES = {
|
|
66
|
+
1: 'user',
|
|
67
|
+
2: 'article',
|
|
68
|
+
9: 'tag',
|
|
69
|
+
12: 'course',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function requireQuery(value) {
|
|
73
|
+
const query = String(value ?? '').trim();
|
|
74
|
+
if (!query) {
|
|
75
|
+
throw new ArgumentError('juejin search query must not be empty', 'Example: bycli juejin search golang --sort hottest');
|
|
76
|
+
}
|
|
77
|
+
return query;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function requireChoice(value, table, flag, example) {
|
|
81
|
+
const key = String(value ?? '');
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(table, key)) {
|
|
83
|
+
throw new ArgumentError(`juejin search --${flag} must be one of: ${Object.keys(table).join(', ')}`, example);
|
|
84
|
+
}
|
|
85
|
+
return table[key];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requireLimit(value) {
|
|
89
|
+
const raw = value ?? 20;
|
|
90
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
91
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
92
|
+
throw new ArgumentError('juejin search --limit must be a positive integer');
|
|
93
|
+
}
|
|
94
|
+
if (n > MAX_LIMIT) {
|
|
95
|
+
throw new ArgumentError(`juejin search --limit must be <= ${MAX_LIMIT}`, 'Deep pagination hits juejin rate limits; narrow the query instead');
|
|
96
|
+
}
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toIsoTime(seconds) {
|
|
101
|
+
const n = Number(seconds);
|
|
102
|
+
// 掘金对"没有时间"用 -62135596800(Go 零值 time.Time)而不是 0/null。
|
|
103
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
104
|
+
return new Date(n * 1000).toISOString();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// rtime(发布时间) 在**较新**的文章上是 Go 零值 -62135596800,只有老文章才填。
|
|
108
|
+
// 所以不能写 `rtime ?? ctime`(?? 只兜 null/undefined,兜不住这个哨兵值),
|
|
109
|
+
// 必须按"第一个能转出合法时间的字段"取,否则 --sort newest 整列时间全 null。
|
|
110
|
+
function firstIsoTime(...candidates) {
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
const iso = toIsoTime(candidate);
|
|
113
|
+
if (iso) return iso;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function textOf(value) {
|
|
119
|
+
return String(value ?? '').trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildUrl({ query, idType, sortType, period, cursor }) {
|
|
123
|
+
const url = new URL(API);
|
|
124
|
+
url.searchParams.set('aid', '2608');
|
|
125
|
+
url.searchParams.set('spider', '0');
|
|
126
|
+
url.searchParams.set('version', '1');
|
|
127
|
+
url.searchParams.set('query', query);
|
|
128
|
+
url.searchParams.set('id_type', String(idType));
|
|
129
|
+
url.searchParams.set('sort_type', String(sortType));
|
|
130
|
+
url.searchParams.set('search_type', String(period));
|
|
131
|
+
url.searchParams.set('cursor', cursor);
|
|
132
|
+
url.searchParams.set('limit', String(PAGE_SIZE));
|
|
133
|
+
return url.toString();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function fetchPage(url) {
|
|
137
|
+
let resp;
|
|
138
|
+
try {
|
|
139
|
+
resp = await fetch(url, {
|
|
140
|
+
headers: {
|
|
141
|
+
accept: 'application/json',
|
|
142
|
+
'user-agent': 'Mozilla/5.0',
|
|
143
|
+
referer: 'https://juejin.cn/',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
throw new CommandExecutionError(
|
|
149
|
+
`juejin search request failed: ${err?.message ?? err}`,
|
|
150
|
+
'Check that api.juejin.cn is reachable from this network.',
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (!resp.ok) {
|
|
154
|
+
throw new CommandExecutionError(`juejin search returned HTTP ${resp.status}`, `URL: ${url}`);
|
|
155
|
+
}
|
|
156
|
+
let payload;
|
|
157
|
+
try {
|
|
158
|
+
payload = await resp.json();
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
throw new CommandExecutionError(`juejin search returned malformed JSON: ${err?.message ?? err}`);
|
|
162
|
+
}
|
|
163
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
164
|
+
throw new CommandExecutionError('juejin search returned malformed payload');
|
|
165
|
+
}
|
|
166
|
+
if (payload.err_no !== 0) {
|
|
167
|
+
throw new CommandExecutionError(`juejin search API error: ${textOf(payload.err_msg) || `err_no ${payload.err_no}`}`);
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(payload.data)) {
|
|
170
|
+
throw new CommandExecutionError('juejin search returned malformed data list', `URL: ${url}`);
|
|
171
|
+
}
|
|
172
|
+
return payload;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 每个分支返回中间结构(identity/label/…),最后统一映射到 columns 命名,
|
|
176
|
+
// 避免中间 key 和 columns 重叠触发 silent-column-drop 误判。
|
|
177
|
+
function normalizeArticle(model) {
|
|
178
|
+
const info = model?.article_info;
|
|
179
|
+
if (!info || typeof info !== 'object') return null;
|
|
180
|
+
const identity = textOf(model.article_id ?? info.article_id);
|
|
181
|
+
const label = textOf(info.title);
|
|
182
|
+
if (!identity || !label) return null;
|
|
183
|
+
return {
|
|
184
|
+
identity,
|
|
185
|
+
label,
|
|
186
|
+
byline: textOf(model.author_user_info?.user_name),
|
|
187
|
+
viewTotal: Number(info.view_count ?? 0),
|
|
188
|
+
likeTotal: Number(info.digg_count ?? 0),
|
|
189
|
+
commentTotal: Number(info.comment_count ?? 0),
|
|
190
|
+
heat: Number(info.hot_index ?? 0),
|
|
191
|
+
stamp: firstIsoTime(info.rtime, info.ctime),
|
|
192
|
+
link: `https://juejin.cn/post/${identity}`,
|
|
193
|
+
aside: {
|
|
194
|
+
collect_count: Number(info.collect_count ?? 0),
|
|
195
|
+
category: textOf(model.category?.category_name) || null,
|
|
196
|
+
// 逗号连接而不是数组:row shape 门禁要求嵌套深度 <= 1(agent-native 行)。
|
|
197
|
+
tags: (Array.isArray(model.tags) ? model.tags : [])
|
|
198
|
+
.map((tag) => textOf(tag?.tag_name))
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.join(',') || null,
|
|
201
|
+
brief: textOf(info.brief_content) || null,
|
|
202
|
+
is_original: info.is_original === 1,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function normalizeUser(model) {
|
|
208
|
+
const identity = textOf(model?.user_id);
|
|
209
|
+
const label = textOf(model?.user_name);
|
|
210
|
+
if (!identity || !label) return null;
|
|
211
|
+
return {
|
|
212
|
+
identity,
|
|
213
|
+
label,
|
|
214
|
+
// 用户自身就是作者,byline 放职位/公司当副标题更有信息量。
|
|
215
|
+
byline: [textOf(model.job_title), textOf(model.company)].filter(Boolean).join(' @ '),
|
|
216
|
+
viewTotal: Number(model.got_view_count ?? 0),
|
|
217
|
+
likeTotal: Number(model.got_digg_count ?? 0),
|
|
218
|
+
commentTotal: null,
|
|
219
|
+
heat: Number(model.follower_count ?? 0),
|
|
220
|
+
stamp: null,
|
|
221
|
+
link: `https://juejin.cn/user/${identity}`,
|
|
222
|
+
aside: {
|
|
223
|
+
level: Number(model.level ?? 0),
|
|
224
|
+
follower_count: Number(model.follower_count ?? 0),
|
|
225
|
+
post_article_count: Number(model.post_article_count ?? 0),
|
|
226
|
+
description: textOf(model.description) || null,
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeTag(model) {
|
|
232
|
+
const tag = model?.tag;
|
|
233
|
+
const identity = textOf(model?.tag_id ?? tag?.tag_id);
|
|
234
|
+
const label = textOf(tag?.tag_name);
|
|
235
|
+
if (!identity || !label) return null;
|
|
236
|
+
return {
|
|
237
|
+
identity,
|
|
238
|
+
label,
|
|
239
|
+
byline: null,
|
|
240
|
+
viewTotal: null,
|
|
241
|
+
likeTotal: null,
|
|
242
|
+
commentTotal: null,
|
|
243
|
+
heat: Number(tag?.concern_user_count ?? 0),
|
|
244
|
+
stamp: toIsoTime(tag?.ctime),
|
|
245
|
+
link: `https://juejin.cn/tag/${encodeURIComponent(label)}`,
|
|
246
|
+
aside: {
|
|
247
|
+
post_article_count: Number(tag?.post_article_count ?? 0),
|
|
248
|
+
concern_user_count: Number(tag?.concern_user_count ?? 0),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function normalizeCourse(model) {
|
|
254
|
+
const base = model?.base_info;
|
|
255
|
+
const identity = textOf(model?.booklet_id ?? base?.booklet_id);
|
|
256
|
+
const label = textOf(base?.title);
|
|
257
|
+
if (!identity || !label) return null;
|
|
258
|
+
return {
|
|
259
|
+
identity,
|
|
260
|
+
label,
|
|
261
|
+
byline: textOf(model.user_info?.user_name),
|
|
262
|
+
viewTotal: Number(base?.read_time ?? 0),
|
|
263
|
+
likeTotal: null,
|
|
264
|
+
commentTotal: null,
|
|
265
|
+
heat: Number(base?.buy_count ?? 0),
|
|
266
|
+
stamp: firstIsoTime(base?.put_on_time, base?.ctime),
|
|
267
|
+
link: `https://juejin.cn/book/${identity}`,
|
|
268
|
+
aside: {
|
|
269
|
+
// price 是分,转成元避免下游误读成 2990 元。
|
|
270
|
+
price_yuan: Number.isFinite(Number(base?.price)) ? Number(base.price) / 100 : null,
|
|
271
|
+
section_count: Number(base?.section_count ?? 0),
|
|
272
|
+
buy_count: Number(base?.buy_count ?? 0),
|
|
273
|
+
summary: textOf(base?.summary) || null,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const NORMALIZERS = {
|
|
279
|
+
article: normalizeArticle,
|
|
280
|
+
user: normalizeUser,
|
|
281
|
+
tag: normalizeTag,
|
|
282
|
+
course: normalizeCourse,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
function normalizeEntry(entry) {
|
|
286
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
287
|
+
const kind = RESULT_TYPES[entry.result_type];
|
|
288
|
+
// 掘金以后可能加新的 result_type(沸点等)。未知类型静默跳过而不是抛错,
|
|
289
|
+
// 否则综合 tab 上线一个新卡片类型就会让整个命令挂掉。
|
|
290
|
+
if (!kind) return null;
|
|
291
|
+
const parsed = NORMALIZERS[kind](entry.result_model);
|
|
292
|
+
if (!parsed) return null;
|
|
293
|
+
return { resultKind: kind, parsed };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
cli({
|
|
297
|
+
site: 'juejin',
|
|
298
|
+
name: 'search',
|
|
299
|
+
access: 'read',
|
|
300
|
+
description: '掘金搜索,支持综合/文章/课程/标签/用户维度与综合/最新/最热排序',
|
|
301
|
+
domain: 'api.juejin.cn',
|
|
302
|
+
strategy: Strategy.PUBLIC,
|
|
303
|
+
browser: false,
|
|
304
|
+
args: [
|
|
305
|
+
{ name: 'query', required: true, positional: true, help: '搜索关键词' },
|
|
306
|
+
{ name: 'type', default: 'all', choices: Object.keys(TYPES), help: '结果类型:all(综合) / article(文章) / course(课程) / tag(标签) / user(用户)' },
|
|
307
|
+
{ name: 'sort', default: 'relevance', choices: Object.keys(SORTS), help: '排序:relevance(综合) / newest(最新优先) / hottest(最热优先)' },
|
|
308
|
+
{ name: 'period', default: 'all', choices: Object.keys(PERIODS), help: '时间范围:all(不限) / day(最近一天) / week(最近一周) / month3(最近三月)' },
|
|
309
|
+
{ name: 'limit', type: 'int', default: 20, help: `返回条数 (max ${MAX_LIMIT})` },
|
|
310
|
+
],
|
|
311
|
+
columns: ['rank', 'kind', 'id', 'title', 'author', 'views', 'likes', 'comments', 'hot_index', 'published_at', 'url', 'extra'],
|
|
312
|
+
func: async (args) => {
|
|
313
|
+
const query = requireQuery(args.query);
|
|
314
|
+
const idType = requireChoice(args.type, TYPES, 'type', 'Example: bycli juejin search rust --type article');
|
|
315
|
+
const sortType = requireChoice(args.sort, SORTS, 'sort', 'Example: bycli juejin search rust --sort hottest');
|
|
316
|
+
const period = requireChoice(args.period, PERIODS, 'period', 'Example: bycli juejin search rust --period week');
|
|
317
|
+
const limit = requireLimit(args.limit);
|
|
318
|
+
|
|
319
|
+
const rows = [];
|
|
320
|
+
const seen = new Set();
|
|
321
|
+
let cursor = '0';
|
|
322
|
+
let pages = 0;
|
|
323
|
+
|
|
324
|
+
while (rows.length < limit && pages < MAX_PAGES) {
|
|
325
|
+
const url = buildUrl({ query, idType, sortType, period, cursor });
|
|
326
|
+
let payload = await fetchPage(url);
|
|
327
|
+
pages += 1;
|
|
328
|
+
|
|
329
|
+
// 首页空 → 可能是上面说的分片抖动,有界重试;真实空态重试后仍是空。
|
|
330
|
+
if (rows.length === 0 && payload.data.length === 0) {
|
|
331
|
+
for (let attempt = 1; attempt < EMPTY_RETRY_ATTEMPTS; attempt += 1) {
|
|
332
|
+
await delay(EMPTY_RETRY_DELAY_MS);
|
|
333
|
+
payload = await fetchPage(url);
|
|
334
|
+
if (payload.data.length > 0) break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const entry of payload.data) {
|
|
339
|
+
const normalized = normalizeEntry(entry);
|
|
340
|
+
if (!normalized) continue;
|
|
341
|
+
const { resultKind: kind, parsed } = normalized;
|
|
342
|
+
const dedupeKey = `${kind}:${parsed.identity}`;
|
|
343
|
+
// 服务端跨页会重复少量结果(实测 6 页 118 条里 6 条重复)。
|
|
344
|
+
if (seen.has(dedupeKey)) continue;
|
|
345
|
+
seen.add(dedupeKey);
|
|
346
|
+
rows.push({
|
|
347
|
+
rank: rows.length + 1,
|
|
348
|
+
kind,
|
|
349
|
+
id: parsed.identity,
|
|
350
|
+
title: parsed.label,
|
|
351
|
+
author: parsed.byline || null,
|
|
352
|
+
views: parsed.viewTotal,
|
|
353
|
+
likes: parsed.likeTotal,
|
|
354
|
+
comments: parsed.commentTotal,
|
|
355
|
+
hot_index: parsed.heat,
|
|
356
|
+
published_at: parsed.stamp,
|
|
357
|
+
url: parsed.link,
|
|
358
|
+
extra: parsed.aside,
|
|
359
|
+
});
|
|
360
|
+
if (rows.length >= limit) break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (rows.length >= limit) break;
|
|
364
|
+
const nextCursor = textOf(payload.cursor);
|
|
365
|
+
if (!payload.has_more || !nextCursor || nextCursor === cursor) break;
|
|
366
|
+
cursor = nextCursor;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (rows.length === 0) {
|
|
370
|
+
throw new EmptyResultError('juejin search', `No ${args.type === 'all' ? '' : `${args.type} `}results for "${query}"`);
|
|
371
|
+
}
|
|
372
|
+
return rows;
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
export const __test__ = {
|
|
377
|
+
TYPES,
|
|
378
|
+
SORTS,
|
|
379
|
+
PERIODS,
|
|
380
|
+
RESULT_TYPES,
|
|
381
|
+
PAGE_SIZE,
|
|
382
|
+
MAX_LIMIT,
|
|
383
|
+
MAX_PAGES,
|
|
384
|
+
EMPTY_RETRY_ATTEMPTS,
|
|
385
|
+
requireQuery,
|
|
386
|
+
requireChoice,
|
|
387
|
+
requireLimit,
|
|
388
|
+
toIsoTime,
|
|
389
|
+
firstIsoTime,
|
|
390
|
+
buildUrl,
|
|
391
|
+
fetchPage,
|
|
392
|
+
normalizeArticle,
|
|
393
|
+
normalizeUser,
|
|
394
|
+
normalizeTag,
|
|
395
|
+
normalizeCourse,
|
|
396
|
+
normalizeEntry,
|
|
397
|
+
};
|
|
@@ -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(
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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.
|
|
137
|
-
} catch {
|
|
138
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|