@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.
- 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-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +28 -0
- package/dist/core/engine/chrome.js +208 -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 +33 -0
- package/dist/core/extract/structured.js +316 -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 +62 -0
- package/dist/core/local-engine.js +726 -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 +721 -0
- package/dist/core/search/providers.d.ts +84 -0
- package/dist/core/search/providers.js +1408 -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 +5 -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 +6 -0
- 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
|
+
}
|