@webcrawl/mcp 1.4.2 → 1.4.4

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