@sovovs/bycli 2.1.35 → 2.1.37
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 +49 -1
- package/clis/toutiao/search.js +63 -0
- package/clis/toutiao/utils.js +110 -1
- package/clis/weixin/create-draft.js +18 -8
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -25493,6 +25493,54 @@
|
|
|
25493
25493
|
"modulePath": "toutiao/hot.js",
|
|
25494
25494
|
"sourceFile": "toutiao/hot.js"
|
|
25495
25495
|
},
|
|
25496
|
+
{
|
|
25497
|
+
"site": "toutiao",
|
|
25498
|
+
"name": "search",
|
|
25499
|
+
"description": "搜索今日头条公开站内内容",
|
|
25500
|
+
"access": "read",
|
|
25501
|
+
"domain": "www.toutiao.com",
|
|
25502
|
+
"strategy": "public",
|
|
25503
|
+
"browser": false,
|
|
25504
|
+
"args": [
|
|
25505
|
+
{
|
|
25506
|
+
"name": "query",
|
|
25507
|
+
"type": "string",
|
|
25508
|
+
"required": false,
|
|
25509
|
+
"positional": true,
|
|
25510
|
+
"help": "搜索关键词"
|
|
25511
|
+
},
|
|
25512
|
+
{
|
|
25513
|
+
"name": "type",
|
|
25514
|
+
"type": "string",
|
|
25515
|
+
"default": "synthesis",
|
|
25516
|
+
"required": false,
|
|
25517
|
+
"help": "搜索类型 (synthesis/information/video/atlas/user/xiaoshipin/weitoutiao/music)"
|
|
25518
|
+
},
|
|
25519
|
+
{
|
|
25520
|
+
"name": "limit",
|
|
25521
|
+
"type": "int",
|
|
25522
|
+
"default": 20,
|
|
25523
|
+
"required": false,
|
|
25524
|
+
"help": "返回条数 (1-50)"
|
|
25525
|
+
}
|
|
25526
|
+
],
|
|
25527
|
+
"columns": [
|
|
25528
|
+
"rank",
|
|
25529
|
+
"title",
|
|
25530
|
+
"url",
|
|
25531
|
+
"source",
|
|
25532
|
+
"publish_time",
|
|
25533
|
+
"summary",
|
|
25534
|
+
"image_url",
|
|
25535
|
+
"like_count",
|
|
25536
|
+
"comment_count",
|
|
25537
|
+
"share_count",
|
|
25538
|
+
"read_count"
|
|
25539
|
+
],
|
|
25540
|
+
"type": "js",
|
|
25541
|
+
"modulePath": "toutiao/search.js",
|
|
25542
|
+
"sourceFile": "toutiao/search.js"
|
|
25543
|
+
},
|
|
25496
25544
|
{
|
|
25497
25545
|
"site": "tvmaze",
|
|
25498
25546
|
"name": "search",
|
|
@@ -28297,7 +28345,7 @@
|
|
|
28297
28345
|
"example": "bycli weixin create-draft --title \"文章标题\" --content-file article.html --content-format html --cover-image cover.jpg",
|
|
28298
28346
|
"domain": "mp.weixin.qq.com",
|
|
28299
28347
|
"strategy": "cookie",
|
|
28300
|
-
"browser":
|
|
28348
|
+
"browser": "conditional",
|
|
28301
28349
|
"args": [
|
|
28302
28350
|
{
|
|
28303
28351
|
"name": "title",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toutiao public site search — extracts rich result cards from the rendered
|
|
3
|
+
* public search page. No authentication required.
|
|
4
|
+
*/
|
|
5
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
6
|
+
import { CommandExecutionError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
7
|
+
import { parseSearchLimit, parseSearchType, parseToutiaoSearchHtml, TOUTIAO_SEARCH_URL } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'toutiao',
|
|
11
|
+
name: 'search',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: '搜索今日头条公开站内内容',
|
|
14
|
+
domain: 'www.toutiao.com',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'query', type: 'string', positional: true, help: '搜索关键词' },
|
|
19
|
+
{ name: 'type', type: 'string', default: 'synthesis', help: '搜索类型 (synthesis/information/video/atlas/user/xiaoshipin/weitoutiao/music)' },
|
|
20
|
+
{ name: 'limit', type: 'int', default: 20, help: '返回条数 (1-50)' },
|
|
21
|
+
],
|
|
22
|
+
columns: [
|
|
23
|
+
'rank', 'title', 'url', 'source', 'publish_time', 'summary', 'image_url',
|
|
24
|
+
'like_count', 'comment_count', 'share_count', 'read_count',
|
|
25
|
+
],
|
|
26
|
+
func: async (_page, kwargs) => {
|
|
27
|
+
const query = String(kwargs?.query ?? '').trim();
|
|
28
|
+
if (!query) throw new CommandExecutionError('toutiao search requires a non-empty query');
|
|
29
|
+
const type = parseSearchType(kwargs?.type, 'synthesis');
|
|
30
|
+
const limit = parseSearchLimit(kwargs?.limit, 20);
|
|
31
|
+
const url = new URL(TOUTIAO_SEARCH_URL);
|
|
32
|
+
url.searchParams.set('keyword', query);
|
|
33
|
+
url.searchParams.set('pd', type);
|
|
34
|
+
url.searchParams.set('page_num', '0');
|
|
35
|
+
|
|
36
|
+
let resp;
|
|
37
|
+
try {
|
|
38
|
+
resp = await fetch(url, {
|
|
39
|
+
headers: {
|
|
40
|
+
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
41
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
42
|
+
Referer: 'https://www.toutiao.com/',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw new CommandExecutionError(`toutiao search request failed: ${error?.message || error}`);
|
|
47
|
+
}
|
|
48
|
+
if (!resp.ok) {
|
|
49
|
+
throw new CommandExecutionError(`toutiao search failed: HTTP ${resp.status}`);
|
|
50
|
+
}
|
|
51
|
+
let html;
|
|
52
|
+
try {
|
|
53
|
+
html = await resp.text();
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new CommandExecutionError(`toutiao search response read failed: ${error?.message || error}`);
|
|
56
|
+
}
|
|
57
|
+
const rows = parseToutiaoSearchHtml(html, limit);
|
|
58
|
+
if (rows.length === 0) {
|
|
59
|
+
throw new EmptyResultError('toutiao search', `未找到与「${query}」相关的公开内容。`);
|
|
60
|
+
}
|
|
61
|
+
return rows;
|
|
62
|
+
},
|
|
63
|
+
});
|
package/clis/toutiao/utils.js
CHANGED
|
@@ -7,6 +7,9 @@ const ARTICLES_MIN_PAGE = 1;
|
|
|
7
7
|
const ARTICLES_MAX_PAGE = 4;
|
|
8
8
|
const HOT_MIN_LIMIT = 1;
|
|
9
9
|
const HOT_MAX_LIMIT = 50;
|
|
10
|
+
const SEARCH_MIN_LIMIT = 1;
|
|
11
|
+
const SEARCH_MAX_LIMIT = 50;
|
|
12
|
+
const SEARCH_TYPES = ['synthesis', 'information', 'video', 'atlas', 'user', 'xiaoshipin', 'weitoutiao', 'music'];
|
|
10
13
|
|
|
11
14
|
export function parseArticlesPage(raw, fallback = 1) {
|
|
12
15
|
if (raw === undefined || raw === null || raw === '') return fallback;
|
|
@@ -32,6 +35,27 @@ export function parseHotLimit(raw, fallback = 30) {
|
|
|
32
35
|
return parsed;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
export function parseSearchLimit(raw, fallback = 20) {
|
|
39
|
+
if (raw === undefined || raw === null || raw === '') return fallback;
|
|
40
|
+
const parsed = Number(raw);
|
|
41
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
42
|
+
throw new ArgumentError(`--limit must be an integer between ${SEARCH_MIN_LIMIT} and ${SEARCH_MAX_LIMIT}, got ${JSON.stringify(raw)}`);
|
|
43
|
+
}
|
|
44
|
+
if (parsed < SEARCH_MIN_LIMIT || parsed > SEARCH_MAX_LIMIT) {
|
|
45
|
+
throw new ArgumentError(`--limit must be between ${SEARCH_MIN_LIMIT} and ${SEARCH_MAX_LIMIT}, got ${parsed}`);
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseSearchType(raw, fallback = 'synthesis') {
|
|
51
|
+
if (raw === undefined || raw === null || raw === '') return fallback;
|
|
52
|
+
const value = String(raw).trim();
|
|
53
|
+
if (!SEARCH_TYPES.includes(value)) {
|
|
54
|
+
throw new ArgumentError(`--type must be one of ${SEARCH_TYPES.join(', ')}, got ${JSON.stringify(raw)}`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
const NON_TITLE_LINES = new Set([
|
|
36
60
|
'展现', '阅读', '点赞', '评论',
|
|
37
61
|
'查看数据', '查看评论', '修改', '更多', '首发',
|
|
@@ -149,6 +173,83 @@ export function mapHotRow(item, index) {
|
|
|
149
173
|
}
|
|
150
174
|
|
|
151
175
|
export const HOT_BOARD_URL = 'https://www.toutiao.com/hot-event/hot-board/?origin=toutiao_pc';
|
|
176
|
+
export const TOUTIAO_SEARCH_URL = 'https://www.toutiao.com/search/';
|
|
177
|
+
|
|
178
|
+
function parseSearchNumber(value) {
|
|
179
|
+
if (value === undefined || value === null || value === '') return null;
|
|
180
|
+
const parsed = Number(String(value).replace(/,/g, ''));
|
|
181
|
+
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function absoluteSearchUrl(value) {
|
|
185
|
+
const url = trimOrNull(value);
|
|
186
|
+
if (!url) return null;
|
|
187
|
+
try {
|
|
188
|
+
return new URL(url, 'https://www.toutiao.com/').toString();
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function searchImage(item) {
|
|
195
|
+
const candidates = [
|
|
196
|
+
item?.image_url,
|
|
197
|
+
item?.large_image_url,
|
|
198
|
+
item?.other_image_url,
|
|
199
|
+
...(Array.isArray(item?.image_list) ? item.image_list.map((image) => image?.url || image) : []),
|
|
200
|
+
...(Array.isArray(item?.detail_image_list) ? item.detail_image_list.map((image) => image?.url || image) : []),
|
|
201
|
+
];
|
|
202
|
+
return candidates.map(trimOrNull).find(Boolean) || null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function searchRowFromCard(item, index) {
|
|
206
|
+
if (!item || typeof item !== 'object') return null;
|
|
207
|
+
const title = trimOrNull(item.title);
|
|
208
|
+
const url = absoluteSearchUrl(
|
|
209
|
+
item.article_url || item.open_url || item.source_url || item.item_source_url || item?.display?.info?.url,
|
|
210
|
+
);
|
|
211
|
+
if (!title || !url) return null;
|
|
212
|
+
return {
|
|
213
|
+
rank: index + 1,
|
|
214
|
+
title,
|
|
215
|
+
url,
|
|
216
|
+
source: trimOrNull(item.source || item.media_name),
|
|
217
|
+
publish_time: trimOrNull(item.datetime || item.publish_time || item.display_time),
|
|
218
|
+
summary: trimOrNull(item.abstract || item.summary || item?.emphasized?.summary),
|
|
219
|
+
image_url: searchImage(item),
|
|
220
|
+
like_count: parseSearchNumber(item.like_count ?? item.digg_count),
|
|
221
|
+
comment_count: parseSearchNumber(item.comment_count),
|
|
222
|
+
share_count: parseSearchNumber(item.share_count ?? item.repin_count ?? item.forward_count),
|
|
223
|
+
read_count: parseSearchNumber(item.read_count),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Extract search result cards embedded in the public Toutiao search page.
|
|
229
|
+
* The page currently serializes each result as an application/json script.
|
|
230
|
+
*/
|
|
231
|
+
export function parseToutiaoSearchHtml(html, limit = 20) {
|
|
232
|
+
const source = String(html || '');
|
|
233
|
+
const rows = [];
|
|
234
|
+
const seenUrls = new Set();
|
|
235
|
+
const scriptPattern = /<script\b[^>]*type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
236
|
+
let match;
|
|
237
|
+
while ((match = scriptPattern.exec(source))) {
|
|
238
|
+
let payload;
|
|
239
|
+
try {
|
|
240
|
+
payload = JSON.parse(match[1]);
|
|
241
|
+
} catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const candidate = payload?.data;
|
|
245
|
+
const row = searchRowFromCard(candidate, rows.length);
|
|
246
|
+
if (!row || seenUrls.has(row.url)) continue;
|
|
247
|
+
seenUrls.add(row.url);
|
|
248
|
+
rows.push(row);
|
|
249
|
+
if (rows.length >= limit) break;
|
|
250
|
+
}
|
|
251
|
+
return rows.map((row, index) => ({ ...row, rank: index + 1 }));
|
|
252
|
+
}
|
|
152
253
|
|
|
153
254
|
export function looksToutiaoAuthWallText(value) {
|
|
154
255
|
const text = String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
@@ -158,4 +259,12 @@ export function looksToutiaoAuthWallText(value) {
|
|
|
158
259
|
/mp\.toutiao\.com\/profile_v4\/login/.test(text);
|
|
159
260
|
}
|
|
160
261
|
|
|
161
|
-
export const __test__ = {
|
|
262
|
+
export const __test__ = {
|
|
263
|
+
ARTICLES_MIN_PAGE,
|
|
264
|
+
ARTICLES_MAX_PAGE,
|
|
265
|
+
HOT_MIN_LIMIT,
|
|
266
|
+
HOT_MAX_LIMIT,
|
|
267
|
+
SEARCH_MIN_LIMIT,
|
|
268
|
+
SEARCH_MAX_LIMIT,
|
|
269
|
+
SEARCH_TYPES,
|
|
270
|
+
};
|
|
@@ -41,6 +41,20 @@ function validateCoverImage(value) {
|
|
|
41
41
|
return coverPath;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function readApiCredentials(kwargs) {
|
|
45
|
+
const appid = kwargs.appid == null ? '' : String(kwargs.appid).trim();
|
|
46
|
+
const appsecret = kwargs.appsecret == null ? '' : String(kwargs.appsecret).trim();
|
|
47
|
+
if (Boolean(appid) !== Boolean(appsecret)) {
|
|
48
|
+
throw new ArgumentError('appid and appsecret must be provided together');
|
|
49
|
+
}
|
|
50
|
+
return { appid: appid || null, appsecret: appsecret || null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requiresBrowser(kwargs) {
|
|
54
|
+
const { appid, appsecret } = readApiCredentials(kwargs);
|
|
55
|
+
return !(appid && appsecret);
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
function normalizeCreateDraftArgs(kwargs) {
|
|
45
59
|
const title = requiredText(kwargs.title, 'title');
|
|
46
60
|
if (codePointLength(title) > MAX_TITLE_LENGTH) {
|
|
@@ -55,11 +69,7 @@ function normalizeCreateDraftArgs(kwargs) {
|
|
|
55
69
|
contentFile: kwargs['content-file'],
|
|
56
70
|
contentFormat: kwargs['content-format'],
|
|
57
71
|
});
|
|
58
|
-
const
|
|
59
|
-
const appsecret = kwargs.appsecret == null ? '' : String(kwargs.appsecret).trim();
|
|
60
|
-
if (Boolean(appid) !== Boolean(appsecret)) {
|
|
61
|
-
throw new ArgumentError('appid and appsecret must be provided together');
|
|
62
|
-
}
|
|
72
|
+
const { appid, appsecret } = readApiCredentials(kwargs);
|
|
63
73
|
return {
|
|
64
74
|
title,
|
|
65
75
|
...draftContent,
|
|
@@ -67,8 +77,8 @@ function normalizeCreateDraftArgs(kwargs) {
|
|
|
67
77
|
summary: kwargs.summary == null ? null : String(kwargs.summary).trim(),
|
|
68
78
|
coverImage: validateCoverImage(kwargs['cover-image']),
|
|
69
79
|
dryRun: kwargs['dry-run'] === true,
|
|
70
|
-
appid
|
|
71
|
-
appsecret
|
|
80
|
+
appid,
|
|
81
|
+
appsecret,
|
|
72
82
|
};
|
|
73
83
|
}
|
|
74
84
|
|
|
@@ -361,7 +371,7 @@ export const createDraftCommand = cli({
|
|
|
361
371
|
example: 'bycli weixin create-draft --title "文章标题" --content-file article.html --content-format html --cover-image cover.jpg',
|
|
362
372
|
domain: WEIXIN_DOMAIN,
|
|
363
373
|
strategy: Strategy.COOKIE,
|
|
364
|
-
browser:
|
|
374
|
+
browser: requiresBrowser,
|
|
365
375
|
navigateBefore: false,
|
|
366
376
|
args: [
|
|
367
377
|
{ name: 'title', required: true, help: '文章标题(最长 64 字)' },
|