dsh-web-search-pro 0.1.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/LOGIN.md +67 -0
  3. package/README.md +123 -0
  4. package/cordis.patch.yml +21 -0
  5. package/lib/browser-service.d.ts +68 -0
  6. package/lib/browser-service.js +8 -0
  7. package/lib/browser-service.js.map +1 -0
  8. package/lib/config.d.ts +91 -0
  9. package/lib/config.js +72 -0
  10. package/lib/config.js.map +1 -0
  11. package/lib/deps.d.ts +34 -0
  12. package/lib/deps.js +90 -0
  13. package/lib/deps.js.map +1 -0
  14. package/lib/engines.d.ts +71 -0
  15. package/lib/engines.js +562 -0
  16. package/lib/engines.js.map +1 -0
  17. package/lib/extract.d.ts +40 -0
  18. package/lib/extract.js +243 -0
  19. package/lib/extract.js.map +1 -0
  20. package/lib/fetch.d.ts +42 -0
  21. package/lib/fetch.js +175 -0
  22. package/lib/fetch.js.map +1 -0
  23. package/lib/index.d.ts +23 -0
  24. package/lib/index.js +117 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/memory-cache.d.ts +18 -0
  27. package/lib/memory-cache.js +42 -0
  28. package/lib/memory-cache.js.map +1 -0
  29. package/lib/platform-search.d.ts +34 -0
  30. package/lib/platform-search.js +61 -0
  31. package/lib/platform-search.js.map +1 -0
  32. package/lib/playwright.d.ts +71 -0
  33. package/lib/playwright.js +165 -0
  34. package/lib/playwright.js.map +1 -0
  35. package/lib/router.d.ts +66 -0
  36. package/lib/router.js +332 -0
  37. package/lib/router.js.map +1 -0
  38. package/lib/store.d.ts +117 -0
  39. package/lib/store.js +223 -0
  40. package/lib/store.js.map +1 -0
  41. package/lib/tools.d.ts +28 -0
  42. package/lib/tools.js +542 -0
  43. package/lib/tools.js.map +1 -0
  44. package/lib/util.d.ts +73 -0
  45. package/lib/util.js +220 -0
  46. package/lib/util.js.map +1 -0
  47. package/package.json +59 -0
  48. package/scripts/save-login.mjs +77 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Search engine backends for web-search-pro. Each engine is a plain object
3
+ * with { id, label, available(), search(query, count, signal) }. Routing,
4
+ * caching, and persistence live in router.ts.
5
+ * @module web-search-pro/engines
6
+ */
7
+ import type { WebSearchSource, WebRuntime } from '@deepseek-ai/dsh-web';
8
+ import type { BrowserService } from './browser-service.ts';
9
+ import type { CustomPlatformSpec } from './config.ts';
10
+ export interface SearchOutcome {
11
+ /** Provider-generated answer/summary text, when any. */
12
+ content?: string;
13
+ sources: WebSearchSource[];
14
+ }
15
+ export interface Engine {
16
+ id: string;
17
+ label: string;
18
+ /** Cheap local availability check; must not do network I/O. */
19
+ available(): boolean;
20
+ search(query: string, count: number, signal?: AbortSignal): Promise<SearchOutcome>;
21
+ }
22
+ export declare class EngineError extends Error {
23
+ readonly code: string;
24
+ readonly retryable: boolean;
25
+ constructor(message: string, code: string, retryable?: boolean);
26
+ }
27
+ export interface EngineDeps {
28
+ web?: WebRuntime;
29
+ exaApiKey?: string;
30
+ jinaApiKey?: string;
31
+ enableCli: boolean;
32
+ opencliEnabled: boolean;
33
+ agentReachEnabled: boolean;
34
+ /** Browser service (dsh-browser) for Playwright platform search + bundled opencli. */
35
+ browser?: BrowserService;
36
+ /** Per-platform selector overrides (settings.yaml `platformRules`). */
37
+ platformRules?: Record<string, {
38
+ item: string;
39
+ title: string;
40
+ link: string;
41
+ text?: string;
42
+ }>;
43
+ /** User-defined custom platforms (settings.yaml `customPlatforms`). */
44
+ customPlatforms?: Record<string, CustomPlatformSpec>;
45
+ /** True when this call originates from the ctx.web provider (avoid seam recursion). */
46
+ skipSeam: boolean;
47
+ }
48
+ export declare function seamEngine(deps: EngineDeps): Engine;
49
+ export declare function exaEngine(deps: EngineDeps): Engine;
50
+ export declare function ddgEngine(): Engine;
51
+ export declare function bingEngine(): Engine;
52
+ /** Parse RSS/Atom XML into sources (used by bing engine and rss platform). */
53
+ export declare function parseRss(xml: string, count?: number): WebSearchSource[];
54
+ export declare function jinaSearchEngine(deps: EngineDeps): Engine;
55
+ export declare function githubEngine(deps: EngineDeps): Engine;
56
+ export declare function githubCodeEngine(deps: EngineDeps): Engine;
57
+ export declare function githubIssuesEngine(deps: EngineDeps): Engine;
58
+ export declare function bilibiliEngine(deps: EngineDeps): Engine;
59
+ export declare function v2exEngine(): Engine;
60
+ export declare function youtubeEngine(deps: EngineDeps): Engine;
61
+ export declare function opencliEngine(platform: string, deps: EngineDeps): Engine;
62
+ export declare function agentReachEngine(platform: string, deps: EngineDeps): Engine;
63
+ export declare function arxivEngine(): Engine;
64
+ export declare function pubmedEngine(): Engine;
65
+ export declare function customPlatformEngine(id: string, spec: CustomPlatformSpec, deps: EngineDeps): Engine;
66
+ export declare function playwrightPlatformEngine(platform: string, deps: EngineDeps): Engine;
67
+ export declare function rssEngine(url: string): Engine;
68
+ /** Build the ordered engine list for a platform search. */
69
+ export declare function platformEngines(platform: string, deps: EngineDeps): Engine[];
70
+ export declare const SEARCH_ENGINE_IDS: readonly ["seam", "exa", "ddg", "bing", "jina", "github", "bilibili", "v2ex", "youtube", "arxiv", "pubmed"];
71
+ export declare const PLATFORM_IDS: readonly ["github", "github-code", "github-issues", "bilibili", "youtube", "v2ex", "xiaohongshu", "twitter", "reddit", "instagram", "facebook", "rss", "zhihu", "weibo", "douban", "tieba", "douyin", "kuaishou", "arxiv", "pubmed"];
package/lib/engines.js ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Search engine backends for web-search-pro. Each engine is a plain object
3
+ * with { id, label, available(), search(query, count, signal) }. Routing,
4
+ * caching, and persistence live in router.ts.
5
+ * @module web-search-pro/engines
6
+ */
7
+ import { httpGet, runCli, jsYaml, stripTags, capText, decodeRedirectUrl } from "./util.js";
8
+ import { PLATFORM_SEARCH_SPECS, parseCookieString } from "./platform-search.js";
9
+ export class EngineError extends Error {
10
+ code;
11
+ retryable;
12
+ constructor(message, code, retryable = true) {
13
+ super(message);
14
+ this.code = code;
15
+ this.retryable = retryable;
16
+ this.name = 'EngineError';
17
+ }
18
+ }
19
+ function withTimeout(promise, ms, label) {
20
+ return new Promise((resolve, reject) => {
21
+ const timer = setTimeout(() => reject(new EngineError(label + ' timed out', 'ENGINE_TIMEOUT')), ms);
22
+ promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
23
+ });
24
+ }
25
+ // ── ctx.web seam (DeepSeek native search) ───────────────────────────────────
26
+ export function seamEngine(deps) {
27
+ return {
28
+ id: 'seam',
29
+ label: 'DeepSeek 原生搜索 (ctx.web)',
30
+ available: () => !!deps.web && !deps.skipSeam,
31
+ async search(query, count, signal) {
32
+ if (!deps.web)
33
+ throw new EngineError('ctx.web seam unavailable', 'ENGINE_UNAVAILABLE');
34
+ const result = await withTimeout(deps.web.search({ query, maxResults: count }, signal), 45_000, 'seam search');
35
+ return { sources: [...result.sources], ...result.content !== undefined ? { content: result.content } : {} };
36
+ },
37
+ };
38
+ }
39
+ // ── Exa (API key) ────────────────────────────────────────────────────────────
40
+ export function exaEngine(deps) {
41
+ const key = () => deps.exaApiKey || process.env.EXA_API_KEY;
42
+ return {
43
+ id: 'exa',
44
+ label: 'Exa',
45
+ available: () => (key()?.length ?? 0) > 0,
46
+ async search(query, count, signal) {
47
+ const res = await httpGet('https://api.exa.ai/search', {
48
+ method: 'POST',
49
+ headers: { 'x-api-key': key(), 'content-type': 'application/json' },
50
+ body: JSON.stringify({ query, numResults: Math.min(count, 10), type: 'auto', contents: { text: false, highlights: true } }),
51
+ signal,
52
+ timeoutMs: 30_000,
53
+ });
54
+ if (!res.ok)
55
+ throw new EngineError('Exa API error HTTP ' + res.status, 'ENGINE_ERROR');
56
+ const data = JSON.parse(res.text);
57
+ const sources = (data.results ?? []).map(r => ({
58
+ url: r.url ?? '',
59
+ ...r.title ? { title: r.title } : {},
60
+ ...(r.highlights?.length ? { snippet: capText(r.highlights.join(' '), 400) } : {}),
61
+ ...r.publishedDate ? { publishedAt: r.publishedDate } : {},
62
+ })).filter(s => s.url.length > 0);
63
+ return { sources };
64
+ },
65
+ };
66
+ }
67
+ // ── DuckDuckGo HTML (no key) ────────────────────────────────────────────────
68
+ export function ddgEngine() {
69
+ return {
70
+ id: 'ddg',
71
+ label: 'DuckDuckGo',
72
+ available: () => true,
73
+ async search(query, count, signal) {
74
+ const res = await httpGet('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), { signal, timeoutMs: 30_000 });
75
+ if (!res.ok)
76
+ throw new EngineError('DuckDuckGo HTTP ' + res.status, 'ENGINE_ERROR');
77
+ const sources = [];
78
+ const blockRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>)?/g;
79
+ let m;
80
+ while ((m = blockRe.exec(res.text)) !== null) {
81
+ const rawHref = m[1] ?? '';
82
+ const url = decodeRedirectUrl(rawHref);
83
+ const title = stripTags(m[2] ?? '').trim();
84
+ const snippet = m[3] ? stripTags(m[3]).trim() : undefined;
85
+ if (!/^https?:\/\//i.test(url) || title.length < 2)
86
+ continue;
87
+ sources.push({ url, ...title ? { title } : {}, ...snippet ? { snippet: capText(snippet, 400) } : {} });
88
+ if (sources.length >= count)
89
+ break;
90
+ }
91
+ if (!sources.length)
92
+ throw new EngineError('DuckDuckGo returned no results (may be rate-limited)', 'ENGINE_EMPTY', true);
93
+ return { sources };
94
+ },
95
+ };
96
+ }
97
+ // ── Bing RSS (no key) ───────────────────────────────────────────────────────
98
+ export function bingEngine() {
99
+ return {
100
+ id: 'bing',
101
+ label: 'Bing',
102
+ available: () => true,
103
+ async search(query, count, signal) {
104
+ const res = await httpGet('https://www.bing.com/search?q=' + encodeURIComponent(query) + '&format=rss&count=' + Math.min(count, 20), { signal, timeoutMs: 30_000 });
105
+ if (!res.ok)
106
+ throw new EngineError('Bing HTTP ' + res.status, 'ENGINE_ERROR');
107
+ const sources = parseRss(res.text, count);
108
+ if (!sources.length)
109
+ throw new EngineError('Bing returned no results', 'ENGINE_EMPTY', true);
110
+ return { sources };
111
+ },
112
+ };
113
+ }
114
+ /** Parse RSS/Atom XML into sources (used by bing engine and rss platform). */
115
+ export function parseRss(xml, count = 20) {
116
+ const sources = [];
117
+ const itemRe = /<(item|entry)[^>]*>([\s\S]*?)<\/(?:item|entry)>/gi;
118
+ let m;
119
+ while ((m = itemRe.exec(xml)) !== null) {
120
+ const block = m[2] ?? '';
121
+ const grab = (tag) => {
122
+ const t = new RegExp('<' + tag + '(?:[^>]*)>([\\s\\S]*?)<\\/' + tag + '>', 'i').exec(block);
123
+ return t ? decodeCdata(stripTags(t[1])) : undefined;
124
+ };
125
+ const linkMatch = /<link[^>]*href="([^"]+)"/i.exec(block) ?? /<link[^>]*>([\s\S]*?)<\/link>/i.exec(block);
126
+ const title = grab('title');
127
+ // Atom feeds (arXiv) use <id> as the canonical URL.
128
+ const idMatch = /<id[^>]*>([\s\S]*?)<\/id>/i.exec(block);
129
+ const link = linkMatch ? (linkMatch[1] ?? stripTags(linkMatch[2] ?? '')) : (idMatch ? stripTags(idMatch[1] ?? '') : undefined);
130
+ const description = grab('description') ?? grab('summary') ?? grab('content');
131
+ const pubDate = grab('pubDate') ?? grab('published') ?? grab('updated');
132
+ if (!link || !/^https?:\/\//i.test(link))
133
+ continue;
134
+ sources.push({
135
+ url: link,
136
+ ...title && title.length > 1 ? { title } : {},
137
+ ...description && description.length > 1 ? { snippet: capText(description, 400) } : {},
138
+ ...pubDate ? { publishedAt: pubDate } : {},
139
+ });
140
+ if (sources.length >= count)
141
+ break;
142
+ }
143
+ return sources;
144
+ }
145
+ function decodeCdata(s) {
146
+ const m = /<!\[CDATA\[([\s\S]*?)\]\]>/.exec(s);
147
+ return m ? m[1] : s;
148
+ }
149
+ // ── Jina AI search / reader (optional key) ──────────────────────────────────
150
+ export function jinaSearchEngine(deps) {
151
+ const key = () => deps.jinaApiKey || process.env.JINA_API_KEY;
152
+ return {
153
+ id: 'jina',
154
+ label: 'Jina AI',
155
+ available: () => true,
156
+ async search(query, count, signal) {
157
+ const headers = {};
158
+ const k = key();
159
+ if (k)
160
+ headers['authorization'] = 'Bearer ' + k;
161
+ const res = await httpGet('https://s.jina.ai/?q=' + encodeURIComponent(query), { headers, signal, timeoutMs: 30_000 });
162
+ if (res.status === 401 && !k)
163
+ throw new EngineError('Jina AI requires an API key (set jinaApiKey or $JINA_API_KEY)', 'ENGINE_UNAVAILABLE', false);
164
+ if (!res.ok)
165
+ throw new EngineError('Jina search HTTP ' + res.status, 'ENGINE_ERROR');
166
+ const sources = [];
167
+ const lineRe = /^\s*(\d+)\.\s*\[([^\]]+)\]\(([^)]+)\)(?:[::\-—]?\s*([\s\S]*?))?$/gm;
168
+ let m;
169
+ while ((m = lineRe.exec(res.text)) !== null) {
170
+ const url = m[3] ?? '';
171
+ if (!/^https?:\/\//i.test(url))
172
+ continue;
173
+ sources.push({
174
+ url,
175
+ ...(m[2] ?? '').trim() ? { title: (m[2] ?? '').trim() } : {},
176
+ ...(m[4] ?? '').trim() ? { snippet: capText((m[4] ?? '').trim(), 400) } : {},
177
+ });
178
+ if (sources.length >= count)
179
+ break;
180
+ }
181
+ if (!sources.length) {
182
+ // Jina may return a plain markdown list without numbering.
183
+ throw new EngineError('Jina returned no parseable results', 'ENGINE_EMPTY', true);
184
+ }
185
+ return { sources };
186
+ },
187
+ };
188
+ }
189
+ // ── GitHub (gh CLI) ─────────────────────────────────────────────────────────
190
+ export function githubEngine(deps) {
191
+ return {
192
+ id: 'github',
193
+ label: 'GitHub',
194
+ available: () => deps.enableCli,
195
+ async search(query, count, signal) {
196
+ const res = await runCli('gh', ['search', 'repos', query, '--limit', String(Math.min(count, 15)), '--json', 'fullName,url,description,stargazersCount,language,updatedAt'], { timeoutMs: 30_000, signal });
197
+ if (res.code !== 0)
198
+ throw new EngineError('gh search failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
199
+ const rows = JSON.parse(res.stdout);
200
+ const sources = rows.map(r => {
201
+ const stars = r.stargazersCount != null ? ' ⭐' + r.stargazersCount : '';
202
+ const lang = r.language ? ' [' + r.language + ']' : '';
203
+ return {
204
+ url: r.url ?? 'https://github.com/' + (r.fullName ?? ''),
205
+ ...r.fullName ? { title: r.fullName } : {},
206
+ ...(r.description ?? r.fullName) ? { snippet: capText((r.description ?? '') + stars + lang, 400) } : {},
207
+ };
208
+ });
209
+ return { sources };
210
+ },
211
+ };
212
+ }
213
+ // ── GitHub code / issues search (gh CLI) ────────────────────────────────────
214
+ export function githubCodeEngine(deps) {
215
+ return {
216
+ id: 'github-code', label: 'GitHub 代码',
217
+ available: () => deps.enableCli,
218
+ async search(query, count, signal) {
219
+ const res = await runCli('gh', ['search', 'code', query, '--limit', String(Math.min(count, 15)), '--json', 'repository,path,url'], { timeoutMs: 30_000, signal });
220
+ if (res.code !== 0)
221
+ throw new EngineError('gh code search failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
222
+ const rows = JSON.parse(res.stdout);
223
+ const sources = rows.map(r => ({
224
+ url: r.url ?? '',
225
+ ...(r.path && r.repository?.nameWithOwner) ? { title: r.repository.nameWithOwner + ' / ' + r.path } : { title: r.path ?? 'code match' },
226
+ ...r.repository?.nameWithOwner ? { snippet: '仓库: ' + r.repository.nameWithOwner } : {},
227
+ })).filter(s => /^https?:\/\//i.test(s.url));
228
+ return { sources };
229
+ },
230
+ };
231
+ }
232
+ export function githubIssuesEngine(deps) {
233
+ return {
234
+ id: 'github-issues', label: 'GitHub Issues',
235
+ available: () => deps.enableCli,
236
+ async search(query, count, signal) {
237
+ const res = await runCli('gh', ['search', 'issues', query, '--limit', String(Math.min(count, 15)), '--json', 'repository,title,url,state,commentsCount'], { timeoutMs: 30_000, signal });
238
+ if (res.code !== 0)
239
+ throw new EngineError('gh issue search failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
240
+ const rows = JSON.parse(res.stdout);
241
+ const sources = rows.map(r => ({
242
+ url: r.url ?? '',
243
+ ...r.title ? { title: r.title } : {},
244
+ ...(r.state || r.repository?.nameWithOwner) ? { snippet: '[' + (r.state ?? '') + ']' + (r.repository?.nameWithOwner ? ' · ' + r.repository.nameWithOwner : '') } : {},
245
+ })).filter(s => /^https?:\/\//i.test(s.url));
246
+ return { sources };
247
+ },
248
+ };
249
+ }
250
+ // ── Bilibili (bili CLI) ─────────────────────────────────────────────────────
251
+ export function bilibiliEngine(deps) {
252
+ return {
253
+ id: 'bilibili',
254
+ label: 'B站 (bili-cli)',
255
+ available: () => deps.enableCli,
256
+ async search(query, count, signal) {
257
+ const res = await runCli('bili', ['search', query, '--type', 'video', '-n', String(Math.min(count, 10))], { timeoutMs: 30_000, signal });
258
+ if (res.code !== 0)
259
+ throw new EngineError('bili search failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
260
+ let data;
261
+ try {
262
+ data = jsYaml.load(res.stdout);
263
+ }
264
+ catch {
265
+ throw new EngineError('bili output not parseable', 'ENGINE_ERROR');
266
+ }
267
+ const items = data?.data ?? [];
268
+ const sources = items.filter(i => i.bvid).map(i => ({
269
+ url: 'https://www.bilibili.com/video/' + i.bvid,
270
+ ...i.title ? { title: i.title } : {},
271
+ ...(i.author || i.play != null || i.duration) ? { snippet: capText(['UP: ' + (i.author ?? ''), '播放: ' + i.play, i.duration ?? ''].filter(Boolean).join(' | '), 300) } : {},
272
+ }));
273
+ return { sources };
274
+ },
275
+ };
276
+ }
277
+ // ── V2EX (sov2ex community search API) ──────────────────────────────────────
278
+ export function v2exEngine() {
279
+ return {
280
+ id: 'v2ex',
281
+ label: 'V2EX (sov2ex)',
282
+ available: () => true,
283
+ async search(query, count, signal) {
284
+ const res = await httpGet('https://www.sov2ex.com/api/search?q=' + encodeURIComponent(query) + '&size=' + Math.min(count, 15), { signal, timeoutMs: 25_000 });
285
+ if (!res.ok)
286
+ throw new EngineError('sov2ex HTTP ' + res.status, 'ENGINE_ERROR');
287
+ const parsed = JSON.parse(res.text);
288
+ // sov2ex returns the hits array at top level; keep a defensive fallback.
289
+ const rawHits = Array.isArray(parsed.hits)
290
+ ? parsed.hits
291
+ : (parsed.hits?.hits ?? []);
292
+ const sources = rawHits.map(h => {
293
+ const s = h._source;
294
+ const url = s?.id != null ? 'https://www.v2ex.com/t/' + s.id : undefined;
295
+ const created = typeof s?.created === 'number' ? new Date(s.created * 1000).toISOString().slice(0, 10) : s?.created;
296
+ return {
297
+ url: url ?? '',
298
+ ...s?.title ? { title: s.title } : {},
299
+ ...(s?.content || s?.node?.title) ? { snippet: capText((s.content ?? '') + (s.node?.title ? ' [节点: ' + s.node.title + ']' : ''), 400) } : {},
300
+ ...created ? { publishedAt: String(created) } : {},
301
+ };
302
+ }).filter(s => s.url.length > 0);
303
+ return { sources };
304
+ },
305
+ };
306
+ }
307
+ // ── YouTube (yt-dlp search) ─────────────────────────────────────────────────
308
+ export function youtubeEngine(deps) {
309
+ return {
310
+ id: 'youtube',
311
+ label: 'YouTube (yt-dlp)',
312
+ available: () => deps.enableCli,
313
+ async search(query, count, signal) {
314
+ const n = Math.min(count, 10);
315
+ const res = await runCli('yt-dlp', ['ytsearch' + n + ':' + query, '--flat-playlist', '--skip-download', '--no-warnings', '--print', '%(id)s\t%(title)s\t%(channel)s\t%(view_count)s\t%(duration_string)s'], { timeoutMs: 60_000, signal });
316
+ if (res.code !== 0)
317
+ throw new EngineError('yt-dlp failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
318
+ const sources = [];
319
+ for (const line of res.stdout.split(/\r?\n/)) {
320
+ const [id, title, channel, views, duration] = line.split('\t');
321
+ if (!id || !title)
322
+ continue;
323
+ const meta = [];
324
+ if (channel)
325
+ meta.push(channel);
326
+ if (views && views !== 'None')
327
+ meta.push(views + ' views');
328
+ if (duration)
329
+ meta.push(duration);
330
+ sources.push({
331
+ url: 'https://www.youtube.com/watch?v=' + id,
332
+ title,
333
+ ...meta.length ? { snippet: meta.join(' | ') } : {},
334
+ });
335
+ if (sources.length >= n)
336
+ break;
337
+ }
338
+ if (!sources.length)
339
+ throw new EngineError('yt-dlp returned no results', 'ENGINE_EMPTY', true);
340
+ return { sources };
341
+ },
342
+ };
343
+ }
344
+ // ── OpenCLI platform search (reuses the user's logged-in browser session) ───
345
+ const OPENCLI_PLATFORMS = {
346
+ xiaohongshu: 'xiaohongshu',
347
+ twitter: 'twitter',
348
+ reddit: 'reddit',
349
+ instagram: 'instagram',
350
+ facebook: 'facebook',
351
+ };
352
+ export function opencliEngine(platform, deps) {
353
+ const adapter = OPENCLI_PLATFORMS[platform];
354
+ return {
355
+ id: 'opencli-' + platform,
356
+ label: 'OpenCLI ' + platform,
357
+ available: () => deps.enableCli && deps.opencliEnabled && !!adapter && !!deps.browser,
358
+ async search(query, count, signal) {
359
+ if (!adapter || !deps.browser)
360
+ throw new EngineError('opencli bundled backend unavailable for ' + platform, 'ENGINE_UNAVAILABLE', false);
361
+ const res = await deps.browser.opencli([adapter, 'search', query, '-f', 'yaml'], { timeoutMs: 45_000, signal });
362
+ if (res.code !== 0) {
363
+ const msg = res.stderr.trim() || res.stdout.trim() || 'exit ' + res.code;
364
+ throw new EngineError('opencli ' + platform + ' search failed (browser session connected?): ' + msg.slice(0, 200), 'ENGINE_UNAVAILABLE', false);
365
+ }
366
+ let rows = [];
367
+ try {
368
+ const parsed = jsYaml.load(res.stdout);
369
+ rows = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' ? Object.values(parsed).find(Array.isArray) ?? [] : []);
370
+ }
371
+ catch {
372
+ try {
373
+ rows = JSON.parse(res.stdout);
374
+ }
375
+ catch { /* fallthrough */ }
376
+ }
377
+ const sources = rows.slice(0, count).map((r) => ({
378
+ url: String(r.url ?? r.link ?? r.href ?? ''),
379
+ ...(r.title ?? r.name ?? r.text) ? { title: String(r.title ?? r.name ?? r.text ?? '') } : {},
380
+ ...(r.description ?? r.snippet ?? r.desc ?? r.author ?? r.user) ? { snippet: capText(String(r.description ?? r.snippet ?? r.desc ?? r.author ?? r.user ?? ''), 400) } : {},
381
+ })).filter(s => /^https?:\/\//i.test(s.url));
382
+ if (!sources.length)
383
+ throw new EngineError('opencli ' + platform + ' returned no parseable results', 'ENGINE_EMPTY', false);
384
+ return { sources };
385
+ },
386
+ };
387
+ }
388
+ // ── agent-reach CLI backends (twitter etc.) ─────────────────────────────────
389
+ export function agentReachEngine(platform, deps) {
390
+ if (platform === 'twitter') {
391
+ return {
392
+ id: 'agentreach-twitter',
393
+ label: 'agent-reach twitter-cli',
394
+ available: () => deps.enableCli && deps.agentReachEnabled && !!process.env.TWITTER_AUTH_TOKEN && !!process.env.TWITTER_CT0,
395
+ async search(query, count, signal) {
396
+ const res = await runCli('twitter', ['search', query, '-n', String(Math.min(count, 10))], { timeoutMs: 45_000, signal });
397
+ if (res.code !== 0)
398
+ throw new EngineError('twitter search failed: ' + res.stderr.trim().slice(0, 200), 'ENGINE_ERROR');
399
+ const sources = [];
400
+ for (const line of res.stdout.split(/\r?\n/)) {
401
+ const m = /(https?:\/\/[^\s]+)/.exec(line);
402
+ if (!m)
403
+ continue;
404
+ const title = stripTags(line).replace(m[1], '').trim();
405
+ if (title)
406
+ sources.push({ url: m[1], title: capText(title, 200) });
407
+ if (sources.length >= count)
408
+ break;
409
+ }
410
+ return { sources };
411
+ },
412
+ };
413
+ }
414
+ return {
415
+ id: 'agentreach-' + platform,
416
+ label: 'agent-reach ' + platform,
417
+ available: () => false,
418
+ async search() {
419
+ throw new EngineError('agent-reach has no backend for ' + platform, 'ENGINE_UNAVAILABLE', false);
420
+ },
421
+ };
422
+ }
423
+ // ── Academic verticals (public APIs, no login) ─────────────────────────────
424
+ export function arxivEngine() {
425
+ return {
426
+ id: 'arxiv', label: 'arXiv',
427
+ available: () => true,
428
+ async search(query, count, signal) {
429
+ const res = await httpGet('http://export.arxiv.org/api/query?search_query=all:' + encodeURIComponent(query) + '&start=0&max_results=' + Math.min(count, 20), { signal, timeoutMs: 30_000 });
430
+ if (!res.ok)
431
+ throw new EngineError('arXiv HTTP ' + res.status, 'ENGINE_ERROR');
432
+ const sources = parseRss(res.text, count);
433
+ if (!sources.length)
434
+ throw new EngineError('arXiv returned no results', 'ENGINE_EMPTY', true);
435
+ return { sources };
436
+ },
437
+ };
438
+ }
439
+ export function pubmedEngine() {
440
+ return {
441
+ id: 'pubmed', label: 'PubMed',
442
+ available: () => true,
443
+ async search(query, count, signal) {
444
+ const n = Math.min(Math.max(count, 1), 20);
445
+ const esearch = await httpGet('https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?db=pubmed&term=' + encodeURIComponent(query) + '&retmax=' + n + '&retmode=json', { signal, timeoutMs: 30_000 });
446
+ if (!esearch.ok)
447
+ throw new EngineError('PubMed esearch HTTP ' + esearch.status, 'ENGINE_ERROR');
448
+ const ids = JSON.parse(esearch.text)?.esearchresult?.idlist ?? [];
449
+ if (!ids.length)
450
+ throw new EngineError('PubMed returned no results', 'ENGINE_EMPTY', true);
451
+ const sources = ids.map(id => ({ url: 'https://pubmed.ncbi.nlm.nih.gov/' + id + '/', title: 'PubMed ' + id }));
452
+ const esummary = await httpGet('https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&id=' + ids.join(','), { signal, timeoutMs: 30_000 });
453
+ if (esummary.ok) {
454
+ const result = JSON.parse(esummary.text)?.result ?? {};
455
+ return {
456
+ sources: ids.map(id => {
457
+ const doc = result[id];
458
+ return {
459
+ url: 'https://pubmed.ncbi.nlm.nih.gov/' + id + '/',
460
+ ...doc?.title ? { title: String(doc.title) } : { title: 'PubMed ' + id },
461
+ ...doc?.pubdate ? { publishedAt: String(doc.pubdate) } : {},
462
+ };
463
+ }),
464
+ };
465
+ }
466
+ return { sources };
467
+ },
468
+ };
469
+ }
470
+ // ── User-defined custom platform (url template + selectors + cookie) ───────
471
+ export function customPlatformEngine(id, spec, deps) {
472
+ const searchSpec = {
473
+ id: 'custom-' + id,
474
+ label: spec.name,
475
+ url: () => spec.url,
476
+ item: spec.item,
477
+ title: spec.title,
478
+ link: spec.link,
479
+ ...spec.text ? { text: spec.text } : {},
480
+ };
481
+ return {
482
+ id: 'custom-' + id,
483
+ label: spec.name + ' (自定义)',
484
+ available: () => !!deps.browser,
485
+ async search(query, count, signal) {
486
+ if (!deps.browser)
487
+ throw new EngineError('custom platform search unavailable (no browser service)', 'ENGINE_UNAVAILABLE', false);
488
+ const url = spec.url.replace(/{query}/g, encodeURIComponent(query));
489
+ const cookies = spec.cookie ? parseCookieString(spec.cookie, url) : undefined;
490
+ const sources = await deps.browser.searchResults(url, searchSpec, { signal, count, cookies });
491
+ if (!sources.length)
492
+ throw new EngineError('自定义平台 ' + spec.name + ' 未取到结果:检查 url 的 {query} 占位、item/title/link 选择器,或补充 cookie。', 'ENGINE_EMPTY', false);
493
+ return { sources };
494
+ },
495
+ };
496
+ }
497
+ // ── Chinese community search via Playwright (logged-in browser) ────────────
498
+ export function playwrightPlatformEngine(platform, deps) {
499
+ const builtin = PLATFORM_SEARCH_SPECS[platform];
500
+ return {
501
+ id: 'playwright-' + platform,
502
+ label: (builtin?.label ?? platform) + ' (Playwright)',
503
+ available: () => !!builtin && !!deps.browser,
504
+ async search(query, count, signal) {
505
+ if (!builtin || !deps.browser)
506
+ throw new EngineError('playwright platform search unavailable for ' + platform, 'ENGINE_UNAVAILABLE', false);
507
+ const override = deps.platformRules?.[platform];
508
+ const spec = { ...builtin, ...override ?? {} };
509
+ const sources = await deps.browser.searchResults(spec.url(query), spec, { signal, count });
510
+ if (!sources.length) {
511
+ throw new EngineError(builtin.label + ' 未取到结果:该平台需要浏览器登录态(复用你已登录的浏览器)。运行 node scripts/save-login.mjs 登录一次并设置 dsh-browser 的 storageStatePath;或到 $DSH_HOME/settings.yaml 的 platformRules.' + platform + ' 微调结果选择器。', 'ENGINE_EMPTY', false);
512
+ }
513
+ return { sources };
514
+ },
515
+ };
516
+ }
517
+ // ── RSS feed (platform tool) ────────────────────────────────────────────────
518
+ export function rssEngine(url) {
519
+ return {
520
+ id: 'rss',
521
+ label: 'RSS ' + url,
522
+ available: () => /^https?:\/\//i.test(url),
523
+ async search(_query, count, signal) {
524
+ const res = await httpGet(url, { signal, timeoutMs: 25_000 });
525
+ if (!res.ok)
526
+ throw new EngineError('RSS HTTP ' + res.status, 'ENGINE_ERROR');
527
+ const sources = parseRss(res.text, count);
528
+ if (!sources.length)
529
+ throw new EngineError('RSS feed has no items', 'ENGINE_EMPTY', false);
530
+ return { sources };
531
+ },
532
+ };
533
+ }
534
+ /** Build the ordered engine list for a platform search. */
535
+ export function platformEngines(platform, deps) {
536
+ switch (platform) {
537
+ case 'github': return [githubEngine(deps)];
538
+ case 'github-code': return [githubCodeEngine(deps)];
539
+ case 'github-issues': return [githubIssuesEngine(deps)];
540
+ case 'bilibili': return [bilibiliEngine(deps)];
541
+ case 'youtube': return [youtubeEngine(deps)];
542
+ case 'v2ex': return [v2exEngine()];
543
+ case 'xiaohongshu': return [opencliEngine('xiaohongshu', deps)];
544
+ case 'twitter': return [opencliEngine('twitter', deps), agentReachEngine('twitter', deps)];
545
+ case 'reddit': return [opencliEngine('reddit', deps)];
546
+ case 'instagram': return [opencliEngine('instagram', deps)];
547
+ case 'facebook': return [opencliEngine('facebook', deps)];
548
+ // Chinese communities (MediaCrawler-style): Playwright drives the logged-in search page.
549
+ case 'arxiv': return [arxivEngine()];
550
+ case 'pubmed': return [pubmedEngine()];
551
+ case 'zhihu': return [playwrightPlatformEngine('zhihu', deps)];
552
+ case 'weibo': return [playwrightPlatformEngine('weibo', deps)];
553
+ case 'douban': return [playwrightPlatformEngine('douban', deps)];
554
+ case 'tieba': return [playwrightPlatformEngine('tieba', deps)];
555
+ case 'douyin': return [playwrightPlatformEngine('douyin', deps)];
556
+ case 'kuaishou': return [playwrightPlatformEngine('kuaishou', deps)];
557
+ default: return [];
558
+ }
559
+ }
560
+ export const SEARCH_ENGINE_IDS = ['seam', 'exa', 'ddg', 'bing', 'jina', 'github', 'bilibili', 'v2ex', 'youtube', 'arxiv', 'pubmed'];
561
+ export const PLATFORM_IDS = ['github', 'github-code', 'github-issues', 'bilibili', 'youtube', 'v2ex', 'xiaohongshu', 'twitter', 'reddit', 'instagram', 'facebook', 'rss', 'zhihu', 'weibo', 'douban', 'tieba', 'douyin', 'kuaishou', 'arxiv', 'pubmed'];
562
+ //# sourceMappingURL=engines.js.map