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,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-key Local Execution Engine for Webcrawl Anything.
|
|
3
|
+
*
|
|
4
|
+
* Implements the exact same method surface as `WebcrawlClient` (`search`, `scrape`,
|
|
5
|
+
* `parse`, `map`, `crawl`, `extract`, `rerank`, `categories`, `targets`) in-process
|
|
6
|
+
* using the shared `src/engine/`, `src/search/`, `src/parse/`, `src/extract/`,
|
|
7
|
+
* `src/ranker/`, and `src/targets/` pipeline.
|
|
8
|
+
*
|
|
9
|
+
* Every community improvement to extractors, parsers, or targets in `src/` immediately
|
|
10
|
+
* powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
|
|
11
|
+
*/
|
|
12
|
+
import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
|
|
13
|
+
import { fetchResource } from "./engine/fetcher.js";
|
|
14
|
+
import { parseFile, publicParseMeta } from "./parse/index.js";
|
|
15
|
+
import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
|
|
16
|
+
import { maxDownloadBytes } from "./parse/limits.js";
|
|
17
|
+
import { parseYouTubeCached, youtubeVideoId } from "./parse/youtube.js";
|
|
18
|
+
import { rankDocuments } from "./ranker.js";
|
|
19
|
+
import { getSearchCategoriesCatalog, searchWeb } from "./search.js";
|
|
20
|
+
import { inspectTarget, listTargets, resolveTarget } from "./targets.js";
|
|
21
|
+
export function createLocalEngineClient() {
|
|
22
|
+
return {
|
|
23
|
+
baseUrl: "local://engine",
|
|
24
|
+
async search(p) {
|
|
25
|
+
const query = String(p.query ?? "").trim();
|
|
26
|
+
const category = typeof p.category === "string" ? p.category : undefined;
|
|
27
|
+
const subcategory = typeof p.subcategory === "string" ? p.subcategory : undefined;
|
|
28
|
+
const limit = typeof p.limit === "number" ? p.limit : 5;
|
|
29
|
+
const country = typeof p.country === "string" ? p.country : undefined;
|
|
30
|
+
const city = typeof p.city === "string" ? p.city : undefined;
|
|
31
|
+
const sites = Array.isArray(p.sites) ? p.sites.map(String) : undefined;
|
|
32
|
+
const scrapeContent = Boolean(p.scrapeContent);
|
|
33
|
+
const res = await searchWeb({
|
|
34
|
+
query,
|
|
35
|
+
category: category,
|
|
36
|
+
subcategory,
|
|
37
|
+
limit,
|
|
38
|
+
country,
|
|
39
|
+
city,
|
|
40
|
+
filters: sites?.length ? { sites } : undefined,
|
|
41
|
+
scrapeOptions: scrapeContent ? { formats: ["markdown"] } : undefined,
|
|
42
|
+
});
|
|
43
|
+
const resolved = resolveTarget({ query, category });
|
|
44
|
+
return {
|
|
45
|
+
...res,
|
|
46
|
+
success: true,
|
|
47
|
+
mode: "local",
|
|
48
|
+
target: {
|
|
49
|
+
id: resolved.plugin.id,
|
|
50
|
+
name: resolved.plugin.name,
|
|
51
|
+
confidence: resolved.match.confidence,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
async scrape(p) {
|
|
56
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
// 1. Check if YouTube URL
|
|
59
|
+
if (youtubeVideoId(targetUrl)) {
|
|
60
|
+
const yt = await parseYouTubeCached(targetUrl, { bypassCache: p.noCache });
|
|
61
|
+
return {
|
|
62
|
+
success: true,
|
|
63
|
+
mode: "local",
|
|
64
|
+
url: targetUrl,
|
|
65
|
+
title: yt.title,
|
|
66
|
+
markdown: yt.markdown,
|
|
67
|
+
data: {
|
|
68
|
+
url: targetUrl,
|
|
69
|
+
title: yt.title,
|
|
70
|
+
markdown: yt.markdown,
|
|
71
|
+
metadata: { parse: publicParseMeta(yt) },
|
|
72
|
+
},
|
|
73
|
+
evidence: {
|
|
74
|
+
targetId: "youtube-media",
|
|
75
|
+
tier: "native-parser",
|
|
76
|
+
contentHash: yt.contentHash,
|
|
77
|
+
latencyMs: Date.now() - start,
|
|
78
|
+
sourceUrl: targetUrl,
|
|
79
|
+
matchedSignals: ["youtube:captions"],
|
|
80
|
+
limitations: yt.meta.warnings,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// 2. Check if binary document URL (PDF, DOCX, XLSX, PPTX)
|
|
85
|
+
if (looksLikeBinaryUrl(targetUrl)) {
|
|
86
|
+
const fetched = await fetchResource(targetUrl, { maxBytes: maxDownloadBytes() });
|
|
87
|
+
if (isBinaryContentType(fetched.contentType) || looksLikeBinaryUrl(fetched.url)) {
|
|
88
|
+
const parsed = await parseFile({ bytes: fetched.body, contentType: fetched.contentType, fileName: fetched.url }, { bypassCache: p.noCache });
|
|
89
|
+
return {
|
|
90
|
+
success: true,
|
|
91
|
+
mode: "local",
|
|
92
|
+
url: fetched.url,
|
|
93
|
+
title: parsed.title,
|
|
94
|
+
markdown: parsed.markdown,
|
|
95
|
+
data: {
|
|
96
|
+
url: fetched.url,
|
|
97
|
+
title: parsed.title,
|
|
98
|
+
markdown: parsed.markdown,
|
|
99
|
+
metadata: { parse: publicParseMeta(parsed) },
|
|
100
|
+
},
|
|
101
|
+
evidence: {
|
|
102
|
+
targetId: "pdf-office",
|
|
103
|
+
tier: parsed.meta.method === "ocr" ? "ocr" : "native-parser",
|
|
104
|
+
contentHash: parsed.contentHash,
|
|
105
|
+
latencyMs: Date.now() - start,
|
|
106
|
+
sourceUrl: fetched.url,
|
|
107
|
+
matchedSignals: [`parse:${parsed.meta.kind}:${parsed.meta.method}`],
|
|
108
|
+
limitations: parsed.meta.warnings,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 3. Check local cache first
|
|
114
|
+
if (!p.noCache) {
|
|
115
|
+
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
116
|
+
if (cached) {
|
|
117
|
+
const targetInspection = await inspectTarget({
|
|
118
|
+
url: targetUrl,
|
|
119
|
+
markdown: cached.markdown,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
success: true,
|
|
123
|
+
mode: "local",
|
|
124
|
+
url: targetUrl,
|
|
125
|
+
title: cached.title,
|
|
126
|
+
markdown: cached.markdown,
|
|
127
|
+
links: cached.links,
|
|
128
|
+
cached: true,
|
|
129
|
+
data: {
|
|
130
|
+
url: targetUrl,
|
|
131
|
+
title: cached.title,
|
|
132
|
+
markdown: cached.markdown,
|
|
133
|
+
links: cached.links,
|
|
134
|
+
structured: targetInspection.structured,
|
|
135
|
+
},
|
|
136
|
+
evidence: {
|
|
137
|
+
...targetInspection.evidence,
|
|
138
|
+
tier: "cache",
|
|
139
|
+
contentHash: cached.contentHash,
|
|
140
|
+
latencyMs: Date.now() - start,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 4. Fetch with TLS impersonation (impit) + target-aware extraction
|
|
146
|
+
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
147
|
+
const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
|
|
148
|
+
const contentHash = sha256(markdown || fetched.html);
|
|
149
|
+
setCachedPage(targetUrl, "none", {
|
|
150
|
+
title,
|
|
151
|
+
markdown,
|
|
152
|
+
links,
|
|
153
|
+
statusCode: fetched.status,
|
|
154
|
+
contentHash,
|
|
155
|
+
});
|
|
156
|
+
const targetInspection = await inspectTarget({
|
|
157
|
+
url: targetUrl,
|
|
158
|
+
html: fetched.html,
|
|
159
|
+
markdown,
|
|
160
|
+
});
|
|
161
|
+
return {
|
|
162
|
+
success: true,
|
|
163
|
+
mode: "local",
|
|
164
|
+
url: targetUrl,
|
|
165
|
+
title: title || targetInspection.title || targetUrl,
|
|
166
|
+
markdown: targetInspection.markdown || markdown,
|
|
167
|
+
links,
|
|
168
|
+
data: {
|
|
169
|
+
url: targetUrl,
|
|
170
|
+
title: title || targetInspection.title || targetUrl,
|
|
171
|
+
markdown: targetInspection.markdown || markdown,
|
|
172
|
+
links,
|
|
173
|
+
structured: targetInspection.structured,
|
|
174
|
+
},
|
|
175
|
+
evidence: {
|
|
176
|
+
...targetInspection.evidence,
|
|
177
|
+
tier: "impit-tls",
|
|
178
|
+
contentHash,
|
|
179
|
+
latencyMs: Date.now() - start,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
async parse(p) {
|
|
184
|
+
const url = typeof p.url === "string" ? p.url : undefined;
|
|
185
|
+
if (url && youtubeVideoId(url)) {
|
|
186
|
+
const yt = await parseYouTubeCached(url, {
|
|
187
|
+
language: typeof p.language === "string" ? p.language : undefined,
|
|
188
|
+
bypassCache: Boolean(p.noCache),
|
|
189
|
+
});
|
|
190
|
+
return {
|
|
191
|
+
success: true,
|
|
192
|
+
mode: "local",
|
|
193
|
+
title: yt.title,
|
|
194
|
+
markdown: yt.markdown,
|
|
195
|
+
metadata: { parse: publicParseMeta(yt) },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
let bytes = p.bytes instanceof Uint8Array ? p.bytes : undefined;
|
|
199
|
+
let contentType = typeof p.contentType === "string" ? p.contentType : undefined;
|
|
200
|
+
let fileName = typeof p.fileName === "string" ? p.fileName : url;
|
|
201
|
+
if (!bytes && typeof p.base64 === "string") {
|
|
202
|
+
bytes = new Uint8Array(Buffer.from(p.base64, "base64"));
|
|
203
|
+
}
|
|
204
|
+
if (!bytes && url) {
|
|
205
|
+
const fetched = await fetchResource(url, { maxBytes: maxDownloadBytes() });
|
|
206
|
+
bytes = fetched.body;
|
|
207
|
+
contentType = contentType ?? fetched.contentType;
|
|
208
|
+
fileName = fileName ?? fetched.url;
|
|
209
|
+
}
|
|
210
|
+
if (!bytes) {
|
|
211
|
+
throw new Error("parse requires `url`, `bytes`, or `base64`");
|
|
212
|
+
}
|
|
213
|
+
const parsed = await parseFile({ bytes, contentType, fileName }, {
|
|
214
|
+
ocr: p.ocr ?? "auto",
|
|
215
|
+
pages: Array.isArray(p.pages) ? p.pages.map(Number) : undefined,
|
|
216
|
+
language: typeof p.language === "string" ? p.language : undefined,
|
|
217
|
+
bypassCache: Boolean(p.noCache),
|
|
218
|
+
});
|
|
219
|
+
return {
|
|
220
|
+
success: true,
|
|
221
|
+
mode: "local",
|
|
222
|
+
title: parsed.title,
|
|
223
|
+
markdown: parsed.markdown,
|
|
224
|
+
metadata: { parse: publicParseMeta(parsed) },
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
async run(id) {
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
mode: "local",
|
|
231
|
+
id,
|
|
232
|
+
status: "not_found",
|
|
233
|
+
error: "Background runs require cloud mode (`webcrawl login` or WEBCRAWL_API_KEY).",
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
async map(p) {
|
|
237
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
238
|
+
const mapped = await mapWebsiteUrls(targetUrl, {
|
|
239
|
+
limit: p.limit ?? 100,
|
|
240
|
+
});
|
|
241
|
+
const needle = p.search?.trim().toLowerCase();
|
|
242
|
+
const links = needle
|
|
243
|
+
? mapped.links.filter((l) => l.toLowerCase().includes(needle))
|
|
244
|
+
: mapped.links;
|
|
245
|
+
return {
|
|
246
|
+
success: true,
|
|
247
|
+
mode: "local",
|
|
248
|
+
url: targetUrl,
|
|
249
|
+
links,
|
|
250
|
+
source: mapped.source,
|
|
251
|
+
total: links.length,
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
async crawl(p) {
|
|
255
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
256
|
+
const maxPages = Math.min(25, Math.max(1, p.maxPages ?? 5));
|
|
257
|
+
const mapped = await mapWebsiteUrls(targetUrl, { limit: maxPages * 3 });
|
|
258
|
+
const urls = Array.from(new Set([targetUrl, ...mapped.links])).slice(0, maxPages);
|
|
259
|
+
const pages = [];
|
|
260
|
+
for (const u of urls) {
|
|
261
|
+
try {
|
|
262
|
+
const scraped = await this.scrape({ url: u });
|
|
263
|
+
pages.push({
|
|
264
|
+
url: scraped.url ?? u,
|
|
265
|
+
title: scraped.title ?? u,
|
|
266
|
+
markdown: scraped.markdown ?? "",
|
|
267
|
+
evidence: scraped.evidence,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// continue crawling remaining pages
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
success: true,
|
|
276
|
+
mode: "local",
|
|
277
|
+
url: targetUrl,
|
|
278
|
+
total: pages.length,
|
|
279
|
+
data: pages,
|
|
280
|
+
pages,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
async extract(p) {
|
|
284
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
285
|
+
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
286
|
+
const targetInspection = await inspectTarget({
|
|
287
|
+
url: targetUrl,
|
|
288
|
+
html: fetched.html,
|
|
289
|
+
});
|
|
290
|
+
const siteData = await extractWebsiteData(targetUrl);
|
|
291
|
+
return {
|
|
292
|
+
success: true,
|
|
293
|
+
mode: "local",
|
|
294
|
+
url: targetUrl,
|
|
295
|
+
data: {
|
|
296
|
+
...siteData,
|
|
297
|
+
...(targetInspection.structured ? { target: targetInspection.structured } : {}),
|
|
298
|
+
},
|
|
299
|
+
evidence: targetInspection.evidence,
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
async rerank(p) {
|
|
303
|
+
const candidates = p.documents.map((doc, idx) => {
|
|
304
|
+
if (typeof doc === "string") {
|
|
305
|
+
return {
|
|
306
|
+
url: `doc://${idx + 1}`,
|
|
307
|
+
title: doc.slice(0, 80),
|
|
308
|
+
description: doc,
|
|
309
|
+
markdown: doc,
|
|
310
|
+
sourceEngine: "direct",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const text = String(doc.text ?? doc.description ?? doc.markdown ?? doc.title ?? "");
|
|
314
|
+
return {
|
|
315
|
+
url: String(doc.url ?? `doc://${idx + 1}`),
|
|
316
|
+
title: String(doc.title ?? text.slice(0, 80)),
|
|
317
|
+
description: text,
|
|
318
|
+
markdown: text,
|
|
319
|
+
sourceEngine: "direct",
|
|
320
|
+
};
|
|
321
|
+
});
|
|
322
|
+
const ranked = await rankDocuments(p.query, candidates, {
|
|
323
|
+
topK: p.topK ?? candidates.length,
|
|
324
|
+
});
|
|
325
|
+
return {
|
|
326
|
+
success: true,
|
|
327
|
+
mode: "local",
|
|
328
|
+
results: ranked.results.map((r) => ({
|
|
329
|
+
url: r.url,
|
|
330
|
+
title: r.title,
|
|
331
|
+
description: r.description,
|
|
332
|
+
score: r.score,
|
|
333
|
+
scoreBreakdown: r.scoreBreakdown,
|
|
334
|
+
})),
|
|
335
|
+
};
|
|
336
|
+
},
|
|
337
|
+
async categories() {
|
|
338
|
+
return {
|
|
339
|
+
success: true,
|
|
340
|
+
mode: "local",
|
|
341
|
+
...getSearchCategoriesCatalog(),
|
|
342
|
+
};
|
|
343
|
+
},
|
|
344
|
+
async targets() {
|
|
345
|
+
return {
|
|
346
|
+
success: true,
|
|
347
|
+
mode: "local",
|
|
348
|
+
targets: listTargets().map((t) => ({
|
|
349
|
+
id: t.id,
|
|
350
|
+
name: t.name,
|
|
351
|
+
category: t.category,
|
|
352
|
+
returns: t.returns,
|
|
353
|
+
requirements: t.requirements,
|
|
354
|
+
examples: t.examples,
|
|
355
|
+
})),
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
async usage() {
|
|
359
|
+
return {
|
|
360
|
+
success: true,
|
|
361
|
+
mode: "local",
|
|
362
|
+
plan: "open-source-local",
|
|
363
|
+
note: "Running locally with zero API key. Set WEBCRAWL_API_KEY or run `webcrawl login` to use webcrawl.app cloud stealth browsers and global cache.",
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -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>;
|