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/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
- export function extractHtml(html: string): { title: string; text: string } {
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
- ): { outline: string[]; sections: string[] } {
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 outline: string[] = [];
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
- if (!b.trim()) continue;
297
- if (isBoilerplate(b)) continue;
298
- if (isOutlineEcho(b, outline)) continue;
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 (/^(navigation|contents|related topics|quick search|sidebar|footer)$/i.test(name)) continue;
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 (outline.length < OUTLINE_CAP && depth <= 4) outline.push(name.replace(/\s+/g, ' ').slice(0, 90));
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, outline);
502
+ return !nonHeading.trim() || !isOutlineEcho(nonHeading, outlineNames);
321
503
  });
322
504
  return body.join('\n\n');
323
505
  });
324
506
  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());
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
- // 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 };
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 = 14;
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
- ): Promise<ScrapeResult | undefined> {
397
- const { body, contentType } = await localFetch(url, signal);
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
- if (render === 'auto' && text.length < LOCAL_MIN_TEXT) return undefined;
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 { url: url.toString(), title, renderer: 'local', outline, sections, text: out, truncated };
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
- // undefined is auto-mode only (thin/empty gates); forced local always produces a result
446
- return (await tryLocal(url, 'local', opts.signal, sectionCap, maxChars))!;
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);