webcrawl 1.2.4 → 1.2.7
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/awswaf.d.ts +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- 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,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>;
|
|
@@ -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
|
+
};
|