pi-wsearch 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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Transport for web.fetch: UA-rotating local HTTP + the key-gated reader
3
+ * backends. Text processing lives in scrape.ts (one-way import).
4
+ */
5
+ import { getConfig, sanitizeError } from './config';
6
+ import { ABORT_ERROR } from './constants';
7
+
8
+ export type Renderer = 'local' | 'firecrawl' | 'tavily' | 'exa' | 'jina';
9
+
10
+ export type RemoteRenderer = Exclude<Renderer, 'local'>;
11
+
12
+ export interface RemoteDoc {
13
+ title: string;
14
+ markdown: string;
15
+ }
16
+
17
+ export const DEFAULT_SECTION_CAP = 3000;
18
+
19
+ const USER_AGENTS = [
20
+ 'curl/8.0',
21
+ 'Mozilla/5.0 (compatible; TextBot/1.0)',
22
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 pi-web-search/0.1',
23
+ ];
24
+ const MAX_HTML_BYTES = 5 * 1024 * 1024;
25
+ const LOCAL_TIMEOUT_MS = 15_000;
26
+ const RETRY_AFTER_MAX_MS = 10_000;
27
+ const READER_TIMEOUTS: Record<RemoteRenderer, number> = {
28
+ firecrawl: 45_000,
29
+ tavily: 30_000,
30
+ exa: 20_000,
31
+ jina: 20_000,
32
+ };
33
+
34
+ function isBotBlocked(status: number, content: string): boolean {
35
+ if (status !== 403 && status !== 503) return false;
36
+ const lower = content.toLowerCase();
37
+ return (
38
+ lower.includes('cloudflare') ||
39
+ lower.includes('captcha') ||
40
+ lower.includes('challenge') ||
41
+ lower.includes('blocked') ||
42
+ lower.includes('access denied') ||
43
+ lower.includes('bot detection') ||
44
+ lower.includes('enable javascript')
45
+ );
46
+ }
47
+
48
+ export function looksLikeHtml(content: string): boolean {
49
+ const t = content.trim().toLowerCase();
50
+ return t.startsWith('<!doctype') || t.startsWith('<html') || t.startsWith('<head') || t.startsWith('<body');
51
+ }
52
+
53
+ const DATA_URI_IMAGE_RE = /!\[((?:\\.|[^\\\]])*)\]\(\s*(?:<data:[^>]*>|data:[^)\s]*)[^)]*\)/gi;
54
+
55
+ export function stripDataUriImages(markdown: string): string {
56
+ return markdown.replace(DATA_URI_IMAGE_RE, (_m, alt: string) => (alt.trim() ? `![${alt}]` : ''));
57
+ }
58
+
59
+ export function isLowQualityOutput(content: string): boolean {
60
+ const lower = content.toLowerCase();
61
+ const jsGated = [
62
+ 'enable javascript',
63
+ 'javascript required',
64
+ 'turn on javascript',
65
+ 'please enable javascript',
66
+ 'browser not supported',
67
+ ];
68
+ if (content.length < 1024 && jsGated.some((t) => lower.includes(t))) return true;
69
+ const lines = content.split('\n').filter((l) => l.trim());
70
+ const shortLines = lines.filter((l) => l.trim().length < 40);
71
+ if (lines.length > 10 && shortLines.length / lines.length > 0.7) return true;
72
+ return false;
73
+ }
74
+
75
+ export function isRawText(contentType: string): boolean {
76
+ const mime = contentType.split(';')[0]?.trim().toLowerCase() ?? '';
77
+ return mime === 'text/plain' || mime === 'text/markdown' || mime.endsWith('+markdown');
78
+ }
79
+
80
+ function parseRetryAfter(value: string | null): number {
81
+ if (!value) return 1_000;
82
+ const seconds = Number(value);
83
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0) * 1000, RETRY_AFTER_MAX_MS);
84
+ const date = Date.parse(value);
85
+ if (!Number.isNaN(date)) return Math.min(Math.max(date - Date.now(), 0), RETRY_AFTER_MAX_MS);
86
+ return 1_000;
87
+ }
88
+
89
+ function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
90
+ return new Promise((resolve, reject) => {
91
+ if (signal?.aborted) return reject(new Error(ABORT_ERROR));
92
+ const onAbort = () => {
93
+ clearTimeout(t);
94
+ reject(new Error(ABORT_ERROR));
95
+ };
96
+ const t = setTimeout(() => {
97
+ signal?.removeEventListener('abort', onAbort);
98
+ resolve();
99
+ }, ms);
100
+ signal?.addEventListener('abort', onAbort, { once: true });
101
+ });
102
+ }
103
+
104
+ function decodeBody(bytes: Uint8Array, contentType: string): string {
105
+ const label =
106
+ /charset\s*=\s*"?([\w-]+)"?/i.exec(contentType)?.[1] ??
107
+ /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(new TextDecoder('latin1').decode(bytes.subarray(0, 2048)))?.[1];
108
+ if (label && !/^utf-?8$/i.test(label)) {
109
+ try {
110
+ return new TextDecoder(label).decode(bytes);
111
+ } catch {}
112
+ }
113
+ return new TextDecoder('utf-8', { fatal: false }).decode(bytes);
114
+ }
115
+
116
+ async function readBodyBounded(res: Response, maxBytes: number): Promise<Uint8Array> {
117
+ const declared = Number(res.headers.get('content-length') ?? 0);
118
+ if (declared > maxBytes) throw new Error('page too large');
119
+ const reader = res.body?.getReader();
120
+ if (!reader) {
121
+ const buf = await res.arrayBuffer();
122
+ if (buf.byteLength > maxBytes) throw new Error('page too large');
123
+ return new Uint8Array(buf);
124
+ }
125
+ const chunks: Uint8Array[] = [];
126
+ let size = 0;
127
+ for (;;) {
128
+ const { done, value } = await reader.read();
129
+ if (done) break;
130
+ size += value.byteLength;
131
+ if (size > maxBytes) throw new Error('page too large');
132
+ chunks.push(value);
133
+ }
134
+ const out = new Uint8Array(size);
135
+ let off = 0;
136
+ for (const c of chunks) {
137
+ out.set(c, off);
138
+ off += c.byteLength;
139
+ }
140
+ return out;
141
+ }
142
+
143
+ export async function localFetch(url: URL, signal?: AbortSignal): Promise<{ body: string; contentType: string }> {
144
+ let retried429 = false;
145
+ for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) {
146
+ if (signal?.aborted) throw new Error(ABORT_ERROR);
147
+ const { ctrl, done } = withTimeout('local fetch', LOCAL_TIMEOUT_MS, signal);
148
+ try {
149
+ const res = await fetch(url, {
150
+ headers: {
151
+ 'User-Agent': USER_AGENTS[attempt],
152
+ Accept: 'text/html,application/xhtml+xml,text/plain,text/markdown,*/*;q=0.8',
153
+ 'Accept-Language': 'en-US,en;q=0.5',
154
+ },
155
+ redirect: 'follow',
156
+ signal: ctrl.signal,
157
+ });
158
+ const ct = res.headers.get('content-type') ?? '';
159
+ const mime = ct.split(';')[0]?.trim().toLowerCase() ?? '';
160
+ if (
161
+ !/html|xml|text/.test(mime) &&
162
+ !mime.includes('application/javascript') &&
163
+ !mime.includes('application/json')
164
+ ) {
165
+ throw new Error(`not a readable page (${mime || '?'})`);
166
+ }
167
+ if (res.status === 429 && !retried429) {
168
+ retried429 = true;
169
+ await waitAbortable(parseRetryAfter(res.headers.get('retry-after')), signal);
170
+ attempt--;
171
+ continue;
172
+ }
173
+ if (!res.ok) {
174
+ const sample = await res.text().catch(() => '');
175
+ if (isBotBlocked(res.status, sample) && attempt < USER_AGENTS.length - 1) continue;
176
+ throw new Error(`HTTP ${res.status}`);
177
+ }
178
+ const bytes = await readBodyBounded(res, MAX_HTML_BYTES);
179
+ if (bytes.byteLength === 0) throw new Error('empty body');
180
+ return { body: decodeBody(bytes, ct), contentType: ct };
181
+ } catch (e) {
182
+ if (signal?.aborted) throw new Error(ABORT_ERROR, { cause: e });
183
+ const clean = sanitizeError(e);
184
+ if (
185
+ attempt < USER_AGENTS.length - 1 &&
186
+ /HTTP 40[13]|HTTP 503|cloudflare|captcha|blocked|access denied/i.test(clean)
187
+ ) {
188
+ continue;
189
+ }
190
+ throw e;
191
+ } finally {
192
+ done();
193
+ }
194
+ }
195
+ throw new Error('local fetch failed');
196
+ }
197
+
198
+ // Per-attempt timeout + pre-abort: an already-aborted signal never fires
199
+ // abort listeners, so the request must reject immediately on its own.
200
+ function withTimeout(label: string, ms: number, signal?: AbortSignal): { ctrl: AbortController; done: () => void } {
201
+ const ctrl = new AbortController();
202
+ const t = setTimeout(() => ctrl.abort(new Error(`${label} timeout`)), ms);
203
+ const onAbort = () => ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
204
+ if (signal?.aborted) ctrl.abort(signal?.reason ?? new Error(ABORT_ERROR));
205
+ else signal?.addEventListener('abort', onAbort, { once: true });
206
+ return {
207
+ ctrl,
208
+ done: () => {
209
+ clearTimeout(t);
210
+ signal?.removeEventListener('abort', onAbort);
211
+ },
212
+ };
213
+ }
214
+
215
+ async function readJson<T>(
216
+ url: string,
217
+ init: { method: string; headers: Record<string, string>; body?: string },
218
+ ctrl: AbortSignal,
219
+ ): Promise<T> {
220
+ const res = await fetch(url, { method: init.method, headers: init.headers, body: init.body, signal: ctrl });
221
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
222
+ return (await res.json()) as T;
223
+ }
224
+
225
+ async function firecrawlRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
226
+ const key = getConfig().firecrawlKey!;
227
+ const { ctrl, done } = withTimeout('firecrawl', READER_TIMEOUTS.firecrawl, signal);
228
+ try {
229
+ const res = await fetch('https://api.firecrawl.dev/v2/scrape', {
230
+ method: 'POST',
231
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
232
+ body: JSON.stringify({ url: url.toString(), formats: ['markdown'], onlyMainContent: true, timeout: 45000 }),
233
+ signal: ctrl.signal,
234
+ });
235
+ if (!res.ok) throw new Error(`firecrawl HTTP ${res.status}`);
236
+ const d = (await res.json()) as { success?: boolean; data?: { markdown?: string; metadata?: { title?: string } } };
237
+ const md = d.data?.markdown ?? '';
238
+ if (!md) throw new Error('firecrawl returned no markdown');
239
+ return {
240
+ title: d.data?.metadata?.title ?? '',
241
+ markdown: md.length > MAX_HTML_BYTES ? md.slice(0, MAX_HTML_BYTES) : md,
242
+ };
243
+ } finally {
244
+ done();
245
+ }
246
+ }
247
+
248
+ // Docs: auth is Authorization: Bearer (not body api_key); per-URL failures
249
+ // land in failed_results, not an error status.
250
+ async function tavilyRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
251
+ const key = getConfig().tavilyKey!;
252
+ const { ctrl, done } = withTimeout('tavily', READER_TIMEOUTS.tavily, signal);
253
+ try {
254
+ const d = await readJson<{
255
+ results?: { url?: string; title?: string; raw_content?: string }[];
256
+ failed_results?: { url?: string; error?: string }[];
257
+ }>(
258
+ 'https://api.tavily.com/extract',
259
+ {
260
+ method: 'POST',
261
+ headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ urls: [url.toString()], extract_depth: 'basic', format: 'markdown' }),
263
+ },
264
+ ctrl.signal,
265
+ );
266
+ const doc = (d.results ?? []).find((r) => r && !!r.raw_content);
267
+ if (!doc?.raw_content?.trim()) {
268
+ const fail = (d.failed_results ?? [])[0];
269
+ throw new Error(
270
+ fail
271
+ ? `tavily extract failed: ${fail.error ?? fail.url ?? 'no content'}`
272
+ : 'tavily extract returned no content',
273
+ );
274
+ }
275
+ return { title: doc.title ?? '', markdown: doc.raw_content };
276
+ } finally {
277
+ done();
278
+ }
279
+ }
280
+
281
+ // Docs: /contents has no contents wrapper; text is an object, not a boolean.
282
+ async function exaRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
283
+ const key = getConfig().exaKey!;
284
+ const { ctrl, done } = withTimeout('exa', READER_TIMEOUTS.exa, signal);
285
+ try {
286
+ const d = await readJson<{ results?: { url?: string; title?: string; text?: string }[] }>(
287
+ 'https://api.exa.ai/contents',
288
+ {
289
+ method: 'POST',
290
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'x-api-key': key },
291
+ body: JSON.stringify({ urls: [url.toString()], text: { maxCharacters: 12000 } }),
292
+ },
293
+ ctrl.signal,
294
+ );
295
+ const doc = (d.results ?? []).find((r) => r && !!r.text);
296
+ if (!doc?.text?.trim()) throw new Error('exa contents returned no content');
297
+ return { title: doc.title ?? '', markdown: doc.text };
298
+ } finally {
299
+ done();
300
+ }
301
+ }
302
+
303
+ async function jinaRead(url: URL, signal?: AbortSignal): Promise<RemoteDoc> {
304
+ const key = getConfig().jinaKey;
305
+ const { ctrl, done } = withTimeout('jina', READER_TIMEOUTS.jina, signal);
306
+ try {
307
+ const headers: Record<string, string> = { Accept: 'text/markdown', 'X-No-Cache': 'true' };
308
+ if (key) headers.Authorization = `Bearer ${key}`;
309
+ const res = await fetch(`https://r.jina.ai/${url.toString()}`, { method: 'GET', headers, signal: ctrl.signal });
310
+ if (!res.ok) throw new Error(`jina reader HTTP ${res.status}`);
311
+ const body = await res.text();
312
+ const marker = 'Markdown Content:';
313
+ const start = body.indexOf(marker);
314
+ const content = (start >= 0 ? body.slice(start + marker.length) : body).trim();
315
+ if (content.length < 100 || content.startsWith('Loading...') || content.startsWith('Please enable JavaScript')) {
316
+ throw new Error('jina reader returned no usable content');
317
+ }
318
+ return { title: '', markdown: content };
319
+ } finally {
320
+ done();
321
+ }
322
+ }
323
+
324
+ interface Reader {
325
+ available(): boolean;
326
+ read(url: URL, signal?: AbortSignal): Promise<RemoteDoc>;
327
+ }
328
+
329
+ const READERS: Record<RemoteRenderer, Reader> = {
330
+ firecrawl: { available: () => !!getConfig().firecrawlKey, read: firecrawlRead },
331
+ tavily: { available: () => !!getConfig().tavilyKey, read: tavilyRead },
332
+ exa: { available: () => !!getConfig().exaKey, read: exaRead },
333
+ jina: { available: () => !!getConfig().jinaKey, read: jinaRead },
334
+ };
335
+
336
+ const REMOTE_ORDER: RemoteRenderer[] = ['firecrawl', 'tavily', 'exa', 'jina'];
337
+
338
+ function readerOrder(): RemoteRenderer[] {
339
+ const raw = getConfig().fetchChain;
340
+ if (raw) {
341
+ const kept: RemoteRenderer[] = [];
342
+ for (const s of raw.split(',')) {
343
+ const t = s.trim().toLowerCase();
344
+ if (!t) continue;
345
+ if (t in READERS) kept.push(t as RemoteRenderer);
346
+ }
347
+ if (kept.length) return kept;
348
+ }
349
+ return REMOTE_ORDER;
350
+ }
351
+
352
+ export function stubReader(id: RemoteRenderer, reader: Reader): () => void {
353
+ const saved = READERS[id];
354
+ READERS[id] = reader;
355
+ return () => {
356
+ READERS[id] = saved;
357
+ };
358
+ }
359
+
360
+ export type ChainResult =
361
+ | { ok: true; doc: RemoteDoc & { renderer: Renderer } }
362
+ | { ok: false; aborted: boolean; error: string };
363
+
364
+ export async function remoteChain(
365
+ url: URL,
366
+ signal: AbortSignal | undefined,
367
+ usable: (markdown: string) => boolean,
368
+ ): Promise<ChainResult> {
369
+ const order = readerOrder();
370
+ let lastError: string | undefined;
371
+ for (const id of order) {
372
+ if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
373
+ const reader = READERS[id];
374
+ if (!reader.available()) continue;
375
+ try {
376
+ const doc = await reader.read(url, signal);
377
+ if (!usable(doc.markdown)) continue;
378
+ return { ok: true, doc: { ...doc, renderer: id } };
379
+ } catch (e) {
380
+ if (signal?.aborted) return { ok: false, aborted: true, error: ABORT_ERROR };
381
+ lastError = sanitizeError(e);
382
+ }
383
+ }
384
+ return {
385
+ ok: false,
386
+ aborted: false,
387
+ error: lastError ? `reader chain exhausted: ${lastError}` : 'no reader backend configured',
388
+ };
389
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,68 @@
1
+ /** URL facts leaf: normalization (dedupe + cache keys), junk classification. */
2
+
3
+ const JUNK_HOSTS = [
4
+ 'quora.com',
5
+ 'fiverr.com',
6
+ 'freelancer.com',
7
+ 'tiktok.com',
8
+ 'pinterest.com',
9
+ 'instagram.com',
10
+ 'facebook.com',
11
+ 'twitter.com',
12
+ 'x.com',
13
+ 'twitch.tv',
14
+ '9gag.com',
15
+ 'buzzfeed.com',
16
+ ];
17
+ const JUNK_TLDS = new Set(['xyz', 'top', 'loan', 'click', 'work', 'gq', 'icu', 'rest', 'cyou']);
18
+
19
+ const TRACKING_PARAMS = new Set([
20
+ 'utm_source',
21
+ 'utm_medium',
22
+ 'utm_campaign',
23
+ 'utm_term',
24
+ 'utm_content',
25
+ 'fbclid',
26
+ 'gclid',
27
+ 'ref',
28
+ 'ref_src',
29
+ 'mc_cid',
30
+ 'mc_eid',
31
+ 'igshid',
32
+ 'spm',
33
+ 'scm',
34
+ ]);
35
+
36
+ export function hostOf(raw: string): string {
37
+ try {
38
+ return new URL(raw).hostname.toLowerCase().replace(/^www\./, '');
39
+ } catch {
40
+ return '';
41
+ }
42
+ }
43
+
44
+ /** Keeps scheme+port deliberately (http vs https twins are different pages). */
45
+ export function normalizeUrl(raw: string): string {
46
+ try {
47
+ const u = new URL(raw);
48
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
49
+ for (const p of Array.from(u.searchParams.keys())) {
50
+ if (TRACKING_PARAMS.has(p.toLowerCase())) u.searchParams.delete(p);
51
+ }
52
+ u.hash = '';
53
+ const pairs = Array.from(u.searchParams.entries()).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
54
+ const q = pairs.map(([k, v]) => `${k}=${v}`).join('&');
55
+ const port = u.port ? `:${u.port}` : '';
56
+ return `${u.protocol}//${host}${port}${u.pathname.replace(/\/+$/, '')}${q ? '?' + q : ''}`;
57
+ } catch {
58
+ return raw.toLowerCase();
59
+ }
60
+ }
61
+
62
+ export function isJunk(raw: string): boolean {
63
+ const host = hostOf(raw);
64
+ if (JUNK_HOSTS.some((j) => host === j || host.endsWith('.' + j))) return true;
65
+ const tld = host.split('.').pop() ?? '';
66
+ if (JUNK_TLDS.has(tld)) return true;
67
+ return false;
68
+ }