@webcrawl/mcp 1.4.1 → 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 +8 -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 +7 -0
- package/package.json +11 -3
|
@@ -0,0 +1,667 @@
|
|
|
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, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls, renderViaLocalChrome, setCachedPage, sha256, } from "./engine.js";
|
|
13
|
+
import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
|
|
14
|
+
import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
|
|
15
|
+
import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
|
|
16
|
+
import { parseFile, publicParseMeta } from "./parse/index.js";
|
|
17
|
+
import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
|
|
18
|
+
import { maxDownloadBytes } from "./parse/limits.js";
|
|
19
|
+
import { parseYouTubeCached, youtubeVideoId } from "./parse/youtube.js";
|
|
20
|
+
import { rankDocuments } from "./ranker.js";
|
|
21
|
+
import { getSearchCategoriesCatalog, searchWeb } from "./search.js";
|
|
22
|
+
import { inspectTarget, listTargets, resolveTarget } from "./targets.js";
|
|
23
|
+
import { hasCustomExtractRequest } from "./extract/structured.js";
|
|
24
|
+
/** Lowercased hostname of a URL (memo key for the Tier R render memo). */
|
|
25
|
+
function safeDomainOf(url) {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url).hostname.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
35
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
36
|
+
* for content. Exported for unit tests.
|
|
37
|
+
*/
|
|
38
|
+
export function blockedScrapeResult(url, statusCode, tier, latencyMs, reason) {
|
|
39
|
+
const error = reason ??
|
|
40
|
+
(`Blocked by the site's bot check (HTTP ${statusCode}). No content was returned. ` +
|
|
41
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
mode: "local",
|
|
45
|
+
url,
|
|
46
|
+
blocked: true,
|
|
47
|
+
statusCode,
|
|
48
|
+
error,
|
|
49
|
+
title: url,
|
|
50
|
+
markdown: "",
|
|
51
|
+
links: [],
|
|
52
|
+
data: { url, title: url, markdown: "", links: [] },
|
|
53
|
+
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
|
|
57
|
+
export const MIN_CACHEABLE_MARKDOWN = 200;
|
|
58
|
+
/** Attach source HTML when the caller asked for the `html` format. */
|
|
59
|
+
export function withOptionalHtml(result, html, formats) {
|
|
60
|
+
if (!formats?.includes("html") || !html)
|
|
61
|
+
return result;
|
|
62
|
+
const data = result.data && typeof result.data === "object" && !Array.isArray(result.data)
|
|
63
|
+
? result.data
|
|
64
|
+
: {};
|
|
65
|
+
return { ...result, html, data: { ...data, html } };
|
|
66
|
+
}
|
|
67
|
+
/** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
|
|
68
|
+
export function extractOutputData(extracted, opts) {
|
|
69
|
+
if (hasCustomExtractRequest(opts.prompt, opts.schema))
|
|
70
|
+
return extracted;
|
|
71
|
+
return {
|
|
72
|
+
...extracted,
|
|
73
|
+
...(opts.target ? { target: opts.target } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function nativeScrapeTier() {
|
|
77
|
+
const t = lastFetchTier();
|
|
78
|
+
return t === "impersonate" || t === "proxy-impersonate" ? t : "impit-tls";
|
|
79
|
+
}
|
|
80
|
+
function emptyPageError(statusCode) {
|
|
81
|
+
return (`Page returned no readable content (HTTP ${statusCode}). ` +
|
|
82
|
+
"The site may be blocking automated clients or serving a client-rendered shell. " +
|
|
83
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* JS / challenge shell → render provider, then local Chrome dump-dom, then stealth.
|
|
87
|
+
* Returns null when no renderer produced real content.
|
|
88
|
+
*/
|
|
89
|
+
async function escalateJsRender(url) {
|
|
90
|
+
if (isRenderProviderEnabled()) {
|
|
91
|
+
const rendered = await renderViaProvider(url);
|
|
92
|
+
if (rendered && rendered.markdown.trim().length >= 40) {
|
|
93
|
+
return {
|
|
94
|
+
title: rendered.title,
|
|
95
|
+
markdown: rendered.markdown,
|
|
96
|
+
html: "",
|
|
97
|
+
links: [],
|
|
98
|
+
tier: "render",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (isLocalChromeAvailable()) {
|
|
103
|
+
const html = await renderViaLocalChrome(url);
|
|
104
|
+
if (html && html.length > 100) {
|
|
105
|
+
const parsed = fastHtmlToMarkdown(html, url);
|
|
106
|
+
if (parsed.markdown.trim().length > 40 &&
|
|
107
|
+
!isAntiBotChallenge(html, 200) &&
|
|
108
|
+
!isChallengeMarkdown(parsed.title, parsed.markdown)) {
|
|
109
|
+
return { ...parsed, html, tier: "browser" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const { stealthAvailable } = await import("./engine/stealth.js");
|
|
115
|
+
const { escalateToSandboxOrBrowser, isChallengeResult } = await import("./sandbox.js");
|
|
116
|
+
if (await stealthAvailable()) {
|
|
117
|
+
const esc = await escalateToSandboxOrBrowser(url, { browser: true, timeoutMs: 25_000 });
|
|
118
|
+
if (esc &&
|
|
119
|
+
esc.markdown.trim().length > 40 &&
|
|
120
|
+
!isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
|
|
121
|
+
return {
|
|
122
|
+
title: esc.title,
|
|
123
|
+
markdown: esc.markdown,
|
|
124
|
+
html: esc.html,
|
|
125
|
+
links: esc.links,
|
|
126
|
+
tier: esc.tier,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
/* stealth optional */
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
138
|
+
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
139
|
+
*/
|
|
140
|
+
export function filterMappedLinks(links, search, limit = 100) {
|
|
141
|
+
const cap = Math.max(1, limit);
|
|
142
|
+
const needle = search?.trim().toLowerCase();
|
|
143
|
+
const filtered = needle
|
|
144
|
+
? links.filter((l) => l.toLowerCase().includes(needle))
|
|
145
|
+
: links;
|
|
146
|
+
return filtered.slice(0, cap);
|
|
147
|
+
}
|
|
148
|
+
/** How many URLs to discover before applying `--filter`, so a small `-n` cannot hide matches. */
|
|
149
|
+
const MAP_FILTER_DISCOVER_LIMIT = 5000;
|
|
150
|
+
export function createLocalEngineClient() {
|
|
151
|
+
return {
|
|
152
|
+
baseUrl: "local://engine",
|
|
153
|
+
async search(p) {
|
|
154
|
+
const query = String(p.query ?? "").trim();
|
|
155
|
+
const category = typeof p.category === "string" ? p.category : undefined;
|
|
156
|
+
const subcategory = typeof p.subcategory === "string" ? p.subcategory : undefined;
|
|
157
|
+
const limit = typeof p.limit === "number" ? p.limit : 5;
|
|
158
|
+
const country = typeof p.country === "string" ? p.country : undefined;
|
|
159
|
+
const city = typeof p.city === "string" ? p.city : undefined;
|
|
160
|
+
const sites = Array.isArray(p.sites) ? p.sites.map(String) : undefined;
|
|
161
|
+
const scrapeContent = Boolean(p.scrapeContent);
|
|
162
|
+
const res = await searchWeb({
|
|
163
|
+
query,
|
|
164
|
+
category: category,
|
|
165
|
+
subcategory,
|
|
166
|
+
limit,
|
|
167
|
+
country,
|
|
168
|
+
city,
|
|
169
|
+
filters: sites?.length ? { sites } : undefined,
|
|
170
|
+
scrapeOptions: scrapeContent ? { formats: ["markdown"] } : undefined,
|
|
171
|
+
});
|
|
172
|
+
const resolved = resolveTarget({ query, category });
|
|
173
|
+
return {
|
|
174
|
+
...res,
|
|
175
|
+
success: res.success !== false,
|
|
176
|
+
mode: "local",
|
|
177
|
+
target: {
|
|
178
|
+
id: resolved.plugin.id,
|
|
179
|
+
name: resolved.plugin.name,
|
|
180
|
+
confidence: resolved.match.confidence,
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
async scrape(p) {
|
|
185
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
186
|
+
const start = Date.now();
|
|
187
|
+
// 1. Check if YouTube URL
|
|
188
|
+
if (youtubeVideoId(targetUrl)) {
|
|
189
|
+
const yt = await parseYouTubeCached(targetUrl, { bypassCache: p.noCache });
|
|
190
|
+
return {
|
|
191
|
+
success: true,
|
|
192
|
+
mode: "local",
|
|
193
|
+
url: targetUrl,
|
|
194
|
+
title: yt.title,
|
|
195
|
+
markdown: yt.markdown,
|
|
196
|
+
data: {
|
|
197
|
+
url: targetUrl,
|
|
198
|
+
title: yt.title,
|
|
199
|
+
markdown: yt.markdown,
|
|
200
|
+
metadata: { parse: publicParseMeta(yt) },
|
|
201
|
+
},
|
|
202
|
+
evidence: {
|
|
203
|
+
targetId: "youtube-media",
|
|
204
|
+
tier: "native-parser",
|
|
205
|
+
contentHash: yt.contentHash,
|
|
206
|
+
latencyMs: Date.now() - start,
|
|
207
|
+
sourceUrl: targetUrl,
|
|
208
|
+
matchedSignals: ["youtube:captions"],
|
|
209
|
+
limitations: yt.meta.warnings,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// 2. Check if binary document URL (PDF, DOCX, XLSX, PPTX)
|
|
214
|
+
if (looksLikeBinaryUrl(targetUrl)) {
|
|
215
|
+
const fetched = await fetchResource(targetUrl, { maxBytes: maxDownloadBytes() });
|
|
216
|
+
if (isBinaryContentType(fetched.contentType) || looksLikeBinaryUrl(fetched.url)) {
|
|
217
|
+
const parsed = await parseFile({ bytes: fetched.body, contentType: fetched.contentType, fileName: fetched.url }, { bypassCache: p.noCache });
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
mode: "local",
|
|
221
|
+
url: fetched.url,
|
|
222
|
+
title: parsed.title,
|
|
223
|
+
markdown: parsed.markdown,
|
|
224
|
+
data: {
|
|
225
|
+
url: fetched.url,
|
|
226
|
+
title: parsed.title,
|
|
227
|
+
markdown: parsed.markdown,
|
|
228
|
+
metadata: { parse: publicParseMeta(parsed) },
|
|
229
|
+
},
|
|
230
|
+
evidence: {
|
|
231
|
+
targetId: "pdf-office",
|
|
232
|
+
tier: parsed.meta.method === "ocr" ? "ocr" : "native-parser",
|
|
233
|
+
contentHash: parsed.contentHash,
|
|
234
|
+
latencyMs: Date.now() - start,
|
|
235
|
+
sourceUrl: fetched.url,
|
|
236
|
+
matchedSignals: [`parse:${parsed.meta.kind}:${parsed.meta.method}`],
|
|
237
|
+
limitations: parsed.meta.warnings,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// 3. Check local cache first
|
|
243
|
+
if (!p.noCache) {
|
|
244
|
+
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
245
|
+
// Entries written before blocked pages stopped being cached may hold a
|
|
246
|
+
// bot-check page: treat those as a miss and fetch again. Thin shells
|
|
247
|
+
// (header-only JS pages) must also miss so local Chrome can hydrate them.
|
|
248
|
+
if (cached &&
|
|
249
|
+
!isChallengeMarkdown(cached.title, cached.markdown) &&
|
|
250
|
+
cached.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
251
|
+
const targetInspection = await inspectTarget({
|
|
252
|
+
url: targetUrl,
|
|
253
|
+
markdown: cached.markdown,
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
success: true,
|
|
257
|
+
mode: "local",
|
|
258
|
+
url: targetUrl,
|
|
259
|
+
title: cached.title,
|
|
260
|
+
markdown: cached.markdown,
|
|
261
|
+
links: cached.links,
|
|
262
|
+
cached: true,
|
|
263
|
+
data: {
|
|
264
|
+
url: targetUrl,
|
|
265
|
+
title: cached.title,
|
|
266
|
+
markdown: cached.markdown,
|
|
267
|
+
links: cached.links,
|
|
268
|
+
structured: targetInspection.structured,
|
|
269
|
+
},
|
|
270
|
+
evidence: {
|
|
271
|
+
...targetInspection.evidence,
|
|
272
|
+
tier: "cache",
|
|
273
|
+
contentHash: cached.contentHash,
|
|
274
|
+
latencyMs: Date.now() - start,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// 4. Fetch with TLS impersonation (impit) + target-aware extraction.
|
|
280
|
+
// Tier R: memoized render-needed domains skip the native ladder
|
|
281
|
+
// entirely when a render provider is configured — cache (step 3)
|
|
282
|
+
// already absorbed repeats, so the provider is the next best hop.
|
|
283
|
+
const renderDomain = safeDomainOf(targetUrl);
|
|
284
|
+
if (isRenderProviderEnabled() && (await isDomainRenderNeeded(renderDomain))) {
|
|
285
|
+
const rendered = await renderViaProvider(targetUrl);
|
|
286
|
+
if (rendered) {
|
|
287
|
+
const renderHash = sha256(rendered.markdown);
|
|
288
|
+
setCachedPage(targetUrl, "none", {
|
|
289
|
+
title: rendered.title,
|
|
290
|
+
markdown: rendered.markdown,
|
|
291
|
+
links: [],
|
|
292
|
+
statusCode: 200,
|
|
293
|
+
contentHash: renderHash,
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
success: true,
|
|
297
|
+
mode: "local",
|
|
298
|
+
url: targetUrl,
|
|
299
|
+
title: rendered.title || targetUrl,
|
|
300
|
+
markdown: rendered.markdown,
|
|
301
|
+
links: [],
|
|
302
|
+
data: {
|
|
303
|
+
url: targetUrl,
|
|
304
|
+
title: rendered.title || targetUrl,
|
|
305
|
+
markdown: rendered.markdown,
|
|
306
|
+
links: [],
|
|
307
|
+
},
|
|
308
|
+
evidence: {
|
|
309
|
+
targetId: "render-provider",
|
|
310
|
+
tier: "render",
|
|
311
|
+
contentHash: renderHash,
|
|
312
|
+
latencyMs: Date.now() - start,
|
|
313
|
+
sourceUrl: rendered.sourceUrl,
|
|
314
|
+
matchedSignals: ["render:provider"],
|
|
315
|
+
limitations: rendered.warnings,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// Provider failed on a memo'd domain: fall through to the native
|
|
320
|
+
// ladder — the memo may be stale or the provider flaky.
|
|
321
|
+
}
|
|
322
|
+
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
323
|
+
const parsed = fastHtmlToMarkdown(fetched.html, targetUrl);
|
|
324
|
+
let title = parsed.title;
|
|
325
|
+
let markdown = parsed.markdown;
|
|
326
|
+
let links = parsed.links;
|
|
327
|
+
let html = fetched.html;
|
|
328
|
+
const blocked = isAntiBotChallenge(html, fetched.status);
|
|
329
|
+
const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
|
|
330
|
+
const nativeTier = nativeScrapeTier();
|
|
331
|
+
// JS shell / challenge: memoize the domain and escalate to a renderer
|
|
332
|
+
// (hosted provider, local Chrome dump-dom, then stealth). Never cache
|
|
333
|
+
// the unrendered shell.
|
|
334
|
+
if (needsRender) {
|
|
335
|
+
await markDomainRenderNeeded(renderDomain);
|
|
336
|
+
// 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
|
|
337
|
+
const rendered = blocked && fetched.status >= 400 ? null : await escalateJsRender(targetUrl);
|
|
338
|
+
if (rendered) {
|
|
339
|
+
const renderHash = sha256(rendered.markdown);
|
|
340
|
+
if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
341
|
+
setCachedPage(targetUrl, "none", {
|
|
342
|
+
title: rendered.title,
|
|
343
|
+
markdown: rendered.markdown,
|
|
344
|
+
links: rendered.links,
|
|
345
|
+
statusCode: 200,
|
|
346
|
+
contentHash: renderHash,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return withOptionalHtml({
|
|
350
|
+
success: true,
|
|
351
|
+
mode: "local",
|
|
352
|
+
url: targetUrl,
|
|
353
|
+
title: rendered.title || targetUrl,
|
|
354
|
+
markdown: rendered.markdown,
|
|
355
|
+
links: rendered.links,
|
|
356
|
+
data: {
|
|
357
|
+
url: targetUrl,
|
|
358
|
+
title: rendered.title || targetUrl,
|
|
359
|
+
markdown: rendered.markdown,
|
|
360
|
+
links: rendered.links,
|
|
361
|
+
},
|
|
362
|
+
evidence: {
|
|
363
|
+
targetId: rendered.tier === "render" ? "render-provider" : "web-spa",
|
|
364
|
+
tier: rendered.tier,
|
|
365
|
+
contentHash: renderHash,
|
|
366
|
+
latencyMs: Date.now() - start,
|
|
367
|
+
sourceUrl: targetUrl,
|
|
368
|
+
matchedSignals: rendered.tier === "browser" ? ["render:local-chrome"] : [`render:${rendered.tier}`],
|
|
369
|
+
},
|
|
370
|
+
}, rendered.html, p.formats);
|
|
371
|
+
}
|
|
372
|
+
if (blocked || !markdown.trim()) {
|
|
373
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
374
|
+
}
|
|
375
|
+
const targetInspection = await inspectTarget({
|
|
376
|
+
url: targetUrl,
|
|
377
|
+
html,
|
|
378
|
+
markdown,
|
|
379
|
+
});
|
|
380
|
+
return withOptionalHtml({
|
|
381
|
+
success: true,
|
|
382
|
+
mode: "local",
|
|
383
|
+
url: targetUrl,
|
|
384
|
+
title: title || targetInspection.title || targetUrl,
|
|
385
|
+
markdown: targetInspection.markdown || markdown,
|
|
386
|
+
links,
|
|
387
|
+
data: {
|
|
388
|
+
url: targetUrl,
|
|
389
|
+
title: title || targetInspection.title || targetUrl,
|
|
390
|
+
markdown: targetInspection.markdown || markdown,
|
|
391
|
+
links,
|
|
392
|
+
structured: targetInspection.structured,
|
|
393
|
+
},
|
|
394
|
+
evidence: {
|
|
395
|
+
...targetInspection.evidence,
|
|
396
|
+
tier: nativeTier,
|
|
397
|
+
contentHash: sha256(markdown || html),
|
|
398
|
+
latencyMs: Date.now() - start,
|
|
399
|
+
limitations: [
|
|
400
|
+
...(Array.isArray(targetInspection.evidence?.limitations)
|
|
401
|
+
? targetInspection.evidence.limitations
|
|
402
|
+
: []),
|
|
403
|
+
"Page needs JS rendering; local Chrome was not available or produced no content. " +
|
|
404
|
+
"Install Chrome (see `webcrawl doctor`), run `webcrawl login` and retry with `--mode cloud`, " +
|
|
405
|
+
"or self-host a render provider via WEBCRAWL_RENDER_URL.",
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
}, html, p.formats);
|
|
409
|
+
}
|
|
410
|
+
if (blocked || !markdown.trim()) {
|
|
411
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
|
|
412
|
+
}
|
|
413
|
+
const contentHash = sha256(markdown || html);
|
|
414
|
+
if (markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
|
|
415
|
+
setCachedPage(targetUrl, "none", {
|
|
416
|
+
title,
|
|
417
|
+
markdown,
|
|
418
|
+
links,
|
|
419
|
+
statusCode: fetched.status,
|
|
420
|
+
contentHash,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const targetInspection = await inspectTarget({
|
|
424
|
+
url: targetUrl,
|
|
425
|
+
html,
|
|
426
|
+
markdown,
|
|
427
|
+
});
|
|
428
|
+
return withOptionalHtml({
|
|
429
|
+
success: true,
|
|
430
|
+
mode: "local",
|
|
431
|
+
url: targetUrl,
|
|
432
|
+
title: title || targetInspection.title || targetUrl,
|
|
433
|
+
markdown: targetInspection.markdown || markdown,
|
|
434
|
+
links,
|
|
435
|
+
data: {
|
|
436
|
+
url: targetUrl,
|
|
437
|
+
title: title || targetInspection.title || targetUrl,
|
|
438
|
+
markdown: targetInspection.markdown || markdown,
|
|
439
|
+
links,
|
|
440
|
+
structured: targetInspection.structured,
|
|
441
|
+
},
|
|
442
|
+
evidence: {
|
|
443
|
+
...targetInspection.evidence,
|
|
444
|
+
tier: nativeTier,
|
|
445
|
+
contentHash,
|
|
446
|
+
latencyMs: Date.now() - start,
|
|
447
|
+
},
|
|
448
|
+
}, html, p.formats);
|
|
449
|
+
},
|
|
450
|
+
async parse(p) {
|
|
451
|
+
const url = typeof p.url === "string" ? p.url : undefined;
|
|
452
|
+
if (url && youtubeVideoId(url)) {
|
|
453
|
+
const yt = await parseYouTubeCached(url, {
|
|
454
|
+
language: typeof p.language === "string" ? p.language : undefined,
|
|
455
|
+
bypassCache: Boolean(p.noCache),
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
success: true,
|
|
459
|
+
mode: "local",
|
|
460
|
+
title: yt.title,
|
|
461
|
+
markdown: yt.markdown,
|
|
462
|
+
metadata: { parse: publicParseMeta(yt) },
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
let bytes = p.bytes instanceof Uint8Array ? p.bytes : undefined;
|
|
466
|
+
let contentType = typeof p.contentType === "string" ? p.contentType : undefined;
|
|
467
|
+
let fileName = typeof p.fileName === "string" ? p.fileName : url;
|
|
468
|
+
if (!bytes && typeof p.base64 === "string") {
|
|
469
|
+
bytes = new Uint8Array(Buffer.from(p.base64, "base64"));
|
|
470
|
+
}
|
|
471
|
+
if (!bytes && url) {
|
|
472
|
+
const fetched = await fetchResource(url, { maxBytes: maxDownloadBytes() });
|
|
473
|
+
bytes = fetched.body;
|
|
474
|
+
contentType = contentType ?? fetched.contentType;
|
|
475
|
+
fileName = fileName ?? fetched.url;
|
|
476
|
+
}
|
|
477
|
+
if (!bytes) {
|
|
478
|
+
throw new Error("parse requires `url`, `bytes`, or `base64`");
|
|
479
|
+
}
|
|
480
|
+
const parsed = await parseFile({ bytes, contentType, fileName }, {
|
|
481
|
+
ocr: p.ocr ?? "auto",
|
|
482
|
+
pages: Array.isArray(p.pages) ? p.pages.map(Number) : undefined,
|
|
483
|
+
language: typeof p.language === "string" ? p.language : undefined,
|
|
484
|
+
bypassCache: Boolean(p.noCache),
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
success: true,
|
|
488
|
+
mode: "local",
|
|
489
|
+
title: parsed.title,
|
|
490
|
+
markdown: parsed.markdown,
|
|
491
|
+
metadata: { parse: publicParseMeta(parsed) },
|
|
492
|
+
};
|
|
493
|
+
},
|
|
494
|
+
async run(id) {
|
|
495
|
+
return {
|
|
496
|
+
success: false,
|
|
497
|
+
mode: "local",
|
|
498
|
+
id,
|
|
499
|
+
status: "not_found",
|
|
500
|
+
error: "Background runs require cloud mode (`webcrawl login` or WEBCRAWL_API_KEY).",
|
|
501
|
+
};
|
|
502
|
+
},
|
|
503
|
+
async map(p) {
|
|
504
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
505
|
+
const limit = Math.max(1, p.limit ?? 100);
|
|
506
|
+
const needle = p.search?.trim();
|
|
507
|
+
// Discover first (widely when filtering), then apply search and limit so
|
|
508
|
+
// `map --filter webhooks -n 10` can still find /webhooks/* URLs that are
|
|
509
|
+
// not among the first 10 sitemap entries (#11).
|
|
510
|
+
const mapped = await mapWebsiteUrls(targetUrl, {
|
|
511
|
+
limit: needle ? Math.max(limit, MAP_FILTER_DISCOVER_LIMIT) : limit,
|
|
512
|
+
});
|
|
513
|
+
const links = filterMappedLinks(mapped.links, needle, limit);
|
|
514
|
+
return {
|
|
515
|
+
success: true,
|
|
516
|
+
mode: "local",
|
|
517
|
+
url: targetUrl,
|
|
518
|
+
links,
|
|
519
|
+
source: mapped.source,
|
|
520
|
+
total: links.length,
|
|
521
|
+
};
|
|
522
|
+
},
|
|
523
|
+
async crawl(p) {
|
|
524
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
525
|
+
const maxPages = Math.min(25, Math.max(1, p.maxPages ?? 5));
|
|
526
|
+
const mapped = await mapWebsiteUrls(targetUrl, { limit: maxPages * 3 });
|
|
527
|
+
const urls = Array.from(new Set([targetUrl, ...mapped.links])).slice(0, maxPages);
|
|
528
|
+
const pages = [];
|
|
529
|
+
for (const u of urls) {
|
|
530
|
+
try {
|
|
531
|
+
const scraped = await this.scrape({ url: u });
|
|
532
|
+
pages.push({
|
|
533
|
+
url: scraped.url ?? u,
|
|
534
|
+
title: scraped.title ?? u,
|
|
535
|
+
markdown: scraped.markdown ?? "",
|
|
536
|
+
evidence: scraped.evidence,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// continue crawling remaining pages
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
success: true,
|
|
545
|
+
mode: "local",
|
|
546
|
+
url: targetUrl,
|
|
547
|
+
total: pages.length,
|
|
548
|
+
data: pages,
|
|
549
|
+
pages,
|
|
550
|
+
};
|
|
551
|
+
},
|
|
552
|
+
async extract(p) {
|
|
553
|
+
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
554
|
+
// Scrape first (local Chrome / render provider) so JS pages yield rendered HTML, not the shell.
|
|
555
|
+
const scraped = (await this.scrape({
|
|
556
|
+
url: targetUrl,
|
|
557
|
+
formats: ["markdown", "html"],
|
|
558
|
+
noCache: true,
|
|
559
|
+
}));
|
|
560
|
+
const markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
|
|
561
|
+
const html = (scraped.data && scraped.data.html) || scraped.html || "";
|
|
562
|
+
if (scraped.blocked || scraped.success === false) {
|
|
563
|
+
return {
|
|
564
|
+
success: false,
|
|
565
|
+
blocked: true,
|
|
566
|
+
mode: "local",
|
|
567
|
+
url: targetUrl,
|
|
568
|
+
data: {},
|
|
569
|
+
error: scraped.error || emptyPageError(200),
|
|
570
|
+
evidence: {
|
|
571
|
+
tier: lastFetchTier(),
|
|
572
|
+
sourceUrl: targetUrl,
|
|
573
|
+
limitations: [String(scraped.error || emptyPageError(200))],
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const targetInspection = await inspectTarget({
|
|
578
|
+
url: targetUrl,
|
|
579
|
+
html,
|
|
580
|
+
markdown,
|
|
581
|
+
});
|
|
582
|
+
const siteData = await extractWebsiteData(targetUrl, {
|
|
583
|
+
prompt: p.prompt,
|
|
584
|
+
schema: p.schema,
|
|
585
|
+
markdown,
|
|
586
|
+
html,
|
|
587
|
+
bypassCache: true,
|
|
588
|
+
});
|
|
589
|
+
const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
|
|
590
|
+
return {
|
|
591
|
+
success: true,
|
|
592
|
+
mode: "local",
|
|
593
|
+
url: targetUrl,
|
|
594
|
+
data: extractOutputData(extracted, {
|
|
595
|
+
prompt: p.prompt,
|
|
596
|
+
schema: p.schema,
|
|
597
|
+
target: targetInspection.structured,
|
|
598
|
+
}),
|
|
599
|
+
evidence: targetInspection.evidence,
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
async rerank(p) {
|
|
603
|
+
const candidates = p.documents.map((doc, idx) => {
|
|
604
|
+
if (typeof doc === "string") {
|
|
605
|
+
return {
|
|
606
|
+
url: `doc://${idx + 1}`,
|
|
607
|
+
title: doc.slice(0, 80),
|
|
608
|
+
description: doc,
|
|
609
|
+
markdown: doc,
|
|
610
|
+
sourceEngine: "direct",
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const text = String(doc.text ?? doc.description ?? doc.markdown ?? doc.title ?? "");
|
|
614
|
+
return {
|
|
615
|
+
url: String(doc.url ?? `doc://${idx + 1}`),
|
|
616
|
+
title: String(doc.title ?? text.slice(0, 80)),
|
|
617
|
+
description: text,
|
|
618
|
+
markdown: text,
|
|
619
|
+
sourceEngine: "direct",
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
const ranked = await rankDocuments(p.query, candidates, {
|
|
623
|
+
topK: p.topK ?? candidates.length,
|
|
624
|
+
});
|
|
625
|
+
return {
|
|
626
|
+
success: true,
|
|
627
|
+
mode: "local",
|
|
628
|
+
results: ranked.results.map((r) => ({
|
|
629
|
+
url: r.url,
|
|
630
|
+
title: r.title,
|
|
631
|
+
description: r.description,
|
|
632
|
+
score: r.score,
|
|
633
|
+
scoreBreakdown: r.scoreBreakdown,
|
|
634
|
+
})),
|
|
635
|
+
};
|
|
636
|
+
},
|
|
637
|
+
async categories() {
|
|
638
|
+
return {
|
|
639
|
+
success: true,
|
|
640
|
+
mode: "local",
|
|
641
|
+
...getSearchCategoriesCatalog(),
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
async targets() {
|
|
645
|
+
return {
|
|
646
|
+
success: true,
|
|
647
|
+
mode: "local",
|
|
648
|
+
targets: listTargets().map((t) => ({
|
|
649
|
+
id: t.id,
|
|
650
|
+
name: t.name,
|
|
651
|
+
category: t.category,
|
|
652
|
+
returns: t.returns,
|
|
653
|
+
requirements: t.requirements,
|
|
654
|
+
examples: t.examples,
|
|
655
|
+
})),
|
|
656
|
+
};
|
|
657
|
+
},
|
|
658
|
+
async usage() {
|
|
659
|
+
return {
|
|
660
|
+
success: true,
|
|
661
|
+
mode: "local",
|
|
662
|
+
plan: "open-source-local",
|
|
663
|
+
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.",
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|