@sylphx/lookout 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.
package/src/extract.ts ADDED
@@ -0,0 +1,274 @@
1
+ export type CiteSpan = {
2
+ text: string;
3
+ start: number;
4
+ end: number;
5
+ kind: string;
6
+ };
7
+
8
+ export type ExtractResult = {
9
+ ok: boolean;
10
+ url?: string;
11
+ title?: string;
12
+ description?: string;
13
+ canonicalUrl?: string;
14
+ author?: string;
15
+ siteName?: string;
16
+ language?: string;
17
+ textExcerpt?: string;
18
+ headings: { level: number; text: string }[];
19
+ links: { href: string; text: string }[];
20
+ jsonLd: unknown[];
21
+ tables: { rows: string[][] }[];
22
+ spans: CiteSpan[];
23
+ route: string;
24
+ warnings: string[];
25
+ };
26
+
27
+ function stripTags(html: string): string {
28
+ return html
29
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
30
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
31
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
32
+ .replace(/<[^>]+>/g, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+ }
36
+
37
+ function decodeBasicEntities(s: string): string {
38
+ return s
39
+ .replace(/&amp;/g, '&')
40
+ .replace(/&lt;/g, '<')
41
+ .replace(/&gt;/g, '>')
42
+ .replace(/&quot;/g, '"')
43
+ .replace(/&#39;/g, "'")
44
+ .replace(/&nbsp;/g, ' ');
45
+ }
46
+
47
+ /** Prefer <article>/<main>, else body, else full document for readable text. */
48
+ function pickContentHtml(html: string): { html: string; route: string } {
49
+ const article = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
50
+ if (article?.[1] && stripTags(article[1]).length > 40) {
51
+ return { html: article[1], route: 'html_article' };
52
+ }
53
+ const main = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
54
+ if (main?.[1] && stripTags(main[1]).length > 40) {
55
+ return { html: main[1], route: 'html_main' };
56
+ }
57
+ const body = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
58
+ if (body?.[1]) {
59
+ return { html: body[1], route: 'html_body' };
60
+ }
61
+ return { html, route: 'html_dom_light' };
62
+ }
63
+
64
+
65
+ function resolveHref(href: string, base?: string): string {
66
+ try {
67
+ if (!base) return href;
68
+ return new URL(href, base).toString();
69
+ } catch {
70
+ return href;
71
+ }
72
+ }
73
+
74
+ export function extractFromHtml(html: string, url?: string): ExtractResult {
75
+ const warnings: string[] = [];
76
+ const spans: CiteSpan[] = [];
77
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
78
+ let title = titleMatch ? decodeBasicEntities(stripTags(titleMatch[1] ?? '')) : undefined;
79
+ if (title && titleMatch && titleMatch.index !== undefined) {
80
+ const raw = titleMatch[1] ?? '';
81
+ const start = titleMatch.index + titleMatch[0].indexOf(raw);
82
+ spans.push({ text: title, start, end: start + raw.length, kind: 'title' });
83
+ }
84
+
85
+ let language: string | undefined;
86
+ const langMatch = html.match(/<html\b[^>]*\blang=["']([^"']+)["'][^>]*>/i);
87
+ if (langMatch?.[1]) {
88
+ language = langMatch[1].trim();
89
+ }
90
+
91
+ let description: string | undefined;
92
+ const metaDesc =
93
+ html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
94
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["'][^>]*>/i);
95
+ if (metaDesc) {
96
+ description = decodeBasicEntities(stripTags(metaDesc[1] ?? ''));
97
+ if (metaDesc.index !== undefined) {
98
+ spans.push({
99
+ text: description,
100
+ start: metaDesc.index,
101
+ end: metaDesc.index + metaDesc[0].length,
102
+ kind: 'meta_description',
103
+ });
104
+ }
105
+ }
106
+
107
+ if (!description) {
108
+ const ogDesc =
109
+ html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
110
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["'][^>]*>/i);
111
+ if (ogDesc?.[1]) {
112
+ description = decodeBasicEntities(stripTags(ogDesc[1]));
113
+ spans.push({
114
+ text: description,
115
+ start: ogDesc.index ?? 0,
116
+ end: (ogDesc.index ?? 0) + ogDesc[0].length,
117
+ kind: 'og_description',
118
+ });
119
+ }
120
+ }
121
+
122
+ const ogTitle =
123
+ html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
124
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["'][^>]*>/i);
125
+ if (ogTitle?.[1] && !title) {
126
+ title = decodeBasicEntities(stripTags(ogTitle[1]));
127
+ spans.push({ text: title, start: ogTitle.index ?? 0, end: (ogTitle.index ?? 0) + ogTitle[0].length, kind: 'og_title' });
128
+ } else if (ogTitle?.[1] && title && title !== decodeBasicEntities(stripTags(ogTitle[1]))) {
129
+ spans.push({
130
+ text: decodeBasicEntities(stripTags(ogTitle[1])),
131
+ start: ogTitle.index ?? 0,
132
+ end: (ogTitle.index ?? 0) + ogTitle[0].length,
133
+ kind: 'og_title',
134
+ });
135
+ }
136
+
137
+ let canonicalUrl: string | undefined;
138
+ const canonical =
139
+ html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["'][^>]*>/i) ||
140
+ html.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']canonical["'][^>]*>/i);
141
+ if (canonical?.[1]) {
142
+ canonicalUrl = resolveHref(decodeBasicEntities(canonical[1].trim()), url);
143
+ spans.push({
144
+ text: canonicalUrl,
145
+ start: canonical.index ?? 0,
146
+ end: (canonical.index ?? 0) + canonical[0].length,
147
+ kind: 'canonical',
148
+ });
149
+ }
150
+
151
+ let author: string | undefined;
152
+ const authorMeta =
153
+ html.match(/<meta[^>]+name=["']author["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
154
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']author["'][^>]*>/i);
155
+ if (authorMeta?.[1]) {
156
+ author = decodeBasicEntities(stripTags(authorMeta[1]));
157
+ spans.push({
158
+ text: author,
159
+ start: authorMeta.index ?? 0,
160
+ end: (authorMeta.index ?? 0) + authorMeta[0].length,
161
+ kind: 'author',
162
+ });
163
+ }
164
+
165
+ let siteName: string | undefined;
166
+ const siteMeta =
167
+ html.match(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
168
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["'][^>]*>/i);
169
+ if (siteMeta?.[1]) {
170
+ siteName = decodeBasicEntities(stripTags(siteMeta[1]));
171
+ }
172
+
173
+ const jsonLd: unknown[] = [];
174
+ const ldRe = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
175
+ let m: RegExpExecArray | null;
176
+ while ((m = ldRe.exec(html)) !== null) {
177
+ try {
178
+ jsonLd.push(JSON.parse(m[1] ?? ''));
179
+ } catch {
180
+ warnings.push('json_ld_parse_error');
181
+ }
182
+ }
183
+
184
+ const tables: { rows: string[][] }[] = [];
185
+ const tableRe = /<table[^>]*>([\s\S]*?)<\/table>/gi;
186
+ while ((m = tableRe.exec(html)) !== null) {
187
+ const rows: string[][] = [];
188
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
189
+ let r: RegExpExecArray | null;
190
+ const tableHtml = m[1] ?? '';
191
+ while ((r = rowRe.exec(tableHtml)) !== null) {
192
+ const cells: string[] = [];
193
+ const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
194
+ let c: RegExpExecArray | null;
195
+ while ((c = cellRe.exec(r[1] ?? '')) !== null) {
196
+ cells.push(decodeBasicEntities(stripTags(c[1] ?? '')));
197
+ }
198
+ if (cells.length) rows.push(cells);
199
+ }
200
+ if (rows.length) tables.push({ rows });
201
+ if (tables.length >= 8) break;
202
+ }
203
+
204
+ const headings: { level: number; text: string }[] = [];
205
+ const hRe = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
206
+ while ((m = hRe.exec(html)) !== null) {
207
+ const level = Number(m[1] ?? 1);
208
+ const text = decodeBasicEntities(stripTags(m[2] ?? ''));
209
+ if (!text) continue;
210
+ headings.push({ level, text });
211
+ if (m.index !== undefined) {
212
+ spans.push({
213
+ text,
214
+ start: m.index,
215
+ end: m.index + m[0].length,
216
+ kind: `heading_h${level}`,
217
+ });
218
+ }
219
+ if (headings.length >= 30) break;
220
+ }
221
+
222
+ const links: { href: string; text: string }[] = [];
223
+ const aRe = /<a\b[^>]*href=["']([^"'#][^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
224
+ while ((m = aRe.exec(html)) !== null) {
225
+ const rawHref = (m[1] ?? '').trim();
226
+ const text = decodeBasicEntities(stripTags(m[2] ?? '')).slice(0, 200);
227
+ if (!rawHref || rawHref.startsWith('javascript:')) continue;
228
+ const href = resolveHref(rawHref, url);
229
+ links.push({ href, text });
230
+ if (links.length >= 40) break;
231
+ }
232
+
233
+ const content = pickContentHtml(html);
234
+ const textExcerpt = decodeBasicEntities(stripTags(content.html)).slice(0, 4000);
235
+ if (textExcerpt) {
236
+ spans.push({
237
+ text: textExcerpt.slice(0, 280),
238
+ start: 0,
239
+ end: Math.min(280, html.length),
240
+ kind: 'excerpt',
241
+ });
242
+ } else {
243
+ warnings.push('empty_text_excerpt');
244
+ }
245
+
246
+
247
+ const robots =
248
+ html.match(/<meta[^>]+name=["']robots["'][^>]+content=["']([^"']+)["'][^>]*>/i) ||
249
+ html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']robots["'][^>]*>/i);
250
+ if (robots?.[1] && /noindex/i.test(robots[1])) {
251
+ warnings.push('robots_noindex');
252
+ }
253
+ if (!title) warnings.push('missing_title');
254
+ if (!description) warnings.push('missing_description');
255
+
256
+ return {
257
+ ok: true,
258
+ url,
259
+ title,
260
+ description,
261
+ canonicalUrl,
262
+ author,
263
+ siteName,
264
+ language,
265
+ textExcerpt,
266
+ headings,
267
+ links,
268
+ jsonLd,
269
+ tables,
270
+ spans,
271
+ route: content.route,
272
+ warnings,
273
+ };
274
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,145 @@
1
+ import { assertSafeUrl } from './ssrf.ts';
2
+
3
+ export type FetchResult = {
4
+ ok: boolean;
5
+ url: string;
6
+ finalUrl: string;
7
+ status?: number;
8
+ contentType?: string;
9
+ body?: string;
10
+ route: string;
11
+ warnings: string[];
12
+ code?: string;
13
+ message?: string;
14
+ redirects?: string[];
15
+ truncated?: boolean;
16
+ };
17
+
18
+ const DEFAULT_MAX_BYTES = 1_500_000;
19
+ const DEFAULT_TIMEOUT_MS = 20_000;
20
+ const MAX_REDIRECTS = 5;
21
+
22
+ export async function webFetch(
23
+ rawUrl: string,
24
+ options: { maxBytes?: number; timeoutMs?: number; userAgent?: string } = {},
25
+ ): Promise<FetchResult> {
26
+ const warnings: string[] = [];
27
+ const redirects: string[] = [];
28
+ let current = rawUrl;
29
+ const envMax = process.env.LOOKOUT_FETCH_MAX_BYTES?.trim();
30
+ const envTimeout = process.env.LOOKOUT_FETCH_TIMEOUT_MS?.trim();
31
+ const maxBytes =
32
+ options.maxBytes ??
33
+ (envMax && Number.isFinite(Number(envMax)) ? Number(envMax) : DEFAULT_MAX_BYTES);
34
+ const timeoutMs =
35
+ options.timeoutMs ??
36
+ (envTimeout && Number.isFinite(Number(envTimeout)) ? Number(envTimeout) : DEFAULT_TIMEOUT_MS);
37
+ const ua =
38
+ options.userAgent ??
39
+ process.env.LOOKOUT_USER_AGENT?.trim() ??
40
+ 'Lookout/0.1 (+https://github.com/SylphxAI/lookout; local-first agent web instrument)';
41
+
42
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
43
+ const safety = assertSafeUrl(current);
44
+ if (!safety.ok) {
45
+ return {
46
+ ok: false,
47
+ url: rawUrl,
48
+ finalUrl: current,
49
+ route: 'http',
50
+ warnings,
51
+ code: safety.code,
52
+ message: safety.message,
53
+ redirects,
54
+ };
55
+ }
56
+
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
59
+ try {
60
+ const res = await fetch(safety.url, {
61
+ method: 'GET',
62
+ redirect: 'manual',
63
+ signal: controller.signal,
64
+ headers: {
65
+ 'user-agent': ua,
66
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5',
67
+ },
68
+ });
69
+
70
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
71
+ const loc = res.headers.get('location');
72
+ if (!loc) {
73
+ return {
74
+ ok: false,
75
+ url: rawUrl,
76
+ finalUrl: current,
77
+ status: res.status,
78
+ route: 'http',
79
+ warnings,
80
+ code: 'REDIRECT_MISSING_LOCATION',
81
+ message: 'Redirect without Location header',
82
+ redirects,
83
+ };
84
+ }
85
+ const next = new URL(loc, safety.url).toString();
86
+ redirects.push(next);
87
+ current = next;
88
+ continue;
89
+ }
90
+
91
+ const contentType = res.headers.get('content-type') ?? undefined;
92
+ const buf = new Uint8Array(await res.arrayBuffer());
93
+ let truncated = false;
94
+ let slice = buf;
95
+ if (buf.byteLength > maxBytes) {
96
+ slice = buf.slice(0, maxBytes);
97
+ truncated = true;
98
+ warnings.push(`Response truncated to ${maxBytes} bytes`);
99
+ }
100
+ const body = new TextDecoder('utf-8', { fatal: false }).decode(slice);
101
+ if (!res.ok) {
102
+ warnings.push(`HTTP ${res.status}`);
103
+ }
104
+ return {
105
+ ok: res.ok,
106
+ url: rawUrl,
107
+ finalUrl: current,
108
+ status: res.status,
109
+ contentType,
110
+ body,
111
+ route: 'http',
112
+ warnings,
113
+ redirects,
114
+ truncated,
115
+ code: res.ok ? undefined : 'HTTP_ERROR',
116
+ message: res.ok ? undefined : `HTTP ${res.status}`,
117
+ };
118
+ } catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ return {
121
+ ok: false,
122
+ url: rawUrl,
123
+ finalUrl: current,
124
+ route: 'http',
125
+ warnings,
126
+ code: message.includes('abort') ? 'TIMEOUT' : 'FETCH_FAILED',
127
+ message,
128
+ redirects,
129
+ };
130
+ } finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+
135
+ return {
136
+ ok: false,
137
+ url: rawUrl,
138
+ finalUrl: current,
139
+ route: 'http',
140
+ warnings,
141
+ code: 'TOO_MANY_REDIRECTS',
142
+ message: `Exceeded ${MAX_REDIRECTS} redirects`,
143
+ redirects,
144
+ };
145
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Lookout MCP server — stdio tools:
3
+ * core: web_search, web_fetch, web_extract, web_cache
4
+ * advanced: web_crawl, web_research
5
+ */
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { z } from 'zod';
9
+ import { LookoutEngine } from './engine.ts';
10
+
11
+ const engine = new LookoutEngine();
12
+
13
+ function textResult(envelope: unknown, isError = false) {
14
+ return {
15
+ content: [{ type: 'text' as const, text: JSON.stringify(envelope, null, 2) }],
16
+ isError,
17
+ };
18
+ }
19
+
20
+ const server = new McpServer({
21
+ name: 'lookout',
22
+ version: '0.1.0',
23
+ });
24
+
25
+ server.tool(
26
+ 'web_search',
27
+ 'Local-first web search via public adapters (no API key). Returns ranked hits with score explanations.',
28
+ {
29
+ query: z.union([z.string(), z.array(z.string())]).describe('Search query or queries'),
30
+ useCache: z.boolean().optional(),
31
+ hostsInclude: z.array(z.string()).optional().describe('Keep only hits whose host matches any entry'),
32
+ hostsExclude: z.array(z.string()).optional().describe('Drop hits whose host matches any entry'),
33
+ },
34
+ async (args) => {
35
+ const envelope = await engine.handle('web_search', args as Record<string, unknown>);
36
+ return textResult(envelope, envelope.status !== 'ok');
37
+ },
38
+ );
39
+
40
+ server.tool(
41
+ 'web_fetch',
42
+ 'Fetch a URL with SSRF protection, redirect limits, and citeable body prefix spans.',
43
+ {
44
+ url: z.string().url().describe('http(s) URL to fetch'),
45
+ useCache: z.boolean().optional(),
46
+ maxBytes: z.number().int().positive().optional(),
47
+ timeoutMs: z.number().int().positive().optional(),
48
+ respectRobots: z.boolean().optional().describe('If true, honor robots.txt Disallow (default false for fetch)'),
49
+ },
50
+ async (args) => {
51
+ const envelope = await engine.handle('web_fetch', args as Record<string, unknown>);
52
+ return textResult(envelope, envelope.status !== 'ok');
53
+ },
54
+ );
55
+
56
+ server.tool(
57
+ 'web_extract',
58
+ 'Extract title, description, headings, links, JSON-LD, tables, and cite spans from HTML or by URL.',
59
+ {
60
+ url: z.string().url().optional(),
61
+ html: z.string().optional(),
62
+ useCache: z.boolean().optional(),
63
+ },
64
+ async (args) => {
65
+ const envelope = await engine.handle('web_extract', args as Record<string, unknown>);
66
+ return textResult(envelope, envelope.status !== 'ok');
67
+ },
68
+ );
69
+
70
+ server.tool(
71
+ 'web_cache',
72
+ 'Query, stats, or clear the local Lookout cache.',
73
+ {
74
+ op: z.enum(['query', 'stats', 'clear', 'prune']).optional(),
75
+ operation: z.enum(['query', 'stats', 'clear', 'prune']).optional(),
76
+ query: z.string().optional(),
77
+ limit: z.number().int().positive().optional(),
78
+ maxAgeMs: z.number().int().nonnegative().optional(),
79
+ },
80
+ async (args) => {
81
+ const envelope = await engine.handle('web_cache', args as Record<string, unknown>);
82
+ return textResult(envelope, envelope.status !== 'ok');
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ 'web_crawl',
88
+ 'Depth-limited same-origin crawl (advanced). SSRF-safe; not a full-site crawler.',
89
+ {
90
+ url: z.string().url(),
91
+ maxDepth: z.number().int().min(0).max(3).optional(),
92
+ maxPages: z.number().int().min(1).max(25).optional(),
93
+ respectRobots: z.boolean().optional().describe('Honor robots.txt User-agent: * Disallow (default true)'),
94
+ useSitemap: z.boolean().optional().describe('If true, seed same-origin URLs from /sitemap.xml'),
95
+ },
96
+ async (args) => {
97
+ const envelope = await engine.handle('web_crawl', args as Record<string, unknown>);
98
+ return textResult(envelope, envelope.status !== 'ok');
99
+ },
100
+ );
101
+
102
+ server.tool(
103
+ 'web_research',
104
+ 'Advanced: search then fetch/extract top public pages with citeable excerpts (local-first, no API key).',
105
+ {
106
+ query: z.string().describe('Research question or keywords'),
107
+ maxPages: z.number().int().min(1).max(6).optional(),
108
+ hostsInclude: z.array(z.string()).optional().describe('Keep only research hits from these hosts'),
109
+ hostsExclude: z.array(z.string()).optional().describe('Drop research hits from these hosts'),
110
+ },
111
+ async (args) => {
112
+ const envelope = await engine.handle('web_research', args as Record<string, unknown>);
113
+ return textResult(envelope, envelope.status !== 'ok');
114
+ },
115
+ );
116
+
117
+ const transport = new StdioServerTransport();
118
+ await server.connect(transport);
@@ -0,0 +1,98 @@
1
+ import { webSearch, filterHitsByHosts, type SearchHit, type SearchResult } from './search.ts';
2
+ import { webFetch, type FetchResult } from './fetch.ts';
3
+ import { extractFromHtml } from './extract.ts';
4
+
5
+ export type ResearchPage = {
6
+ url: string;
7
+ title?: string;
8
+ score?: number;
9
+ engine?: string;
10
+ fetchOk: boolean;
11
+ extractRoute?: string;
12
+ textExcerpt?: string;
13
+ headings?: { level: number; text: string }[];
14
+ warnings: string[];
15
+ spans?: { text: string; kind: string }[];
16
+ };
17
+
18
+ export type ResearchResult = {
19
+ query: string;
20
+ hits: SearchHit[];
21
+ pages: ResearchPage[];
22
+ warnings: string[];
23
+ route: string;
24
+ };
25
+
26
+ export type ResearchDeps = {
27
+ searchFn?: (query: string) => Promise<SearchResult>;
28
+ fetchFn?: (url: string, options?: { maxBytes?: number }) => Promise<FetchResult>;
29
+ };
30
+
31
+ export async function webResearch(
32
+ query: string,
33
+ options: {
34
+ maxPages?: number;
35
+ maxBytes?: number;
36
+ hostsInclude?: string[];
37
+ hostsExclude?: string[];
38
+ } & ResearchDeps = {},
39
+ ): Promise<ResearchResult> {
40
+ const maxPages = Math.min(Math.max(options.maxPages ?? 3, 1), 6);
41
+ const maxBytes = options.maxBytes ?? 512_000;
42
+ const searchFn = options.searchFn ?? webSearch;
43
+ const fetchFn = options.fetchFn ?? webFetch;
44
+ const search = await searchFn(query);
45
+ const warnings = [...(search.warnings ?? [])];
46
+ let hits = search.hits;
47
+ if ((options.hostsInclude?.length ?? 0) > 0 || (options.hostsExclude?.length ?? 0) > 0) {
48
+ const before = hits.length;
49
+ hits = filterHitsByHosts(hits, {
50
+ include: options.hostsInclude,
51
+ exclude: options.hostsExclude,
52
+ });
53
+ warnings.push(
54
+ `host_filter kept ${hits.length}/${before} for research (include=${(options.hostsInclude ?? []).join(',') || '*'}; exclude=${(options.hostsExclude ?? []).join(',') || 'none'})`,
55
+ );
56
+ }
57
+ const pages: ResearchPage[] = [];
58
+
59
+ for (const hit of hits.slice(0, maxPages)) {
60
+ const page: ResearchPage = {
61
+ url: hit.url,
62
+ title: hit.title,
63
+ score: hit.score,
64
+ engine: hit.engine,
65
+ fetchOk: false,
66
+ warnings: [],
67
+ };
68
+ try {
69
+ const fetched = await fetchFn(hit.url, { maxBytes });
70
+ if (!fetched.ok || !fetched.body) {
71
+ page.warnings.push(fetched.message ?? fetched.code ?? 'fetch_failed');
72
+ pages.push(page);
73
+ continue;
74
+ }
75
+ page.fetchOk = true;
76
+ const extracted = extractFromHtml(fetched.body, hit.url);
77
+ page.extractRoute = extracted.route;
78
+ page.textExcerpt = extracted.textExcerpt?.slice(0, 1200);
79
+ page.headings = extracted.headings.slice(0, 8);
80
+ page.spans = extracted.spans.slice(0, 6).map((s) => ({ text: s.text.slice(0, 200), kind: s.kind }));
81
+ page.warnings.push(...extracted.warnings);
82
+ if (!page.title && extracted.title) page.title = extracted.title;
83
+ } catch (e) {
84
+ page.warnings.push(e instanceof Error ? e.message : 'research_page_error');
85
+ }
86
+ pages.push(page);
87
+ }
88
+
89
+ if (pages.length === 0) warnings.push('no_research_pages');
90
+
91
+ return {
92
+ query,
93
+ hits,
94
+ pages,
95
+ warnings,
96
+ route: 'search_then_extract',
97
+ };
98
+ }