@webcrawl/mcp 1.4.2 → 1.4.3

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 (131) 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.d.ts +14 -0
  8. package/dist/core/engine/chrome.js +140 -0
  9. package/dist/core/engine/crawl-policy.d.ts +36 -0
  10. package/dist/core/engine/crawl-policy.js +134 -0
  11. package/dist/core/engine/crawler.d.ts +152 -0
  12. package/dist/core/engine/crawler.js +918 -0
  13. package/dist/core/engine/fetcher.d.ts +92 -0
  14. package/dist/core/engine/fetcher.js +494 -0
  15. package/dist/core/engine/index.d.ts +7 -0
  16. package/dist/core/engine/index.js +7 -0
  17. package/dist/core/engine/markdown.d.ts +33 -0
  18. package/dist/core/engine/markdown.js +334 -0
  19. package/dist/core/engine/path-scope.d.ts +21 -0
  20. package/dist/core/engine/path-scope.js +88 -0
  21. package/dist/core/engine/render.d.ts +41 -0
  22. package/dist/core/engine/render.js +138 -0
  23. package/dist/core/engine/safe-url.d.ts +29 -0
  24. package/dist/core/engine/safe-url.js +109 -0
  25. package/dist/core/engine/session.d.ts +93 -0
  26. package/dist/core/engine/session.js +250 -0
  27. package/dist/core/engine/stealth.d.ts +83 -0
  28. package/dist/core/engine/stealth.js +222 -0
  29. package/dist/core/engine.d.ts +1 -0
  30. package/dist/core/engine.js +1 -0
  31. package/dist/core/extract/business.d.ts +28 -0
  32. package/dist/core/extract/business.js +344 -0
  33. package/dist/core/extract/currency.d.ts +15 -0
  34. package/dist/core/extract/currency.js +259 -0
  35. package/dist/core/extract/cve-cache.d.ts +62 -0
  36. package/dist/core/extract/cve-cache.js +277 -0
  37. package/dist/core/extract/cve.d.ts +57 -0
  38. package/dist/core/extract/cve.js +397 -0
  39. package/dist/core/extract/dedupe-key.d.ts +12 -0
  40. package/dist/core/extract/dedupe-key.js +36 -0
  41. package/dist/core/extract/flights.d.ts +37 -0
  42. package/dist/core/extract/flights.js +373 -0
  43. package/dist/core/extract/hotels.d.ts +35 -0
  44. package/dist/core/extract/hotels.js +473 -0
  45. package/dist/core/extract/index.d.ts +9 -0
  46. package/dist/core/extract/index.js +9 -0
  47. package/dist/core/extract/real-estate.d.ts +53 -0
  48. package/dist/core/extract/real-estate.js +835 -0
  49. package/dist/core/extract/scaledown.d.ts +14 -0
  50. package/dist/core/extract/scaledown.js +484 -0
  51. package/dist/core/extract/shopping.d.ts +44 -0
  52. package/dist/core/extract/shopping.js +393 -0
  53. package/dist/core/extract/structured.d.ts +14 -0
  54. package/dist/core/extract/structured.js +144 -0
  55. package/dist/core/image-url.d.ts +5 -0
  56. package/dist/core/image-url.js +13 -0
  57. package/dist/core/interact/driver.d.ts +72 -0
  58. package/dist/core/interact/driver.js +384 -0
  59. package/dist/core/interact/remote.d.ts +41 -0
  60. package/dist/core/interact/remote.js +133 -0
  61. package/dist/core/interact/spans.d.ts +46 -0
  62. package/dist/core/interact/spans.js +32 -0
  63. package/dist/core/interact/validate.d.ts +34 -0
  64. package/dist/core/interact/validate.js +135 -0
  65. package/dist/core/interact/vm.d.ts +43 -0
  66. package/dist/core/interact/vm.js +338 -0
  67. package/dist/core/local-engine.d.ts +56 -0
  68. package/dist/core/local-engine.js +667 -0
  69. package/dist/core/parse/detect.d.ts +29 -0
  70. package/dist/core/parse/detect.js +152 -0
  71. package/dist/core/parse/fetch.d.ts +20 -0
  72. package/dist/core/parse/fetch.js +39 -0
  73. package/dist/core/parse/image.d.ts +13 -0
  74. package/dist/core/parse/image.js +100 -0
  75. package/dist/core/parse/index.d.ts +53 -0
  76. package/dist/core/parse/index.js +153 -0
  77. package/dist/core/parse/limits.d.ts +12 -0
  78. package/dist/core/parse/limits.js +29 -0
  79. package/dist/core/parse/media.d.ts +42 -0
  80. package/dist/core/parse/media.js +238 -0
  81. package/dist/core/parse/merge.d.ts +19 -0
  82. package/dist/core/parse/merge.js +48 -0
  83. package/dist/core/parse/ocr.d.ts +25 -0
  84. package/dist/core/parse/ocr.js +121 -0
  85. package/dist/core/parse/office.d.ts +28 -0
  86. package/dist/core/parse/office.js +83 -0
  87. package/dist/core/parse/pdf.d.ts +27 -0
  88. package/dist/core/parse/pdf.js +156 -0
  89. package/dist/core/parse/types.d.ts +38 -0
  90. package/dist/core/parse/types.js +1 -0
  91. package/dist/core/parse/youtube.d.ts +30 -0
  92. package/dist/core/parse/youtube.js +264 -0
  93. package/dist/core/ranker/decision.d.ts +28 -0
  94. package/dist/core/ranker/decision.js +151 -0
  95. package/dist/core/ranker/index.d.ts +5 -0
  96. package/dist/core/ranker/index.js +361 -0
  97. package/dist/core/ranker/lexical.d.ts +82 -0
  98. package/dist/core/ranker/lexical.js +643 -0
  99. package/dist/core/ranker/types.d.ts +90 -0
  100. package/dist/core/ranker/types.js +1 -0
  101. package/dist/core/ranker.d.ts +1 -0
  102. package/dist/core/ranker.js +1 -0
  103. package/dist/core/sandbox.d.ts +29 -0
  104. package/dist/core/sandbox.js +222 -0
  105. package/dist/core/search/catalog.d.ts +176 -0
  106. package/dist/core/search/catalog.js +488 -0
  107. package/dist/core/search/index.d.ts +3 -0
  108. package/dist/core/search/index.js +3 -0
  109. package/dist/core/search/pipeline.d.ts +7 -0
  110. package/dist/core/search/pipeline.js +719 -0
  111. package/dist/core/search/providers.d.ts +72 -0
  112. package/dist/core/search/providers.js +1270 -0
  113. package/dist/core/search.d.ts +1 -0
  114. package/dist/core/search.js +1 -0
  115. package/dist/core/targets/developer.d.ts +44 -0
  116. package/dist/core/targets/developer.js +338 -0
  117. package/dist/core/targets/index.d.ts +3 -0
  118. package/dist/core/targets/index.js +3 -0
  119. package/dist/core/targets/registry.d.ts +32 -0
  120. package/dist/core/targets/registry.js +411 -0
  121. package/dist/core/targets/types.d.ts +72 -0
  122. package/dist/core/targets/types.js +8 -0
  123. package/dist/core/targets.d.ts +1 -0
  124. package/dist/core/targets.js +1 -0
  125. package/dist/format.js +5 -1
  126. package/dist/index.d.ts +1 -0
  127. package/dist/index.js +1 -0
  128. package/dist/load-local-engine.d.ts +7 -0
  129. package/dist/load-local-engine.js +22 -0
  130. package/dist/server.js +3 -0
  131. package/package.json +11 -3
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Audio / video -> timestamped transcript.
3
+ * 1. Captions first: if the caller passes captions (WebVTT or SRT), use them. No model call.
4
+ * 2. Otherwise Groq `whisper-large-v3-turbo` if GROQ_API_KEY is set, falling back to the AI Gateway
5
+ * (`spacexai/grok-stt` by default, billed at 2 credits/min) when Groq is missing or fails.
6
+ * 3. Optional visual description for video via Gemini Flash-Lite (AI Gateway).
7
+ * Nothing is invented: without a provider the transcript is empty with a warning.
8
+ */
9
+ import { createGateway } from "@ai-sdk/gateway";
10
+ import { experimental_transcribe, generateText } from "ai";
11
+ const GROQ_URL = "https://api.groq.com/openai/v1/audio/transcriptions";
12
+ const DEFAULT_TRANSCRIBE_MODEL = "whisper-large-v3-turbo";
13
+ const DEFAULT_VISION_MODEL = "google/gemini-2.5-flash-lite";
14
+ /** Gemini inline video limit through the gateway; larger videos skip the description. */
15
+ const MAX_VIDEO_DESCRIBE_BYTES = 20 * 1024 * 1024;
16
+ export function transcriptionAvailable() {
17
+ return Boolean(process.env.GROQ_API_KEY || gatewayKey());
18
+ }
19
+ function gatewayKey() {
20
+ return process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN;
21
+ }
22
+ /** ~$0.0017/min with segment timestamps; whisper-1 is $0.006/min. Override with WEBCRAWL_GATEWAY_TRANSCRIBE_MODEL. */
23
+ const DEFAULT_GATEWAY_TRANSCRIBE_MODEL = "spacexai/grok-stt";
24
+ /** Transcribes through the Vercel AI Gateway (no extra provider key needed). */
25
+ export async function gatewayTranscribe(bytes, options = {}) {
26
+ const key = gatewayKey();
27
+ if (!key)
28
+ throw new Error("no transcription provider configured");
29
+ const gw = createGateway({ apiKey: key });
30
+ const r = await experimental_transcribe({
31
+ model: gw.transcription(process.env.WEBCRAWL_GATEWAY_TRANSCRIBE_MODEL || DEFAULT_GATEWAY_TRANSCRIBE_MODEL),
32
+ audio: bytes,
33
+ abortSignal: options.signal ?? AbortSignal.timeout(300_000),
34
+ ...(options.language ? { providerOptions: { openai: { language: options.language } } } : {}),
35
+ });
36
+ const segments = r.segments
37
+ .map((s) => ({ start: s.startSecond, end: s.endSecond, text: s.text.trim() }))
38
+ .filter((s) => s.text);
39
+ if (!segments.length && r.text.trim())
40
+ segments.push({ start: 0, end: r.durationInSeconds ?? 0, text: r.text.trim() });
41
+ return { segments, duration: r.durationInSeconds ?? segments[segments.length - 1]?.end ?? 0, language: r.language };
42
+ }
43
+ /** 3725.4 -> "1:02:05"; 65 -> "1:05". */
44
+ export function formatTimestamp(seconds) {
45
+ const s = Math.max(0, Math.floor(seconds));
46
+ const h = Math.floor(s / 3600);
47
+ const m = Math.floor((s % 3600) / 60);
48
+ const sec = String(s % 60).padStart(2, "0");
49
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
50
+ }
51
+ function parseClock(t) {
52
+ const parts = t.trim().replace(",", ".").split(":").map(Number);
53
+ return parts.reduce((acc, n) => acc * 60 + n, 0);
54
+ }
55
+ /** Parses WebVTT or SRT cues. Drops styling tags and repeated rolling-caption lines. */
56
+ export function parseCaptions(text) {
57
+ const cues = [];
58
+ const blocks = text.replace(/\r/g, "").split(/\n{2,}/);
59
+ for (const block of blocks) {
60
+ const lines = block.split("\n");
61
+ const i = lines.findIndex((l) => l.includes("-->"));
62
+ if (i === -1)
63
+ continue;
64
+ const [a, b] = lines[i].split("-->");
65
+ const body = lines
66
+ .slice(i + 1)
67
+ .join(" ")
68
+ .replace(/<[^>]+>/g, "")
69
+ .replace(/\s+/g, " ")
70
+ .trim();
71
+ if (!body)
72
+ continue;
73
+ const start = parseClock(a);
74
+ const end = parseClock(b.trim().split(/\s/)[0]);
75
+ const prev = cues[cues.length - 1];
76
+ if (prev && prev.text === body) {
77
+ prev.end = end;
78
+ continue;
79
+ }
80
+ cues.push({ start, end, text: body });
81
+ }
82
+ return cues;
83
+ }
84
+ /** Groups segments into ~30 s paragraphs, each prefixed with its start time. */
85
+ export function transcriptMarkdown(segments, paragraphSeconds = 30) {
86
+ const out = [];
87
+ let buf = [];
88
+ let bufStart = 0;
89
+ for (const seg of segments) {
90
+ if (buf.length === 0)
91
+ bufStart = seg.start;
92
+ buf.push(seg.text.trim());
93
+ if (seg.end - bufStart >= paragraphSeconds) {
94
+ out.push(`**[${formatTimestamp(bufStart)}]** ${buf.join(" ")}`);
95
+ buf = [];
96
+ }
97
+ }
98
+ if (buf.length)
99
+ out.push(`**[${formatTimestamp(bufStart)}]** ${buf.join(" ")}`);
100
+ return out.join("\n\n");
101
+ }
102
+ export async function groqTranscribe(bytes, mime, options = {}) {
103
+ const key = process.env.GROQ_API_KEY;
104
+ if (!key)
105
+ throw new Error("GROQ_API_KEY not set");
106
+ const form = new FormData();
107
+ const name = options.fileName?.split("/").pop() || `media.${mime.split("/")[1] || "bin"}`;
108
+ form.append("file", new Blob([bytes], { type: mime }), name);
109
+ form.append("model", process.env.WEBCRAWL_TRANSCRIBE_MODEL || DEFAULT_TRANSCRIBE_MODEL);
110
+ form.append("response_format", "verbose_json");
111
+ form.append("timestamp_granularities[]", "segment");
112
+ if (options.language)
113
+ form.append("language", options.language);
114
+ const res = await fetch(GROQ_URL, {
115
+ method: "POST",
116
+ headers: { Authorization: `Bearer ${key}` },
117
+ body: form,
118
+ signal: options.signal ?? AbortSignal.timeout(300_000),
119
+ });
120
+ if (!res.ok) {
121
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
122
+ throw new Error(`Groq transcription ${res.status}: ${detail}`);
123
+ }
124
+ const json = (await res.json());
125
+ const segments = (json.segments ?? []).map((s) => ({ start: s.start, end: s.end, text: s.text.trim() }));
126
+ if (segments.length === 0 && json.text?.trim()) {
127
+ segments.push({ start: 0, end: json.duration ?? 0, text: json.text.trim() });
128
+ }
129
+ const duration = json.duration ?? segments[segments.length - 1]?.end ?? 0;
130
+ return { segments, duration, language: json.language };
131
+ }
132
+ async function describeVideo(bytes, mime, signal) {
133
+ const key = gatewayKey();
134
+ if (!key)
135
+ throw new Error("no vision model configured");
136
+ if (bytes.byteLength > MAX_VIDEO_DESCRIBE_BYTES)
137
+ throw new Error("video too large to describe");
138
+ const gw = createGateway({ apiKey: key });
139
+ const { text } = await generateText({
140
+ model: gw(process.env.WEBCRAWL_VISION_MODEL || DEFAULT_VISION_MODEL),
141
+ maxOutputTokens: 1_500,
142
+ abortSignal: signal ?? AbortSignal.timeout(120_000),
143
+ // Low resolution is ~66 tokens/frame instead of ~258: plenty for a scene summary, ~4x cheaper.
144
+ providerOptions: { google: { mediaResolution: "MEDIA_RESOLUTION_LOW" } },
145
+ messages: [
146
+ {
147
+ role: "user",
148
+ content: [
149
+ { type: "file", data: bytes, mediaType: mime },
150
+ {
151
+ type: "text",
152
+ text: "Describe what is shown on screen in this video in 3-6 plain sentences: setting, people, on-screen text, slides or charts. Do not transcribe speech.",
153
+ },
154
+ ],
155
+ },
156
+ ],
157
+ });
158
+ return text.trim();
159
+ }
160
+ export async function parseMedia(bytes, kind, mime, options = {}) {
161
+ const started = performance.now();
162
+ const warnings = [];
163
+ const title = options.fileName?.split("/").pop()?.replace(/\.[a-z0-9]+$/i, "") || "";
164
+ let segments = [];
165
+ let duration;
166
+ let provider;
167
+ if (options.captions?.trim()) {
168
+ segments = parseCaptions(options.captions);
169
+ if (segments.length) {
170
+ provider = "captions";
171
+ duration = segments[segments.length - 1].end;
172
+ }
173
+ else {
174
+ warnings.push("Captions were provided but contained no cues; transcribing instead");
175
+ }
176
+ }
177
+ if (!provider) {
178
+ if (!transcriptionAvailable()) {
179
+ warnings.push("Transcript unavailable: no transcription provider configured");
180
+ }
181
+ else {
182
+ // Groq turbo (if configured) is ~2.5x cheaper than gateway grok-stt; the gateway is billed 2 cr/min.
183
+ const attempts = [];
184
+ if (process.env.GROQ_API_KEY)
185
+ attempts.push({ name: "groq-whisper-turbo", run: () => groqTranscribe(bytes, mime, options) });
186
+ if (gatewayKey())
187
+ attempts.push({ name: "gateway-stt", run: () => gatewayTranscribe(bytes, options) });
188
+ const errors = [];
189
+ for (const a of attempts) {
190
+ try {
191
+ const r = await a.run();
192
+ segments = r.segments;
193
+ duration = r.duration;
194
+ provider = a.name;
195
+ if (!segments.length)
196
+ warnings.push("No speech detected");
197
+ break;
198
+ }
199
+ catch (err) {
200
+ errors.push(`${a.name}: ${err instanceof Error ? err.message : String(err)}`);
201
+ }
202
+ }
203
+ if (!provider)
204
+ warnings.push(`Transcription failed: ${errors.join("; ")}`);
205
+ }
206
+ }
207
+ const parts = [];
208
+ let visionProvider;
209
+ if (kind === "video" && options.describeVideo) {
210
+ try {
211
+ const desc = await describeVideo(bytes, mime, options.signal);
212
+ if (desc) {
213
+ parts.push(`## What's on screen\n\n${desc}`);
214
+ visionProvider = "gemini-flash-lite";
215
+ }
216
+ }
217
+ catch (err) {
218
+ warnings.push(`Video description skipped: ${err instanceof Error ? err.message : String(err)}`);
219
+ }
220
+ }
221
+ if (segments.length)
222
+ parts.push(`## Transcript\n\n${transcriptMarkdown(segments)}`);
223
+ return {
224
+ title,
225
+ markdown: parts.join("\n\n"),
226
+ meta: {
227
+ kind,
228
+ mime,
229
+ method: "transcript",
230
+ bytes: bytes.byteLength,
231
+ ...(duration !== undefined ? { durationSeconds: Math.round(duration * 10) / 10 } : {}),
232
+ ...(provider ? { transcriptProvider: provider } : {}),
233
+ ...(visionProvider ? { visionProvider } : {}),
234
+ warnings,
235
+ processingMs: Math.round(performance.now() - started),
236
+ },
237
+ };
238
+ }
@@ -0,0 +1,19 @@
1
+ /** Pure helpers for the durable parse workflow: batch PDF pages, then merge results. */
2
+ import type { ParseMeta } from "./types.js";
3
+ export interface Chunk {
4
+ title: string;
5
+ markdown: string;
6
+ meta: ParseMeta;
7
+ cached: boolean;
8
+ }
9
+ /** Splits the pages to read into fixed-size batches. */
10
+ export declare function pdfBatches(pageCount: number, selected: number[] | undefined, size: number): number[][];
11
+ /** Merges per-batch PDF results back into one document, in page order. */
12
+ export declare function mergePdfChunks(chunks: Chunk[], info: {
13
+ pageCount?: number;
14
+ title?: string;
15
+ }): {
16
+ title: string;
17
+ markdown: string;
18
+ meta: ParseMeta;
19
+ };
@@ -0,0 +1,48 @@
1
+ /** Splits the pages to read into fixed-size batches. */
2
+ export function pdfBatches(pageCount, selected, size) {
3
+ const pages = selected?.length
4
+ ? [...new Set(selected)].filter((p) => p >= 1 && p <= pageCount).sort((a, b) => a - b)
5
+ : Array.from({ length: pageCount }, (_, i) => i + 1);
6
+ const out = [];
7
+ for (let i = 0; i < pages.length; i += size)
8
+ out.push(pages.slice(i, i + size));
9
+ return out;
10
+ }
11
+ /** Merges per-batch PDF results back into one document, in page order. */
12
+ export function mergePdfChunks(chunks, info) {
13
+ const nums = (pick) => chunks.flatMap((c) => pick(c.meta) ?? []).sort((a, b) => a - b);
14
+ const skipped = chunks.flatMap((c) => c.meta.pagesSkipped ?? []).sort((a, b) => a.page - b.page);
15
+ const pagesNative = nums((m) => m.pagesNative);
16
+ const pagesOcr = nums((m) => m.pagesOcr);
17
+ const parts = chunks.map((c) => c.markdown).filter(Boolean);
18
+ // Single-page batches have no page marker; add one so the merged document is navigable.
19
+ const markdown = chunks
20
+ .map((c) => {
21
+ if (!c.markdown)
22
+ return "";
23
+ if (c.markdown.startsWith("<!-- page "))
24
+ return c.markdown;
25
+ const p = [...(c.meta.pagesNative ?? []), ...(c.meta.pagesOcr ?? [])].sort((a, b) => a - b)[0];
26
+ return parts.length > 1 && p ? `<!-- page ${p} -->\n\n${c.markdown}` : c.markdown;
27
+ })
28
+ .filter(Boolean)
29
+ .join("\n\n");
30
+ const first = chunks[0]?.meta;
31
+ return {
32
+ title: info.title || chunks.find((c) => c.title)?.title || "",
33
+ markdown,
34
+ meta: {
35
+ kind: "pdf",
36
+ mime: "application/pdf",
37
+ method: pagesOcr.length > pagesNative.length ? "ocr" : "native",
38
+ bytes: first?.bytes ?? 0,
39
+ pages: info.pageCount,
40
+ pagesNative,
41
+ pagesOcr,
42
+ pagesSkipped: skipped,
43
+ ocrProvider: chunks.map((c) => c.meta.ocrProvider).find(Boolean),
44
+ warnings: [...new Set(chunks.flatMap((c) => c.meta.warnings))],
45
+ processingMs: chunks.reduce((n, c) => n + c.meta.processingMs, 0),
46
+ },
47
+ };
48
+ }
@@ -0,0 +1,25 @@
1
+ export interface OcrPage {
2
+ /** 1-indexed. */
3
+ page: number;
4
+ markdown: string;
5
+ }
6
+ export interface OcrResult {
7
+ provider: string;
8
+ pages: OcrPage[];
9
+ }
10
+ export interface OcrInput {
11
+ bytes: Uint8Array;
12
+ mime: string;
13
+ /** 1-indexed pages to read (PDF only). Omit for images or the whole file. */
14
+ pages?: number[];
15
+ signal?: AbortSignal;
16
+ }
17
+ export declare function ocrAvailable(): boolean;
18
+ export declare function mistralOcr(input: OcrInput): Promise<OcrResult>;
19
+ /** Splits "<<<PAGE n>>>" sections; keeps only pages that were asked for. */
20
+ export declare function splitPageMarkers(text: string, wanted?: number[]): OcrPage[];
21
+ export declare function geminiOcr(input: OcrInput): Promise<OcrResult>;
22
+ /** Tries Mistral, then Gemini. Throws only if every configured provider fails. */
23
+ export declare function runOcr(input: OcrInput): Promise<OcrResult & {
24
+ warnings: string[];
25
+ }>;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * OCR for scanned PDF pages and images.
3
+ * Primary: Mistral OCR (MISTRAL_API_KEY). Fallback: Gemini Flash-Lite via AI Gateway.
4
+ * Each provider returns only the pages it actually read; callers report the rest as skipped.
5
+ */
6
+ import { createGateway } from "@ai-sdk/gateway";
7
+ import { generateText } from "ai";
8
+ const MISTRAL_URL = "https://api.mistral.ai/v1/ocr";
9
+ const DEFAULT_MISTRAL_MODEL = "mistral-ocr-latest";
10
+ const DEFAULT_VISION_MODEL = "google/gemini-2.5-flash-lite";
11
+ function gatewayKey() {
12
+ return process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN;
13
+ }
14
+ export function ocrAvailable() {
15
+ return Boolean(process.env.MISTRAL_API_KEY || gatewayKey());
16
+ }
17
+ function toBase64(bytes) {
18
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
19
+ }
20
+ /** Mistral replaces figures with `![img-0.jpeg](img-0.jpeg)` placeholders we can't serve. */
21
+ function stripPlaceholders(md) {
22
+ return md.replace(/!\[[^\]]*\]\((img-\d+\.\w+)\)\s*/g, "").trim();
23
+ }
24
+ export async function mistralOcr(input) {
25
+ const key = process.env.MISTRAL_API_KEY;
26
+ if (!key)
27
+ throw new Error("MISTRAL_API_KEY not set");
28
+ const isImage = input.mime.startsWith("image/");
29
+ const dataUrl = `data:${input.mime};base64,${toBase64(input.bytes)}`;
30
+ const body = {
31
+ model: process.env.WEBCRAWL_OCR_MODEL || DEFAULT_MISTRAL_MODEL,
32
+ document: isImage ? { type: "image_url", image_url: dataUrl } : { type: "document_url", document_url: dataUrl },
33
+ include_image_base64: false,
34
+ };
35
+ if (!isImage && input.pages?.length)
36
+ body.pages = input.pages.map((p) => p - 1);
37
+ const res = await fetch(MISTRAL_URL, {
38
+ method: "POST",
39
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
40
+ body: JSON.stringify(body),
41
+ signal: input.signal ?? AbortSignal.timeout(120_000),
42
+ });
43
+ if (!res.ok) {
44
+ const detail = (await res.text().catch(() => "")).slice(0, 300);
45
+ throw new Error(`Mistral OCR ${res.status}: ${detail}`);
46
+ }
47
+ const json = (await res.json());
48
+ return {
49
+ provider: "mistral",
50
+ pages: (json.pages ?? []).map((p) => ({ page: p.index + 1, markdown: stripPlaceholders(p.markdown || "") })),
51
+ };
52
+ }
53
+ const PAGE_MARK = /<<<PAGE (\d+)>>>/g;
54
+ /** Splits "<<<PAGE n>>>" sections; keeps only pages that were asked for. */
55
+ export function splitPageMarkers(text, wanted) {
56
+ const marks = [...text.matchAll(PAGE_MARK)];
57
+ if (marks.length === 0) {
58
+ const md = text.trim();
59
+ return md && (!wanted || wanted.length === 1) ? [{ page: wanted?.[0] ?? 1, markdown: md }] : [];
60
+ }
61
+ const allowed = wanted ? new Set(wanted) : null;
62
+ const out = [];
63
+ marks.forEach((m, i) => {
64
+ const page = Number(m[1]);
65
+ const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
66
+ const md = text.slice((m.index ?? 0) + m[0].length, end).trim();
67
+ if (md && (!allowed || allowed.has(page)))
68
+ out.push({ page, markdown: md });
69
+ });
70
+ return out;
71
+ }
72
+ export async function geminiOcr(input) {
73
+ const key = gatewayKey();
74
+ if (!key)
75
+ throw new Error("AI gateway credentials not set");
76
+ const gw = createGateway({ apiKey: key });
77
+ const isImage = input.mime.startsWith("image/");
78
+ const pageList = input.pages?.length ? input.pages.join(", ") : "all";
79
+ const instruction = isImage
80
+ ? "Transcribe all text in this image as GitHub-flavoured Markdown. Keep headings, lists and tables. Output only the transcription; output nothing if there is no text."
81
+ : `Transcribe pages ${pageList} of this PDF as GitHub-flavoured Markdown. Keep headings, lists and tables (as Markdown tables). ` +
82
+ "Start each page with a line <<<PAGE n>>> where n is the 1-based page number. Output only the transcription. Do not summarise or invent text.";
83
+ const { text } = await generateText({
84
+ model: gw(process.env.WEBCRAWL_VISION_MODEL || DEFAULT_VISION_MODEL),
85
+ maxOutputTokens: 32_000,
86
+ abortSignal: input.signal ?? AbortSignal.timeout(120_000),
87
+ messages: [
88
+ {
89
+ role: "user",
90
+ content: [
91
+ { type: "file", data: input.bytes, mediaType: input.mime },
92
+ { type: "text", text: instruction },
93
+ ],
94
+ },
95
+ ],
96
+ });
97
+ const pages = isImage ? (text.trim() ? [{ page: 1, markdown: text.trim() }] : []) : splitPageMarkers(text, input.pages);
98
+ return { provider: "gemini-flash-lite", pages };
99
+ }
100
+ /** Tries Mistral, then Gemini. Throws only if every configured provider fails. */
101
+ export async function runOcr(input) {
102
+ const warnings = [];
103
+ const providers = [];
104
+ if (process.env.MISTRAL_API_KEY)
105
+ providers.push(() => mistralOcr(input));
106
+ if (gatewayKey())
107
+ providers.push(() => geminiOcr(input));
108
+ if (providers.length === 0)
109
+ throw new Error("No OCR provider configured");
110
+ let lastErr;
111
+ for (const run of providers) {
112
+ try {
113
+ return { ...(await run()), warnings };
114
+ }
115
+ catch (err) {
116
+ lastErr = err;
117
+ warnings.push(err instanceof Error ? err.message : String(err));
118
+ }
119
+ }
120
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
121
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * DOCX / XLSX / PPTX -> Markdown, locally with officeparser (no network, no model).
3
+ * Sheets and slides get their own headings so a reader can tell where content came from.
4
+ */
5
+ import type { FileKind } from "./detect.js";
6
+ import type { ParsedFile } from "./types.js";
7
+ type OfficeKind = Extract<FileKind, "docx" | "xlsx" | "pptx">;
8
+ interface AstNode {
9
+ type: string;
10
+ metadata?: {
11
+ sheetName?: string;
12
+ slideNumber?: number;
13
+ };
14
+ }
15
+ /** Removes YAML front matter and `{#anchor}` heading ids officeparser adds. */
16
+ export declare function cleanOfficeMarkdown(md: string): string;
17
+ /**
18
+ * officeparser separates slides / sheets with `---` lines. Split on those so each
19
+ * section can be labelled with the slide number or sheet name from the AST.
20
+ */
21
+ export declare function splitSections(md: string): string[];
22
+ export declare function labelSections(kind: OfficeKind, sections: string[], nodes: AstNode[]): {
23
+ markdown: string;
24
+ units: number;
25
+ empty: string[];
26
+ };
27
+ export declare function parseOffice(bytes: Uint8Array, kind: OfficeKind, mime: string): Promise<ParsedFile>;
28
+ export {};
@@ -0,0 +1,83 @@
1
+ /** Removes YAML front matter and `{#anchor}` heading ids officeparser adds. */
2
+ export function cleanOfficeMarkdown(md) {
3
+ return md
4
+ // Only strip a leading block that is really YAML (every line `key: value`); PPTX output
5
+ // also starts with `---`, and there the block is slide 1.
6
+ .replace(/^---\n((?:[\w:-]+:.*\n)+)---\n?/, "")
7
+ .replace(/^(#{1,6} .*?)[ \t]*\{#[^}]+\}[ \t]*$/gm, "$1")
8
+ .replace(/\n{3,}/g, "\n\n")
9
+ .trim();
10
+ }
11
+ /**
12
+ * officeparser separates slides / sheets with `---` lines. Split on those so each
13
+ * section can be labelled with the slide number or sheet name from the AST.
14
+ */
15
+ export function splitSections(md) {
16
+ return md
17
+ .split(/^---\s*$/m)
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ }
21
+ export function labelSections(kind, sections, nodes) {
22
+ if (kind === "docx")
23
+ return { markdown: sections.join("\n\n---\n\n"), units: 1, empty: [] };
24
+ const unitType = kind === "xlsx" ? "sheet" : "slide";
25
+ const units = nodes.filter((n) => n.type === unitType);
26
+ const empty = [];
27
+ const out = [];
28
+ // Empty sheets/slides produce no section, so match non-empty sections to units in order.
29
+ let si = 0;
30
+ units.forEach((u, i) => {
31
+ const label = kind === "xlsx"
32
+ ? `## Sheet: ${u.metadata?.sheetName || `Sheet ${i + 1}`}`
33
+ : `<!-- slide ${u.metadata?.slideNumber ?? i + 1} -->`;
34
+ const isEmpty = kind === "xlsx" ? u.children?.length === 0 : false;
35
+ if (isEmpty) {
36
+ empty.push(u.metadata?.sheetName || `Sheet ${i + 1}`);
37
+ return;
38
+ }
39
+ const body = sections[si++];
40
+ if (body !== undefined)
41
+ out.push(`${label}\n\n${body}`);
42
+ });
43
+ // Anything left over (AST and Markdown disagree) is kept rather than dropped.
44
+ while (si < sections.length)
45
+ out.push(sections[si++]);
46
+ return { markdown: out.join("\n\n"), units: units.length || sections.length, empty };
47
+ }
48
+ export async function parseOffice(bytes, kind, mime) {
49
+ const started = performance.now();
50
+ const { OfficeConverter, OfficeParser } = await import("officeparser");
51
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52
+ const parseConfig = { fileType: kind, ignoreNotes: false };
53
+ const [converted, ast] = await Promise.all([
54
+ OfficeConverter.convert(buf, "md", { parseConfig }),
55
+ OfficeParser.parseOffice(buf, parseConfig),
56
+ ]);
57
+ const cleaned = cleanOfficeMarkdown(String(converted.value ?? ""));
58
+ const nodes = (ast.content ?? []);
59
+ const { markdown, units, empty } = labelSections(kind, splitSections(cleaned), nodes);
60
+ const warnings = (converted.messages ?? [])
61
+ .filter((m) => m.type === "warning")
62
+ .map((m) => String(m.message ?? m))
63
+ .slice(0, 10);
64
+ if (empty.length)
65
+ warnings.push(`Empty sheets: ${empty.join(", ")}`);
66
+ const title = ast.metadata?.title?.trim() ||
67
+ markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() ||
68
+ "";
69
+ return {
70
+ title,
71
+ markdown,
72
+ meta: {
73
+ kind,
74
+ mime,
75
+ method: "native",
76
+ bytes: bytes.byteLength,
77
+ // "pages" means sheets for XLSX and slides for PPTX; DOCX has no reliable page count.
78
+ ...(kind === "docx" ? {} : { pages: units }),
79
+ warnings,
80
+ processingMs: Math.round(performance.now() - started),
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,27 @@
1
+ import type { ParsedFile, PdfParseOptions, SkippedPage } from "./types.js";
2
+ export interface PdfPlan {
3
+ pageCount: number;
4
+ /** 1-indexed pages to read natively. */
5
+ native: number[];
6
+ /** 1-indexed pages to OCR. */
7
+ ocr: number[];
8
+ skipped: SkippedPage[];
9
+ }
10
+ /**
11
+ * Decides which pages go where. Pure, so it can be unit-tested without a PDF.
12
+ */
13
+ export declare function planPdfPages(input: {
14
+ pageCount: number;
15
+ needsOcr: number[];
16
+ options: PdfParseOptions;
17
+ ocrConfigured: boolean;
18
+ }): PdfPlan;
19
+ /** First real-looking h1/h2 (skipping arXiv/DOI stamps and page furniture), else first h3. */
20
+ export declare function pickTitle(markdown: string): string;
21
+ /** Page count and pages needing OCR, without extracting text (cheap: ~1 ms/page). */
22
+ export declare function pdfPageInfo(bytes: Uint8Array): {
23
+ pageCount: number;
24
+ pagesNeedingOcr: number[];
25
+ title?: string;
26
+ };
27
+ export declare function parsePdf(bytes: Uint8Array, options?: PdfParseOptions): Promise<ParsedFile>;