@sovovs/bycli 2.1.37 → 2.1.39

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.
@@ -0,0 +1,95 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import {
3
+ ArgumentError,
4
+ CommandExecutionError,
5
+ ConfigError,
6
+ EmptyResultError,
7
+ } from '@sovovs/bycli/errors';
8
+
9
+ import { readKnowledgeBaseFromChrome } from './native-client.js';
10
+ import { toKnowledgeRow } from './utils.js';
11
+
12
+ const COMMAND = 'ima knowledge';
13
+
14
+ function throwDriverError(envelope) {
15
+ const message = envelope?.message || 'ima reader failed';
16
+ switch (envelope?.code) {
17
+ case 'EMPTY_QUERY':
18
+ case 'AMBIGUOUS_KNOWLEDGE':
19
+ throw new ArgumentError(message);
20
+ case 'KNOWLEDGE_NOT_FOUND':
21
+ throw new EmptyResultError(COMMAND, message);
22
+ case 'IMA_CHROME_AUTH_REQUIRED':
23
+ throw new ConfigError(
24
+ message,
25
+ 'Open https://ima.qq.com/wikis in Chrome, sign in, then retry with the latest bycli Browser Bridge extension.',
26
+ );
27
+ default:
28
+ throw new CommandExecutionError(message);
29
+ }
30
+ }
31
+
32
+ export async function runKnowledgeCommand(kwargs, read) {
33
+ const query = String(kwargs?.knowledgeBase ?? '').trim();
34
+ if (!query) {
35
+ throw new ArgumentError('knowledge-base name or ID is required');
36
+ }
37
+
38
+ let envelope;
39
+ try {
40
+ envelope = await read(query);
41
+ } catch (error) {
42
+ if (error instanceof ArgumentError || error instanceof ConfigError
43
+ || error instanceof EmptyResultError || error instanceof CommandExecutionError) {
44
+ throw error;
45
+ }
46
+ if (error && typeof error === 'object' && typeof error.code === 'string') {
47
+ throwDriverError({ code: error.code, message: error.message });
48
+ }
49
+ throw new CommandExecutionError(
50
+ `ima knowledge reader failed: ${error instanceof Error ? error.message : String(error)}`,
51
+ );
52
+ }
53
+ if (!envelope?.ok) throwDriverError(envelope);
54
+ if (!Array.isArray(envelope.items)) {
55
+ throw new CommandExecutionError('ima knowledge reader returned malformed items');
56
+ }
57
+ if (envelope.items.length === 0) {
58
+ throw new EmptyResultError(COMMAND, `Knowledge base "${query}" contains no readable articles.`);
59
+ }
60
+ return envelope.items.map(toKnowledgeRow);
61
+ }
62
+
63
+ export const knowledgeCommand = cli({
64
+ site: 'ima',
65
+ name: 'knowledge',
66
+ access: 'read',
67
+ description: '按名称或 ID 获取 ima 知识库中的文章标题、URL 与文件夹路径',
68
+ domain: 'ima.qq.com',
69
+ defaultFormat: 'json',
70
+ args: [
71
+ {
72
+ name: 'knowledgeBase',
73
+ type: 'string',
74
+ required: true,
75
+ positional: true,
76
+ help: '知识库的完整名称或 ima 页面中的 knowledgeBaseId',
77
+ },
78
+ ],
79
+ columns: [
80
+ 'knowledgeBaseId',
81
+ 'knowledgeBase',
82
+ 'folderPath',
83
+ 'title',
84
+ 'url',
85
+ 'contentType',
86
+ 'addedDate',
87
+ ],
88
+ strategy: Strategy.COOKIE,
89
+ browser: true,
90
+ navigateBefore: false,
91
+ func: async (page, kwargs) => runKnowledgeCommand(
92
+ kwargs,
93
+ (query) => readKnowledgeBaseFromChrome(page, query),
94
+ ),
95
+ });
@@ -0,0 +1,169 @@
1
+ const MEDIA_TYPE_NAMES = new Map([
2
+ [0, '未知'], [1, 'PDF'], [2, '网址'], [3, 'WORD'], [4, 'PPT'],
3
+ [5, 'EXCEL'], [6, '公众号'], [7, 'MD'], [9, '图片'], [11, '笔记'],
4
+ [12, '问答'], [13, 'TXT'], [14, 'XMIND'], [15, '音频'], [16, '视频网站'],
5
+ [19, '播客'], [20, 'HTML'], [21, 'EPUB'], [98, '源代码'], [99, '文件夹'],
6
+ ]);
7
+ function field(value, camelName, snakeName) {
8
+ return value?.[camelName] ?? value?.[snakeName];
9
+ }
10
+
11
+ function codedError(code, message) {
12
+ return Object.assign(new Error(message), { code });
13
+ }
14
+
15
+ function knowledgeBaseFromRaw(raw) {
16
+ const basicInfo = field(raw, 'basicInfo', 'basic_info') || {};
17
+ return {
18
+ id: String(field(raw, 'id', 'id') || ''),
19
+ name: String(field(basicInfo, 'name', 'name') || ''),
20
+ };
21
+ }
22
+
23
+ function basesFromGroups(response) {
24
+ const groups = field(response, 'results', 'results');
25
+ if (!Array.isArray(groups)) throw new Error('ima API returned malformed knowledge-base groups');
26
+ return groups.flatMap((group) => {
27
+ const list = field(group, 'knowledgeBaseList', 'knowledge_base_list');
28
+ return Array.isArray(list) ? list.map(knowledgeBaseFromRaw) : [];
29
+ });
30
+ }
31
+
32
+ export async function findKnowledgeBase(query, request) {
33
+ const initialGroups = [
34
+ { type: 1001, limit: 20 },
35
+ { type: 1002, limit: 20 },
36
+ { type: 1004, limit: 20 },
37
+ { type: 1005, limit: 50 },
38
+ ];
39
+ let response = await request('/get_knowledge_base_list', {
40
+ params: initialGroups.map(({ type, limit }) => ({ type, cursor: '', limit })),
41
+ });
42
+ const all = [];
43
+ const pendingPages = [];
44
+ const queuedPages = new Set();
45
+
46
+ for (;;) {
47
+ if (Number(response?.code) !== 0) {
48
+ throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
49
+ }
50
+ all.push(...basesFromGroups(response));
51
+ const groups = field(response, 'results', 'results');
52
+ for (const group of groups) {
53
+ const cursor = field(group, 'nextCursor', 'next_cursor');
54
+ if (field(group, 'isEnd', 'is_end') === false) {
55
+ const type = Number(field(group, 'type', 'type'));
56
+ if (!cursor) {
57
+ throw new Error(`ima API returned a missing cursor for knowledge-base group ${type}`);
58
+ }
59
+ const pageKey = `${type}:${cursor}`;
60
+ if (queuedPages.has(pageKey)) {
61
+ throw new Error(`ima API returned a repeated cursor for knowledge-base group ${type}`);
62
+ }
63
+ queuedPages.add(pageKey);
64
+ pendingPages.push({
65
+ type,
66
+ cursor: String(cursor),
67
+ limit: 10,
68
+ });
69
+ }
70
+ }
71
+ const next = pendingPages.shift();
72
+ if (!next) break;
73
+ response = await request('/get_knowledge_base_list', {
74
+ params: [next],
75
+ });
76
+ }
77
+
78
+ const matches = all.filter((base) => base.id === query || base.name === query);
79
+ if (matches.length === 0) {
80
+ throw codedError('KNOWLEDGE_NOT_FOUND', `Knowledge base "${query}" was not found`);
81
+ }
82
+ const unique = [...new Map(matches.map((base) => [base.id, base])).values()];
83
+ if (unique.length > 1) {
84
+ throw codedError('AMBIGUOUS_KNOWLEDGE', `Knowledge base name "${query}" is ambiguous`);
85
+ }
86
+ return unique[0];
87
+ }
88
+
89
+ function articleFromRaw(raw, knowledgeBaseId, knowledgeBaseName, folderPath) {
90
+ const mediaType = Number(field(raw, 'mediaType', 'media_type') ?? 0);
91
+ const jumpUrl = field(raw, 'jumpUrl', 'jump_url');
92
+ const sourcePath = field(raw, 'sourcePath', 'source_path');
93
+ return {
94
+ knowledgeBaseId,
95
+ knowledgeBase: knowledgeBaseName,
96
+ folderPath,
97
+ title: field(raw, 'title', 'title') || '',
98
+ url: jumpUrl || (/^https?:\/\//i.test(sourcePath || '') ? sourcePath : null),
99
+ contentType: MEDIA_TYPE_NAMES.get(mediaType) ?? String(mediaType),
100
+ addedDate: field(raw, 'timeWording', 'time_wording')
101
+ || field(raw, 'createTime', 'create_time')
102
+ || null,
103
+ };
104
+ }
105
+
106
+ export async function collectKnowledgeTree({ knowledgeBaseId, knowledgeBaseName, request }) {
107
+ const articles = [];
108
+ const pendingFolders = [{ id: '', path: [] }];
109
+ const visitedFolders = new Set();
110
+
111
+ while (pendingFolders.length > 0) {
112
+ const folder = pendingFolders.shift();
113
+ if (visitedFolders.has(folder.id)) continue;
114
+ visitedFolders.add(folder.id);
115
+ let cursor = '';
116
+ const visitedCursors = new Set();
117
+
118
+ do {
119
+ if (visitedCursors.has(cursor)) {
120
+ throw new Error(`ima API returned a repeated cursor for folder ${folder.id || 'root'}`);
121
+ }
122
+ visitedCursors.add(cursor);
123
+ const response = await request('/get_knowledge_list', {
124
+ cursor,
125
+ limit: 20,
126
+ knowledge_base_id: knowledgeBaseId,
127
+ need_default_cover: true,
128
+ ...(folder.id ? { folder_id: folder.id } : {}),
129
+ ext_info: { share_id: '' },
130
+ });
131
+ if (Number(response?.code) !== 0) {
132
+ throw new Error(response?.msg || `ima API error ${response?.code ?? 'unknown'}`);
133
+ }
134
+
135
+ const items = field(response, 'knowledgeList', 'knowledge_list');
136
+ if (!Array.isArray(items)) throw new Error('ima API returned malformed knowledge_list');
137
+ for (const item of items) {
138
+ const mediaType = Number(field(item, 'mediaType', 'media_type') ?? 0);
139
+ if (mediaType === 99) {
140
+ const info = field(item, 'folderInfo', 'folder_info') || {};
141
+ const id = field(info, 'folderId', 'folder_id');
142
+ const name = field(info, 'name', 'name');
143
+ if (id && name) pendingFolders.push({ id, path: [...folder.path, name] });
144
+ continue;
145
+ }
146
+ articles.push(articleFromRaw(item, knowledgeBaseId, knowledgeBaseName, folder.path));
147
+ }
148
+
149
+ const isEnd = field(response, 'isEnd', 'is_end') !== false;
150
+ cursor = String(field(response, 'nextCursor', 'next_cursor') || '');
151
+ if (!isEnd && !cursor) {
152
+ throw new Error(`ima API returned a missing cursor for folder ${folder.id || 'root'}`);
153
+ }
154
+ if (isEnd) cursor = '';
155
+ } while (cursor);
156
+ }
157
+
158
+ return articles;
159
+ }
160
+
161
+ export async function readKnowledgeBaseFromApi(query, request) {
162
+ const knowledgeBase = await findKnowledgeBase(query, request);
163
+ const items = await collectKnowledgeTree({
164
+ knowledgeBaseId: knowledgeBase.id,
165
+ knowledgeBaseName: knowledgeBase.name,
166
+ request,
167
+ });
168
+ return { ok: true, items };
169
+ }
@@ -0,0 +1,70 @@
1
+ import { readKnowledgeBaseFromApi } from './native-api.js';
2
+
3
+ const IMA_WIKIS_URL = 'https://ima.qq.com/wikis';
4
+
5
+ function codedError(code, message) {
6
+ return Object.assign(new Error(message), { code });
7
+ }
8
+
9
+ const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
10
+
11
+ async function waitForImaAuth(page, timeoutMs, sleep) {
12
+ const deadline = Date.now() + timeoutMs;
13
+ do {
14
+ const auth = await page.readImaAuth();
15
+ if (auth?.authId) return auth.authId;
16
+ if (Date.now() >= deadline) break;
17
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
18
+ } while (true);
19
+ throw codedError(
20
+ 'IMA_CHROME_AUTH_REQUIRED',
21
+ 'ima reader authentication was not observed in the Chrome session',
22
+ );
23
+ }
24
+
25
+ async function triggerImaAuthRequest(page, query) {
26
+ await page.evaluate((knowledgeBase) => {
27
+ const candidates = [...document.querySelectorAll('._knowledgeListItem_xfmpc_1')];
28
+ const target = candidates.find((element) => element.innerText?.trim() === knowledgeBase)
29
+ ?? candidates[0];
30
+ if (!target) return false;
31
+ target.click();
32
+ return true;
33
+ }, query);
34
+ }
35
+
36
+ export async function readKnowledgeBaseFromChrome(page, query, dependencies = {}) {
37
+ if (!page || typeof page.startImaAuthCapture !== 'function'
38
+ || typeof page.readImaAuth !== 'function' || typeof page.requestImaReader !== 'function'
39
+ || typeof page.evaluate !== 'function') {
40
+ throw codedError(
41
+ 'IMA_CHROME_AUTH_REQUIRED',
42
+ 'The installed bycli Browser Bridge does not support private ima reader authentication',
43
+ );
44
+ }
45
+ const timeoutMs = dependencies.timeoutMs ?? 30_000;
46
+ const sleep = dependencies.sleep ?? wait;
47
+ let authId;
48
+ try {
49
+ await page.startImaAuthCapture();
50
+ await page.goto(IMA_WIKIS_URL);
51
+ await triggerImaAuthRequest(page, query);
52
+ authId = await waitForImaAuth(page, timeoutMs, sleep);
53
+ } catch (error) {
54
+ if (error?.code === 'IMA_CHROME_AUTH_REQUIRED') throw error;
55
+ throw codedError(
56
+ 'IMA_CHROME_AUTH_REQUIRED',
57
+ `Chrome Browser Bridge could not acquire ima reader authentication: ${error instanceof Error ? error.message : String(error)}`,
58
+ );
59
+ }
60
+ try {
61
+ return await readKnowledgeBaseFromApi(
62
+ query,
63
+ (path, body) => page.requestImaReader(authId, path, body),
64
+ );
65
+ } finally {
66
+ if (typeof page.releaseImaAuth === 'function') {
67
+ await page.releaseImaAuth(authId).catch(() => {});
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,48 @@
1
+ const VOLATILE_QUERY_KEYS = new Set([
2
+ 'sessionid',
3
+ 'pass_ticket',
4
+ 'exportkey',
5
+ 'scene',
6
+ 'ascene',
7
+ 'devicetype',
8
+ 'version',
9
+ 'nettype',
10
+ 'abtest_cookie',
11
+ 'lang',
12
+ 'countrycode',
13
+ 'fontscale',
14
+ 'wx_header',
15
+ ]);
16
+
17
+ export function normalizeArticleUrl(value) {
18
+ if (typeof value !== 'string' || !value.trim()) return null;
19
+
20
+ try {
21
+ const raw = value.trim();
22
+ const parsed = new URL(raw.includes('://') ? raw : `https://${raw}`);
23
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
24
+
25
+ parsed.hash = '';
26
+ for (const key of [...parsed.searchParams.keys()]) {
27
+ const normalizedKey = key.toLowerCase();
28
+ if (VOLATILE_QUERY_KEYS.has(normalizedKey) || normalizedKey.startsWith('utm_')) {
29
+ parsed.searchParams.delete(key);
30
+ }
31
+ }
32
+ return parsed.toString();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function toKnowledgeRow(item) {
39
+ return {
40
+ knowledgeBaseId: item.knowledgeBaseId || null,
41
+ knowledgeBase: item.knowledgeBase,
42
+ folderPath: Array.isArray(item.folderPath) ? item.folderPath : [],
43
+ title: item.title,
44
+ url: normalizeArticleUrl(item.url),
45
+ contentType: item.contentType || null,
46
+ addedDate: item.addedDate || null,
47
+ };
48
+ }
@@ -0,0 +1,36 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
3
+ import { emptySearchResults, requireBoundedInteger, requireSearchQuery, runBrowserStep } from '../_shared/search-adapter.js';
4
+
5
+ const TYPES = ['web', 'news', 'video', 'image']; const SAFE = ['off', 'on'];
6
+
7
+ export function buildSearchUrl({ keyword, limit = 10, page = 1, type, safe }) {
8
+ const url = new URL('https://so.com/s'); url.searchParams.set('q', keyword); url.searchParams.set('pn', String(page)); url.searchParams.set('num', String(limit));
9
+ if (type && type !== 'web') url.searchParams.set('type', type); if (safe) url.searchParams.set('safe', safe); return url.toString();
10
+ }
11
+
12
+ function extractItems() {
13
+ const blocked = /验证码|安全验证|访问异常|captcha|access denied/i.test(document.body?.textContent || ''); const items = []; const seen = new Set();
14
+ for (const card of document.querySelectorAll('.result, .res-list')) {
15
+ if (/result-ad|ad-result|推广/.test(card.className || '') || /推广/.test(card.textContent || '')) continue;
16
+ const anchor = card.querySelector('h3 a[href], h2 a[href]'); if (!anchor || !/^https?:/i.test(anchor.href) || seen.has(anchor.href)) continue;
17
+ seen.add(anchor.href); items.push({ title: (anchor.textContent || '').trim(), url: anchor.href, snippet: (card.querySelector('.res-desc, .res-rich, p')?.textContent || '').trim(), displayUrl: (card.querySelector('cite, .res-link')?.textContent || '').trim(), resultType: 'web', extra: {} });
18
+ }
19
+ return { blocked, items };
20
+ }
21
+
22
+ export const command = cli({
23
+ site: 'so', name: 'search', access: 'read', description: 'Search 360 Search', domain: 'so.com', strategy: Strategy.PUBLIC, browser: true,
24
+ args: [{ name: 'keyword', positional: true, required: true, help: 'Search query' }, { name: 'limit', type: 'int', default: 10, help: 'Number of results (1-50)' }, { name: 'page', type: 'int', default: 1, help: 'Result page number' }, { name: 'type', help: 'Result type: web, news, video, image' }, { name: 'safe', help: 'Safe search: off or on' }],
25
+ columns: ['rank', 'title', 'url', 'snippet', 'displayUrl', 'source', 'resultType', 'author', 'publishedAt', 'score', 'extra'],
26
+ func: async (page, kwargs) => {
27
+ const keyword = requireSearchQuery(kwargs.keyword); const limit = requireBoundedInteger(kwargs.limit, 10, 1, 50, '--limit'); const pageNumber = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
28
+ if (kwargs.type !== undefined && !TYPES.includes(String(kwargs.type))) throw new ArgumentError(`--type must be one of: ${TYPES.join(', ')}`); if (kwargs.safe !== undefined && !SAFE.includes(String(kwargs.safe))) throw new ArgumentError('--safe must be one of: off, on');
29
+ await runBrowserStep('360 search navigation', () => page.goto(buildSearchUrl({ keyword, limit, page: pageNumber, type: kwargs.type, safe: kwargs.safe })));
30
+ await page.wait({ selector: '.result, .res-list', timeout: 8 }).catch(() => page.wait(2).catch(() => {})); const data = await runBrowserStep('360 search extraction', () => page.evaluate(`(${extractItems.toString()})()`));
31
+ if (data?.blocked) throw new CommandExecutionError('360 Search was blocked by a verification page', 'Complete the verification in the browser and retry.'); if (!data?.items?.length) throw emptySearchResults('360 Search', keyword);
32
+ return data.items.slice(0, limit).map((item, index) => ({ rank: (pageNumber - 1) * limit + index + 1, ...item, source: 'so', author: null, publishedAt: null, score: null }));
33
+ },
34
+ });
35
+
36
+ export const __test__ = { command, buildSearchUrl };
@@ -0,0 +1,35 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
3
+ import { emptySearchResults, requireBoundedInteger, requireSearchQuery, runBrowserStep } from '../_shared/search-adapter.js';
4
+
5
+ const TYPES = ['web', 'news', 'video', 'image']; const TIMES = ['day', 'week', 'month', 'year']; const SORTS = ['relevance', 'date'];
6
+
7
+ export function buildSearchUrl({ keyword, limit = 10, page = 1, type, time, sort }) {
8
+ const url = new URL('https://sogou.com/web'); url.searchParams.set('query', keyword); url.searchParams.set('num', String(limit)); url.searchParams.set('page', String(page));
9
+ if (type && type !== 'web') url.searchParams.set('type', type); if (time) url.searchParams.set('tsn', time); if (sort === 'date') url.searchParams.set('sort', 'time'); return url.toString();
10
+ }
11
+
12
+ function extractItems() {
13
+ const blocked = /验证码|安全验证|访问异常|请完成验证|captcha/i.test(document.body?.textContent || ''); const items = []; const seen = new Set();
14
+ for (const card of document.querySelectorAll('.vrwrap, .rb')) {
15
+ const anchor = card.querySelector('h3 a[href], h4 a[href]'); if (!anchor || !/^https?:/i.test(anchor.href) || seen.has(anchor.href)) continue;
16
+ if (/推广|广告/.test(card.textContent || '')) continue; seen.add(anchor.href); items.push({ title: (anchor.textContent || '').trim(), url: anchor.href, snippet: (card.querySelector('.str_info, .ft, .text-layout')?.textContent || '').trim(), displayUrl: (card.querySelector('cite, .citeurl')?.textContent || '').trim(), resultType: 'web', extra: {} });
17
+ }
18
+ return { blocked, items };
19
+ }
20
+
21
+ export const command = cli({
22
+ site: 'sogou', name: 'search', access: 'read', description: 'Search Sogou', domain: 'sogou.com', strategy: Strategy.PUBLIC, browser: true,
23
+ args: [{ name: 'keyword', positional: true, required: true, help: 'Search query' }, { name: 'limit', type: 'int', default: 10, help: 'Number of results (1-50)' }, { name: 'page', type: 'int', default: 1, help: 'Result page number' }, { name: 'type', help: 'Result type: web, news, video, image' }, { name: 'time', help: 'Time range: day, week, month, year' }, { name: 'sort', help: 'Sort order: relevance or date' }],
24
+ columns: ['rank', 'title', 'url', 'snippet', 'displayUrl', 'source', 'resultType', 'author', 'publishedAt', 'score', 'extra'],
25
+ func: async (page, kwargs) => {
26
+ const keyword = requireSearchQuery(kwargs.keyword); const limit = requireBoundedInteger(kwargs.limit, 10, 1, 50, '--limit'); const pageNumber = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
27
+ for (const [value, choices, flag] of [[kwargs.type, TYPES, 'type'], [kwargs.time, TIMES, 'time'], [kwargs.sort, SORTS, 'sort']]) if (value !== undefined && !choices.includes(String(value))) throw new ArgumentError(`--${flag} must be one of: ${choices.join(', ')}`);
28
+ await runBrowserStep('Sogou search navigation', () => page.goto(buildSearchUrl({ keyword, limit, page: pageNumber, type: kwargs.type, time: kwargs.time, sort: kwargs.sort })));
29
+ await page.wait({ selector: '.vrwrap, .rb', timeout: 8 }).catch(() => page.wait(2).catch(() => {})); const data = await runBrowserStep('Sogou search extraction', () => page.evaluate(`(${extractItems.toString()})()`));
30
+ if (data?.blocked) throw new CommandExecutionError('Sogou search was blocked by a verification page', 'Complete the verification in the browser and retry.'); if (!data?.items?.length) throw emptySearchResults('Sogou', keyword);
31
+ return data.items.slice(0, limit).map((item, index) => ({ rank: (pageNumber - 1) * limit + index + 1, ...item, source: 'sogou', author: null, publishedAt: null, score: null }));
32
+ },
33
+ });
34
+
35
+ export const __test__ = { command, buildSearchUrl };
@@ -0,0 +1,33 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@sovovs/bycli/errors';
3
+ import { emptySearchResults, requireBoundedInteger, requireSearchQuery, runBrowserStep } from '../_shared/search-adapter.js';
4
+
5
+ export function buildSearchUrl({ keyword, limit = 10, page = 1, author, since, until }) {
6
+ const url = new URL('https://www.threads.com/search'); url.searchParams.set('q', keyword); url.searchParams.set('limit', String(limit)); url.searchParams.set('page', String(page));
7
+ if (author) url.searchParams.set('author', String(author).replace(/^@+/, '')); if (since) url.searchParams.set('since', since); if (until) url.searchParams.set('until', until); return url.toString();
8
+ }
9
+
10
+ function extractItems() {
11
+ const body = document.body?.textContent || ''; const authRequired = /log in|sign up|登录|注册/i.test(body) && !document.querySelector('article[data-pressable-container], article'); const blocked = /captcha|unusual activity|try again later|暂时无法/i.test(body); const items = []; const seen = new Set();
12
+ for (const card of document.querySelectorAll('article[data-pressable-container], article')) {
13
+ const anchor = card.querySelector('a[href*="/post/"]'); if (!anchor || !/^https?:/i.test(anchor.href) || seen.has(anchor.href)) continue; seen.add(anchor.href);
14
+ const authorAnchor = card.querySelector('a[href^="/@"], a[href*="/@"]'); const author = (authorAnchor?.textContent || authorAnchor?.getAttribute('href') || '').trim().replace(/^@+/, '').replace(/^\//, '').split('/')[0] || null; const time = card.querySelector('time');
15
+ items.push({ title: (card.querySelector('.text, [data-pressable-container] div')?.textContent || card.textContent || '').trim().slice(0, 500), url: anchor.href, snippet: (card.textContent || '').trim().slice(0, 500), displayUrl: new URL(anchor.href).hostname + new URL(anchor.href).pathname, source: 'threads', resultType: 'post', author, publishedAt: time?.getAttribute('datetime') || null, score: null, extra: {} });
16
+ }
17
+ return { authRequired, blocked, items };
18
+ }
19
+
20
+ export const command = cli({
21
+ site: 'threads', name: 'search', access: 'read', description: 'Search Threads', domain: 'www.threads.com', strategy: Strategy.PUBLIC, browser: true,
22
+ args: [{ name: 'keyword', positional: true, required: true, help: 'Search query' }, { name: 'limit', type: 'int', default: 10, help: 'Number of posts (1-50)' }, { name: 'page', type: 'int', default: 1, help: 'Result page number' }, { name: 'author', help: 'Filter by author handle' }, { name: 'since', help: 'Only posts on or after YYYY-MM-DD' }, { name: 'until', help: 'Only posts on or before YYYY-MM-DD' }],
23
+ columns: ['rank', 'title', 'url', 'snippet', 'displayUrl', 'source', 'resultType', 'author', 'publishedAt', 'score', 'extra'],
24
+ func: async (page, kwargs) => {
25
+ const keyword = requireSearchQuery(kwargs.keyword); const limit = requireBoundedInteger(kwargs.limit, 10, 1, 50, '--limit'); const pageNumber = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
26
+ for (const [value, flag] of [[kwargs.since, 'since'], [kwargs.until, 'until']]) if (value !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(value))) throw new ArgumentError(`--${flag} must use YYYY-MM-DD format`);
27
+ await runBrowserStep('Threads search navigation', () => page.goto(buildSearchUrl({ keyword, limit, page: pageNumber, author: kwargs.author, since: kwargs.since, until: kwargs.until }))); await page.wait({ selector: 'article', timeout: 8 }).catch(() => page.wait(2).catch(() => {}));
28
+ const data = await runBrowserStep('Threads search extraction', () => page.evaluate(`(${extractItems.toString()})()`)); if (data?.authRequired) throw new AuthRequiredError('Threads search requires login', 'Open Threads in the browser and sign in before retrying.'); if (data?.blocked) throw new CommandExecutionError('Threads search was blocked by an anti-bot page', 'Complete the browser prompt and retry.'); if (!data?.items?.length) throw emptySearchResults('Threads', keyword);
29
+ return data.items.slice(0, limit).map((item, index) => ({ rank: (pageNumber - 1) * limit + index + 1, ...item }));
30
+ },
31
+ });
32
+
33
+ export const __test__ = { command, buildSearchUrl };
@@ -0,0 +1,43 @@
1
+ import { cli, Strategy } from '@sovovs/bycli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@sovovs/bycli/errors';
3
+ import { emptySearchResults, requireBoundedInteger, requireSearchQuery, runBrowserStep } from '../_shared/search-adapter.js';
4
+
5
+ const SORTS = ['relevance', 'date'];
6
+
7
+ export function buildSearchUrl({ keyword, limit = 10, page = 1, lr, lang, sort }) {
8
+ const url = new URL('https://yandex.com/search/');
9
+ url.searchParams.set('text', keyword); url.searchParams.set('numdoc', String(limit)); url.searchParams.set('p', String(page - 1));
10
+ if (lr) url.searchParams.set('lr', lr); if (lang) url.searchParams.set('lang', lang); if (sort === 'date') url.searchParams.set('how', 'tm');
11
+ return url.toString();
12
+ }
13
+
14
+ function extractItems() {
15
+ const blocked = /captcha|verify|access denied|consent/i.test(document.body?.textContent || '') && !document.querySelector('.serp-item');
16
+ const items = []; const seen = new Set();
17
+ for (const card of document.querySelectorAll('.serp-item')) {
18
+ const anchor = card.querySelector('h2 a[href], .OrganicTitle a[href]'); if (!anchor || !/^https?:/i.test(anchor.href) || seen.has(anchor.href)) continue;
19
+ seen.add(anchor.href); items.push({ title: (anchor.textContent || '').trim(), url: anchor.href, snippet: (card.querySelector('.OrganicText, .TextContainer')?.textContent || '').trim(), displayUrl: (card.querySelector('.Path, .OrganicUrl')?.textContent || '').trim(), resultType: 'web', extra: {} });
20
+ }
21
+ return { blocked, items };
22
+ }
23
+
24
+ export const command = cli({
25
+ site: 'yandex', name: 'search', access: 'read', description: 'Search Yandex', domain: 'yandex.com', strategy: Strategy.PUBLIC, browser: true,
26
+ args: [
27
+ { name: 'keyword', positional: true, required: true, help: 'Search query' }, { name: 'limit', type: 'int', default: 10, help: 'Number of results (1-50)' }, { name: 'page', type: 'int', default: 1, help: 'Result page number' },
28
+ { name: 'lr', help: 'Yandex region code' }, { name: 'lang', help: 'Language code' }, { name: 'sort', help: 'Sort order: relevance or date' },
29
+ ],
30
+ columns: ['rank', 'title', 'url', 'snippet', 'displayUrl', 'source', 'resultType', 'author', 'publishedAt', 'score', 'extra'],
31
+ func: async (page, kwargs) => {
32
+ const keyword = requireSearchQuery(kwargs.keyword); const limit = requireBoundedInteger(kwargs.limit, 10, 1, 50, '--limit'); const pageNumber = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
33
+ if (kwargs.sort !== undefined && !SORTS.includes(String(kwargs.sort))) throw new ArgumentError('--sort must be one of: relevance, date');
34
+ await runBrowserStep('Yandex search navigation', () => page.goto(buildSearchUrl({ keyword, limit, page: pageNumber, lr: kwargs.lr, lang: kwargs.lang, sort: kwargs.sort })));
35
+ await page.wait({ selector: '.serp-item', timeout: 8 }).catch(() => page.wait(2).catch(() => {}));
36
+ const data = await runBrowserStep('Yandex search extraction', () => page.evaluate(`(${extractItems.toString()})()`));
37
+ if (data?.blocked) throw new CommandExecutionError('Yandex search was blocked by a consent or verification page', 'Complete the page prompt in the browser and retry.');
38
+ if (!data?.items?.length) throw emptySearchResults('Yandex', keyword);
39
+ return data.items.slice(0, limit).map((item, index) => ({ rank: (pageNumber - 1) * limit + index + 1, ...item, source: 'yandex', author: null, publishedAt: null, score: null }));
40
+ },
41
+ });
42
+
43
+ export const __test__ = { command, buildSearchUrl };
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export interface DaemonCommand {
7
7
  id: string;
8
- action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
8
+ action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'ima-auth-start' | 'ima-auth-read' | 'ima-reader-request' | 'ima-auth-release' | 'wait-download' | 'cdp' | 'frames';
9
9
  /** Target page identity (targetId). Cross-layer contract with the extension. */
10
10
  page?: string;
11
11
  code?: string;
@@ -32,6 +32,9 @@ export interface DaemonCommand {
32
32
  text?: string;
33
33
  /** URL substring filter pattern for network capture */
34
34
  pattern?: string;
35
+ authId?: string;
36
+ readerPath?: string;
37
+ readerBody?: Record<string, unknown>;
35
38
  /** Download wait timeout in milliseconds */
36
39
  timeoutMs?: number;
37
40
  cdpMethod?: string;
@@ -58,6 +58,12 @@ export declare class Page extends BasePage {
58
58
  screenshot(options?: ScreenshotOptions): Promise<string>;
59
59
  startNetworkCapture(pattern?: string): Promise<boolean>;
60
60
  readNetworkCapture(): Promise<unknown[]>;
61
+ startImaAuthCapture(): Promise<void>;
62
+ readImaAuth(): Promise<{
63
+ authId: string;
64
+ } | null>;
65
+ requestImaReader(authId: string, path: string, body: Record<string, unknown>): Promise<unknown>;
66
+ releaseImaAuth(authId: string): Promise<void>;
61
67
  waitForDownload(pattern?: string, timeoutMs?: number, options?: BrowserDownloadWaitOptions): Promise<BrowserDownloadWaitResult>;
62
68
  /**
63
69
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
@@ -305,6 +305,26 @@ export class Page extends BasePage {
305
305
  return [];
306
306
  }
307
307
  }
308
+ async startImaAuthCapture() {
309
+ await sendCommand('ima-auth-start', this._cmdOpts());
310
+ }
311
+ async readImaAuth() {
312
+ const result = await sendCommand('ima-auth-read', this._cmdOpts());
313
+ if (!result || typeof result !== 'object' || typeof result.authId !== 'string')
314
+ return null;
315
+ return { authId: result.authId };
316
+ }
317
+ async requestImaReader(authId, path, body) {
318
+ return sendCommand('ima-reader-request', {
319
+ authId,
320
+ readerPath: path,
321
+ readerBody: body,
322
+ ...this._cmdOpts(),
323
+ });
324
+ }
325
+ async releaseImaAuth(authId) {
326
+ await sendCommand('ima-auth-release', { authId, ...this._cmdOpts() });
327
+ }
308
328
  async waitForDownload(pattern = '', timeoutMs = 30_000, options) {
309
329
  const result = await sendCommand('wait-download', {
310
330
  pattern,
@@ -203,6 +203,12 @@ export interface IPage {
203
203
  annotatedScreenshot?(options?: ScreenshotOptions): Promise<string>;
204
204
  startNetworkCapture?(pattern?: string): Promise<boolean>;
205
205
  readNetworkCapture?(): Promise<unknown[]>;
206
+ startImaAuthCapture?(): Promise<void>;
207
+ readImaAuth?(): Promise<{
208
+ authId: string;
209
+ } | null>;
210
+ requestImaReader?(authId: string, path: string, body: Record<string, unknown>): Promise<unknown>;
211
+ releaseImaAuth?(authId: string): Promise<void>;
206
212
  /**
207
213
  * Set local file paths on a file input element via CDP DOM.setFileInputFiles.
208
214
  * Chrome reads the files directly — no base64 encoding or payload size limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sovovs/bycli",
3
- "version": "2.1.37",
3
+ "version": "2.1.39",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },