webcrawl 1.2.4 → 1.2.5
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/dist/cli.js +3 -2
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/package.json +82 -4
|
@@ -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 `` 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>;
|
|
@@ -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>;
|