pi-wsearch 0.1.0 → 0.2.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/CHANGELOG.md +62 -0
- package/README.md +4 -4
- package/package.json +13 -2
- package/src/cache.ts +10 -11
- package/src/config.ts +2 -0
- package/src/engines.ts +33 -4
- package/src/fuse.ts +33 -0
- package/src/keys.ts +4 -1
- package/src/scrape.ts +395 -38
- package/src/tools.ts +159 -13
- package/src/transport.ts +229 -16
- package/src/urls.ts +20 -1
- package/justfile +0 -31
package/src/scrape.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isRawText,
|
|
13
13
|
isLowQualityOutput,
|
|
14
14
|
stripDataUriImages,
|
|
15
|
+
withTimeout,
|
|
15
16
|
DEFAULT_SECTION_CAP,
|
|
16
17
|
type RemoteDoc,
|
|
17
18
|
type Renderer,
|
|
@@ -25,6 +26,10 @@ export interface ScrapeResult {
|
|
|
25
26
|
sections: string[];
|
|
26
27
|
text: string;
|
|
27
28
|
truncated: boolean;
|
|
29
|
+
continued: number[];
|
|
30
|
+
publishedAt?: string;
|
|
31
|
+
paywalled?: boolean;
|
|
32
|
+
validators?: { etag?: string; lastModified?: string };
|
|
28
33
|
error?: string;
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -146,11 +151,47 @@ export function validateUrl(raw: string, allowPrivate = false): URL {
|
|
|
146
151
|
return u;
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
function stripKatexSpans(h: string): string {
|
|
155
|
+
let out = '';
|
|
156
|
+
let i = 0;
|
|
157
|
+
for (;;) {
|
|
158
|
+
const start = /<span[^>]*class="[^"]*katex[^"]*"[^>]*>/i.exec(h.slice(i));
|
|
159
|
+
if (!start || start.index === undefined) {
|
|
160
|
+
out += h.slice(i);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const abs = i + start.index;
|
|
164
|
+
out += h.slice(i, abs);
|
|
165
|
+
let depth = 1;
|
|
166
|
+
let j = abs + start[0].length;
|
|
167
|
+
while (depth > 0 && j < h.length) {
|
|
168
|
+
const next = /<span\b[^>]*>|<\/span>/gi;
|
|
169
|
+
next.lastIndex = j;
|
|
170
|
+
const tag = next.exec(h);
|
|
171
|
+
if (!tag) {
|
|
172
|
+
j = h.length;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
j = tag.index + tag[0].length;
|
|
176
|
+
depth += tag[0].startsWith('</') ? -1 : 1;
|
|
177
|
+
}
|
|
178
|
+
i = j;
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function extractHtml(html: string): { title: string; text: string; publishedAt?: string; paywalled: boolean } {
|
|
184
|
+
const paywalled =
|
|
185
|
+
/class="[^"]*(?:fc-ab-root|metering-modal|paywall|pw-overlay|subscription-overlay|regwall)[^"]*"/i.test(html) ||
|
|
186
|
+
/id="[^"]*(?:paywall|metering|pay-overlay)[^"]*"/i.test(html) ||
|
|
187
|
+
/<meta[^>]+(?:property|name)=["'](?:isAccessibleForFree|tunnel:[^"']*paywall)["'][^>]+content=["'](?:false|yes)["']/i.test(
|
|
188
|
+
html,
|
|
189
|
+
);
|
|
150
190
|
const title =
|
|
151
191
|
/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim()?.slice(0, 200) ||
|
|
152
192
|
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i.exec(html)?.[1]?.slice(0, 200) ||
|
|
153
193
|
'';
|
|
194
|
+
const publishedAt = extractPublishedDate(html);
|
|
154
195
|
let h = html
|
|
155
196
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
156
197
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
@@ -161,6 +202,34 @@ export function extractHtml(html: string): { title: string; text: string } {
|
|
|
161
202
|
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
|
162
203
|
.replace(/<aside[\s\S]*?<\/aside>/gi, ' ');
|
|
163
204
|
h = h.replace(/<pre[\s>][\s\S]*?<\/pre>/gi, (m) => m.replace(/\n/g, '\uE000'));
|
|
205
|
+
h = h.replace(/<table[\s>][\s\S]*?<\/table>/gi, (table) => {
|
|
206
|
+
const rows = [...table.matchAll(/<tr[\s>][\s\S]*?<\/tr>/gi)].map((r) =>
|
|
207
|
+
[...r[0].matchAll(/<t([hd])[\s>][\s\S]*?<\/t\1>/gi)].map((c) =>
|
|
208
|
+
c[0]
|
|
209
|
+
.replace(/<[^>]+>/g, ' ')
|
|
210
|
+
.replace(/\s+/g, ' ')
|
|
211
|
+
.replace(/\|/g, '\\|')
|
|
212
|
+
.trim(),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
const grid = rows.filter((r) => r.length);
|
|
216
|
+
if (grid.length < 2) return table;
|
|
217
|
+
const cols = Math.max(...grid.map((g) => g.length));
|
|
218
|
+
if (cols < 2) return table;
|
|
219
|
+
const line = (g: string[]) => {
|
|
220
|
+
const cells = g.slice(0, cols);
|
|
221
|
+
while (cells.length < cols) cells.push(' ');
|
|
222
|
+
return `| ${cells.join(' | ')} |`;
|
|
223
|
+
};
|
|
224
|
+
const md = [line(grid[0]), `| ${Array.from({ length: cols }, () => '---').join(' | ')} |`];
|
|
225
|
+
for (const g of grid.slice(1)) md.push(line(g));
|
|
226
|
+
return `\n${md.join('\n')}\n`;
|
|
227
|
+
});
|
|
228
|
+
h = stripKatexSpans(h);
|
|
229
|
+
h = h.replace(/<math[\s>][\s\S]*?<\/math>/gi, (m) => {
|
|
230
|
+
const tex = /<annotation[^>]*encoding=["']application\/x-tex["'][^>]*>([\s\S]*?)<\/annotation>/i.exec(m)?.[1];
|
|
231
|
+
return tex?.trim() ? ` $${tex.trim()}$ ` : ' ';
|
|
232
|
+
});
|
|
164
233
|
h = h.replace(
|
|
165
234
|
/<h([1-6])[^>]*>(.*?)<\/h\1>/gis,
|
|
166
235
|
(_, n, inner) => `\n${'#'.repeat(Number(n))} ${inner.replace(/<[^>]+>/g, ' ').trim()}\n`,
|
|
@@ -178,7 +247,10 @@ export function extractHtml(html: string): { title: string; text: string } {
|
|
|
178
247
|
.split('\n')
|
|
179
248
|
.map((l) => l.trim())
|
|
180
249
|
.filter((l) => l.length > 0 && !/^•\s*$/.test(l))
|
|
181
|
-
.filter((l) => !(/^[-*•]\s/.test(l) && isNavBullet(l)))
|
|
250
|
+
.filter((l) => !(/^[-*•]\s/.test(l) && isNavBullet(l)))
|
|
251
|
+
.filter((l) => !/^#{1,6}\s*$/.test(l))
|
|
252
|
+
.map((l) => (/^[-*•]\s{2,}/.test(l) ? l.replace(/^[-*•]\s+/, '').replace(/\s{2,}/g, ' ') : l))
|
|
253
|
+
.filter((l) => !(l.length < 120 && isPromo(l)));
|
|
182
254
|
const out: string[] = [];
|
|
183
255
|
let blank = 0;
|
|
184
256
|
for (const l of lines) {
|
|
@@ -212,7 +284,22 @@ export function extractHtml(html: string): { title: string; text: string } {
|
|
|
212
284
|
out.push(l);
|
|
213
285
|
blank = 0;
|
|
214
286
|
}
|
|
215
|
-
return { title, text: out.join('\n').replace(/\n{3,}/g, '\n\n') };
|
|
287
|
+
return { title, publishedAt, paywalled, text: out.join('\n').replace(/\n{3,}/g, '\n\n') };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractPublishedDate(html: string): string | undefined {
|
|
291
|
+
const iso = (v: string | undefined) => {
|
|
292
|
+
if (!v) return undefined;
|
|
293
|
+
const t = Date.parse(v.trim());
|
|
294
|
+
return Number.isNaN(t) || t < 0 ? undefined : new Date(t).toISOString().slice(0, 10);
|
|
295
|
+
};
|
|
296
|
+
const ld = /"datePublished"\s*:\s*"([^"]+)"/.exec(html)?.[1];
|
|
297
|
+
const meta =
|
|
298
|
+
/<meta[^>]+(?:property|name)=["'](?:article:published_time|datePublished|publish-date|pubdate|parsely-pub-date|date)["'][^>]+content=["']([^"']+)["']/i.exec(
|
|
299
|
+
html,
|
|
300
|
+
)?.[1];
|
|
301
|
+
const timeEl = /<time[^>]+datetime=["']([^"']+)["']/i.exec(html)?.[1];
|
|
302
|
+
return iso(ld) ?? iso(meta) ?? iso(timeEl);
|
|
216
303
|
}
|
|
217
304
|
|
|
218
305
|
const NAV_LABEL_RE =
|
|
@@ -284,27 +371,122 @@ function isOutlineEcho(block: string, outline: string[]): boolean {
|
|
|
284
371
|
return raw.filter(hit).length / raw.length >= 0.6;
|
|
285
372
|
}
|
|
286
373
|
|
|
374
|
+
const PROMO_STRONG = [
|
|
375
|
+
/become a (?:premium )?member/i,
|
|
376
|
+
/unlock exclusive/i,
|
|
377
|
+
/go premium/i,
|
|
378
|
+
/upgrade to (?:the )?premium/i,
|
|
379
|
+
/your membership journey/i,
|
|
380
|
+
/stay on the cutting edge/i,
|
|
381
|
+
/enter your email below/i,
|
|
382
|
+
/by submitting your information/i,
|
|
383
|
+
/sign (?:up|you up) (?:for|to) (?:our )?(?:free )?newsletter/i,
|
|
384
|
+
/subscribe to (?:our )?newsletter/i,
|
|
385
|
+
/get the [a-z][a-z ]{2,40}newsletter/i,
|
|
386
|
+
/limited-?time (?:premium )?offer/i,
|
|
387
|
+
/choose how you want to join/i,
|
|
388
|
+
/explore (?:our )?(?:premium|pro)\b/i,
|
|
389
|
+
/^email newsletter\b/i,
|
|
390
|
+
/^your \(?[\w\s-]+\)? email$/i,
|
|
391
|
+
/^IE \d+ is not supported/i,
|
|
392
|
+
/for an optimal experience visit our site/i,
|
|
393
|
+
/^skip to (?:content|main)/i,
|
|
394
|
+
/^for subscribers$/i,
|
|
395
|
+
/^subscriber (?:only|exclusive)$/i,
|
|
396
|
+
/^[A-Z][A-Z0-9 &'’-]{5,50}$/,
|
|
397
|
+
/\|\s*[A-Z][\w'’&. ]{2,40}\s+\|\s*[A-Z][\w'’&. ]{2,40}\s+News\s*$/,
|
|
398
|
+
/^(?:[A-Z][\w'’.\- ]{2,30}\s+\/\s+)?(?:via )?(?:Getty Images(?:\s+file)?|AP(?: Photo)?|Reuters|Xinhua(?:[\s\w/]+)?|AFP|Bloomberg(?: Photo)?|The Associated Press|NBC(?: News)? via|CNN Wire|Sipa USA|AP Photo\/[^,]{2,40})[\s.]*$/i,
|
|
399
|
+
/^\d+\s*(?:s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|day|days|w|weeks?)\s+ago[\s.]*$/i,
|
|
400
|
+
/^(?:updated|published)\s+(?:just\s+now|\d+\s*\w+\s+ago)$/i,
|
|
401
|
+
/^\d+\s+(?:hours?|minutes?)\s+ago$/i,
|
|
402
|
+
/get started with free access/i,
|
|
403
|
+
/premium (?:newsletter|articles?|membership|features|offer)/i,
|
|
404
|
+
/sign[- ]?up (?:was|is) successful/i,
|
|
405
|
+
/we use cookies[^.]*(?:to (?:improve|enhance|personalize|analyse|analyze))/i,
|
|
406
|
+
/^by continuing (?:to use|to browse)/i,
|
|
407
|
+
/^(?:accept|allow) (?:all|selected|necessary)/i,
|
|
408
|
+
/^(?:manage|customize|consent) (?:preferences|settings|choices)/i,
|
|
409
|
+
/^this (?:site|website) uses (?:cookies|essential cookies)/i,
|
|
410
|
+
];
|
|
411
|
+
const PROMO_CTA_RE =
|
|
412
|
+
/\b(?:newsletter|membership|club membership|premium member|sign ?up|subscribe|unlock|upgrade|cancel anytime|limited-?time|free trial|join now|join today)\b/gi;
|
|
413
|
+
|
|
414
|
+
function isPromo(t: string): boolean {
|
|
415
|
+
if (PROMO_STRONG.some((r) => r.test(t))) return true;
|
|
416
|
+
return (t.match(PROMO_CTA_RE) ?? []).length >= 2;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function splitSection(s: string, sectionCap: number): string[] {
|
|
420
|
+
const hm = /^(#{1,6})\s+([^\n]+)/.exec(s);
|
|
421
|
+
const partHead = hm ? `${'#'.repeat(hm[1].length)} ${hm[2].trim()}\n\n` : '';
|
|
422
|
+
const floor = Math.min(partHead.length + 1, sectionCap);
|
|
423
|
+
const parts: string[] = [];
|
|
424
|
+
let rest = s;
|
|
425
|
+
for (;;) {
|
|
426
|
+
if (rest.length <= sectionCap) {
|
|
427
|
+
parts.push(rest);
|
|
428
|
+
return parts;
|
|
429
|
+
}
|
|
430
|
+
const cut = rest.slice(0, sectionCap);
|
|
431
|
+
let at = cut.lastIndexOf('\n\n');
|
|
432
|
+
if (at < floor) at = cut.lastIndexOf(' ');
|
|
433
|
+
if (at < floor) at = sectionCap;
|
|
434
|
+
const used = Math.min(at, rest.length - 1);
|
|
435
|
+
parts.push(rest.slice(0, used).replace(/\s+$/, ''));
|
|
436
|
+
rest = partHead + rest.slice(used).trimStart();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
287
440
|
export function sliceSections(
|
|
288
441
|
text: string,
|
|
289
442
|
sectionCap = DEFAULT_SECTION_CAP,
|
|
290
|
-
|
|
443
|
+
docTitle = '',
|
|
444
|
+
): { outline: string[]; sections: string[]; continued: number[] } {
|
|
445
|
+
const titleEcho = docTitle ? normHeading(docTitle) : '';
|
|
291
446
|
const blocks = text.replace(/\n{3,}/g, '\n\n').split('\n\n');
|
|
292
447
|
const sections: string[] = [];
|
|
293
|
-
const
|
|
448
|
+
const outlineNames: string[] = [];
|
|
449
|
+
const seenParas = new Set<string>();
|
|
294
450
|
let cur = '';
|
|
451
|
+
let lastBlock = '';
|
|
295
452
|
for (const b of blocks) {
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
453
|
+
const tb = b.trim();
|
|
454
|
+
if (!tb || tb === lastBlock) continue;
|
|
455
|
+
lastBlock = tb;
|
|
456
|
+
if (!tb.startsWith('#')) {
|
|
457
|
+
const paraKey = tb.replace(/\s+/g, ' ').toLowerCase();
|
|
458
|
+
if (seenParas.has(paraKey)) continue;
|
|
459
|
+
seenParas.add(paraKey);
|
|
460
|
+
} else {
|
|
461
|
+
const hm = /^(#{1,6})\s+([^\n]+)([\s\S]*)$/.exec(tb);
|
|
462
|
+
const body = (hm?.[3] ?? '').trim();
|
|
463
|
+
if (body) {
|
|
464
|
+
const paraKey = body.replace(/\s+/g, ' ').toLowerCase();
|
|
465
|
+
if (seenParas.has(paraKey)) {
|
|
466
|
+
const name = hm![2].trim();
|
|
467
|
+
if (isBoilerplateHeading(name) || isPromo(name)) continue;
|
|
468
|
+
const depth = hm![1].length;
|
|
469
|
+
if (cur.trim()) sections.push(cur.trim());
|
|
470
|
+
cur = `${'#'.repeat(depth)} ${name}\n`;
|
|
471
|
+
if (outlineNames.length < OUTLINE_CAP && depth <= 4)
|
|
472
|
+
outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
seenParas.add(paraKey);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (titleEcho && normHeading(tb) === titleEcho) continue;
|
|
479
|
+
if (isBoilerplate(b) || isPromo(b)) continue;
|
|
480
|
+
if (isOutlineEcho(b, outlineNames)) continue;
|
|
299
481
|
const hm = /^(#{1,6})\s+([^\n]+)([\s\S]*)$/.exec(b);
|
|
300
482
|
if (hm) {
|
|
301
483
|
const name = hm[2].trim();
|
|
302
|
-
if (
|
|
484
|
+
if (isBoilerplateHeading(name) || isPromo(name)) continue;
|
|
303
485
|
if (cur.trim()) sections.push(cur.trim());
|
|
304
486
|
const depth = hm[1].length;
|
|
305
487
|
const rest = (hm[3] ?? '').replace(/^[\n\s]+/, '');
|
|
306
488
|
cur = `${'#'.repeat(depth)} ${name}` + (rest ? `\n${rest}` : '') + '\n';
|
|
307
|
-
if (
|
|
489
|
+
if (outlineNames.length < OUTLINE_CAP && depth <= 4) outlineNames.push(name.replace(/\s+/g, ' ').slice(0, 90));
|
|
308
490
|
continue;
|
|
309
491
|
}
|
|
310
492
|
cur += scrubNavBullets(b) + '\n\n';
|
|
@@ -317,26 +499,36 @@ export function sliceSections(
|
|
|
317
499
|
.split('\n')
|
|
318
500
|
.filter((l) => !/^#{1,6} /.test(l))
|
|
319
501
|
.join('\n');
|
|
320
|
-
return !nonHeading.trim() || !isOutlineEcho(nonHeading,
|
|
502
|
+
return !nonHeading.trim() || !isOutlineEcho(nonHeading, outlineNames);
|
|
321
503
|
});
|
|
322
504
|
return body.join('\n\n');
|
|
323
505
|
});
|
|
324
506
|
const capped: string[] = [];
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
507
|
+
const continued: number[] = [];
|
|
508
|
+
const outline: string[] = [];
|
|
509
|
+
const lead = sections.length > 0 && !/^#{1,6} /.test(sections[0]);
|
|
510
|
+
for (let i = 0; i < pruned.length; i++) {
|
|
511
|
+
const s = pruned[i].trim();
|
|
512
|
+
const headingOnly = /^#{1,6} [^\n]+$/.test(s);
|
|
513
|
+
if (s.length < SECTION_MIN && !/^#{1,6} /.test(s)) continue;
|
|
514
|
+
if (headingOnly && s.length < 120) continue;
|
|
515
|
+
const hm = /^(#{1,6})\s+([^\n]+)/.exec(s);
|
|
516
|
+
const own = hm ? hm[2].trim() : '(lead)';
|
|
517
|
+
const base = hm ? (outlineNames[i - (lead ? 1 : 0)] ?? own) : '(lead)';
|
|
518
|
+
const name = base.replace(/\s+/g, ' ').slice(0, 90);
|
|
519
|
+
const parts = s.length <= sectionCap ? [s] : splitSection(s, sectionCap);
|
|
520
|
+
const last = parts.length - 1;
|
|
521
|
+
parts.forEach((p, k) => {
|
|
522
|
+
outline.push(k === 0 ? name : `${name} (cont. ${k})`);
|
|
523
|
+
if (k < last) continued.push(capped.length);
|
|
524
|
+
const body =
|
|
525
|
+
parts.length === 1 || k === last
|
|
526
|
+
? p
|
|
527
|
+
: `${p.replace(/\s+$/, '')}\n\n…(continues at outline index ${capped.length + 1})`;
|
|
528
|
+
capped.push(body);
|
|
529
|
+
});
|
|
334
530
|
}
|
|
335
|
-
|
|
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 };
|
|
531
|
+
return { outline, sections: capped, continued };
|
|
340
532
|
}
|
|
341
533
|
|
|
342
534
|
export interface ScrapeOptions {
|
|
@@ -345,15 +537,15 @@ export interface ScrapeOptions {
|
|
|
345
537
|
render?: 'auto' | 'local' | 'firecrawl';
|
|
346
538
|
allowPrivate?: boolean;
|
|
347
539
|
signal?: AbortSignal;
|
|
540
|
+
validators?: { etag?: string; lastModified?: string };
|
|
348
541
|
}
|
|
349
542
|
|
|
350
543
|
const SHORT_LINE_JOIN = 28;
|
|
351
544
|
const NAV_LABEL_MAX = 40;
|
|
352
545
|
const NAV_BLOCK_MAX = 90;
|
|
353
|
-
const OUTLINE_CAP =
|
|
546
|
+
const OUTLINE_CAP = 20;
|
|
354
547
|
const OUTLINE_LINE_MAX = 90;
|
|
355
548
|
const MIN_OUTLINE_FOR_ECHO = 3;
|
|
356
|
-
const CAP_BREAK_GRACE = 400;
|
|
357
549
|
const SECTION_MIN = 12;
|
|
358
550
|
|
|
359
551
|
const LOCAL_MIN_TEXT = 240;
|
|
@@ -370,6 +562,7 @@ function abortResult(url: URL): ScrapeResult {
|
|
|
370
562
|
sections: [],
|
|
371
563
|
text: '',
|
|
372
564
|
truncated: false,
|
|
565
|
+
continued: [],
|
|
373
566
|
error: ABORT_ERROR,
|
|
374
567
|
};
|
|
375
568
|
}
|
|
@@ -383,34 +576,107 @@ function errorResult(url: URL, error: string): ScrapeResult {
|
|
|
383
576
|
sections: [],
|
|
384
577
|
text: '',
|
|
385
578
|
truncated: false,
|
|
579
|
+
continued: [],
|
|
386
580
|
error,
|
|
387
581
|
};
|
|
388
582
|
}
|
|
389
583
|
|
|
584
|
+
const GITHUB_REPO_RE = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:\/?|\?.*)?$/;
|
|
585
|
+
const GITHUB_BLOB_RE = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/blob\/([^/]+)\/(.+)$/;
|
|
586
|
+
|
|
587
|
+
function readmeCandidates(url: URL): string[] {
|
|
588
|
+
if (url.hostname !== 'github.com') return [];
|
|
589
|
+
const repo = GITHUB_REPO_RE.exec(url.pathname);
|
|
590
|
+
if (repo) {
|
|
591
|
+
return ['README.md', 'readme.md', 'README.rst', 'README.org'].map(
|
|
592
|
+
(name) => `https://raw.githubusercontent.com/${repo[1]}/${repo[2]}/HEAD/${name}`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
const blob = GITHUB_BLOB_RE.exec(url.pathname);
|
|
596
|
+
if (blob) {
|
|
597
|
+
return [`https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}/${blob[4]}`];
|
|
598
|
+
}
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function scrapeReadme(
|
|
603
|
+
origUrl: URL,
|
|
604
|
+
opts: ScrapeOptions,
|
|
605
|
+
sectionCap: number,
|
|
606
|
+
maxChars: number,
|
|
607
|
+
): Promise<ScrapeResult | undefined> {
|
|
608
|
+
const isBlob = GITHUB_BLOB_RE.test(origUrl.pathname);
|
|
609
|
+
const minLen = isBlob ? 1 : 500;
|
|
610
|
+
for (const rawUrl of readmeCandidates(origUrl)) {
|
|
611
|
+
try {
|
|
612
|
+
const { body, contentType } = await localFetch(new URL(rawUrl), opts.signal);
|
|
613
|
+
if (!isRawText(contentType) || body.length < minLen) continue;
|
|
614
|
+
const text = stripToAscii(decodeHtmlEntities(body));
|
|
615
|
+
if (text.length < (isBlob ? 1 : LOCAL_MIN_TEXT)) continue;
|
|
616
|
+
const { outline, sections, continued } = sliceSections(text, sectionCap);
|
|
617
|
+
const { text: out, truncated } = joinSections(outline, sections, maxChars);
|
|
618
|
+
if (!out) continue;
|
|
619
|
+
const label = isBlob ? 'source (github.com)' : 'README (github.com)';
|
|
620
|
+
return {
|
|
621
|
+
url: origUrl.toString(),
|
|
622
|
+
title: `${origUrl.pathname.split('/')[2]} — ${label}`,
|
|
623
|
+
renderer: 'local',
|
|
624
|
+
outline,
|
|
625
|
+
sections,
|
|
626
|
+
continued,
|
|
627
|
+
text: out,
|
|
628
|
+
truncated,
|
|
629
|
+
};
|
|
630
|
+
} catch {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return undefined;
|
|
635
|
+
}
|
|
636
|
+
|
|
390
637
|
async function tryLocal(
|
|
391
638
|
url: URL,
|
|
392
639
|
render: 'auto' | 'local',
|
|
393
640
|
signal: AbortSignal | undefined,
|
|
394
641
|
sectionCap: number,
|
|
395
642
|
maxChars: number,
|
|
396
|
-
|
|
397
|
-
|
|
643
|
+
validators?: { etag?: string; lastModified?: string },
|
|
644
|
+
): Promise<(ScrapeResult & { validators?: { etag?: string; lastModified?: string } }) | undefined | 'not-modified'> {
|
|
645
|
+
const cond = await localFetch(url, signal, validators);
|
|
646
|
+
if (cond.notModified) return 'not-modified';
|
|
647
|
+
const { body, contentType } = cond;
|
|
398
648
|
let title = '';
|
|
399
649
|
let text: string;
|
|
650
|
+
let publishedAt: string | undefined;
|
|
651
|
+
let paywalled = false;
|
|
400
652
|
if (isRawText(contentType)) {
|
|
401
653
|
text = stripToAscii(decodeHtmlEntities(body));
|
|
402
654
|
} else {
|
|
403
655
|
const extracted = extractHtml(body);
|
|
404
656
|
title = extracted.title;
|
|
405
657
|
text = extracted.text;
|
|
658
|
+
publishedAt = extracted.publishedAt;
|
|
659
|
+
paywalled = extracted.paywalled;
|
|
406
660
|
}
|
|
407
|
-
|
|
408
|
-
const { outline, sections } = sliceSections(text, sectionCap);
|
|
661
|
+
const { outline, sections, continued } = sliceSections(text, sectionCap, title);
|
|
409
662
|
const { text: out, truncated } = joinSections(outline, sections, maxChars);
|
|
663
|
+
if (render === 'auto' && text.length < LOCAL_MIN_TEXT) return undefined;
|
|
410
664
|
if (render === 'auto' && (sections.length === 0 || out.length < LOCAL_MIN_OUTPUT || isLowQualityOutput(out))) {
|
|
411
665
|
return undefined;
|
|
412
666
|
}
|
|
413
|
-
return {
|
|
667
|
+
return {
|
|
668
|
+
url: url.toString(),
|
|
669
|
+
title,
|
|
670
|
+
renderer: 'local',
|
|
671
|
+
outline,
|
|
672
|
+
sections,
|
|
673
|
+
continued,
|
|
674
|
+
text: out,
|
|
675
|
+
truncated,
|
|
676
|
+
publishedAt,
|
|
677
|
+
paywalled: paywalled || undefined,
|
|
678
|
+
validators: cond.validators,
|
|
679
|
+
};
|
|
414
680
|
}
|
|
415
681
|
|
|
416
682
|
function fromChain(
|
|
@@ -426,9 +692,72 @@ function fromChain(
|
|
|
426
692
|
markdown = extracted.text;
|
|
427
693
|
if (extracted.title) title = extracted.title;
|
|
428
694
|
}
|
|
429
|
-
const { outline, sections } = sliceSections(markdown, sectionCap);
|
|
695
|
+
const { outline, sections, continued } = sliceSections(markdown, sectionCap);
|
|
430
696
|
const { text, truncated } = joinSections(outline, sections, maxChars);
|
|
431
|
-
return { url: url.toString(), title, renderer: doc.renderer, outline, sections, text, truncated };
|
|
697
|
+
return { url: url.toString(), title, renderer: doc.renderer, outline, sections, continued, text, truncated };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const BOILERPLATE_HEADING =
|
|
701
|
+
/^(?:site |main |global |primary )?(?:navigation(?: menu| bar| links)?|menu|sidebar|footer|header|contents?|related topics|quick search|site index|breadcrumbs?|personal tools|namespaces|views|user links|wiki tools|languages|repository navigation|in other(?: languages| projects)|print\/export|navigation menu)$/i;
|
|
702
|
+
|
|
703
|
+
function isBoilerplateHeading(name: string): boolean {
|
|
704
|
+
return BOILERPLATE_HEADING.test(name.trim());
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Archive fallback for DEAD pages only (doctrine line): 404/dead-host, never
|
|
708
|
+
// 403-walls. Resurrecting lost content is a service; bypassing walls is not.
|
|
709
|
+
async function archivedFetch(
|
|
710
|
+
url: URL,
|
|
711
|
+
signal: AbortSignal | undefined,
|
|
712
|
+
sectionCap: number,
|
|
713
|
+
maxChars: number,
|
|
714
|
+
): Promise<ScrapeResult | undefined> {
|
|
715
|
+
if (signal?.aborted) return undefined;
|
|
716
|
+
try {
|
|
717
|
+
const { ctrl, done } = withTimeout('archive lookup', 10_000, signal);
|
|
718
|
+
let snapshotUrl: string | undefined;
|
|
719
|
+
try {
|
|
720
|
+
const res = await fetch(`https://archive.org/wayback/available?url=${encodeURIComponent(url.toString())}`, {
|
|
721
|
+
signal: ctrl.signal,
|
|
722
|
+
headers: { Accept: 'application/json' },
|
|
723
|
+
});
|
|
724
|
+
if (res.ok) {
|
|
725
|
+
const d = (await res.json()) as {
|
|
726
|
+
archived_snapshots?: { closest?: { url?: string; status?: string; timestamp?: string } };
|
|
727
|
+
};
|
|
728
|
+
const snap = d.archived_snapshots?.closest;
|
|
729
|
+
if (snap?.url && snap.status === '200') snapshotUrl = snap.url;
|
|
730
|
+
var snapTs = snap?.timestamp;
|
|
731
|
+
}
|
|
732
|
+
} finally {
|
|
733
|
+
done();
|
|
734
|
+
}
|
|
735
|
+
if (!snapshotUrl || signal?.aborted) return undefined;
|
|
736
|
+
const { body, contentType } = await localFetch(new URL(snapshotUrl), signal);
|
|
737
|
+
if (!/html|text/.test(contentType)) return undefined;
|
|
738
|
+
const extracted = extractHtml(body);
|
|
739
|
+
if (extracted.text.length < LOCAL_MIN_TEXT) return undefined;
|
|
740
|
+
const { outline, sections, continued } = sliceSections(extracted.text, sectionCap, extracted.title);
|
|
741
|
+
const { text: out, truncated } = joinSections(outline, sections, maxChars);
|
|
742
|
+
if (!out) return undefined;
|
|
743
|
+
const snapDate =
|
|
744
|
+
typeof snapTs === 'string' && snapTs.length >= 8
|
|
745
|
+
? `${snapTs.slice(0, 4)}-${snapTs.slice(4, 6)}-${snapTs.slice(6, 8)}`
|
|
746
|
+
: 'unknown date';
|
|
747
|
+
return {
|
|
748
|
+
url: url.toString(),
|
|
749
|
+
title: `${extracted.title || url.hostname} [ARCHIVED copy ${snapDate} — live page is gone]`,
|
|
750
|
+
renderer: 'local',
|
|
751
|
+
outline,
|
|
752
|
+
sections,
|
|
753
|
+
continued,
|
|
754
|
+
text: out,
|
|
755
|
+
truncated,
|
|
756
|
+
publishedAt: extracted.publishedAt,
|
|
757
|
+
};
|
|
758
|
+
} catch {
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
432
761
|
}
|
|
433
762
|
|
|
434
763
|
export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<ScrapeResult> {
|
|
@@ -440,10 +769,16 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
|
|
|
440
769
|
|
|
441
770
|
if (opts.signal?.aborted) return abortResult(url);
|
|
442
771
|
|
|
772
|
+
if (render === 'auto' || render === 'local') {
|
|
773
|
+
const readme = await scrapeReadme(url, opts, sectionCap, maxChars);
|
|
774
|
+
if (readme) return readme;
|
|
775
|
+
}
|
|
776
|
+
|
|
443
777
|
if (render === 'local') {
|
|
444
778
|
try {
|
|
445
|
-
|
|
446
|
-
|
|
779
|
+
const forced = await tryLocal(url, 'local', opts.signal, sectionCap, maxChars, opts.validators);
|
|
780
|
+
if (forced === 'not-modified') return { ...abortResult(url), error: 'not-modified', renderer: 'local' };
|
|
781
|
+
return forced!;
|
|
447
782
|
} catch (e) {
|
|
448
783
|
return opts.signal?.aborted ? abortResult(url) : errorResult(url, sanitizeError(e));
|
|
449
784
|
}
|
|
@@ -455,14 +790,36 @@ export async function scrape(urlRaw: string, opts: ScrapeOptions = {}): Promise<
|
|
|
455
790
|
return sections.length > 0;
|
|
456
791
|
};
|
|
457
792
|
|
|
793
|
+
const isJinaProxy = url.hostname === 'r.jina.ai' || url.hostname === 's.jina.ai';
|
|
794
|
+
|
|
458
795
|
if (render === 'auto') {
|
|
459
|
-
let local: ScrapeResult | undefined;
|
|
796
|
+
let local: (ScrapeResult & { validators?: { etag?: string; lastModified?: string } }) | undefined | 'not-modified';
|
|
797
|
+
let localErr: string | undefined;
|
|
460
798
|
try {
|
|
461
|
-
local = await tryLocal(url, 'auto', opts.signal, sectionCap, maxChars);
|
|
799
|
+
local = await tryLocal(url, 'auto', opts.signal, sectionCap, maxChars, opts.validators);
|
|
462
800
|
} catch (e) {
|
|
463
801
|
if (opts.signal?.aborted) return abortResult(url);
|
|
802
|
+
localErr = sanitizeError(e);
|
|
803
|
+
}
|
|
804
|
+
if (local === 'not-modified') {
|
|
805
|
+
return { ...abortResult(url), error: 'not-modified', renderer: 'local' };
|
|
464
806
|
}
|
|
465
807
|
if (local) return local;
|
|
808
|
+
if (localErr && /HTTP 404|not a readable page|page too large|empty body/.test(localErr)) {
|
|
809
|
+
if (/HTTP 404/.test(localErr)) {
|
|
810
|
+
const archived = await archivedFetch(url, opts.signal, sectionCap, maxChars);
|
|
811
|
+
if (archived) return archived;
|
|
812
|
+
}
|
|
813
|
+
return errorResult(url, localErr);
|
|
814
|
+
}
|
|
815
|
+
if (isJinaProxy) {
|
|
816
|
+
return errorResult(
|
|
817
|
+
url,
|
|
818
|
+
localErr
|
|
819
|
+
? `${localErr} (jina proxy; target walled or unavailable)`
|
|
820
|
+
: 'jina reader returned no usable content for the target (walled or unavailable)',
|
|
821
|
+
);
|
|
822
|
+
}
|
|
466
823
|
}
|
|
467
824
|
|
|
468
825
|
const chainResult = await remoteChain(url, opts.signal, usable);
|