@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,918 @@
1
+ import { buildPathScope } from "./path-scope.js";
2
+ import { followSafely } from "./safe-url.js";
3
+ import { AdaptiveConcurrency, parseRobots, sleep, withRetries } from "./crawl-policy.js";
4
+ import { escalateToSandboxOrBrowser } from "../sandbox.js";
5
+ import { getCachedMapAsync, getCachedPageAsync, setCachedMap, setCachedPage, sha256, } from "./cache.js";
6
+ import { fetchWithTlsImpersonation, getBrowserHeaders, isAntiBotChallenge, } from "./fetcher.js";
7
+ import { decodeHtmlEntities, extractLinksFromHtml, fastHtmlToMarkdown, } from "./markdown.js";
8
+ import { extractByPromptAndSchema, hasCustomExtractRequest, } from "../extract/structured.js";
9
+ export async function mapWebsiteUrls(targetUrl, options = {}) {
10
+ const start = performance.now();
11
+ const limit = Math.max(1, options.limit ?? 5000);
12
+ const maxCrawlPages = Math.max(1, options.maxCrawlPages ?? options.maxCrawl ?? 250);
13
+ const maxDepth = Math.max(1, options.maxDepth ?? 8);
14
+ const timeoutMs = options.timeoutMs ?? 25_000;
15
+ const bypassCache = Boolean(options.bypassCache);
16
+ const rawSelectedDomains = (options.selectedDomains ?? [])
17
+ .map((d) => d
18
+ .trim()
19
+ .toLowerCase()
20
+ .replace(/^https?:\/\//, "")
21
+ .replace(/\/.*$/, ""))
22
+ .filter(Boolean);
23
+ // If `onlyRootDomain: true` is explicitly passed, it overrides `includeSubdomains`
24
+ const includeSubdomains = options.onlyRootDomain === true
25
+ ? false
26
+ : Boolean(options.includeSubdomains);
27
+ const onlyRootDomain = options.onlyRootDomain ??
28
+ (!includeSubdomains && rawSelectedDomains.length === 0);
29
+ const base = new URL(targetUrl);
30
+ const normalizedTarget = base.toString().replace(/\/$/, "") || base.origin;
31
+ const baseHost = base.hostname.toLowerCase().replace(/^www\./, "");
32
+ const pathScope = buildPathScope(targetUrl, {
33
+ includePaths: options.includePaths,
34
+ excludePaths: options.excludePaths,
35
+ restrictToSubpath: options.restrictToSubpath,
36
+ });
37
+ const scopeSignature = `${normalizedTarget}::${pathScope.signature}::root=${onlyRootDomain}::sub=${includeSubdomains}::sel=${rawSelectedDomains.slice().sort().join(",")}::lim=${limit}::maxCrawl=${maxCrawlPages}::depth=${maxDepth}::sm=${(options.sitemapUrls ?? []).join(",")}`;
38
+ if (!bypassCache) {
39
+ const cached = await getCachedMapAsync(scopeSignature);
40
+ if (cached) {
41
+ return {
42
+ success: true,
43
+ links: cached.links.slice(0, limit),
44
+ latencyMs: Math.round(performance.now() - start),
45
+ source: `${cached.source} (${cached.cacheLayer || "24h"}-cache)`,
46
+ cached: true,
47
+ cacheLayer: cached.cacheLayer || "memory",
48
+ pagesCrawled: cached.pagesCrawled,
49
+ scope: {
50
+ onlyRootDomain,
51
+ includeSubdomains,
52
+ selectedDomains: rawSelectedDomains,
53
+ maxCrawlPages,
54
+ maxDepth,
55
+ includePaths: pathScope.includes,
56
+ excludePaths: pathScope.excludes,
57
+ },
58
+ };
59
+ }
60
+ }
61
+ const allowedHosts = new Set();
62
+ const wildcardSuffixes = [];
63
+ if (rawSelectedDomains.length > 0) {
64
+ for (const domain of rawSelectedDomains) {
65
+ if (domain.startsWith("*.")) {
66
+ const suffix = domain.slice(2).replace(/^www\./, "");
67
+ if (suffix) {
68
+ wildcardSuffixes.push(suffix);
69
+ allowedHosts.add(suffix);
70
+ }
71
+ }
72
+ else {
73
+ allowedHosts.add(domain.replace(/^www\./, ""));
74
+ }
75
+ }
76
+ }
77
+ else {
78
+ allowedHosts.add(baseHost);
79
+ }
80
+ const origin = base.origin;
81
+ const discovered = new Set();
82
+ const isAllowedUrl = (candidate) => {
83
+ try {
84
+ const u = new URL(candidate, origin);
85
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
86
+ return false;
87
+ }
88
+ const candHost = u.hostname.toLowerCase().replace(/^www\./, "");
89
+ let hostMatch = allowedHosts.has(candHost);
90
+ if (!hostMatch && wildcardSuffixes.length > 0) {
91
+ for (const suffix of wildcardSuffixes) {
92
+ if (candHost === suffix || candHost.endsWith(`.${suffix}`)) {
93
+ hostMatch = true;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ if (!hostMatch && includeSubdomains && !onlyRootDomain) {
99
+ for (const h of allowedHosts) {
100
+ if (candHost.endsWith(`.${h}`)) {
101
+ hostMatch = true;
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ if (!hostMatch) {
107
+ return false;
108
+ }
109
+ // Skip binary/static assets
110
+ if (/\.(png|jpg|jpeg|gif|svg|webp|ico|pdf|zip|tar|gz|css|js|mjs|map|woff2?|ttf|eot|mp4|mp3|wav|avi|mov|xml|json)$/i.test(u.pathname)) {
111
+ return false;
112
+ }
113
+ return pathScope.allows(u.toString());
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ };
119
+ const normalizeUrl = (raw, baseUrl = origin) => {
120
+ try {
121
+ const u = new URL(raw, baseUrl);
122
+ u.hash = "";
123
+ // Strip tracking query params (`utm_*`, `ref`, `fbclid`, `gclid`) so BFS doesn't loop on marketing tags
124
+ for (const key of Array.from(u.searchParams.keys())) {
125
+ if (/^(utm_|fbclid|gclid|msclkid|_ga|_gl|ref$)/i.test(key)) {
126
+ u.searchParams.delete(key);
127
+ }
128
+ }
129
+ const normalized = u.toString().replace(/\/$/, "") || origin;
130
+ return isAllowedUrl(normalized) ? normalized : null;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ };
136
+ let robots = parseRobots(null);
137
+ let robotsBlocked = 0;
138
+ const addUrl = (raw, baseUrl = origin) => {
139
+ if (discovered.size >= limit) {
140
+ return null;
141
+ }
142
+ const normalized = normalizeUrl(raw, baseUrl);
143
+ if (normalized) {
144
+ if (!robots.allows(normalized)) {
145
+ robotsBlocked++;
146
+ return null;
147
+ }
148
+ discovered.add(normalized);
149
+ return normalized;
150
+ }
151
+ return null;
152
+ };
153
+ addUrl(targetUrl);
154
+ // Race sitemap discovery (root + subpath) + root HTML link discovery in parallel
155
+ const sitemapCandidates = new Set([
156
+ `${origin}/sitemap.xml`,
157
+ `${origin}/sitemap_index.xml`,
158
+ `${origin}/sitemap-index.xml`,
159
+ ]);
160
+ if (base.pathname && base.pathname !== "/") {
161
+ const cleanSubpath = base.pathname.replace(/\/$/, "");
162
+ sitemapCandidates.add(`${origin}${cleanSubpath}/sitemap.xml`);
163
+ }
164
+ // Set when the site answers with a bot check instead of content.
165
+ let blockedByBotCheck = false;
166
+ let sawThrottle = false;
167
+ const fetchTextFast = async (url, maxMs = 4500) => {
168
+ try {
169
+ const res = await withRetries(() => fetchWithTlsImpersonation(url, maxMs), { retries: 1 });
170
+ if (res.status === 429 || res.status === 503)
171
+ sawThrottle = true;
172
+ if (isAntiBotChallenge(res.html, res.status)) {
173
+ blockedByBotCheck = true;
174
+ return null;
175
+ }
176
+ if (res.status < 200 || res.status >= 400) {
177
+ return null;
178
+ }
179
+ return res.html;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ };
185
+ const userSitemaps = (options.sitemapUrls ?? []).slice(0, 5);
186
+ const [robotsTxt, rootHtml, defaultSitemapXml, ...userSitemapXmls] = await Promise.all([
187
+ fetchTextFast(`${origin}/robots.txt`, 2500),
188
+ fetchTextFast(targetUrl, 4500),
189
+ fetchTextFast(`${origin}/sitemap.xml`, 4500),
190
+ ...userSitemaps.map((u) => fetchTextFast(u, 6000)),
191
+ ]);
192
+ // Detect canonical domain from `<link rel="canonical" href="...">` only when selectedDomains is not restricting hosts
193
+ if (rootHtml && rawSelectedDomains.length === 0) {
194
+ const canonicalMatch = rootHtml.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i);
195
+ if (canonicalMatch?.[1]) {
196
+ try {
197
+ const canonUrl = new URL(canonicalMatch[1], origin);
198
+ const canonHost = canonUrl.hostname.toLowerCase().replace(/^www\./, "");
199
+ if (!onlyRootDomain || canonHost === baseHost) {
200
+ allowedHosts.add(canonHost);
201
+ }
202
+ sitemapCandidates.add(`${canonUrl.origin}/sitemap.xml`);
203
+ sitemapCandidates.add(`${canonUrl.origin}/sitemap-index.xml`);
204
+ sitemapCandidates.add(`${canonUrl.origin}/sitemap_index.xml`);
205
+ }
206
+ catch {
207
+ // ignore invalid canonical URL
208
+ }
209
+ }
210
+ }
211
+ robots = parseRobots(robotsTxt);
212
+ if (robotsTxt) {
213
+ const matches = robotsTxt.matchAll(/^Sitemap:\s*(.+)$/gim);
214
+ for (const m of matches) {
215
+ const smUrl = m[1].trim();
216
+ if (smUrl) {
217
+ sitemapCandidates.add(smUrl);
218
+ }
219
+ }
220
+ }
221
+ let foundSitemap = false;
222
+ const parseSitemapXml = async (xml, depth = 0) => {
223
+ if (depth > 3 || discovered.size >= limit) {
224
+ return;
225
+ }
226
+ const locMatches = Array.from(xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?([^<\]]+?)(?:\]\]>)?\s*<\/loc>/gi));
227
+ const subSitemaps = [];
228
+ for (const m of locMatches) {
229
+ const loc = decodeHtmlEntities(m[1].trim());
230
+ if (loc.endsWith(".xml") ||
231
+ loc.endsWith(".xml.gz") ||
232
+ loc.includes("sitemap")) {
233
+ subSitemaps.push(loc);
234
+ }
235
+ else {
236
+ addUrl(loc);
237
+ }
238
+ if (discovered.size >= limit) {
239
+ break;
240
+ }
241
+ }
242
+ if (subSitemaps.length > 0 &&
243
+ discovered.size < limit &&
244
+ performance.now() - start < timeoutMs * 0.75) {
245
+ const childXmls = await Promise.all(subSitemaps.slice(0, 10).map((u) => fetchTextFast(u, 3500)));
246
+ for (const childXml of childXmls) {
247
+ if (childXml) {
248
+ await parseSitemapXml(childXml, depth + 1);
249
+ }
250
+ }
251
+ }
252
+ };
253
+ // Sitemaps the caller named come first; they usually list exactly the pages wanted.
254
+ for (const xml of userSitemapXmls) {
255
+ if (xml && xml.includes("<loc>")) {
256
+ foundSitemap = true;
257
+ await parseSitemapXml(xml, 0);
258
+ }
259
+ }
260
+ if (foundSitemap) {
261
+ // Caller's sitemap answered: skip guessing at default locations.
262
+ }
263
+ else if (defaultSitemapXml && defaultSitemapXml.includes("<loc>")) {
264
+ foundSitemap = true;
265
+ await parseSitemapXml(defaultSitemapXml, 0);
266
+ }
267
+ else {
268
+ const extraSitemaps = Array.from(sitemapCandidates).filter((u) => u !== `${origin}/sitemap.xml`);
269
+ if (extraSitemaps.length > 0) {
270
+ const xmls = await Promise.all(extraSitemaps.slice(0, 5).map((u) => fetchTextFast(u, 3500)));
271
+ for (const xml of xmls) {
272
+ if (xml && xml.includes("<loc>")) {
273
+ foundSitemap = true;
274
+ await parseSitemapXml(xml, 0);
275
+ }
276
+ }
277
+ }
278
+ }
279
+ // Track BFS queue and visited pages for full-website crawling
280
+ const visited = new Set([normalizedTarget]);
281
+ const bfsQueue = [];
282
+ let pagesCrawled = 0;
283
+ const ingestHtmlPage = (pageUrl, html, depth) => {
284
+ pagesCrawled++;
285
+ // Warm the 24-hour page scrape cacheopportunistically while crawling!
286
+ const parsed = fastHtmlToMarkdown(html, pageUrl, { retainImages: "all" });
287
+ if (parsed.markdown.length > 0) {
288
+ setCachedPage(pageUrl, "all", {
289
+ title: parsed.title,
290
+ markdown: parsed.markdown,
291
+ links: parsed.links,
292
+ statusCode: 200,
293
+ contentHash: sha256(parsed.markdown),
294
+ });
295
+ }
296
+ const pageLinks = extractLinksFromHtml(html, pageUrl);
297
+ for (const rawLink of pageLinks) {
298
+ const added = addUrl(rawLink, pageUrl);
299
+ if (added && !visited.has(added) && depth + 1 <= maxDepth) {
300
+ visited.add(added);
301
+ bfsQueue.push({ url: added, depth: depth + 1 });
302
+ }
303
+ }
304
+ };
305
+ if (rootHtml) {
306
+ ingestHtmlPage(normalizedTarget, rootHtml, 0);
307
+ }
308
+ // Bot check in the way (e.g. Vercel Security Checkpoint, Cloudflare): pass it
309
+ // once in a real browser and read robots.txt + sitemaps through that session.
310
+ let stealthUsed = false;
311
+ let stealthCostUsd;
312
+ if (blockedByBotCheck && !foundSitemap && options.stealth !== false) {
313
+ const { stealthAvailable, stealthDiscover } = await import("./stealth.js");
314
+ if (await stealthAvailable()) {
315
+ stealthUsed = true;
316
+ const found = await stealthDiscover(targetUrl, {
317
+ budgetMs: Math.max(30_000, options.stealthBudgetMs ?? 75_000),
318
+ sitemapUrls: userSitemaps,
319
+ remote: options.remoteStealth,
320
+ });
321
+ stealthCostUsd = found.providerCostUsd;
322
+ for (const u of found.sitemapPages)
323
+ addUrl(u);
324
+ if (found.sitemapPages.length > 0)
325
+ foundSitemap = true;
326
+ const entry = found.entry;
327
+ if (entry?.body && !entry.blocked) {
328
+ // Links from the rendered entry page count when there is no sitemap.
329
+ for (const rawLink of extractLinksFromHtml(entry.body, entry.finalUrl || targetUrl)) {
330
+ addUrl(rawLink, entry.finalUrl || targetUrl);
331
+ }
332
+ }
333
+ console.info("[map] stealth discovery", {
334
+ url: targetUrl,
335
+ solved: found.solved,
336
+ sitemapsRead: found.sitemapsRead.length,
337
+ sitemapPages: found.sitemapPages.length,
338
+ discovered: discovered.size,
339
+ durationMs: found.durationMs,
340
+ error: found.error,
341
+ });
342
+ }
343
+ }
344
+ // If sitemap is missing and static HTML on root page found < 2 internal links (e.g. client-rendered SPA or WAF),
345
+ // escalate the root page to @vercel/sandbox / headless browser to hydrate the DOM links!
346
+ // Skipped after stealth ran: it already tried a real browser on this site.
347
+ if (!foundSitemap && discovered.size <= 2 && !stealthUsed) {
348
+ const escalated = await escalateToSandboxOrBrowser(targetUrl, {
349
+ retainImages: "all",
350
+ timeoutMs: Math.min(12_000, timeoutMs),
351
+ });
352
+ if (escalated && escalated.links.length > 0 && !isAntiBotChallenge(escalated.html, 200)) {
353
+ pagesCrawled++;
354
+ setCachedPage(normalizedTarget, "all", {
355
+ title: escalated.title,
356
+ markdown: escalated.markdown,
357
+ links: escalated.links,
358
+ statusCode: escalated.statusCode,
359
+ contentHash: sha256(escalated.markdown),
360
+ });
361
+ for (const rawLink of escalated.links) {
362
+ const added = addUrl(rawLink, normalizedTarget);
363
+ if (added && !visited.has(added)) {
364
+ visited.add(added);
365
+ bfsQueue.push({ url: added, depth: 1 });
366
+ }
367
+ }
368
+ }
369
+ }
370
+ // Multi-hop concurrent BFS crawl across the website when sitemap is missing (or yielded < 15 links)
371
+ const concurrency = new AdaptiveConcurrency(1, 12, 6);
372
+ if (!foundSitemap || discovered.size < 15) {
373
+ while (bfsQueue.length > 0 &&
374
+ discovered.size < limit &&
375
+ pagesCrawled < maxCrawlPages &&
376
+ performance.now() - start < timeoutMs * 0.88) {
377
+ const remainingCrawlBudget = Math.max(0, maxCrawlPages - pagesCrawled);
378
+ if (remainingCrawlBudget === 0) {
379
+ break;
380
+ }
381
+ const wave = bfsQueue.splice(0, Math.min(concurrency.current, remainingCrawlBudget));
382
+ sawThrottle = false;
383
+ const htmlResults = await Promise.all(wave.map(async (item) => {
384
+ // Check if page is already in L1/L2/L3 Postgres 24-hour cache
385
+ const cachedPage = await getCachedPageAsync(item.url, "all");
386
+ if (cachedPage && cachedPage.links.length > 0) {
387
+ return { item, cachedLinks: cachedPage.links, html: null };
388
+ }
389
+ const html = await fetchTextFast(item.url, 3200);
390
+ return { item, cachedLinks: null, html };
391
+ }));
392
+ concurrency.record({
393
+ ok: htmlResults.filter((r) => r.cachedLinks || r.html).length,
394
+ failed: htmlResults.filter((r) => !r.cachedLinks && !r.html).length,
395
+ throttled: sawThrottle,
396
+ });
397
+ if (robots.crawlDelayMs > 0 || sawThrottle) {
398
+ await sleep(Math.min(robots.crawlDelayMs || 1000, 3000));
399
+ }
400
+ for (const { item, cachedLinks, html } of htmlResults) {
401
+ if (discovered.size >= limit || pagesCrawled >= maxCrawlPages) {
402
+ break;
403
+ }
404
+ if (cachedLinks) {
405
+ pagesCrawled++;
406
+ for (const rawLink of cachedLinks) {
407
+ const added = addUrl(rawLink, item.url);
408
+ if (added && !visited.has(added) && item.depth + 1 <= maxDepth) {
409
+ visited.add(added);
410
+ bfsQueue.push({ url: added, depth: item.depth + 1 });
411
+ }
412
+ }
413
+ }
414
+ else if (html) {
415
+ ingestHtmlPage(item.url, html, item.depth);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ const finalLinks = Array.from(discovered).slice(0, limit);
421
+ const source = `${foundSitemap ? "sitemap+html" : "bfs-full-crawl"}${stealthUsed ? "+stealth" : ""}`;
422
+ // A blocked map that found nothing is not cached, so the next run tries again.
423
+ if (finalLinks.length > 1 || (pagesCrawled > 0 && !blockedByBotCheck)) {
424
+ setCachedMap(scopeSignature, {
425
+ links: finalLinks,
426
+ source,
427
+ pagesCrawled,
428
+ });
429
+ }
430
+ return {
431
+ success: true,
432
+ links: finalLinks,
433
+ latencyMs: Math.round(performance.now() - start),
434
+ source,
435
+ cached: false,
436
+ pagesCrawled,
437
+ blocked: blockedByBotCheck,
438
+ stealth: stealthUsed,
439
+ ...(stealthCostUsd !== undefined ? { stealthCostUsd } : {}),
440
+ scope: {
441
+ onlyRootDomain,
442
+ includeSubdomains,
443
+ selectedDomains: rawSelectedDomains,
444
+ maxCrawlPages,
445
+ maxDepth,
446
+ includePaths: pathScope.includes,
447
+ excludePaths: pathScope.excludes,
448
+ },
449
+ };
450
+ }
451
+ /**
452
+ * Ultra-fast HTTP status batch check for `websiteDeletedPages.ts`.
453
+ * Checks if URLs return 404/410 without downloading/rendering full markdown!
454
+ */
455
+ export async function checkUrlsStatusBatch(urls, timeoutMs = 8000) {
456
+ return Promise.all(urls.map(async (url) => {
457
+ try {
458
+ const controller = new AbortController();
459
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
460
+ const hop = (method) => (hopUrl) => fetch(hopUrl, {
461
+ method,
462
+ headers: getBrowserHeaders(hopUrl),
463
+ redirect: "manual",
464
+ signal: controller.signal,
465
+ }).then((r) => ({
466
+ status: r.status,
467
+ headers: { location: r.headers.get("location") ?? "" },
468
+ ok: r.ok,
469
+ body: r.body,
470
+ }));
471
+ // Try HEAD first, fallback to GET if 405/403; every redirect hop is SSRF-checked
472
+ let { res } = await followSafely(url, hop("HEAD"));
473
+ if (res.status === 405 || res.status === 403) {
474
+ ({ res } = await followSafely(url, hop("GET")));
475
+ await res.body?.cancel().catch(() => undefined);
476
+ }
477
+ clearTimeout(timer);
478
+ return {
479
+ url,
480
+ statusCode: res.status,
481
+ ok: res.ok,
482
+ };
483
+ }
484
+ catch {
485
+ return {
486
+ url,
487
+ statusCode: 0,
488
+ ok: false,
489
+ };
490
+ }
491
+ }));
492
+ }
493
+ /**
494
+ * Extracts structured JSON from a URL.
495
+ * Honours `prompt` / `schema` when provided; otherwise falls back to
496
+ * `{ app_name, product_features }` from title and headings.
497
+ */
498
+ export async function extractWebsiteData(url, options = {}) {
499
+ const custom = hasCustomExtractRequest(options.prompt, options.schema);
500
+ if (!custom && !options.bypassCache && !options.markdown) {
501
+ const cachedPage = await getCachedPageAsync(url, "all");
502
+ if (cachedPage?.structuredData &&
503
+ typeof cachedPage.structuredData.app_name === "string" &&
504
+ Array.isArray(cachedPage.structuredData.product_features)) {
505
+ return {
506
+ app_name: cachedPage.structuredData.app_name,
507
+ product_features: cachedPage.structuredData.product_features.map(String),
508
+ cached: true,
509
+ cacheLayer: cachedPage.cacheLayer || "postgres",
510
+ };
511
+ }
512
+ }
513
+ let title = "";
514
+ let markdown = "";
515
+ let html = options.html ?? "";
516
+ let links = [];
517
+ const existingPage = !options.bypassCache && !options.markdown
518
+ ? await getCachedPageAsync(url, "all")
519
+ : undefined;
520
+ if (options.markdown) {
521
+ markdown = options.markdown;
522
+ title = url.split("/").pop() || url;
523
+ }
524
+ else if (existingPage && existingPage.markdown.length > 0) {
525
+ title = existingPage.title;
526
+ markdown = existingPage.markdown;
527
+ links = existingPage.links;
528
+ }
529
+ else {
530
+ const { res } = await followSafely(url, (hopUrl) => fetch(hopUrl, {
531
+ headers: getBrowserHeaders(hopUrl),
532
+ redirect: "manual",
533
+ signal: AbortSignal.timeout(8000),
534
+ }).then((r) => ({
535
+ status: r.status,
536
+ headers: { location: r.headers.get("location") ?? "" },
537
+ text: () => r.text(),
538
+ })));
539
+ html = await res.text();
540
+ const parsed = fastHtmlToMarkdown(html, url);
541
+ title = parsed.title;
542
+ markdown = parsed.markdown;
543
+ links = parsed.links;
544
+ }
545
+ const cacheMeta = () => ({
546
+ cached: Boolean(existingPage),
547
+ cacheLayer: existingPage?.cacheLayer || "miss",
548
+ });
549
+ // If OPENAI_API_KEY is available, use gpt-4o-mini for structured JSON extraction
550
+ const apiKey = options.openaiApiKey || process.env.OPENAI_API_KEY;
551
+ if (apiKey) {
552
+ try {
553
+ const schemaHint = options.schema
554
+ ? `Return a JSON object matching this JSON Schema:\n${JSON.stringify(options.schema)}`
555
+ : options.prompt
556
+ ? `Return JSON that satisfies this request: ${options.prompt}`
557
+ : 'Extract the application/website name and a list of key product features as JSON: {"app_name": string, "product_features": string[]}.';
558
+ const llmRes = await fetch("https://api.openai.com/v1/chat/completions", {
559
+ method: "POST",
560
+ headers: {
561
+ "Content-Type": "application/json",
562
+ Authorization: `Bearer ${apiKey}`,
563
+ },
564
+ body: JSON.stringify({
565
+ model: "gpt-4o-mini",
566
+ response_format: { type: "json_object" },
567
+ messages: [
568
+ {
569
+ role: "system",
570
+ content: schemaHint,
571
+ },
572
+ {
573
+ role: "user",
574
+ content: `${options.prompt ? `${options.prompt}\n\n` : ""}URL: ${url}\nTitle: ${title}\n\nContent:\n${markdown.slice(0, 12000)}`,
575
+ },
576
+ ],
577
+ temperature: 0,
578
+ }),
579
+ signal: AbortSignal.timeout(10000),
580
+ });
581
+ if (llmRes.ok) {
582
+ const json = (await llmRes.json());
583
+ const content = json.choices?.[0]?.message?.content;
584
+ if (content) {
585
+ const parsed = JSON.parse(content);
586
+ const usable = custom
587
+ ? parsed && typeof parsed === "object" && !Array.isArray(parsed)
588
+ : Boolean(parsed.app_name && Array.isArray(parsed.product_features));
589
+ if (usable) {
590
+ const structuredResult = custom
591
+ ? parsed
592
+ : {
593
+ app_name: String(parsed.app_name),
594
+ product_features: parsed.product_features.map(String),
595
+ };
596
+ setCachedPage(url, "all", {
597
+ title,
598
+ markdown,
599
+ links,
600
+ structuredData: structuredResult,
601
+ statusCode: 200,
602
+ contentHash: sha256(markdown),
603
+ });
604
+ return { ...structuredResult, ...cacheMeta() };
605
+ }
606
+ }
607
+ }
608
+ }
609
+ catch {
610
+ // Fall through to deterministic heuristic extraction
611
+ }
612
+ }
613
+ if (custom) {
614
+ const shaped = extractByPromptAndSchema(html, markdown, options.prompt, options.schema);
615
+ if (shaped) {
616
+ setCachedPage(url, "all", {
617
+ title,
618
+ markdown,
619
+ links,
620
+ structuredData: shaped,
621
+ statusCode: 200,
622
+ contentHash: sha256(markdown),
623
+ });
624
+ return { ...shaped, ...cacheMeta() };
625
+ }
626
+ // Honour the caller's schema/prompt: never substitute the generic
627
+ // `{ app_name, product_features }` object when they asked for something else.
628
+ return { ...cacheMeta() };
629
+ }
630
+ // Deterministic fast extraction from Title, Meta Description, and H2/H3/List headings (<5ms)
631
+ const cleanAppName = title.split(/\s+[-|\u2014–:]\s+/)[0]?.trim() ||
632
+ new URL(url).hostname.replace(/^www\./, "").split(".")[0];
633
+ const features = new Set();
634
+ const headingMatches = markdown.matchAll(/^#{2,3}\s+([^\n]+)/gm);
635
+ for (const m of headingMatches) {
636
+ const h = m[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
637
+ if (h.length > 4 &&
638
+ h.length < 90 &&
639
+ !/^(cookie|privacy|terms|contact|sign in|login|faq)/i.test(h)) {
640
+ features.add(h);
641
+ }
642
+ if (features.size >= 10) {
643
+ break;
644
+ }
645
+ }
646
+ if (features.size < 3) {
647
+ const bulletMatches = markdown.matchAll(/^- ([^\n]+)/gm);
648
+ for (const m of bulletMatches) {
649
+ const b = m[1].replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
650
+ if (b.length > 8 && b.length < 100) {
651
+ features.add(b);
652
+ }
653
+ if (features.size >= 8) {
654
+ break;
655
+ }
656
+ }
657
+ }
658
+ const structuredResult = {
659
+ app_name: cleanAppName,
660
+ product_features: features.size > 0
661
+ ? Array.from(features)
662
+ : [`${cleanAppName} core platform`, "Documentation & Knowledge Base"],
663
+ };
664
+ setCachedPage(url, "all", {
665
+ title,
666
+ markdown,
667
+ links,
668
+ structuredData: structuredResult,
669
+ statusCode: 200,
670
+ contentHash: sha256(markdown),
671
+ });
672
+ return {
673
+ ...structuredResult,
674
+ ...cacheMeta(),
675
+ };
676
+ }
677
+ /**
678
+ * Full-website multi-page crawler (`POST /v1/crawl`) backed by the shared 3-tier
679
+ * L1 Memory -> L2 Disk -> L3 Postgres URL store (`crawled_pages`).
680
+ * Pages already stored in Postgres for this website are served directly without re-crawling!
681
+ */
682
+ export async function crawlWebsitePages(targetUrl, options = {}) {
683
+ const start = performance.now();
684
+ const maxPages = Math.min(25, Math.max(1, options.maxPages ?? 5));
685
+ const bypassCache = Boolean(options.bypassCache);
686
+ const timeoutMs = options.timeoutMs ?? 20_000;
687
+ const mapRes = await mapWebsiteUrls(targetUrl, {
688
+ limit: Math.max(maxPages * 3, 25),
689
+ maxCrawlPages: maxPages,
690
+ includeSubdomains: options.includeSubdomains,
691
+ onlyRootDomain: options.onlyRootDomain,
692
+ bypassCache,
693
+ timeoutMs: Math.min(12_000, timeoutMs),
694
+ // Sync crawl: the durable workflow handles stealth; this path must stay fast.
695
+ stealth: false,
696
+ });
697
+ const targetLinks = mapRes.links.slice(0, maxPages);
698
+ if (targetLinks.length === 0) {
699
+ targetLinks.push(targetUrl);
700
+ }
701
+ let cachedPagesCount = 0;
702
+ let postgresHits = 0;
703
+ const pages = await Promise.all(targetLinks.map(async (pageUrl) => {
704
+ const page = await crawlPage(pageUrl, {
705
+ bypassCache,
706
+ extractStructured: options.extractStructured,
707
+ });
708
+ if (page.cached) {
709
+ cachedPagesCount += 1;
710
+ if (page.cacheLayer === "postgres")
711
+ postgresHits += 1;
712
+ }
713
+ return page;
714
+ }));
715
+ return {
716
+ success: true,
717
+ rootUrl: targetUrl,
718
+ pagesCount: pages.length,
719
+ cachedPagesCount,
720
+ postgresHits,
721
+ latencyMs: Math.round(performance.now() - start),
722
+ pages,
723
+ };
724
+ }
725
+ /** True for markdown converted from a bot-check page (old cache entries). */
726
+ export function isChallengeMarkdown(title, markdown) {
727
+ if (/blocked by network security/i.test(`${title}\n${markdown}`)) {
728
+ return true;
729
+ }
730
+ const t = `${title}\n${markdown.slice(0, 600)}`.toLowerCase();
731
+ return (t.includes("vercel security checkpoint") ||
732
+ t.includes("we're verifying your browser") ||
733
+ t.includes("just a moment...") ||
734
+ t.includes("attention required! | cloudflare") ||
735
+ t.includes("checking your browser before accessing"));
736
+ }
737
+ /**
738
+ * Re-reads blocked pages through one stealth browser session. Pages that come
739
+ * back clean are cached and returned as content; pages still blocked keep
740
+ * `blocked: true` and an honest error.
741
+ */
742
+ export async function crawlPagesStealth(entryUrl, urls, options = {}) {
743
+ const { stealthFetch } = await import("./stealth.js");
744
+ const res = await stealthFetch(entryUrl, urls.map((url) => ({ url })), { budgetMs: options.budgetMs ?? 120_000, remote: options.remote });
745
+ const byUrl = new Map(res.docs.map((d) => [d.url, d]));
746
+ const pages = urls.map((url) => {
747
+ const doc = byUrl.get(url);
748
+ const fail = (error, blocked = true) => ({
749
+ url,
750
+ title: url,
751
+ markdown: "",
752
+ linksCount: 0,
753
+ cached: false,
754
+ cacheLayer: "miss",
755
+ structuredData: null,
756
+ statusCode: doc?.status ?? undefined,
757
+ blocked,
758
+ stealth: true,
759
+ error,
760
+ });
761
+ if (!doc)
762
+ return fail(res.error ? `Stealth mode failed: ${res.error}` : "Stealth mode did not reach this page");
763
+ if (!doc.ok)
764
+ return fail(doc.error || "Stealth mode could not open this page");
765
+ if (doc.blocked || isAntiBotChallenge(doc.body ?? "", doc.status ?? 200)) {
766
+ return fail("Still blocked by the site's bot check in stealth mode");
767
+ }
768
+ const parsed = fastHtmlToMarkdown(doc.body ?? "", doc.finalUrl || url, { retainImages: "all" });
769
+ if (parsed.markdown.length === 0)
770
+ return fail(`Empty page (HTTP ${doc.status ?? "?"})`, false);
771
+ const title = parsed.title || doc.title || url;
772
+ let structuredData = null;
773
+ if (options.extractStructured) {
774
+ const headings = Array.from(parsed.markdown.matchAll(/^#{1,3}\s+([^\n]+)/gm))
775
+ .map((m) => m[1].trim())
776
+ .slice(0, 8);
777
+ structuredData = { title, headings, wordCount: parsed.markdown.split(/\s+/).filter(Boolean).length };
778
+ }
779
+ setCachedPage(url, "all", {
780
+ title,
781
+ markdown: parsed.markdown,
782
+ links: parsed.links,
783
+ structuredData,
784
+ statusCode: doc.status ?? 200,
785
+ contentHash: sha256(parsed.markdown),
786
+ });
787
+ return {
788
+ url,
789
+ title,
790
+ markdown: parsed.markdown,
791
+ linksCount: parsed.links.length,
792
+ cached: false,
793
+ cacheLayer: "miss",
794
+ structuredData,
795
+ statusCode: doc.status ?? 200,
796
+ stealth: true,
797
+ ...(res.provider ? { stealthProvider: res.provider } : {}),
798
+ };
799
+ });
800
+ return {
801
+ pages,
802
+ solved: res.solved,
803
+ durationMs: res.durationMs,
804
+ ...(res.error ? { error: res.error } : {}),
805
+ ...(res.provider ? { provider: res.provider } : {}),
806
+ ...(res.providerCostUsd !== undefined ? { providerCostUsd: res.providerCostUsd } : {}),
807
+ };
808
+ }
809
+ /**
810
+ * Fetches one page for a crawl: cache first (unless bypassed), then a
811
+ * TLS-impersonated fetch. Never throws; failures come back with `error`.
812
+ * Used by `crawlWebsitePages` and by the durable crawl workflow (one call per page).
813
+ */
814
+ export async function crawlPage(pageUrl, options = {}) {
815
+ if (!options.bypassCache) {
816
+ const cached = await getCachedPageAsync(pageUrl, "all");
817
+ // Older entries may hold a bot-check page; treat those as a miss.
818
+ if (cached && cached.markdown.length > 0 && !isChallengeMarkdown(cached.title, cached.markdown)) {
819
+ return {
820
+ url: pageUrl,
821
+ title: cached.title,
822
+ markdown: cached.markdown,
823
+ linksCount: cached.links.length,
824
+ cached: true,
825
+ cacheLayer: cached.cacheLayer || "postgres",
826
+ structuredData: cached.structuredData ?? null,
827
+ };
828
+ }
829
+ }
830
+ try {
831
+ const { looksLikeBinaryUrl, fetchAndParse } = await import("../parse/fetch.js");
832
+ if (looksLikeBinaryUrl(pageUrl)) {
833
+ const file = await fetchAndParse(pageUrl, {
834
+ timeoutMs: Math.max(options.timeoutMs ?? 6000, 30_000),
835
+ bypassCache: options.bypassCache,
836
+ // Crawls are bulk: keep OCR bounded per file.
837
+ maxOcrPages: 20,
838
+ });
839
+ if (file) {
840
+ const { result } = file;
841
+ return {
842
+ url: pageUrl,
843
+ title: result.title || pageUrl,
844
+ markdown: result.markdown,
845
+ linksCount: 0,
846
+ cached: result.cached,
847
+ cacheLayer: result.cacheLayer,
848
+ structuredData: { parse: { ...result.meta, contentHash: result.contentHash } },
849
+ statusCode: file.resource.status,
850
+ ...(result.markdown.length === 0
851
+ ? { error: result.meta.warnings[0] || "No content extracted from file" }
852
+ : {}),
853
+ };
854
+ }
855
+ }
856
+ const fetched = await fetchWithTlsImpersonation(pageUrl, options.timeoutMs ?? 6000);
857
+ if (isAntiBotChallenge(fetched.html, fetched.status)) {
858
+ // Never return, cache or bill the bot-check page as content.
859
+ return {
860
+ url: pageUrl,
861
+ title: pageUrl,
862
+ markdown: "",
863
+ linksCount: 0,
864
+ cached: false,
865
+ cacheLayer: "miss",
866
+ structuredData: null,
867
+ statusCode: fetched.status,
868
+ blocked: true,
869
+ error: `Blocked by the site's bot check (HTTP ${fetched.status})`,
870
+ };
871
+ }
872
+ const parsed = fastHtmlToMarkdown(fetched.html, pageUrl, { retainImages: "all" });
873
+ let structuredData = null;
874
+ if (options.extractStructured) {
875
+ const headings = Array.from(parsed.markdown.matchAll(/^#{1,3}\s+([^\n]+)/gm))
876
+ .map((m) => m[1].trim())
877
+ .slice(0, 8);
878
+ structuredData = {
879
+ title: parsed.title,
880
+ headings,
881
+ wordCount: parsed.markdown.split(/\s+/).filter(Boolean).length,
882
+ };
883
+ }
884
+ if (parsed.markdown.length > 0) {
885
+ setCachedPage(pageUrl, "all", {
886
+ title: parsed.title,
887
+ markdown: parsed.markdown,
888
+ links: parsed.links,
889
+ structuredData,
890
+ statusCode: fetched.status,
891
+ contentHash: sha256(parsed.markdown),
892
+ });
893
+ }
894
+ return {
895
+ url: pageUrl,
896
+ title: parsed.title,
897
+ markdown: parsed.markdown,
898
+ linksCount: parsed.links.length,
899
+ cached: false,
900
+ cacheLayer: "miss",
901
+ structuredData,
902
+ statusCode: fetched.status,
903
+ ...(parsed.markdown.length === 0 ? { error: `Empty page (HTTP ${fetched.status})` } : {}),
904
+ };
905
+ }
906
+ catch (err) {
907
+ return {
908
+ url: pageUrl,
909
+ title: pageUrl,
910
+ markdown: "",
911
+ linksCount: 0,
912
+ cached: false,
913
+ cacheLayer: "miss",
914
+ structuredData: null,
915
+ error: err instanceof Error ? err.message : String(err),
916
+ };
917
+ }
918
+ }