@sovovs/bycli 2.1.36 → 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 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",
@@ -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
+ });
@@ -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__ = { ARTICLES_MIN_PAGE, ARTICLES_MAX_PAGE, HOT_MIN_LIMIT, HOT_MAX_LIMIT };
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.36",
3
+ "version": "2.1.37",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },