@webcrawl/mcp 1.4.2 → 1.4.4

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.
Files changed (135) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -7
  3. package/dist/core/engine/awswaf.d.ts +59 -0
  4. package/dist/core/engine/awswaf.js +164 -0
  5. package/dist/core/engine/cache.d.ts +70 -0
  6. package/dist/core/engine/cache.js +297 -0
  7. package/dist/core/engine/chrome-cdp.d.ts +22 -0
  8. package/dist/core/engine/chrome-cdp.js +318 -0
  9. package/dist/core/engine/chrome-launch.d.ts +38 -0
  10. package/dist/core/engine/chrome-launch.js +214 -0
  11. package/dist/core/engine/chrome.d.ts +28 -0
  12. package/dist/core/engine/chrome.js +208 -0
  13. package/dist/core/engine/crawl-policy.d.ts +36 -0
  14. package/dist/core/engine/crawl-policy.js +134 -0
  15. package/dist/core/engine/crawler.d.ts +152 -0
  16. package/dist/core/engine/crawler.js +918 -0
  17. package/dist/core/engine/fetcher.d.ts +92 -0
  18. package/dist/core/engine/fetcher.js +494 -0
  19. package/dist/core/engine/index.d.ts +7 -0
  20. package/dist/core/engine/index.js +7 -0
  21. package/dist/core/engine/markdown.d.ts +33 -0
  22. package/dist/core/engine/markdown.js +334 -0
  23. package/dist/core/engine/path-scope.d.ts +21 -0
  24. package/dist/core/engine/path-scope.js +88 -0
  25. package/dist/core/engine/render.d.ts +41 -0
  26. package/dist/core/engine/render.js +138 -0
  27. package/dist/core/engine/safe-url.d.ts +29 -0
  28. package/dist/core/engine/safe-url.js +109 -0
  29. package/dist/core/engine/session.d.ts +93 -0
  30. package/dist/core/engine/session.js +250 -0
  31. package/dist/core/engine/stealth.d.ts +83 -0
  32. package/dist/core/engine/stealth.js +222 -0
  33. package/dist/core/engine.d.ts +1 -0
  34. package/dist/core/engine.js +1 -0
  35. package/dist/core/extract/business.d.ts +28 -0
  36. package/dist/core/extract/business.js +344 -0
  37. package/dist/core/extract/currency.d.ts +15 -0
  38. package/dist/core/extract/currency.js +259 -0
  39. package/dist/core/extract/cve-cache.d.ts +62 -0
  40. package/dist/core/extract/cve-cache.js +277 -0
  41. package/dist/core/extract/cve.d.ts +57 -0
  42. package/dist/core/extract/cve.js +397 -0
  43. package/dist/core/extract/dedupe-key.d.ts +12 -0
  44. package/dist/core/extract/dedupe-key.js +36 -0
  45. package/dist/core/extract/flights.d.ts +37 -0
  46. package/dist/core/extract/flights.js +373 -0
  47. package/dist/core/extract/hotels.d.ts +35 -0
  48. package/dist/core/extract/hotels.js +473 -0
  49. package/dist/core/extract/index.d.ts +9 -0
  50. package/dist/core/extract/index.js +9 -0
  51. package/dist/core/extract/real-estate.d.ts +53 -0
  52. package/dist/core/extract/real-estate.js +835 -0
  53. package/dist/core/extract/scaledown.d.ts +14 -0
  54. package/dist/core/extract/scaledown.js +484 -0
  55. package/dist/core/extract/shopping.d.ts +44 -0
  56. package/dist/core/extract/shopping.js +393 -0
  57. package/dist/core/extract/structured.d.ts +33 -0
  58. package/dist/core/extract/structured.js +316 -0
  59. package/dist/core/image-url.d.ts +5 -0
  60. package/dist/core/image-url.js +13 -0
  61. package/dist/core/interact/driver.d.ts +72 -0
  62. package/dist/core/interact/driver.js +384 -0
  63. package/dist/core/interact/remote.d.ts +41 -0
  64. package/dist/core/interact/remote.js +133 -0
  65. package/dist/core/interact/spans.d.ts +46 -0
  66. package/dist/core/interact/spans.js +32 -0
  67. package/dist/core/interact/validate.d.ts +34 -0
  68. package/dist/core/interact/validate.js +135 -0
  69. package/dist/core/interact/vm.d.ts +43 -0
  70. package/dist/core/interact/vm.js +338 -0
  71. package/dist/core/local-engine.d.ts +62 -0
  72. package/dist/core/local-engine.js +726 -0
  73. package/dist/core/parse/detect.d.ts +29 -0
  74. package/dist/core/parse/detect.js +152 -0
  75. package/dist/core/parse/fetch.d.ts +20 -0
  76. package/dist/core/parse/fetch.js +39 -0
  77. package/dist/core/parse/image.d.ts +13 -0
  78. package/dist/core/parse/image.js +100 -0
  79. package/dist/core/parse/index.d.ts +53 -0
  80. package/dist/core/parse/index.js +153 -0
  81. package/dist/core/parse/limits.d.ts +12 -0
  82. package/dist/core/parse/limits.js +29 -0
  83. package/dist/core/parse/media.d.ts +42 -0
  84. package/dist/core/parse/media.js +238 -0
  85. package/dist/core/parse/merge.d.ts +19 -0
  86. package/dist/core/parse/merge.js +48 -0
  87. package/dist/core/parse/ocr.d.ts +25 -0
  88. package/dist/core/parse/ocr.js +121 -0
  89. package/dist/core/parse/office.d.ts +28 -0
  90. package/dist/core/parse/office.js +83 -0
  91. package/dist/core/parse/pdf.d.ts +27 -0
  92. package/dist/core/parse/pdf.js +156 -0
  93. package/dist/core/parse/types.d.ts +38 -0
  94. package/dist/core/parse/types.js +1 -0
  95. package/dist/core/parse/youtube.d.ts +30 -0
  96. package/dist/core/parse/youtube.js +264 -0
  97. package/dist/core/ranker/decision.d.ts +28 -0
  98. package/dist/core/ranker/decision.js +151 -0
  99. package/dist/core/ranker/index.d.ts +5 -0
  100. package/dist/core/ranker/index.js +361 -0
  101. package/dist/core/ranker/lexical.d.ts +82 -0
  102. package/dist/core/ranker/lexical.js +643 -0
  103. package/dist/core/ranker/types.d.ts +90 -0
  104. package/dist/core/ranker/types.js +1 -0
  105. package/dist/core/ranker.d.ts +1 -0
  106. package/dist/core/ranker.js +1 -0
  107. package/dist/core/sandbox.d.ts +29 -0
  108. package/dist/core/sandbox.js +222 -0
  109. package/dist/core/search/catalog.d.ts +176 -0
  110. package/dist/core/search/catalog.js +488 -0
  111. package/dist/core/search/index.d.ts +3 -0
  112. package/dist/core/search/index.js +3 -0
  113. package/dist/core/search/pipeline.d.ts +7 -0
  114. package/dist/core/search/pipeline.js +721 -0
  115. package/dist/core/search/providers.d.ts +84 -0
  116. package/dist/core/search/providers.js +1408 -0
  117. package/dist/core/search.d.ts +1 -0
  118. package/dist/core/search.js +1 -0
  119. package/dist/core/targets/developer.d.ts +44 -0
  120. package/dist/core/targets/developer.js +338 -0
  121. package/dist/core/targets/index.d.ts +3 -0
  122. package/dist/core/targets/index.js +3 -0
  123. package/dist/core/targets/registry.d.ts +32 -0
  124. package/dist/core/targets/registry.js +411 -0
  125. package/dist/core/targets/types.d.ts +72 -0
  126. package/dist/core/targets/types.js +8 -0
  127. package/dist/core/targets.d.ts +1 -0
  128. package/dist/core/targets.js +1 -0
  129. package/dist/format.js +5 -1
  130. package/dist/index.d.ts +1 -0
  131. package/dist/index.js +1 -0
  132. package/dist/load-local-engine.d.ts +7 -0
  133. package/dist/load-local-engine.js +22 -0
  134. package/dist/server.js +6 -0
  135. package/package.json +11 -3
@@ -0,0 +1,156 @@
1
+ /**
2
+ * PDF to Markdown. Text pages are read locally with pdf-inspector (free, ~5 ms/page).
3
+ * Only pages it flags as scanned go to OCR. Anything not read is reported as skipped.
4
+ */
5
+ import { createRequire } from "node:module";
6
+ import { ocrAvailable, runOcr } from "./ocr.js";
7
+ let inspector = null;
8
+ function loadInspector() {
9
+ // Native addon: load with require so the bundler leaves it alone.
10
+ if (!inspector)
11
+ inspector = createRequire(import.meta.url)("@firecrawl/pdf-inspector");
12
+ return inspector;
13
+ }
14
+ const DEFAULT_MAX_OCR_PAGES = Number(process.env.WEBCRAWL_PARSE_MAX_OCR_PAGES) || 50;
15
+ /**
16
+ * Decides which pages go where. Pure, so it can be unit-tested without a PDF.
17
+ */
18
+ export function planPdfPages(input) {
19
+ const { pageCount, options } = input;
20
+ const mode = options.ocr ?? "auto";
21
+ const maxOcr = options.maxOcrPages ?? DEFAULT_MAX_OCR_PAGES;
22
+ const all = Array.from({ length: pageCount }, (_, i) => i + 1);
23
+ const selected = options.pages?.length
24
+ ? [...new Set(options.pages)].filter((p) => p >= 1 && p <= pageCount).sort((a, b) => a - b)
25
+ : all;
26
+ const flagged = new Set(input.needsOcr);
27
+ const native = [];
28
+ const wantOcr = [];
29
+ for (const p of selected) {
30
+ if (mode === "force" || (mode === "auto" && flagged.has(p)))
31
+ wantOcr.push(p);
32
+ else
33
+ native.push(p);
34
+ }
35
+ const skipped = [];
36
+ let ocr = [];
37
+ if (mode === "off") {
38
+ for (const p of selected)
39
+ if (flagged.has(p))
40
+ skipped.push({ page: p, reason: "ocr_disabled" });
41
+ }
42
+ else if (!input.ocrConfigured) {
43
+ for (const p of wantOcr)
44
+ skipped.push({ page: p, reason: "ocr_unavailable" });
45
+ // In force mode, still return whatever text the page has natively.
46
+ if (mode === "force")
47
+ native.push(...wantOcr.filter((p) => !flagged.has(p)));
48
+ }
49
+ else {
50
+ ocr = wantOcr.slice(0, maxOcr);
51
+ for (const p of wantOcr.slice(maxOcr))
52
+ skipped.push({ page: p, reason: "page_limit" });
53
+ }
54
+ native.sort((a, b) => a - b);
55
+ return { pageCount, native, ocr, skipped };
56
+ }
57
+ /** First real-looking h1/h2 (skipping arXiv/DOI stamps and page furniture), else first h3. */
58
+ export function pickTitle(markdown) {
59
+ const clean = (s) => s.replace(/<\/?\w+>|[*_]/g, "").trim();
60
+ const usable = (s) => {
61
+ const t = clean(s);
62
+ return t.length >= 3 && t.length <= 160 && !/^(arxiv:|doi:|page \d|\d+$)/i.test(t);
63
+ };
64
+ const headings = [...markdown.matchAll(/^(#{1,3})\s+(.+)$/gm)].map((m) => ({ level: m[1].length, text: m[2] }));
65
+ const pick = headings.find((h) => h.level <= 2 && usable(h.text)) ?? headings.find((h) => usable(h.text));
66
+ return pick ? clean(pick.text) : "";
67
+ }
68
+ function toBuffer(bytes) {
69
+ return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
70
+ }
71
+ /** Page count and pages needing OCR, without extracting text (cheap: ~1 ms/page). */
72
+ export function pdfPageInfo(bytes) {
73
+ const info = loadInspector().detectPdf(toBuffer(bytes));
74
+ return { pageCount: info.pageCount, pagesNeedingOcr: info.pagesNeedingOcr, title: info.title?.trim() || undefined };
75
+ }
76
+ export async function parsePdf(bytes, options = {}) {
77
+ const started = Date.now();
78
+ const buf = toBuffer(bytes);
79
+ const pdf = loadInspector();
80
+ const warnings = [];
81
+ const info = pdf.detectPdf(buf);
82
+ const plan = planPdfPages({
83
+ pageCount: info.pageCount,
84
+ needsOcr: info.pagesNeedingOcr,
85
+ options,
86
+ ocrConfigured: ocrAvailable(),
87
+ });
88
+ if (info.hasEncodingIssues)
89
+ warnings.push("Some fonts have missing character maps; text may contain gaps.");
90
+ const byPage = new Map();
91
+ const nativeRead = [];
92
+ if (plan.native.length) {
93
+ // extractPagesMarkdown takes and returns 0-indexed pages; detectPdf reports 1-indexed ones.
94
+ const extracted = await pdf.extractPagesMarkdownAsync(buf, plan.native.map((p) => p - 1));
95
+ for (const raw of extracted.pages) {
96
+ const page = raw.page + 1;
97
+ const md = raw.markdown.trim();
98
+ if (md) {
99
+ byPage.set(page, md);
100
+ nativeRead.push(page);
101
+ }
102
+ else if (!plan.ocr.includes(page) && !plan.skipped.some((s) => s.page === page)) {
103
+ plan.skipped.push({ page, reason: "empty" });
104
+ }
105
+ }
106
+ }
107
+ const ocrRead = [];
108
+ let ocrProvider;
109
+ if (plan.ocr.length) {
110
+ try {
111
+ const result = await runOcr({ bytes: buf, mime: "application/pdf", pages: plan.ocr, signal: options.signal });
112
+ ocrProvider = result.provider;
113
+ warnings.push(...result.warnings.map((w) => `OCR fallback: ${w}`));
114
+ const wanted = new Set(plan.ocr);
115
+ for (const p of result.pages) {
116
+ if (!wanted.has(p.page) || !p.markdown)
117
+ continue;
118
+ byPage.set(p.page, p.markdown);
119
+ ocrRead.push(p.page);
120
+ }
121
+ for (const p of plan.ocr)
122
+ if (!ocrRead.includes(p))
123
+ plan.skipped.push({ page: p, reason: "ocr_failed" });
124
+ }
125
+ catch (err) {
126
+ warnings.push(`OCR failed: ${err instanceof Error ? err.message : String(err)}`);
127
+ for (const p of plan.ocr)
128
+ plan.skipped.push({ page: p, reason: "ocr_failed" });
129
+ }
130
+ }
131
+ const ordered = [...byPage.keys()].sort((a, b) => a - b);
132
+ const multi = ordered.length > 1;
133
+ const markdown = ordered
134
+ .map((p) => (multi ? `<!-- page ${p} -->\n\n${byPage.get(p)}` : byPage.get(p)))
135
+ .join("\n\n");
136
+ plan.skipped.sort((a, b) => a.page - b.page);
137
+ // Prefer document metadata, then the highest-level heading (first page banners are often ###).
138
+ const title = info.title?.trim() || pickTitle(markdown);
139
+ return {
140
+ title,
141
+ markdown,
142
+ meta: {
143
+ kind: "pdf",
144
+ mime: "application/pdf",
145
+ method: ocrRead.length > nativeRead.length ? "ocr" : "native",
146
+ bytes: buf.byteLength,
147
+ pages: info.pageCount,
148
+ pagesNative: nativeRead.sort((a, b) => a - b),
149
+ pagesOcr: ocrRead.sort((a, b) => a - b),
150
+ pagesSkipped: plan.skipped,
151
+ ocrProvider,
152
+ warnings,
153
+ processingMs: Date.now() - started,
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,38 @@
1
+ import type { FileKind } from "./detect.js";
2
+ /** How the bulk of the content was produced. */
3
+ export type ParseMethod = "native" | "ocr" | "vision" | "transcript";
4
+ export interface SkippedPage {
5
+ page: number;
6
+ reason: "ocr_disabled" | "ocr_unavailable" | "ocr_failed" | "page_limit" | "empty";
7
+ }
8
+ /** Reported to the caller as `metadata.parse`, and used to charge credits. */
9
+ export interface ParseMeta {
10
+ kind: FileKind;
11
+ mime: string;
12
+ method: ParseMethod;
13
+ bytes: number;
14
+ pages?: number;
15
+ pagesNative?: number[];
16
+ pagesOcr?: number[];
17
+ pagesSkipped?: SkippedPage[];
18
+ ocrProvider?: string;
19
+ durationSeconds?: number;
20
+ transcriptProvider?: string;
21
+ visionProvider?: string;
22
+ warnings: string[];
23
+ processingMs: number;
24
+ }
25
+ export interface ParsedFile {
26
+ title: string;
27
+ markdown: string;
28
+ meta: ParseMeta;
29
+ }
30
+ export interface PdfParseOptions {
31
+ /** "auto": OCR only flagged pages; "force": OCR every page; "off": never OCR. */
32
+ ocr?: "auto" | "force" | "off";
33
+ /** 1-indexed page numbers to include. */
34
+ pages?: number[];
35
+ /** Hard cap on pages sent to OCR in one request. */
36
+ maxOcrPages?: number;
37
+ signal?: AbortSignal;
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * YouTube link -> timestamped transcript from the video's own captions.
3
+ * No audio download and no model call: we read the caption track YouTube already publishes.
4
+ * Videos without captions return an error rather than an empty or invented transcript.
5
+ */
6
+ import type { Segment } from "./media.js";
7
+ import type { ParsedFile } from "./types.js";
8
+ export declare class YouTubeError extends Error {
9
+ readonly status: number;
10
+ constructor(message: string, status?: number);
11
+ }
12
+ /** Returns the 11-character video id for watch, short, embed, shorts, live and youtu.be links. */
13
+ export declare function youtubeVideoId(input: string): string | undefined;
14
+ /** Parses YouTube's timedtext XML (`<p t= d=>` in ms, optional `<s>` children). */
15
+ export declare function parseTimedText(xml: string): Segment[];
16
+ export declare function parseYouTube(videoId: string, options?: {
17
+ language?: string;
18
+ signal?: AbortSignal;
19
+ }): Promise<ParsedFile>;
20
+ export interface YouTubeResult extends ParsedFile {
21
+ contentHash: string;
22
+ cached: boolean;
23
+ cacheLayer: "memory" | "disk" | "postgres" | "miss";
24
+ }
25
+ /** Same transcript for the same video and language, so successful reads are kept for 24 hours. Failures are never cached. */
26
+ export declare function parseYouTubeCached(videoId: string, options?: {
27
+ language?: string;
28
+ signal?: AbortSignal;
29
+ bypassCache?: boolean;
30
+ }): Promise<YouTubeResult>;
@@ -0,0 +1,264 @@
1
+ import { transcriptMarkdown } from "./media.js";
2
+ import { getCachedPageAsync, setCachedPage } from "../engine/cache.js";
3
+ const PLAYER_URL = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false";
4
+ const MAX_ATTEMPTS = 3;
5
+ const UA = "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip";
6
+ /** Tried in order; YouTube blocks some clients from datacenter IPs, so we fall through. */
7
+ const CLIENTS = [
8
+ {
9
+ context: {
10
+ client: {
11
+ clientName: "ANDROID",
12
+ clientVersion: "20.10.38",
13
+ androidSdkVersion: 30,
14
+ hl: "en",
15
+ },
16
+ },
17
+ ua: UA,
18
+ },
19
+ {
20
+ context: {
21
+ client: {
22
+ clientName: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
23
+ clientVersion: "2.0",
24
+ hl: "en",
25
+ },
26
+ thirdParty: { embedUrl: "https://www.youtube.com/" },
27
+ },
28
+ ua: "Mozilla/5.0 (SMART-TV; Linux; Tizen 6.5) AppleWebKit/537.36 (KHTML, like Gecko) 85.0.4183.93/6.5 TV Safari/537.36",
29
+ },
30
+ {
31
+ context: {
32
+ client: {
33
+ clientName: "WEB_EMBEDDED_PLAYER",
34
+ clientVersion: "1.20250310.01.00",
35
+ hl: "en",
36
+ },
37
+ thirdParty: { embedUrl: "https://www.google.com/" },
38
+ },
39
+ ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36",
40
+ },
41
+ {
42
+ context: {
43
+ client: {
44
+ clientName: "IOS",
45
+ clientVersion: "20.10.4",
46
+ deviceMake: "Apple",
47
+ deviceModel: "iPhone16,2",
48
+ osName: "iPhone",
49
+ osVersion: "18.3.2.22D82",
50
+ hl: "en",
51
+ },
52
+ },
53
+ ua: "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
54
+ },
55
+ ];
56
+ /**
57
+ * YouTube refuses most cloud-hosting IP ranges. When YOUTUBE_RELAY_URL and YOUTUBE_RELAY_TOKEN are set,
58
+ * requests go through a relay on a network YouTube accepts; otherwise they go straight out.
59
+ */
60
+ function youtubeFetch(url, init) {
61
+ const relay = process.env.YOUTUBE_RELAY_URL;
62
+ const token = process.env.YOUTUBE_RELAY_TOKEN;
63
+ if (!relay || !token)
64
+ return fetch(url, init);
65
+ return fetch(relay, {
66
+ method: "POST",
67
+ headers: {
68
+ Authorization: `Bearer ${token}`,
69
+ "Content-Type": "application/json",
70
+ },
71
+ body: JSON.stringify({
72
+ url,
73
+ method: init.method,
74
+ headers: init.headers,
75
+ body: init.body,
76
+ }),
77
+ signal: init.signal,
78
+ });
79
+ }
80
+ export class YouTubeError extends Error {
81
+ status;
82
+ constructor(message, status = 422) {
83
+ super(message);
84
+ this.status = status;
85
+ this.name = "YouTubeError";
86
+ }
87
+ }
88
+ /** Returns the 11-character video id for watch, short, embed, shorts, live and youtu.be links. */
89
+ export function youtubeVideoId(input) {
90
+ let u;
91
+ try {
92
+ u = new URL(input);
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ const host = u.hostname.replace(/^(www|m|music)\./, "");
98
+ const ok = (s) => s && /^[A-Za-z0-9_-]{11}$/.test(s) ? s : undefined;
99
+ if (host === "youtu.be")
100
+ return ok(u.pathname.slice(1).split("/")[0]);
101
+ if (host !== "youtube.com" && host !== "youtube-nocookie.com")
102
+ return undefined;
103
+ if (u.pathname === "/watch")
104
+ return ok(u.searchParams.get("v"));
105
+ const m = /^\/(?:embed|shorts|live|v)\/([^/?#]+)/.exec(u.pathname);
106
+ return m ? ok(m[1]) : undefined;
107
+ }
108
+ function decodeEntities(s) {
109
+ return s
110
+ .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
111
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
112
+ .replace(/&quot;/g, '"')
113
+ .replace(/&apos;/g, "'")
114
+ .replace(/&lt;/g, "<")
115
+ .replace(/&gt;/g, ">")
116
+ .replace(/&amp;/g, "&");
117
+ }
118
+ /** Parses YouTube's timedtext XML (`<p t= d=>` in ms, optional `<s>` children). */
119
+ export function parseTimedText(xml) {
120
+ const out = [];
121
+ for (const m of xml.matchAll(/<p\s([^>]*)>([\s\S]*?)<\/p>/g)) {
122
+ const t = /\bt="(\d+)"/.exec(m[1]);
123
+ if (!t)
124
+ continue;
125
+ const d = /\bd="(\d+)"/.exec(m[1]);
126
+ const text = decodeEntities(m[2].replace(/<[^>]+>/g, ""))
127
+ .replace(/\s+/g, " ")
128
+ .trim();
129
+ if (!text)
130
+ continue;
131
+ const start = Number(t[1]) / 1000;
132
+ out.push({
133
+ start,
134
+ end: Math.round((start + (d ? Number(d[1]) / 1000 : 0)) * 1000) / 1000,
135
+ text,
136
+ });
137
+ }
138
+ return out;
139
+ }
140
+ function pickTrack(tracks, language) {
141
+ const want = (language || "en").toLowerCase().split("-")[0];
142
+ const byLang = tracks.filter((t) => t.languageCode.toLowerCase().split("-")[0] === want);
143
+ const pool = byLang.length ? byLang : tracks;
144
+ // Human-written captions beat auto-generated ones.
145
+ return pool.find((t) => t.kind !== "asr") ?? pool[0];
146
+ }
147
+ export async function parseYouTube(videoId, options = {}) {
148
+ const started = performance.now();
149
+ const signal = options.signal ?? AbortSignal.timeout(25_000);
150
+ let player;
151
+ let ua = UA;
152
+ let lastReason = "";
153
+ // The relay's addresses are shared, so YouTube occasionally flags one with a bot check.
154
+ // A retry usually leaves from a different address, so retry only that case.
155
+ for (let attempt = 0; attempt < MAX_ATTEMPTS && !player; attempt++) {
156
+ if (attempt > 0 && !/not a bot/i.test(lastReason))
157
+ break;
158
+ if (attempt > 0)
159
+ await new Promise((r) => setTimeout(r, 400 * attempt));
160
+ for (const c of CLIENTS) {
161
+ try {
162
+ const res = await youtubeFetch(PLAYER_URL, {
163
+ method: "POST",
164
+ headers: { "Content-Type": "application/json", "User-Agent": c.ua },
165
+ body: JSON.stringify({
166
+ context: c.context,
167
+ videoId,
168
+ contentCheckOk: true,
169
+ racyCheckOk: true,
170
+ }),
171
+ signal,
172
+ });
173
+ if (!res.ok) {
174
+ lastReason = `HTTP ${res.status}`;
175
+ continue;
176
+ }
177
+ const p = (await res.json());
178
+ if (p.playabilityStatus?.status === "OK") {
179
+ player = p;
180
+ ua = c.ua;
181
+ break;
182
+ }
183
+ lastReason =
184
+ p.playabilityStatus?.reason ||
185
+ p.playabilityStatus?.status?.toLowerCase() ||
186
+ "unavailable";
187
+ }
188
+ catch (err) {
189
+ lastReason = err instanceof Error ? err.message : String(err);
190
+ }
191
+ }
192
+ }
193
+ if (!player)
194
+ throw new YouTubeError(`This video can't be read (${lastReason})`);
195
+ const tracks = player.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
196
+ if (!tracks.length)
197
+ throw new YouTubeError("This video has no captions, so there is no transcript to read");
198
+ const track = pickTrack(tracks, options.language);
199
+ const cap = await youtubeFetch(track.baseUrl, {
200
+ headers: { "User-Agent": ua },
201
+ signal,
202
+ });
203
+ if (!cap.ok)
204
+ throw new YouTubeError(`Could not load the captions (HTTP ${cap.status})`, 502);
205
+ const segments = parseTimedText(await cap.text());
206
+ if (!segments.length)
207
+ throw new YouTubeError("The captions for this video were empty");
208
+ const lengthSeconds = Number(player.videoDetails?.lengthSeconds);
209
+ const duration = Number.isFinite(lengthSeconds) && lengthSeconds > 0
210
+ ? lengthSeconds
211
+ : segments[segments.length - 1].end;
212
+ const warnings = [];
213
+ if (track.kind === "asr")
214
+ warnings.push("Captions are auto-generated by YouTube and may contain errors");
215
+ return {
216
+ title: player.videoDetails?.title || "",
217
+ markdown: `## Transcript\n\n${transcriptMarkdown(segments)}`,
218
+ meta: {
219
+ kind: "video",
220
+ mime: "video/youtube",
221
+ method: "transcript",
222
+ bytes: 0,
223
+ durationSeconds: duration,
224
+ transcriptProvider: "captions",
225
+ warnings,
226
+ processingMs: Math.round(performance.now() - started),
227
+ },
228
+ };
229
+ }
230
+ /** Same transcript for the same video and language, so successful reads are kept for 24 hours. Failures are never cached. */
231
+ export async function parseYouTubeCached(videoId, options = {}) {
232
+ const key = `wc-parse://youtube:${videoId}/lang=${options.language ?? "default"}`;
233
+ if (!options.bypassCache) {
234
+ const hit = await getCachedPageAsync(key, "parse");
235
+ const meta = hit?.structuredData?.parse;
236
+ if (hit && meta) {
237
+ return {
238
+ title: hit.title,
239
+ markdown: hit.markdown,
240
+ meta,
241
+ contentHash: `youtube:${videoId}`,
242
+ cached: true,
243
+ cacheLayer: hit.cacheLayer || "postgres",
244
+ };
245
+ }
246
+ }
247
+ const parsed = await parseYouTube(videoId, options);
248
+ setCachedPage(key, "parse", {
249
+ title: parsed.title,
250
+ markdown: parsed.markdown,
251
+ links: [],
252
+ structuredData: {
253
+ parse: parsed.meta,
254
+ },
255
+ statusCode: 200,
256
+ contentHash: `youtube:${videoId}`,
257
+ });
258
+ return {
259
+ ...parsed,
260
+ contentHash: `youtube:${videoId}`,
261
+ cached: false,
262
+ cacheLayer: "miss",
263
+ };
264
+ }
@@ -0,0 +1,28 @@
1
+ import type { DecisionModelId, SearchFilterContext, UserLocationContext } from "./types.js";
2
+ /** What `decisionModel: "auto"` resolves to. Set WEBCRAWL_DECISION_MODEL to change it in prod. */
3
+ export declare function defaultDecisionModel(): DecisionModelId;
4
+ /**
5
+ * Stage 5: Vercel AI Gateway Decision Model Reranker (`baseten/mammoth-1` & `convaiinnovations/laya`).
6
+ * Scores all top-N candidates in ONE shared-prefix round-trip via `experimental_decide`.
7
+ */
8
+ export declare function runDecisionModelReranker(query: string, candidates: Array<{
9
+ title: string;
10
+ url: string;
11
+ description: string;
12
+ highlights: string[];
13
+ }>, modelOption: DecisionModelId, timeoutMs: number, userLocation?: UserLocationContext, filterContext?: SearchFilterContext): Promise<{
14
+ scores: Map<number, {
15
+ normalizedScore: number;
16
+ rawScore: number;
17
+ confidence: number;
18
+ }>;
19
+ modelUsed: string | null;
20
+ } | null>;
21
+ /**
22
+ * Full 5-Stage Document & Passage Ranking Pipeline:
23
+ * 1. Canonical URL Deduplication
24
+ * 2. Weighted Reciprocal Rank Fusion (W-RRF, k=60) + Multi-Engine Consensus
25
+ * 3. Fielded Okapi BM25F+ (title 3.5x, headings 2.2x, url 2.0x, description 1.5x, body 1.0x)
26
+ * 4. ColBERT-inspired Subword + Token MaxSim Passage Scoring & Highlight Extraction
27
+ * 5. Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
28
+ */
@@ -0,0 +1,151 @@
1
+ import { createGateway } from "@ai-sdk/gateway";
2
+ import { experimental_decide as decide } from "ai";
3
+ import { resolveCountryDisplayName } from "./lexical.js";
4
+ const DECISION_MODELS = [
5
+ "baseten/mammoth-1",
6
+ "baseten/elephant-1",
7
+ "convaiinnovations/laya",
8
+ "none",
9
+ ];
10
+ /** What `decisionModel: "auto"` resolves to. Set WEBCRAWL_DECISION_MODEL to change it in prod. */
11
+ export function defaultDecisionModel() {
12
+ const env = process.env.WEBCRAWL_DECISION_MODEL?.trim();
13
+ return env && DECISION_MODELS.includes(env) ? env : "baseten/mammoth-1";
14
+ }
15
+ /**
16
+ * Stage 5: Vercel AI Gateway Decision Model Reranker (`baseten/mammoth-1` & `convaiinnovations/laya`).
17
+ * Scores all top-N candidates in ONE shared-prefix round-trip via `experimental_decide`.
18
+ */
19
+ export async function runDecisionModelReranker(query, candidates, modelOption, timeoutMs, userLocation, filterContext) {
20
+ // Operator switch: WEBCRAWL_DECISION_MODEL picks what "auto" means (or "none" for lexical-only),
21
+ // so a model price change can be absorbed without a deploy of new code.
22
+ if (modelOption === "auto")
23
+ modelOption = defaultDecisionModel();
24
+ if (modelOption === "none" || candidates.length === 0) {
25
+ return null;
26
+ }
27
+ const apiKey = process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN;
28
+ if (!apiKey) {
29
+ return null;
30
+ }
31
+ // Primary model selection: `baseten/mammoth-1` (#1 on Jev Decision Index 71.24, ~350ms)
32
+ // or user-specified `convaiinnovations/laya` / `baseten/elephant-1`.
33
+ const primaryModel = modelOption === "auto" ? "baseten/mammoth-1" : modelOption;
34
+ const fallbackModels = primaryModel === "baseten/mammoth-1"
35
+ ? ["baseten/elephant-1", "convaiinnovations/laya"]
36
+ : ["baseten/mammoth-1", "baseten/elephant-1"];
37
+ const gw = createGateway({ apiKey });
38
+ const stateCandidates = candidates.map((c, idx) => ({
39
+ id: `doc_${idx}`,
40
+ title: c.title.slice(0, 180),
41
+ url: c.url.slice(0, 200),
42
+ snippet: (c.highlights[0] || c.description || "").slice(0, 360),
43
+ }));
44
+ const contextClauses = [];
45
+ if (filterContext?.category) {
46
+ contextClauses.push(`category="${filterContext.category}"`);
47
+ }
48
+ if (filterContext?.subcategory) {
49
+ const subStr = Array.isArray(filterContext.subcategory)
50
+ ? filterContext.subcategory.join(", ")
51
+ : filterContext.subcategory;
52
+ contextClauses.push(`subcategory="${subStr}"`);
53
+ }
54
+ if (userLocation?.city || userLocation?.region || userLocation?.country) {
55
+ const locParts = [
56
+ userLocation.city,
57
+ userLocation.region,
58
+ userLocation.countryName ||
59
+ resolveCountryDisplayName(userLocation.country),
60
+ ].filter(Boolean);
61
+ contextClauses.push(`userLocation="${locParts.join(", ")}"`);
62
+ }
63
+ if (filterContext?.customInstruction) {
64
+ contextClauses.push(`agentFilter="${filterContext.customInstruction}"`);
65
+ }
66
+ const contextSuffix = contextClauses.length > 0 ? ` (${contextClauses.join("; ")})` : "";
67
+ const questions = {};
68
+ for (let i = 0; i < stateCandidates.length; i++) {
69
+ questions[`doc_${i}`] = {
70
+ type: "score",
71
+ instructions: `Evaluate how relevant, authoritative, and aligned candidate doc_${i} is for the search query${contextSuffix}. Penalize entity homonym mismatches or wrong-region pages when userLocation or category is specified.`,
72
+ criteria: [
73
+ "Irrelevant, off-topic, wrong entity, wrong region, or spam",
74
+ "Tangentially related or generic syndication with weak match to the requested category/subcategory/location",
75
+ "Relevant and addresses a substantial part of the query and category/subcategory context",
76
+ "Highly relevant, authoritative, and directly satisfies the query, category, subcategory, and user location",
77
+ ],
78
+ };
79
+ }
80
+ try {
81
+ const decisionPromise = decide({
82
+ model: gw.decisionModel(primaryModel),
83
+ state: JSON.stringify({
84
+ query,
85
+ ...(filterContext?.category
86
+ ? { category: filterContext.category }
87
+ : {}),
88
+ ...(filterContext?.subcategory
89
+ ? { subcategory: filterContext.subcategory }
90
+ : {}),
91
+ ...(userLocation ? { userLocation } : {}),
92
+ candidates: stateCandidates,
93
+ }),
94
+ questions,
95
+ providerOptions: {
96
+ gateway: {
97
+ models: fallbackModels,
98
+ },
99
+ },
100
+ abortSignal: AbortSignal.timeout(timeoutMs),
101
+ });
102
+ const result = await decisionPromise;
103
+ const scores = new Map();
104
+ for (let i = 0; i < stateCandidates.length; i++) {
105
+ const ans = result.answers?.[`doc_${i}`];
106
+ if (!ans) {
107
+ continue;
108
+ }
109
+ let rawScore = typeof ans.score === "number" ? ans.score : 0;
110
+ let maxProb = typeof ans.confidence === "number" ? ans.confidence : 0;
111
+ if (ans.probabilities) {
112
+ const probs = Array.isArray(ans.probabilities)
113
+ ? ans.probabilities
114
+ : [
115
+ Number(ans.probabilities["0"] ?? 0),
116
+ Number(ans.probabilities["1"] ?? 0),
117
+ Number(ans.probabilities["2"] ?? 0),
118
+ Number(ans.probabilities["3"] ?? 0),
119
+ ];
120
+ const expected = 0 * (probs[0] || 0) +
121
+ 1 * (probs[1] || 0) +
122
+ 2 * (probs[2] || 0) +
123
+ 3 * (probs[3] || 0);
124
+ if (expected > 0 || rawScore === 0) {
125
+ rawScore = expected;
126
+ }
127
+ maxProb = Math.max(maxProb, ...probs.filter((p) => Number.isFinite(p)));
128
+ }
129
+ const normalizedScore = Math.max(0, Math.min(1, rawScore / 3));
130
+ scores.set(i, {
131
+ normalizedScore,
132
+ rawScore: Number(rawScore.toFixed(4)),
133
+ confidence: Number((maxProb || 0.85).toFixed(4)),
134
+ });
135
+ }
136
+ const modelUsed = result.response?.modelId ||
137
+ primaryModel;
138
+ return { scores, modelUsed };
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * Full 5-Stage Document & Passage Ranking Pipeline:
146
+ * 1. Canonical URL Deduplication
147
+ * 2. Weighted Reciprocal Rank Fusion (W-RRF, k=60) + Multi-Engine Consensus
148
+ * 3. Fielded Okapi BM25F+ (title 3.5x, headings 2.2x, url 2.0x, description 1.5x, body 1.0x)
149
+ * 4. ColBERT-inspired Subword + Token MaxSim Passage Scoring & Highlight Extraction
150
+ * 5. Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
151
+ */
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export * from "./lexical.js";
3
+ export * from "./decision.js";
4
+ import type { RawCandidateDocument, RerankOptions, RerankResult } from "./types.js";
5
+ export declare function rankDocuments(query: string, rawCandidates: RawCandidateDocument[], options?: RerankOptions): Promise<RerankResult>;