@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.
- package/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -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 +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -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/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -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 +9 -0
- package/dist/core/extract/index.js +9 -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/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -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 +72 -0
- package/dist/core/interact/driver.js +384 -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 +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -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 +643 -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 +222 -0
- package/dist/core/search/catalog.d.ts +176 -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 +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -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/dist/format.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +3 -0
- package/package.json +11 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Works out what kind of file a response or upload is, from magic bytes first,
|
|
3
|
+
* then the declared content type, then the file extension.
|
|
4
|
+
*/
|
|
5
|
+
export type FileKind = "pdf" | "docx" | "xlsx" | "pptx" | "image" | "audio" | "video" | "html" | "text";
|
|
6
|
+
/** Kinds that go through the file parser instead of the HTML pipeline. */
|
|
7
|
+
export declare const BINARY_KINDS: ReadonlySet<FileKind>;
|
|
8
|
+
/** Content type to send to providers when the declared one is missing or generic. */
|
|
9
|
+
export declare const KIND_DEFAULT_MIME: Partial<Record<FileKind, string>>;
|
|
10
|
+
/** Returns the kind and a concrete mime from the first bytes, or null when unknown. */
|
|
11
|
+
export declare function sniffMagic(bytes: Uint8Array): {
|
|
12
|
+
kind: FileKind;
|
|
13
|
+
mime: string;
|
|
14
|
+
} | null;
|
|
15
|
+
export interface DetectInput {
|
|
16
|
+
bytes?: Uint8Array;
|
|
17
|
+
contentType?: string;
|
|
18
|
+
fileName?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface DetectedFile {
|
|
21
|
+
kind: FileKind;
|
|
22
|
+
/** Best concrete mime for the bytes (used when calling providers). */
|
|
23
|
+
mime: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Magic bytes win over the declared type (servers often send PDFs as
|
|
27
|
+
* octet-stream or text/html); then the declared type; then the extension.
|
|
28
|
+
*/
|
|
29
|
+
export declare function detectFileKind({ bytes, contentType, fileName }: DetectInput): DetectedFile;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Works out what kind of file a response or upload is, from magic bytes first,
|
|
3
|
+
* then the declared content type, then the file extension.
|
|
4
|
+
*/
|
|
5
|
+
/** Kinds that go through the file parser instead of the HTML pipeline. */
|
|
6
|
+
export const BINARY_KINDS = new Set(["pdf", "docx", "xlsx", "pptx", "image", "audio", "video"]);
|
|
7
|
+
const MIME_KINDS = [
|
|
8
|
+
[/^application\/pdf$/, "pdf"],
|
|
9
|
+
[/^application\/vnd\.openxmlformats-officedocument\.wordprocessingml/, "docx"],
|
|
10
|
+
[/^application\/vnd\.openxmlformats-officedocument\.spreadsheetml/, "xlsx"],
|
|
11
|
+
[/^application\/vnd\.openxmlformats-officedocument\.presentationml/, "pptx"],
|
|
12
|
+
[/^image\/svg\+xml$/, "text"],
|
|
13
|
+
[/^image\//, "image"],
|
|
14
|
+
[/^audio\//, "audio"],
|
|
15
|
+
[/^video\//, "video"],
|
|
16
|
+
[/^(text\/html|application\/xhtml\+xml)$/, "html"],
|
|
17
|
+
[/^(text\/|application\/(json|xml|csv|x-ndjson|ld\+json|rss\+xml|atom\+xml|javascript))/, "text"],
|
|
18
|
+
];
|
|
19
|
+
const EXT_KINDS = {
|
|
20
|
+
pdf: "pdf",
|
|
21
|
+
docx: "docx",
|
|
22
|
+
xlsx: "xlsx",
|
|
23
|
+
pptx: "pptx",
|
|
24
|
+
png: "image",
|
|
25
|
+
jpg: "image",
|
|
26
|
+
jpeg: "image",
|
|
27
|
+
webp: "image",
|
|
28
|
+
gif: "image",
|
|
29
|
+
heic: "image",
|
|
30
|
+
mp3: "audio",
|
|
31
|
+
wav: "audio",
|
|
32
|
+
m4a: "audio",
|
|
33
|
+
ogg: "audio",
|
|
34
|
+
oga: "audio",
|
|
35
|
+
flac: "audio",
|
|
36
|
+
opus: "audio",
|
|
37
|
+
aac: "audio",
|
|
38
|
+
mp4: "video",
|
|
39
|
+
m4v: "video",
|
|
40
|
+
mov: "video",
|
|
41
|
+
webm: "video",
|
|
42
|
+
mkv: "video",
|
|
43
|
+
html: "html",
|
|
44
|
+
htm: "html",
|
|
45
|
+
txt: "text",
|
|
46
|
+
md: "text",
|
|
47
|
+
csv: "text",
|
|
48
|
+
json: "text",
|
|
49
|
+
xml: "text",
|
|
50
|
+
};
|
|
51
|
+
/** Content type to send to providers when the declared one is missing or generic. */
|
|
52
|
+
export const KIND_DEFAULT_MIME = {
|
|
53
|
+
pdf: "application/pdf",
|
|
54
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
55
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
56
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
57
|
+
};
|
|
58
|
+
function ascii(bytes, start, length) {
|
|
59
|
+
let s = "";
|
|
60
|
+
for (let i = start; i < Math.min(bytes.length, start + length); i++)
|
|
61
|
+
s += String.fromCharCode(bytes[i]);
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
/** Finds which OOXML package a ZIP is by the part names in its local file headers. */
|
|
65
|
+
function sniffOoxml(bytes) {
|
|
66
|
+
const head = ascii(bytes, 0, Math.min(bytes.length, 64 * 1024));
|
|
67
|
+
if (head.includes("word/"))
|
|
68
|
+
return "docx";
|
|
69
|
+
if (head.includes("xl/"))
|
|
70
|
+
return "xlsx";
|
|
71
|
+
if (head.includes("ppt/"))
|
|
72
|
+
return "pptx";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/** Returns the kind and a concrete mime from the first bytes, or null when unknown. */
|
|
76
|
+
export function sniffMagic(bytes) {
|
|
77
|
+
if (bytes.length < 4)
|
|
78
|
+
return null;
|
|
79
|
+
const b = bytes;
|
|
80
|
+
if (ascii(b, 0, 5) === "%PDF-")
|
|
81
|
+
return { kind: "pdf", mime: "application/pdf" };
|
|
82
|
+
if (b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04) {
|
|
83
|
+
const kind = sniffOoxml(b);
|
|
84
|
+
return kind ? { kind, mime: KIND_DEFAULT_MIME[kind] } : null;
|
|
85
|
+
}
|
|
86
|
+
if (b[0] === 0x89 && ascii(b, 1, 3) === "PNG")
|
|
87
|
+
return { kind: "image", mime: "image/png" };
|
|
88
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
89
|
+
return { kind: "image", mime: "image/jpeg" };
|
|
90
|
+
if (ascii(b, 0, 4) === "GIF8")
|
|
91
|
+
return { kind: "image", mime: "image/gif" };
|
|
92
|
+
if (ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP")
|
|
93
|
+
return { kind: "image", mime: "image/webp" };
|
|
94
|
+
if (ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WAVE")
|
|
95
|
+
return { kind: "audio", mime: "audio/wav" };
|
|
96
|
+
if (ascii(b, 0, 3) === "ID3" || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0 && (b[1] & 0x06) !== 0)) {
|
|
97
|
+
return { kind: "audio", mime: "audio/mpeg" };
|
|
98
|
+
}
|
|
99
|
+
if (ascii(b, 0, 4) === "fLaC")
|
|
100
|
+
return { kind: "audio", mime: "audio/flac" };
|
|
101
|
+
if (ascii(b, 0, 4) === "OggS")
|
|
102
|
+
return { kind: "audio", mime: "audio/ogg" };
|
|
103
|
+
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3)
|
|
104
|
+
return { kind: "video", mime: "video/webm" };
|
|
105
|
+
if (ascii(b, 4, 4) === "ftyp") {
|
|
106
|
+
const brand = ascii(b, 8, 4);
|
|
107
|
+
if (/^(heic|heix|mif1|avif)/.test(brand))
|
|
108
|
+
return { kind: "image", mime: brand === "avif" ? "image/avif" : "image/heic" };
|
|
109
|
+
if (/^(M4A |M4B )/.test(brand))
|
|
110
|
+
return { kind: "audio", mime: "audio/mp4" };
|
|
111
|
+
if (/^qt/.test(brand))
|
|
112
|
+
return { kind: "video", mime: "video/quicktime" };
|
|
113
|
+
return { kind: "video", mime: "video/mp4" };
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function extensionOf(name) {
|
|
118
|
+
if (!name)
|
|
119
|
+
return "";
|
|
120
|
+
const clean = name.split(/[?#]/)[0];
|
|
121
|
+
const dot = clean.lastIndexOf(".");
|
|
122
|
+
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Magic bytes win over the declared type (servers often send PDFs as
|
|
126
|
+
* octet-stream or text/html); then the declared type; then the extension.
|
|
127
|
+
*/
|
|
128
|
+
export function detectFileKind({ bytes, contentType, fileName }) {
|
|
129
|
+
const declared = (contentType || "").split(";")[0].trim().toLowerCase();
|
|
130
|
+
const magic = bytes ? sniffMagic(bytes) : null;
|
|
131
|
+
if (magic)
|
|
132
|
+
return magic;
|
|
133
|
+
const generic = !declared || /^(application\/octet-stream|binary\/octet-stream|application\/zip|application\/x-zip-compressed)$/.test(declared);
|
|
134
|
+
if (!generic) {
|
|
135
|
+
for (const [re, kind] of MIME_KINDS)
|
|
136
|
+
if (re.test(declared))
|
|
137
|
+
return { kind, mime: declared };
|
|
138
|
+
}
|
|
139
|
+
const ext = extensionOf(fileName);
|
|
140
|
+
const byExt = EXT_KINDS[ext];
|
|
141
|
+
if (byExt) {
|
|
142
|
+
const media = byExt === "image" || byExt === "audio" || byExt === "video";
|
|
143
|
+
const sub = { mp3: "mpeg", jpg: "jpeg", mov: "quicktime", m4a: "mp4", m4v: "mp4", oga: "ogg" }[ext] || ext;
|
|
144
|
+
return { kind: byExt, mime: KIND_DEFAULT_MIME[byExt] || (media ? `${byExt}/${sub}` : declared || "text/plain") };
|
|
145
|
+
}
|
|
146
|
+
if (bytes) {
|
|
147
|
+
const head = ascii(bytes, 0, 512).toLowerCase();
|
|
148
|
+
if (/<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]/.test(head))
|
|
149
|
+
return { kind: "html", mime: "text/html" };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "text", mime: declared || "text/plain" };
|
|
152
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a URL as bytes and parse it if it is a binary file (PDF, Office, image, media).
|
|
3
|
+
* Returns null when the URL is HTML/text so callers fall back to the HTML pipeline.
|
|
4
|
+
*/
|
|
5
|
+
import { type FetchedResource } from "../engine/fetcher.js";
|
|
6
|
+
import { type FileKind } from "./detect.js";
|
|
7
|
+
import { type ParseFileOptions, type ParseFileResult } from "./index.js";
|
|
8
|
+
/** Cheap check before any network: does the URL path end in a binary file extension? */
|
|
9
|
+
export declare function looksLikeBinaryUrl(url: string): boolean;
|
|
10
|
+
/** Does a response content type mean we should take the binary path? */
|
|
11
|
+
export declare function isBinaryContentType(contentType: string | undefined): boolean;
|
|
12
|
+
export interface FetchedParse {
|
|
13
|
+
resource: FetchedResource;
|
|
14
|
+
kind: FileKind;
|
|
15
|
+
result: ParseFileResult;
|
|
16
|
+
}
|
|
17
|
+
export declare function fetchAndParse(url: string, options?: ParseFileOptions & {
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
resource?: FetchedResource;
|
|
20
|
+
}): Promise<FetchedParse | null>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a URL as bytes and parse it if it is a binary file (PDF, Office, image, media).
|
|
3
|
+
* Returns null when the URL is HTML/text so callers fall back to the HTML pipeline.
|
|
4
|
+
*/
|
|
5
|
+
import { fetchResource } from "../engine/fetcher.js";
|
|
6
|
+
import { detectFileKind } from "./detect.js";
|
|
7
|
+
import { maxDownloadBytes } from "./limits.js";
|
|
8
|
+
import { isBinaryKind, parseFile } from "./index.js";
|
|
9
|
+
const BINARY_EXT = /\.(pdf|docx|xlsx|pptx|png|jpe?g|webp|gif|heic|avif|mp3|wav|m4a|ogg|oga|flac|opus|aac|mp4|m4v|mov|webm|mkv)$/i;
|
|
10
|
+
/** Cheap check before any network: does the URL path end in a binary file extension? */
|
|
11
|
+
export function looksLikeBinaryUrl(url) {
|
|
12
|
+
try {
|
|
13
|
+
return BINARY_EXT.test(new URL(url).pathname);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return BINARY_EXT.test(url.split(/[?#]/)[0]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Does a response content type mean we should take the binary path? */
|
|
20
|
+
export function isBinaryContentType(contentType) {
|
|
21
|
+
const ct = (contentType || "").split(";")[0].trim().toLowerCase();
|
|
22
|
+
if (!ct)
|
|
23
|
+
return false;
|
|
24
|
+
const { kind } = detectFileKind({ contentType: ct });
|
|
25
|
+
return isBinaryKind(kind) || ct === "application/octet-stream";
|
|
26
|
+
}
|
|
27
|
+
export async function fetchAndParse(url, options = {}) {
|
|
28
|
+
const resource = options.resource ??
|
|
29
|
+
(await fetchResource(url, { timeoutMs: options.timeoutMs ?? 30_000, maxBytes: maxDownloadBytes() }));
|
|
30
|
+
const fileName = new URL(resource.url || url).pathname.split("/").pop() || undefined;
|
|
31
|
+
const { kind } = detectFileKind({ bytes: resource.body, contentType: resource.contentType, fileName });
|
|
32
|
+
if (!isBinaryKind(kind))
|
|
33
|
+
return null;
|
|
34
|
+
if (resource.status >= 400) {
|
|
35
|
+
throw new Error(`File request failed with HTTP ${resource.status}`);
|
|
36
|
+
}
|
|
37
|
+
const result = await parseFile({ bytes: resource.body, contentType: resource.contentType, fileName }, options);
|
|
38
|
+
return { resource, kind, result };
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ParsedFile } from "./types.js";
|
|
2
|
+
export declare const VISION_PROVIDER = "gemini-flash-lite";
|
|
3
|
+
export declare function visionAvailable(): boolean;
|
|
4
|
+
/** Splits the model reply into description and text. Tolerates a missing TEXT marker. */
|
|
5
|
+
export declare function splitVisionReply(reply: string): {
|
|
6
|
+
description: string;
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function imageMarkdown(description: string, text: string): string;
|
|
10
|
+
export declare function parseImage(bytes: Uint8Array, mime: string, options?: {
|
|
11
|
+
fileName?: string;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
}): Promise<ParsedFile>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images -> a short description plus any text in the image, from Gemini Flash-Lite
|
|
3
|
+
* through AI Gateway. One call per image. Without credentials nothing is invented:
|
|
4
|
+
* the result is empty with a warning.
|
|
5
|
+
*/
|
|
6
|
+
import { createGateway } from "@ai-sdk/gateway";
|
|
7
|
+
import { generateText } from "ai";
|
|
8
|
+
const DEFAULT_VISION_MODEL = "google/gemini-2.5-flash-lite";
|
|
9
|
+
export const VISION_PROVIDER = "gemini-flash-lite";
|
|
10
|
+
function gatewayKey() {
|
|
11
|
+
return process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN;
|
|
12
|
+
}
|
|
13
|
+
export function visionAvailable() {
|
|
14
|
+
return Boolean(gatewayKey());
|
|
15
|
+
}
|
|
16
|
+
const PROMPT = [
|
|
17
|
+
"Describe this image for someone who cannot see it, then transcribe any text in it.",
|
|
18
|
+
"Reply in exactly this format:",
|
|
19
|
+
"<<<DESCRIPTION>>>",
|
|
20
|
+
"Two to four plain sentences: what the image shows, and the key facts a reader needs (numbers in charts, labels in diagrams).",
|
|
21
|
+
"<<<TEXT>>>",
|
|
22
|
+
"All legible text in the image as GitHub-flavoured Markdown (keep tables as tables). Write NONE if there is no text.",
|
|
23
|
+
"Do not guess at text you cannot read.",
|
|
24
|
+
].join("\n");
|
|
25
|
+
/** Splits the model reply into description and text. Tolerates a missing TEXT marker. */
|
|
26
|
+
export function splitVisionReply(reply) {
|
|
27
|
+
const d = reply.indexOf("<<<DESCRIPTION>>>");
|
|
28
|
+
const t = reply.indexOf("<<<TEXT>>>");
|
|
29
|
+
if (d === -1 && t === -1)
|
|
30
|
+
return { description: reply.trim(), text: "" };
|
|
31
|
+
const description = (t === -1 ? reply.slice(d + 17) : reply.slice(d === -1 ? 0 : d + 17, t)).trim();
|
|
32
|
+
let text = t === -1 ? "" : reply.slice(t + 10).trim();
|
|
33
|
+
if (/^none\.?$/i.test(text))
|
|
34
|
+
text = "";
|
|
35
|
+
return { description, text };
|
|
36
|
+
}
|
|
37
|
+
export function imageMarkdown(description, text) {
|
|
38
|
+
const parts = [];
|
|
39
|
+
if (description)
|
|
40
|
+
parts.push(`## Description\n\n${description}`);
|
|
41
|
+
if (text)
|
|
42
|
+
parts.push(`## Text in image\n\n${text}`);
|
|
43
|
+
return parts.join("\n\n");
|
|
44
|
+
}
|
|
45
|
+
export async function parseImage(bytes, mime, options = {}) {
|
|
46
|
+
const started = performance.now();
|
|
47
|
+
const warnings = [];
|
|
48
|
+
const base = {
|
|
49
|
+
kind: "image",
|
|
50
|
+
mime,
|
|
51
|
+
method: "vision",
|
|
52
|
+
bytes: bytes.byteLength,
|
|
53
|
+
};
|
|
54
|
+
const title = options.fileName?.split("/").pop()?.replace(/\.[a-z0-9]+$/i, "") || "";
|
|
55
|
+
const key = gatewayKey();
|
|
56
|
+
if (!key) {
|
|
57
|
+
warnings.push("Image description unavailable: no vision model configured");
|
|
58
|
+
return {
|
|
59
|
+
title,
|
|
60
|
+
markdown: "",
|
|
61
|
+
meta: { ...base, warnings, processingMs: Math.round(performance.now() - started) },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const gw = createGateway({ apiKey: key });
|
|
66
|
+
const { text: reply } = await generateText({
|
|
67
|
+
model: gw(process.env.WEBCRAWL_VISION_MODEL || DEFAULT_VISION_MODEL),
|
|
68
|
+
maxOutputTokens: 4_000,
|
|
69
|
+
abortSignal: options.signal ?? AbortSignal.timeout(60_000),
|
|
70
|
+
messages: [
|
|
71
|
+
{
|
|
72
|
+
role: "user",
|
|
73
|
+
content: [
|
|
74
|
+
{ type: "image", image: bytes, mediaType: mime },
|
|
75
|
+
{ type: "text", text: PROMPT },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
const { description, text } = splitVisionReply(reply);
|
|
81
|
+
return {
|
|
82
|
+
title,
|
|
83
|
+
markdown: imageMarkdown(description, text),
|
|
84
|
+
meta: {
|
|
85
|
+
...base,
|
|
86
|
+
visionProvider: VISION_PROVIDER,
|
|
87
|
+
warnings,
|
|
88
|
+
processingMs: Math.round(performance.now() - started),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
warnings.push(`Image description failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
return {
|
|
95
|
+
title,
|
|
96
|
+
markdown: "",
|
|
97
|
+
meta: { ...base, warnings, processingMs: Math.round(performance.now() - started) },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type FileKind } from "./detect.js";
|
|
2
|
+
import type { ParsedFile, ParseMeta } from "./types.js";
|
|
3
|
+
export { detectFileKind, BINARY_KINDS, type FileKind } from "./detect.js";
|
|
4
|
+
export type { ParsedFile, ParseMeta } from "./types.js";
|
|
5
|
+
export interface ParseFileOptions {
|
|
6
|
+
ocr?: "auto" | "force" | "off";
|
|
7
|
+
pages?: number[];
|
|
8
|
+
maxOcrPages?: number;
|
|
9
|
+
captions?: string;
|
|
10
|
+
language?: string;
|
|
11
|
+
describeVideo?: boolean;
|
|
12
|
+
bypassCache?: boolean;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
export interface ParseFileInput {
|
|
16
|
+
bytes: Uint8Array;
|
|
17
|
+
contentType?: string;
|
|
18
|
+
fileName?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare class FileTooLargeError extends Error {
|
|
21
|
+
readonly kind: FileKind;
|
|
22
|
+
readonly bytes: number;
|
|
23
|
+
readonly maxBytes: number;
|
|
24
|
+
constructor(kind: FileKind, bytes: number, maxBytes: number);
|
|
25
|
+
}
|
|
26
|
+
export declare class UnsupportedFileError extends Error {
|
|
27
|
+
readonly kind: FileKind;
|
|
28
|
+
constructor(kind: FileKind);
|
|
29
|
+
}
|
|
30
|
+
/** Converts parse metadata into billable usage. Cache hits are billed like the original parse. */
|
|
31
|
+
export declare function parseUsageFromMeta(meta: ParseMeta): {
|
|
32
|
+
kind: FileKind;
|
|
33
|
+
method: import("./types.js").ParseMethod;
|
|
34
|
+
pagesNative: number | undefined;
|
|
35
|
+
pagesOcr: number | undefined;
|
|
36
|
+
durationSeconds: number | undefined;
|
|
37
|
+
transcribed: boolean;
|
|
38
|
+
transcriptProvider: string | undefined;
|
|
39
|
+
videoDescribed: boolean;
|
|
40
|
+
imageDescribed: boolean;
|
|
41
|
+
};
|
|
42
|
+
/** Public shape of `metadata.parse` in API responses. */
|
|
43
|
+
export declare function publicParseMeta(r: ParseFileResult): Record<string, unknown>;
|
|
44
|
+
export declare function isBinaryKind(kind: FileKind): boolean;
|
|
45
|
+
export declare function contentHash(bytes: Uint8Array): string;
|
|
46
|
+
/** Only options that change the output go into the cache key. */
|
|
47
|
+
export declare function parseCacheKey(hash: string, kind: FileKind, o: ParseFileOptions): string;
|
|
48
|
+
export interface ParseFileResult extends ParsedFile {
|
|
49
|
+
contentHash: string;
|
|
50
|
+
cached: boolean;
|
|
51
|
+
cacheLayer: "memory" | "disk" | "postgres" | "miss";
|
|
52
|
+
}
|
|
53
|
+
export declare function parseFile(input: ParseFileInput, options?: ParseFileOptions): Promise<ParseFileResult>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry point for binary files: detect the kind, check the size cap, route to the
|
|
3
|
+
* right parser, and cache by content hash (same bytes => same result, whatever the URL).
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { getCachedPageAsync, setCachedPage } from "../engine/cache.js";
|
|
7
|
+
import { BINARY_KINDS, detectFileKind } from "./detect.js";
|
|
8
|
+
import { maxBytesFor } from "./limits.js";
|
|
9
|
+
import { parseImage } from "./image.js";
|
|
10
|
+
import { parseMedia } from "./media.js";
|
|
11
|
+
import { parseOffice } from "./office.js";
|
|
12
|
+
import { parsePdf } from "./pdf.js";
|
|
13
|
+
export { detectFileKind, BINARY_KINDS } from "./detect.js";
|
|
14
|
+
export class FileTooLargeError extends Error {
|
|
15
|
+
kind;
|
|
16
|
+
bytes;
|
|
17
|
+
maxBytes;
|
|
18
|
+
constructor(kind, bytes, maxBytes) {
|
|
19
|
+
super(`${kind.toUpperCase()} is ${(bytes / 1e6).toFixed(1)} MB; the limit is ${(maxBytes / 1e6).toFixed(0)} MB`);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.bytes = bytes;
|
|
22
|
+
this.maxBytes = maxBytes;
|
|
23
|
+
this.name = "FileTooLargeError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class UnsupportedFileError extends Error {
|
|
27
|
+
kind;
|
|
28
|
+
constructor(kind) {
|
|
29
|
+
super(`Not a binary file kind (${kind}); use the HTML/text pipeline`);
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.name = "UnsupportedFileError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Converts parse metadata into billable usage. Cache hits are billed like the original parse. */
|
|
35
|
+
export function parseUsageFromMeta(meta) {
|
|
36
|
+
const isOffice = meta.kind === "docx" || meta.kind === "xlsx" || meta.kind === "pptx";
|
|
37
|
+
return {
|
|
38
|
+
kind: meta.kind,
|
|
39
|
+
method: meta.method,
|
|
40
|
+
pagesNative: meta.pagesNative?.length ?? (isOffice ? meta.pages ?? 1 : undefined),
|
|
41
|
+
pagesOcr: meta.pagesOcr?.length,
|
|
42
|
+
durationSeconds: meta.durationSeconds,
|
|
43
|
+
transcribed: Boolean(meta.transcriptProvider && meta.transcriptProvider !== "captions"),
|
|
44
|
+
transcriptProvider: meta.transcriptProvider,
|
|
45
|
+
videoDescribed: meta.kind === "video" && Boolean(meta.visionProvider),
|
|
46
|
+
imageDescribed: meta.kind === "image" && Boolean(meta.visionProvider),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Public shape of `metadata.parse` in API responses. */
|
|
50
|
+
export function publicParseMeta(r) {
|
|
51
|
+
return { ...r.meta, contentHash: r.contentHash, cached: r.cached };
|
|
52
|
+
}
|
|
53
|
+
export function isBinaryKind(kind) {
|
|
54
|
+
return BINARY_KINDS.has(kind);
|
|
55
|
+
}
|
|
56
|
+
export function contentHash(bytes) {
|
|
57
|
+
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
58
|
+
}
|
|
59
|
+
/** Only options that change the output go into the cache key. */
|
|
60
|
+
export function parseCacheKey(hash, kind, o) {
|
|
61
|
+
const parts = [];
|
|
62
|
+
if (kind === "pdf") {
|
|
63
|
+
parts.push(`ocr=${o.ocr ?? "auto"}`);
|
|
64
|
+
if (o.pages?.length)
|
|
65
|
+
parts.push(`pages=${o.pages.join(",")}`);
|
|
66
|
+
if (o.maxOcrPages)
|
|
67
|
+
parts.push(`max=${o.maxOcrPages}`);
|
|
68
|
+
}
|
|
69
|
+
if (kind === "audio" || kind === "video") {
|
|
70
|
+
if (o.captions)
|
|
71
|
+
parts.push(`cap=${crypto.createHash("sha256").update(o.captions).digest("hex").slice(0, 16)}`);
|
|
72
|
+
if (o.language)
|
|
73
|
+
parts.push(`lang=${o.language}`);
|
|
74
|
+
if (kind === "video" && o.describeVideo)
|
|
75
|
+
parts.push("describe");
|
|
76
|
+
}
|
|
77
|
+
return `wc-parse://${hash}/${parts.join("&") || "default"}`;
|
|
78
|
+
}
|
|
79
|
+
/** A result is worth caching only if nothing failed transiently. */
|
|
80
|
+
function cacheable(meta) {
|
|
81
|
+
if (meta.pagesSkipped?.some((s) => s.reason === "ocr_failed" || s.reason === "ocr_unavailable"))
|
|
82
|
+
return false;
|
|
83
|
+
return !meta.warnings.some((w) => /failed|unavailable/i.test(w));
|
|
84
|
+
}
|
|
85
|
+
export async function parseFile(input, options = {}) {
|
|
86
|
+
const { kind, mime } = detectFileKind(input);
|
|
87
|
+
if (!isBinaryKind(kind))
|
|
88
|
+
throw new UnsupportedFileError(kind);
|
|
89
|
+
const max = maxBytesFor(kind);
|
|
90
|
+
if (input.bytes.byteLength > max)
|
|
91
|
+
throw new FileTooLargeError(kind, input.bytes.byteLength, max);
|
|
92
|
+
const hash = contentHash(input.bytes);
|
|
93
|
+
const key = parseCacheKey(hash, kind, options);
|
|
94
|
+
if (!options.bypassCache) {
|
|
95
|
+
const hit = await getCachedPageAsync(key, "parse");
|
|
96
|
+
const meta = hit?.structuredData?.parse;
|
|
97
|
+
if (hit && meta) {
|
|
98
|
+
return {
|
|
99
|
+
title: hit.title,
|
|
100
|
+
markdown: hit.markdown,
|
|
101
|
+
meta,
|
|
102
|
+
contentHash: hash,
|
|
103
|
+
cached: true,
|
|
104
|
+
cacheLayer: hit.cacheLayer || "postgres",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let parsed;
|
|
109
|
+
switch (kind) {
|
|
110
|
+
case "pdf":
|
|
111
|
+
parsed = await parsePdf(input.bytes, {
|
|
112
|
+
ocr: options.ocr,
|
|
113
|
+
pages: options.pages,
|
|
114
|
+
maxOcrPages: options.maxOcrPages,
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
case "docx":
|
|
119
|
+
case "xlsx":
|
|
120
|
+
case "pptx":
|
|
121
|
+
parsed = await parseOffice(input.bytes, kind, mime);
|
|
122
|
+
break;
|
|
123
|
+
case "image":
|
|
124
|
+
parsed = await parseImage(input.bytes, mime, { fileName: input.fileName, signal: options.signal });
|
|
125
|
+
break;
|
|
126
|
+
case "audio":
|
|
127
|
+
case "video":
|
|
128
|
+
parsed = await parseMedia(input.bytes, kind, mime, {
|
|
129
|
+
fileName: input.fileName,
|
|
130
|
+
captions: options.captions,
|
|
131
|
+
language: options.language,
|
|
132
|
+
describeVideo: options.describeVideo,
|
|
133
|
+
signal: options.signal,
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
default:
|
|
137
|
+
throw new UnsupportedFileError(kind);
|
|
138
|
+
}
|
|
139
|
+
if (!parsed.title && input.fileName) {
|
|
140
|
+
parsed.title = input.fileName.split(/[?#]/)[0].split("/").pop() || "";
|
|
141
|
+
}
|
|
142
|
+
if (cacheable(parsed.meta) && parsed.markdown.length > 0) {
|
|
143
|
+
setCachedPage(key, "parse", {
|
|
144
|
+
title: parsed.title,
|
|
145
|
+
markdown: parsed.markdown,
|
|
146
|
+
links: [],
|
|
147
|
+
structuredData: { parse: parsed.meta },
|
|
148
|
+
statusCode: 200,
|
|
149
|
+
contentHash: hash,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { ...parsed, contentHash: hash, cached: false, cacheLayer: "miss" };
|
|
153
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FileKind } from "./detect.js";
|
|
2
|
+
/** Per-kind size caps. Override with WEBCRAWL_PARSE_MAX_{DOC,IMAGE,MEDIA}_MB. */
|
|
3
|
+
export declare function maxBytesFor(kind: FileKind): number;
|
|
4
|
+
/** Upper bound for any download before the kind is known. */
|
|
5
|
+
export declare function maxDownloadBytes(): number;
|
|
6
|
+
/** Jobs bigger than these run as a background workflow instead of inline. */
|
|
7
|
+
export declare const ASYNC_THRESHOLDS: {
|
|
8
|
+
pdfPages: number;
|
|
9
|
+
mediaSeconds: number;
|
|
10
|
+
pdfBatchPages: number;
|
|
11
|
+
mediaChunkSeconds: number;
|
|
12
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MB = 1024 * 1024;
|
|
2
|
+
function envMb(name, fallbackMb) {
|
|
3
|
+
const raw = Number(process.env[name]);
|
|
4
|
+
return (Number.isFinite(raw) && raw > 0 ? raw : fallbackMb) * MB;
|
|
5
|
+
}
|
|
6
|
+
/** Per-kind size caps. Override with WEBCRAWL_PARSE_MAX_{DOC,IMAGE,MEDIA}_MB. */
|
|
7
|
+
export function maxBytesFor(kind) {
|
|
8
|
+
switch (kind) {
|
|
9
|
+
case "image":
|
|
10
|
+
return envMb("WEBCRAWL_PARSE_MAX_IMAGE_MB", 20);
|
|
11
|
+
case "audio":
|
|
12
|
+
case "video":
|
|
13
|
+
// Groq's transcription upload limit.
|
|
14
|
+
return envMb("WEBCRAWL_PARSE_MAX_MEDIA_MB", 100);
|
|
15
|
+
default:
|
|
16
|
+
return envMb("WEBCRAWL_PARSE_MAX_DOC_MB", 50);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Upper bound for any download before the kind is known. */
|
|
20
|
+
export function maxDownloadBytes() {
|
|
21
|
+
return Math.max(maxBytesFor("pdf"), maxBytesFor("image"), maxBytesFor("audio"));
|
|
22
|
+
}
|
|
23
|
+
/** Jobs bigger than these run as a background workflow instead of inline. */
|
|
24
|
+
export const ASYNC_THRESHOLDS = {
|
|
25
|
+
pdfPages: Number(process.env.WEBCRAWL_PARSE_ASYNC_PAGES) || 50,
|
|
26
|
+
mediaSeconds: Number(process.env.WEBCRAWL_PARSE_ASYNC_SECONDS) || 600,
|
|
27
|
+
pdfBatchPages: 20,
|
|
28
|
+
mediaChunkSeconds: 600,
|
|
29
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ParsedFile } from "./types.js";
|
|
2
|
+
export interface Segment {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export interface MediaParseOptions {
|
|
8
|
+
fileName?: string;
|
|
9
|
+
/** WebVTT or SRT text. Used instead of transcription when present. */
|
|
10
|
+
captions?: string;
|
|
11
|
+
/** BCP-47 / ISO-639-1 hint for Whisper, e.g. "en". */
|
|
12
|
+
language?: string;
|
|
13
|
+
/** Video only: add a short description of what is on screen. */
|
|
14
|
+
describeVideo?: boolean;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export declare function transcriptionAvailable(): boolean;
|
|
18
|
+
/** Transcribes through the Vercel AI Gateway (no extra provider key needed). */
|
|
19
|
+
export declare function gatewayTranscribe(bytes: Uint8Array, options?: {
|
|
20
|
+
language?: string;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
segments: Segment[];
|
|
24
|
+
duration: number;
|
|
25
|
+
language?: string;
|
|
26
|
+
}>;
|
|
27
|
+
/** 3725.4 -> "1:02:05"; 65 -> "1:05". */
|
|
28
|
+
export declare function formatTimestamp(seconds: number): string;
|
|
29
|
+
/** Parses WebVTT or SRT cues. Drops styling tags and repeated rolling-caption lines. */
|
|
30
|
+
export declare function parseCaptions(text: string): Segment[];
|
|
31
|
+
/** Groups segments into ~30 s paragraphs, each prefixed with its start time. */
|
|
32
|
+
export declare function transcriptMarkdown(segments: Segment[], paragraphSeconds?: number): string;
|
|
33
|
+
export declare function groqTranscribe(bytes: Uint8Array, mime: string, options?: {
|
|
34
|
+
fileName?: string;
|
|
35
|
+
language?: string;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}): Promise<{
|
|
38
|
+
segments: Segment[];
|
|
39
|
+
duration: number;
|
|
40
|
+
language?: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function parseMedia(bytes: Uint8Array, kind: "audio" | "video", mime: string, options?: MediaParseOptions): Promise<ParsedFile>;
|