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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/index.ts +8 -0
- package/justfile +31 -0
- package/package.json +63 -0
- package/src/cache.ts +91 -0
- package/src/config.ts +263 -0
- package/src/constants.ts +6 -0
- package/src/engines.ts +384 -0
- package/src/fuse.ts +240 -0
- package/src/keys.ts +104 -0
- package/src/scrape.ts +490 -0
- package/src/tools.ts +527 -0
- package/src/transport.ts +389 -0
- package/src/urls.ts +68 -0
package/src/keys.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key-file management for /websearch. The dedicated wsearch/env file is the
|
|
3
|
+
* only file this extension ever writes; writes are atomic and always 0600,
|
|
4
|
+
* and only the target provider's lines are touched.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, chmodSync, existsSync } from 'node:fs';
|
|
7
|
+
import { dirname } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
getConfig,
|
|
10
|
+
keyFilePath,
|
|
11
|
+
keyOrigins,
|
|
12
|
+
keyStatus,
|
|
13
|
+
parseEnvLine,
|
|
14
|
+
PROVIDERS as PROVIDER_LIST,
|
|
15
|
+
disabledFlagPath,
|
|
16
|
+
type ProviderInfo,
|
|
17
|
+
} from './config';
|
|
18
|
+
|
|
19
|
+
const PROVIDER_IDS = PROVIDER_LIST.map((p) => p.id);
|
|
20
|
+
|
|
21
|
+
function providerNames(p: ProviderInfo): string[] {
|
|
22
|
+
return [p.envName, ...p.aliases];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readFile(path: string): string {
|
|
26
|
+
try {
|
|
27
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
28
|
+
} catch {
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mutateFile(mutations: Map<string, string | undefined>): void {
|
|
34
|
+
const path = keyFilePath();
|
|
35
|
+
const lines = readFile(path).split('\n');
|
|
36
|
+
const out = lines.filter((raw) => {
|
|
37
|
+
const pair = parseEnvLine(raw);
|
|
38
|
+
return !pair || !mutations.has(pair[0]);
|
|
39
|
+
});
|
|
40
|
+
let text = out.join('\n');
|
|
41
|
+
const incoming: string[] = [];
|
|
42
|
+
for (const [name, val] of mutations) {
|
|
43
|
+
if (val) incoming.push(`${name}=${val}`);
|
|
44
|
+
}
|
|
45
|
+
if (incoming.length) text = (text.trimEnd() ? text.trimEnd() + '\n' : '') + incoming.join('\n') + '\n';
|
|
46
|
+
writeAtomic(path, text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function writeAtomic(path: string, content: string): void {
|
|
50
|
+
const dir = dirname(path);
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
53
|
+
writeFileSync(tmp, content, 'utf8');
|
|
54
|
+
chmodSync(tmp, 0o600);
|
|
55
|
+
renameSync(tmp, path);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const allNamesRemoved = (p: ProviderInfo): Map<string, string | undefined> =>
|
|
59
|
+
new Map(providerNames(p).map((n) => [n, undefined]));
|
|
60
|
+
|
|
61
|
+
/** Write or replace a provider's key in the dedicated env file. */
|
|
62
|
+
export function writeKey(p: ProviderInfo, value: string): void {
|
|
63
|
+
const mutations = allNamesRemoved(p);
|
|
64
|
+
mutations.set(p.envName, value.trim());
|
|
65
|
+
mutateFile(mutations);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Remove a provider's key lines from the dedicated env file. */
|
|
69
|
+
export function removeKey(p: ProviderInfo): void {
|
|
70
|
+
mutateFile(allNamesRemoved(p));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function statusText(): string {
|
|
74
|
+
const st = keyStatus();
|
|
75
|
+
const origins = keyOrigins();
|
|
76
|
+
const armed = PROVIDER_IDS.filter((id) => st[id]);
|
|
77
|
+
const dormant = PROVIDER_IDS.filter((id) => !st[id]);
|
|
78
|
+
const cfg = getConfig();
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
lines.push(`web tools: ${existsSync(disabledFlagPath()) ? 'OFF' : 'ON'}`);
|
|
81
|
+
lines.push(`web search: ${PROVIDER_IDS.length} pockets`);
|
|
82
|
+
lines.push(
|
|
83
|
+
armed.length ? ` armed: ${armed.map((id) => `${id} ✓(${origins[id] ?? '?'})`).join(', ')}` : ' armed: (none)',
|
|
84
|
+
);
|
|
85
|
+
lines.push(` dormant: ${dormant.length ? dormant.join(', ') : '(none)'}`);
|
|
86
|
+
const chain = cfg.fetchChain ? cfg.fetchChain.replace(/,/g, ', ') : 'firecrawl, tavily, exa, jina';
|
|
87
|
+
lines.push(` fetch: local → ${chain}`);
|
|
88
|
+
const knobs = [];
|
|
89
|
+
if (cfg.searchEngines) knobs.push(`WSEARCH_ENGINES=${cfg.searchEngines}`);
|
|
90
|
+
if (cfg.fetchChain) knobs.push(`WSEARCH_FETCH_CHAIN=${cfg.fetchChain}`);
|
|
91
|
+
if (knobs.length) lines.push(` knobs: ${knobs.join(' · ')}`);
|
|
92
|
+
if (armed.length === 0) {
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push('Getting started (30 seconds):');
|
|
95
|
+
lines.push(' 1. get one free key; cheapest paths:');
|
|
96
|
+
lines.push(' · tavily → tavily.com (1,000 free searches/mo)');
|
|
97
|
+
lines.push(' · exa → exa.ai (1,000 free/mo)');
|
|
98
|
+
lines.push(' · firecrawl → firecrawl.dev (1,000 free credits/mo)');
|
|
99
|
+
lines.push(' · tinyfish → tinyfish.ai (free at any balance)');
|
|
100
|
+
lines.push(' 2. /websearch login, saved to wsearch/env');
|
|
101
|
+
lines.push(' 3. run /websearch again; it should appear under "armed"');
|
|
102
|
+
}
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
package/src/scrape.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch orchestration + pure text pipeline (transport in transport.ts). SSRF:
|
|
3
|
+
* private/loopback numeric literals are rejected on the local path; see
|
|
4
|
+
* isPrivateHost for the deliberate scope.
|
|
5
|
+
*/
|
|
6
|
+
import { getConfig, sanitizeError } from './config';
|
|
7
|
+
import { ABORT_ERROR } from './constants';
|
|
8
|
+
import {
|
|
9
|
+
localFetch,
|
|
10
|
+
looksLikeHtml,
|
|
11
|
+
remoteChain,
|
|
12
|
+
isRawText,
|
|
13
|
+
isLowQualityOutput,
|
|
14
|
+
stripDataUriImages,
|
|
15
|
+
DEFAULT_SECTION_CAP,
|
|
16
|
+
type RemoteDoc,
|
|
17
|
+
type Renderer,
|
|
18
|
+
} from './transport';
|
|
19
|
+
|
|
20
|
+
export interface ScrapeResult {
|
|
21
|
+
url: string;
|
|
22
|
+
title: string;
|
|
23
|
+
renderer: Renderer;
|
|
24
|
+
outline: string[];
|
|
25
|
+
sections: string[];
|
|
26
|
+
text: string;
|
|
27
|
+
truncated: boolean;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MAX_CHARS = 12000;
|
|
32
|
+
|
|
33
|
+
function stripToAscii(s: string): string {
|
|
34
|
+
return s
|
|
35
|
+
.replace(/\u00a0/g, ' ')
|
|
36
|
+
.replace(/\u200b/g, '')
|
|
37
|
+
.replace(/\r/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
41
|
+
amp: '&',
|
|
42
|
+
lt: '<',
|
|
43
|
+
gt: '>',
|
|
44
|
+
quot: '"',
|
|
45
|
+
apos: "'",
|
|
46
|
+
nbsp: '\u00a0',
|
|
47
|
+
ndash: '–',
|
|
48
|
+
mdash: '—',
|
|
49
|
+
hellip: '…',
|
|
50
|
+
copy: '©',
|
|
51
|
+
reg: '®',
|
|
52
|
+
trade: '™',
|
|
53
|
+
laquo: '«',
|
|
54
|
+
raquo: '»',
|
|
55
|
+
lsquo: '‘',
|
|
56
|
+
rsquo: '’',
|
|
57
|
+
ldquo: '“',
|
|
58
|
+
rdquo: '”',
|
|
59
|
+
bull: '•',
|
|
60
|
+
middot: '·',
|
|
61
|
+
deg: '°',
|
|
62
|
+
plusmn: '±',
|
|
63
|
+
times: '×',
|
|
64
|
+
divide: '÷',
|
|
65
|
+
frac12: '½',
|
|
66
|
+
sup2: '²',
|
|
67
|
+
sup3: '³',
|
|
68
|
+
ensp: ' ',
|
|
69
|
+
emsp: ' ',
|
|
70
|
+
thinsp: ' ',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function decodeHtmlEntities(s: string): string {
|
|
74
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (m, body: string) => {
|
|
75
|
+
if (body[0] === '#') {
|
|
76
|
+
const hex = body[1] === 'x' || body[1] === 'X';
|
|
77
|
+
const n = hex ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
78
|
+
if (!Number.isFinite(n) || n <= 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff)) return m;
|
|
79
|
+
try {
|
|
80
|
+
return String.fromCodePoint(n);
|
|
81
|
+
} catch {
|
|
82
|
+
return m;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? m;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isPrivateIpv4(h: string): boolean {
|
|
90
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
91
|
+
if (!ipv4) return false;
|
|
92
|
+
const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
|
|
93
|
+
if (a === 10) return true;
|
|
94
|
+
if (a === 127) return true;
|
|
95
|
+
if (a === 0) return true;
|
|
96
|
+
if (a === 169 && b === 254) return true;
|
|
97
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
98
|
+
if (a === 192 && b === 168) return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function numericIpv4(h: string): string | null {
|
|
103
|
+
if (!/^[0-9.]+$/.test(h) && !/^0[xX][0-9a-fA-F.]+$/.test(h)) return null;
|
|
104
|
+
const fields = h.split('.');
|
|
105
|
+
if (fields.length > 4) return null;
|
|
106
|
+
const parts: number[] = [];
|
|
107
|
+
for (const f of fields) {
|
|
108
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(f)) parts.push(parseInt(f, 16));
|
|
109
|
+
else if (/^0[0-7]+$/.test(f)) parts.push(parseInt(f, 8));
|
|
110
|
+
else if (/^\d+$/.test(f)) parts.push(Number(f));
|
|
111
|
+
else return null;
|
|
112
|
+
}
|
|
113
|
+
const last = parts[parts.length - 1];
|
|
114
|
+
if (parts.length === 1) {
|
|
115
|
+
if (last >= 2 ** 32) return null;
|
|
116
|
+
return [(last >>> 24) & 0xff, (last >>> 16) & 0xff, (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
117
|
+
}
|
|
118
|
+
if (parts.slice(0, -1).some((n) => n > 255)) return null;
|
|
119
|
+
if (last >= 2 ** (8 * (4 - parts.length + 1))) return null;
|
|
120
|
+
if (parts.length === 2) return [parts[0], 0, 0, last].join('.');
|
|
121
|
+
if (parts.length === 3) return [parts[0], parts[1], (last >>> 8) & 0xff, last & 0xff].join('.');
|
|
122
|
+
return parts.join('.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isPrivateHost(host: string): boolean {
|
|
126
|
+
const h = host.toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
127
|
+
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.localhost')) return true;
|
|
128
|
+
const mapped = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(h);
|
|
129
|
+
if (mapped) {
|
|
130
|
+
const [a, b] = [parseInt(mapped[1], 16), parseInt(mapped[2], 16)];
|
|
131
|
+
if (isPrivateIpv4(`${(a >> 8) & 0xff}.${a & 0xff}.${(b >> 8) & 0xff}.${b & 0xff}`)) return true;
|
|
132
|
+
}
|
|
133
|
+
const ipv4 = numericIpv4(h);
|
|
134
|
+
if (ipv4 && isPrivateIpv4(ipv4)) return true;
|
|
135
|
+
if (/^[0-9a-f:]+$/.test(h) && h.includes(':')) {
|
|
136
|
+
if (h.startsWith('::1') || h === '0:0:0:0:0:0:0:1') return true;
|
|
137
|
+
if (h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd')) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function validateUrl(raw: string, allowPrivate = false): URL {
|
|
143
|
+
const u = new URL(raw);
|
|
144
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error(`unsupported protocol: ${u.protocol}`);
|
|
145
|
+
if (!allowPrivate && isPrivateHost(u.hostname)) throw new Error('private/loopback address rejected');
|
|
146
|
+
return u;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function extractHtml(html: string): { title: string; text: string } {
|
|
150
|
+
const title =
|
|
151
|
+
/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()?.slice(0, 200) ||
|
|
152
|
+
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1]?.slice(0, 200) ||
|
|
153
|
+
'';
|
|
154
|
+
let h = html
|
|
155
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
156
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
157
|
+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ')
|
|
158
|
+
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
|
159
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
160
|
+
.replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
|
|
161
|
+
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
|
162
|
+
.replace(/<aside[\s\S]*?<\/aside>/gi, ' ');
|
|
163
|
+
h = h.replace(/<pre[\s>][\s\S]*?<\/pre>/gi, (m) => m.replace(/\n/g, '\uE000'));
|
|
164
|
+
h = h.replace(
|
|
165
|
+
/<h([1-6])[^>]*>(.*?)<\/h\1>/gis,
|
|
166
|
+
(_, n, inner) => `\n${'#'.repeat(Number(n))} ${inner.replace(/<[^>]+>/g, ' ').trim()}\n`,
|
|
167
|
+
);
|
|
168
|
+
h = h
|
|
169
|
+
.replace(/<li[^>]*>/gi, '\n• ')
|
|
170
|
+
.replace(/<\/(p|div|li|tr|section|article|blockquote|table|ul|ol|pre)>/gi, '\n')
|
|
171
|
+
.replace(/<(br|hr)[^>]*>/gi, '\n');
|
|
172
|
+
// strip tags first, THEN decode entities: escaped markup shown as literal
|
|
173
|
+
// text (`<limit...>`) must survive as text, not be stripped as a tag
|
|
174
|
+
h = h.replace(/<[^>]+>/g, ' ');
|
|
175
|
+
h = h.replace(/\uE000/g, '\n');
|
|
176
|
+
h = stripToAscii(decodeHtmlEntities(h));
|
|
177
|
+
const lines = h
|
|
178
|
+
.split('\n')
|
|
179
|
+
.map((l) => l.trim())
|
|
180
|
+
.filter((l) => l.length > 0 && !/^•\s*$/.test(l))
|
|
181
|
+
.filter((l) => !(/^[-*•]\s/.test(l) && isNavBullet(l)));
|
|
182
|
+
const out: string[] = [];
|
|
183
|
+
let blank = 0;
|
|
184
|
+
for (const l of lines) {
|
|
185
|
+
if (l.startsWith('#')) {
|
|
186
|
+
if (out.length) out.push('');
|
|
187
|
+
out.push(l);
|
|
188
|
+
blank = 0;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (out.length === 0 || /^#{1,6} /.test(out[out.length - 1])) {
|
|
192
|
+
out.push(l);
|
|
193
|
+
blank = 0;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (/^[-*•]\s|^\d{1,2}[.)]\s/.test(l) && l.length < 80) {
|
|
197
|
+
const prev = out[out.length - 1];
|
|
198
|
+
if (prev && /^[-*•]\s|^\d{1,2}[.)]\s/.test(prev)) out.push(l);
|
|
199
|
+
else {
|
|
200
|
+
out.push('');
|
|
201
|
+
out.push(l);
|
|
202
|
+
}
|
|
203
|
+
blank = 0;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (l.length < SHORT_LINE_JOIN && blank < 2) {
|
|
207
|
+
out[out.length - 1] += ' ' + l;
|
|
208
|
+
blank++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out.push('');
|
|
212
|
+
out.push(l);
|
|
213
|
+
blank = 0;
|
|
214
|
+
}
|
|
215
|
+
return { title, text: out.join('\n').replace(/\n{3,}/g, '\n\n') };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const NAV_LABEL_RE =
|
|
219
|
+
/^(home|menu|about( us)?|docs?|documentation|downloads?|license|licence|support|purchase|buy|pricing|search|login|log ?in|sign ?(in|up|out)|register|account|blog|news|contact|careers?|jobs|store|shop|forum|community|tutorials?|api|guide|guides|reference|examples?|faq|help|terms|privacy|cookies?|sitemap|rss|changelog|releases?|twitter|facebook|github|discord|youtube|linkedin|mastodon|x)$/i;
|
|
220
|
+
|
|
221
|
+
function isBoilerplate(block: string): boolean {
|
|
222
|
+
const t = block.trim();
|
|
223
|
+
const words = t.split(/\s{2,}|\s*[|·>]\s*|\s+/).filter(Boolean);
|
|
224
|
+
if (t.length < NAV_BLOCK_MAX && words.length >= 2 && words.every((w) => NAV_LABEL_RE.test(w.trim()))) return true;
|
|
225
|
+
const tldr = t.length < 120;
|
|
226
|
+
if (tldr) {
|
|
227
|
+
if (
|
|
228
|
+
/^(skip to|jump to|table of contents|related (articles|posts|links)|share this|back to top|sign (in|up| out)|subscribe to|newsletter|follow (us|@)|menu|navigation|breadcrumbs\b|tags:|categories:|©|all rights reserved|privacy policy|terms of (use|service)|cookie|cookies|accept )/i.test(
|
|
229
|
+
t,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const links = t.match(/\[[^\]]+\]\([^)]+\)/g) ?? [];
|
|
235
|
+
if (links.length) {
|
|
236
|
+
const rest = t.replace(/\[[^\]]+\]\([^)]+\)/g, '').trim();
|
|
237
|
+
if (rest.length === 0) return true;
|
|
238
|
+
if (rest.length < 8 && links.length >= 3) return true;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isNavBullet(line: string): boolean {
|
|
244
|
+
const t = line
|
|
245
|
+
.replace(/^[-*•]\s*/, '')
|
|
246
|
+
.replace(/\s+/g, ' ')
|
|
247
|
+
.trim();
|
|
248
|
+
if (!t) return true;
|
|
249
|
+
if (t.length > NAV_LABEL_MAX) return false;
|
|
250
|
+
const words = t.split(/\s{2,}|\s*[|·>]\s*/).filter(Boolean);
|
|
251
|
+
if (words.length > 1) return words.every((w) => NAV_LABEL_RE.test(w.trim()));
|
|
252
|
+
return NAV_LABEL_RE.test(t);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function scrubNavBullets(text: string): string {
|
|
256
|
+
const lines = text.split('\n');
|
|
257
|
+
const kept = lines.filter((l) => !/^[-*•]\s/.test(l) || !isNavBullet(l));
|
|
258
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const normHeading = (s: string): string =>
|
|
262
|
+
s
|
|
263
|
+
.replace(/^[#\d.\s]+/, '')
|
|
264
|
+
.replace(/[^a-z0-9 ]/gi, ' ')
|
|
265
|
+
.replace(/\s+/g, ' ')
|
|
266
|
+
.trim()
|
|
267
|
+
.toLowerCase();
|
|
268
|
+
|
|
269
|
+
function isOutlineEcho(block: string, outline: string[]): boolean {
|
|
270
|
+
if (outline.length < MIN_OUTLINE_FOR_ECHO) return false;
|
|
271
|
+
const trimmed = block.trim();
|
|
272
|
+
if (/\btable of contents\b/i.test(trimmed) && trimmed.length < 80) return true;
|
|
273
|
+
const heads = outline.map(normHeading).filter(Boolean);
|
|
274
|
+
const raw = trimmed
|
|
275
|
+
.split('\n')
|
|
276
|
+
.flatMap((l) => l.split(/(?=\d+(?:\.\d+)*\.\s)/))
|
|
277
|
+
.map((l) => l.replace(/^[-*•]?\s*\d*\.?\s*/, '').trim())
|
|
278
|
+
.filter((l) => l.length > 2 && l.length < OUTLINE_LINE_MAX);
|
|
279
|
+
if (raw.length < MIN_OUTLINE_FOR_ECHO) return false;
|
|
280
|
+
const hit = (l: string): boolean => {
|
|
281
|
+
const n = normHeading(l);
|
|
282
|
+
return heads.some((h) => h === n || h.startsWith(n + ' ') || n.startsWith(h + ' '));
|
|
283
|
+
};
|
|
284
|
+
return raw.filter(hit).length / raw.length >= 0.6;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function sliceSections(
|
|
288
|
+
text: string,
|
|
289
|
+
sectionCap = DEFAULT_SECTION_CAP,
|
|
290
|
+
): { outline: string[]; sections: string[] } {
|
|
291
|
+
const blocks = text.replace(/\n{3,}/g, '\n\n').split('\n\n');
|
|
292
|
+
const sections: string[] = [];
|
|
293
|
+
const outline: string[] = [];
|
|
294
|
+
let cur = '';
|
|
295
|
+
for (const b of blocks) {
|
|
296
|
+
if (!b.trim()) continue;
|
|
297
|
+
if (isBoilerplate(b)) continue;
|
|
298
|
+
if (isOutlineEcho(b, outline)) continue;
|
|
299
|
+
const hm = /^(#{1,6})\s+([^\n]+)([\s\S]*)$/.exec(b);
|
|
300
|
+
if (hm) {
|
|
301
|
+
const name = hm[2].trim();
|
|
302
|
+
if (/^(navigation|contents|related topics|quick search|sidebar|footer)$/i.test(name)) continue;
|
|
303
|
+
if (cur.trim()) sections.push(cur.trim());
|
|
304
|
+
const depth = hm[1].length;
|
|
305
|
+
const rest = (hm[3] ?? '').replace(/^[\n\s]+/, '');
|
|
306
|
+
cur = `${'#'.repeat(depth)} ${name}` + (rest ? `\n${rest}` : '') + '\n';
|
|
307
|
+
if (outline.length < OUTLINE_CAP && depth <= 4) outline.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
cur += scrubNavBullets(b) + '\n\n';
|
|
311
|
+
}
|
|
312
|
+
if (cur.trim()) sections.push(cur.trim());
|
|
313
|
+
const pruned = sections.map((s) => {
|
|
314
|
+
const paras = s.split(/\n\n/);
|
|
315
|
+
const body = paras.filter((p) => {
|
|
316
|
+
const nonHeading = p
|
|
317
|
+
.split('\n')
|
|
318
|
+
.filter((l) => !/^#{1,6} /.test(l))
|
|
319
|
+
.join('\n');
|
|
320
|
+
return !nonHeading.trim() || !isOutlineEcho(nonHeading, outline);
|
|
321
|
+
});
|
|
322
|
+
return body.join('\n\n');
|
|
323
|
+
});
|
|
324
|
+
const capped: string[] = [];
|
|
325
|
+
for (let s of pruned) {
|
|
326
|
+
if (s.length > sectionCap) {
|
|
327
|
+
const cut = s.slice(0, sectionCap);
|
|
328
|
+
const at = cut.lastIndexOf('\n\n');
|
|
329
|
+
const note = `…[section truncated: capped at ${sectionCap} chars — the rest of this section is not retrievable]`;
|
|
330
|
+
if (at > sectionCap - CAP_BREAK_GRACE) s = cut.slice(0, at) + '\n' + note;
|
|
331
|
+
else s = cut + ' ' + note;
|
|
332
|
+
}
|
|
333
|
+
if (s.trim().length >= SECTION_MIN || /^#{1,6} /.test(s)) capped.push(s.trim());
|
|
334
|
+
}
|
|
335
|
+
// A leading heading-less section (the page lead) shifts every outline index off by one;
|
|
336
|
+
// synthesizing its outline entry keeps section[i] and outline[i] aligned and the lead requestable.
|
|
337
|
+
const preambleIdx = capped.findIndex((s) => !/^#{1,6} /.test(s));
|
|
338
|
+
if (preambleIdx === 0 && outline.length > 0) outline.unshift('(lead)');
|
|
339
|
+
return { outline, sections: capped };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface ScrapeOptions {
|
|
343
|
+
sectionCap?: number;
|
|
344
|
+
maxChars?: number;
|
|
345
|
+
render?: 'auto' | 'local' | 'firecrawl';
|
|
346
|
+
allowPrivate?: boolean;
|
|
347
|
+
signal?: AbortSignal;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const SHORT_LINE_JOIN = 28;
|
|
351
|
+
const NAV_LABEL_MAX = 40;
|
|
352
|
+
const NAV_BLOCK_MAX = 90;
|
|
353
|
+
const OUTLINE_CAP = 14;
|
|
354
|
+
const OUTLINE_LINE_MAX = 90;
|
|
355
|
+
const MIN_OUTLINE_FOR_ECHO = 3;
|
|
356
|
+
const CAP_BREAK_GRACE = 400;
|
|
357
|
+
const SECTION_MIN = 12;
|
|
358
|
+
|
|
359
|
+
const LOCAL_MIN_TEXT = 240;
|
|
360
|
+
const LOCAL_MIN_OUTPUT = 200;
|
|
361
|
+
const MAX_JOINED_SECTIONS = 12;
|
|
362
|
+
const TRUNCATED_MARKER = '\n…[truncated]';
|
|
363
|
+
|
|
364
|
+
function abortResult(url: URL): ScrapeResult {
|
|
365
|
+
return {
|
|
366
|
+
url: url.toString(),
|
|
367
|
+
title: '',
|
|
368
|
+
renderer: 'local',
|
|
369
|
+
outline: [],
|
|
370
|
+
sections: [],
|
|
371
|
+
text: '',
|
|
372
|
+
truncated: false,
|
|
373
|
+
error: ABORT_ERROR,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function errorResult(url: URL, error: string): ScrapeResult {
|
|
378
|
+
return {
|
|
379
|
+
url: url.toString(),
|
|
380
|
+
title: '',
|
|
381
|
+
renderer: 'local',
|
|
382
|
+
outline: [],
|
|
383
|
+
sections: [],
|
|
384
|
+
text: '',
|
|
385
|
+
truncated: false,
|
|
386
|
+
error,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function tryLocal(
|
|
391
|
+
url: URL,
|
|
392
|
+
render: 'auto' | 'local',
|
|
393
|
+
signal: AbortSignal | undefined,
|
|
394
|
+
sectionCap: number,
|
|
395
|
+
maxChars: number,
|
|
396
|
+
): Promise<ScrapeResult | undefined> {
|
|
397
|
+
const { body, contentType } = await localFetch(url, signal);
|
|
398
|
+
let title = '';
|
|
399
|
+
let text: string;
|
|
400
|
+
if (isRawText(contentType)) {
|
|
401
|
+
text = stripToAscii(decodeHtmlEntities(body));
|
|
402
|
+
} else {
|
|
403
|
+
const extracted = extractHtml(body);
|
|
404
|
+
title = extracted.title;
|
|
405
|
+
text = extracted.text;
|
|
406
|
+
}
|
|
407
|
+
if (render === 'auto' && text.length < LOCAL_MIN_TEXT) return undefined;
|
|
408
|
+
const { outline, sections } = sliceSections(text, sectionCap);
|
|
409
|
+
const { text: out, truncated } = joinSections(outline, sections, maxChars);
|
|
410
|
+
if (render === 'auto' && (sections.length === 0 || out.length < LOCAL_MIN_OUTPUT || isLowQualityOutput(out))) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
return { url: url.toString(), title, renderer: 'local', outline, sections, text: out, truncated };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function fromChain(
|
|
417
|
+
url: URL,
|
|
418
|
+
doc: RemoteDoc & { renderer: Renderer },
|
|
419
|
+
sectionCap: number,
|
|
420
|
+
maxChars: number,
|
|
421
|
+
): ScrapeResult {
|
|
422
|
+
let markdown = stripDataUriImages(doc.markdown);
|
|
423
|
+
let title = doc.title;
|
|
424
|
+
if (looksLikeHtml(markdown)) {
|
|
425
|
+
const extracted = extractHtml(markdown);
|
|
426
|
+
markdown = extracted.text;
|
|
427
|
+
if (extracted.title) title = extracted.title;
|
|
428
|
+
}
|
|
429
|
+
const { outline, sections } = sliceSections(markdown, sectionCap);
|
|
430
|
+
const { text, truncated } = joinSections(outline, sections, maxChars);
|
|
431
|
+
return { url: url.toString(), title, renderer: doc.renderer, outline, sections, text, truncated };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<ScrapeResult> {
|
|
435
|
+
const cfg = getConfig();
|
|
436
|
+
const url = validateUrl(urlRaw, opts.allowPrivate ?? cfg.allowPrivate);
|
|
437
|
+
const sectionCap = opts.sectionCap ?? DEFAULT_SECTION_CAP;
|
|
438
|
+
const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
|
|
439
|
+
const render = opts.render ?? 'auto';
|
|
440
|
+
|
|
441
|
+
if (opts.signal?.aborted) return abortResult(url);
|
|
442
|
+
|
|
443
|
+
if (render === 'local') {
|
|
444
|
+
try {
|
|
445
|
+
// undefined is auto-mode only (thin/empty gates); forced local always produces a result
|
|
446
|
+
return (await tryLocal(url, 'local', opts.signal, sectionCap, maxChars))!;
|
|
447
|
+
} catch (e) {
|
|
448
|
+
return opts.signal?.aborted ? abortResult(url) : errorResult(url, sanitizeError(e));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const usable = (markdown: string): boolean => {
|
|
453
|
+
if (markdown.replace(/\s+/g, '').length < 100) return false;
|
|
454
|
+
const { sections } = sliceSections(markdown, DEFAULT_SECTION_CAP);
|
|
455
|
+
return sections.length > 0;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
if (render === 'auto') {
|
|
459
|
+
let local: ScrapeResult | undefined;
|
|
460
|
+
try {
|
|
461
|
+
local = await tryLocal(url, 'auto', opts.signal, sectionCap, maxChars);
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if (opts.signal?.aborted) return abortResult(url);
|
|
464
|
+
}
|
|
465
|
+
if (local) return local;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const chainResult = await remoteChain(url, opts.signal, usable);
|
|
469
|
+
if (!chainResult.ok) {
|
|
470
|
+
return chainResult.aborted || opts.signal?.aborted ? abortResult(url) : errorResult(url, chainResult.error);
|
|
471
|
+
}
|
|
472
|
+
return fromChain(url, chainResult.doc, sectionCap, maxChars);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Compose outline + sections; the cap is honest (marker included). */
|
|
476
|
+
export function joinSections(
|
|
477
|
+
outline: string[],
|
|
478
|
+
sections: string[],
|
|
479
|
+
maxChars: number,
|
|
480
|
+
): { text: string; truncated: boolean } {
|
|
481
|
+
const parts: string[] = [];
|
|
482
|
+
if (outline.length) parts.push('Outline: ' + outline.join(' | '));
|
|
483
|
+
for (const s of sections.slice(0, MAX_JOINED_SECTIONS)) parts.push(s);
|
|
484
|
+
let out = parts.join('\n\n').replace(/\n{3,}/g, '\n\n');
|
|
485
|
+
const truncated = out.length > maxChars;
|
|
486
|
+
if (truncated) out = out.slice(0, maxChars - TRUNCATED_MARKER.length) + TRUNCATED_MARKER;
|
|
487
|
+
return { text: out, truncated };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Deterministic over the cached sections: repeated offset reads avoid re-fetching. */
|