webcrawl 1.2.4 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -2
- package/dist/core/engine/awswaf.d.ts +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/package.json +82 -4
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface ScrapeOptions {
|
|
2
|
+
url: string;
|
|
3
|
+
formats?: Array<"markdown" | "html" | "links">;
|
|
4
|
+
retainImages?: "all" | "none";
|
|
5
|
+
removeSelectors?: string[];
|
|
6
|
+
render?: boolean;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
bypassCache?: boolean;
|
|
9
|
+
cache?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ScrapeResult {
|
|
12
|
+
success: boolean;
|
|
13
|
+
url: string;
|
|
14
|
+
title: string;
|
|
15
|
+
markdown: string;
|
|
16
|
+
html?: string;
|
|
17
|
+
links?: string[];
|
|
18
|
+
statusCode: number;
|
|
19
|
+
contentHash: string;
|
|
20
|
+
tier: "cache" | "static" | "sandbox" | "cloudflare" | "browser";
|
|
21
|
+
latencyMs: number;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function getBrowserHeaders(targetUrl: string, extraHeaders?: Record<string, string>): Record<string, string>;
|
|
25
|
+
export declare function setDomainCookie(hostname: string, cookieHeader: string): void;
|
|
26
|
+
export declare function isAntiBotChallenge(html: string, status: number): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
|
|
29
|
+
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
30
|
+
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
31
|
+
* Every redirect hop is checked against the SSRF guard.
|
|
32
|
+
*/
|
|
33
|
+
export declare function fetchWithTlsImpersonation(url: string, timeoutMs?: number): Promise<{
|
|
34
|
+
status: number;
|
|
35
|
+
html: string;
|
|
36
|
+
headers: Record<string, string>;
|
|
37
|
+
}>;
|
|
38
|
+
export interface FetchedResource {
|
|
39
|
+
status: number;
|
|
40
|
+
headers: Record<string, string>;
|
|
41
|
+
/** Declared content type without parameters, lower-cased ("" when missing). */
|
|
42
|
+
contentType: string;
|
|
43
|
+
body: Uint8Array;
|
|
44
|
+
/** Final URL after redirects when the transport reports it. */
|
|
45
|
+
url: string;
|
|
46
|
+
}
|
|
47
|
+
export declare class ResourceTooLargeError extends Error {
|
|
48
|
+
readonly bytes: number;
|
|
49
|
+
readonly maxBytes: number;
|
|
50
|
+
constructor(bytes: number, maxBytes: number);
|
|
51
|
+
}
|
|
52
|
+
/** Largest download accepted for any file (the per-type caps in src/parse/limits.ts are tighter). */
|
|
53
|
+
export declare const MAX_RESOURCE_BYTES: number;
|
|
54
|
+
/**
|
|
55
|
+
* Binary-safe GET. Same TLS impersonation as `fetchWithTlsImpersonation`, but keeps
|
|
56
|
+
* the body as bytes so PDFs, Office files, images and media survive intact.
|
|
57
|
+
*/
|
|
58
|
+
export declare function fetchResource(url: string, options?: {
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
maxBytes?: number;
|
|
61
|
+
}): Promise<FetchedResource>;
|
|
62
|
+
/**
|
|
63
|
+
* Detects whether an HTML response is an empty SPA shell or a Cloudflare/WAF challenge page
|
|
64
|
+
* that requires headless browser / Vercel Sandbox escalation.
|
|
65
|
+
*/
|
|
66
|
+
export declare function needsBrowserEscalation(status: number, html: string, markdown: string): boolean;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { followSafely, UnsafeUrlError } from "./safe-url.js";
|
|
2
|
+
const domainCookies = new Map();
|
|
3
|
+
const BROWSER_USER_AGENTS = [
|
|
4
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
5
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
6
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
|
7
|
+
];
|
|
8
|
+
export function getBrowserHeaders(targetUrl, extraHeaders) {
|
|
9
|
+
const ua = BROWSER_USER_AGENTS[Math.abs(hashCode(targetUrl)) % BROWSER_USER_AGENTS.length];
|
|
10
|
+
let hostCookie;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = new URL(targetUrl);
|
|
13
|
+
hostCookie = domainCookies.get(parsed.hostname);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore invalid URL
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
"User-Agent": ua,
|
|
20
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
|
21
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
22
|
+
"Accept-Encoding": "gzip, deflate, br",
|
|
23
|
+
"Cache-Control": "no-cache",
|
|
24
|
+
Pragma: "no-cache",
|
|
25
|
+
"Sec-Ch-Ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
|
|
26
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
27
|
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
28
|
+
"Sec-Fetch-Dest": "document",
|
|
29
|
+
"Sec-Fetch-Mode": "navigate",
|
|
30
|
+
"Sec-Fetch-Site": "none",
|
|
31
|
+
"Sec-Fetch-User": "?1",
|
|
32
|
+
"Upgrade-Insecure-Requests": "1",
|
|
33
|
+
...(hostCookie ? { Cookie: hostCookie } : {}),
|
|
34
|
+
...extraHeaders,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function setDomainCookie(hostname, cookieHeader) {
|
|
38
|
+
if (hostname && cookieHeader) {
|
|
39
|
+
domainCookies.set(hostname, cookieHeader);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function hashCode(str) {
|
|
43
|
+
let hash = 0;
|
|
44
|
+
for (let i = 0; i < str.length; i++) {
|
|
45
|
+
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
46
|
+
hash |= 0;
|
|
47
|
+
}
|
|
48
|
+
return hash;
|
|
49
|
+
}
|
|
50
|
+
export function isAntiBotChallenge(html, status) {
|
|
51
|
+
if (status === 403 || status === 429 || status === 503) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
const lower = html.slice(0, 6000).toLowerCase();
|
|
55
|
+
return (lower.includes("cf-browser-verification") ||
|
|
56
|
+
lower.includes("challenge-platform") ||
|
|
57
|
+
lower.includes("just a moment...") ||
|
|
58
|
+
lower.includes("vercel security checkpoint") ||
|
|
59
|
+
lower.includes("_vercel/security") ||
|
|
60
|
+
lower.includes("enable javascript and cookies to continue") ||
|
|
61
|
+
lower.includes("_cf_chl_opt"));
|
|
62
|
+
}
|
|
63
|
+
let sharedImpit = null;
|
|
64
|
+
/** One request, redirects NOT followed (callers validate each hop via followSafely). */
|
|
65
|
+
async function rawGet(url, reqHeaders, timeoutMs, maxBytes) {
|
|
66
|
+
const read = async (status, headers, getBuf) => {
|
|
67
|
+
const isRedirect = status >= 300 && status < 400 && !!headers["location"];
|
|
68
|
+
if (!isRedirect && maxBytes !== undefined)
|
|
69
|
+
checkDeclaredLength(headers, maxBytes);
|
|
70
|
+
return { status, headers, body: isRedirect ? new ArrayBuffer(0) : await getBuf() };
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
if (!sharedImpit) {
|
|
74
|
+
const impitMod = await import("impit");
|
|
75
|
+
sharedImpit = new impitMod.Impit({
|
|
76
|
+
browser: "chrome",
|
|
77
|
+
ignoreTlsErrors: true,
|
|
78
|
+
timeout: timeoutMs,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const res = await sharedImpit.fetch(url, {
|
|
82
|
+
method: "GET",
|
|
83
|
+
headers: reqHeaders,
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
redirect: "manual",
|
|
86
|
+
});
|
|
87
|
+
return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err instanceof ResourceTooLargeError || err instanceof UnsafeUrlError)
|
|
91
|
+
throw err;
|
|
92
|
+
const res = await fetch(url, {
|
|
93
|
+
headers: reqHeaders,
|
|
94
|
+
redirect: "manual",
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
96
|
+
});
|
|
97
|
+
return await read(res.status, headersToObject(res.headers), () => res.arrayBuffer());
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Fetches a URL using `impit` (BoringSSL Chrome 136 TLS JA3/JA4 + HTTP/2 fingerprint impersonation)
|
|
102
|
+
* so Cloudflare Bot Management and Vercel Security Checkpoint treat requests as real Chrome.
|
|
103
|
+
* Falls back to standard `fetch` if `impit` is unavailable on the current platform.
|
|
104
|
+
* Every redirect hop is checked against the SSRF guard.
|
|
105
|
+
*/
|
|
106
|
+
export async function fetchWithTlsImpersonation(url, timeoutMs = 12_000) {
|
|
107
|
+
const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, getBrowserHeaders(hop), timeoutMs));
|
|
108
|
+
if (res.headers["set-cookie"]) {
|
|
109
|
+
try {
|
|
110
|
+
setDomainCookie(new URL(finalUrl).hostname, res.headers["set-cookie"].split(";")[0]);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: res.status,
|
|
118
|
+
html: new TextDecoder().decode(res.body),
|
|
119
|
+
headers: res.headers,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export class ResourceTooLargeError extends Error {
|
|
123
|
+
bytes;
|
|
124
|
+
maxBytes;
|
|
125
|
+
constructor(bytes, maxBytes) {
|
|
126
|
+
super(`File is ${(bytes / 1e6).toFixed(1)} MB; the limit is ${(maxBytes / 1e6).toFixed(0)} MB`);
|
|
127
|
+
this.bytes = bytes;
|
|
128
|
+
this.maxBytes = maxBytes;
|
|
129
|
+
this.name = "ResourceTooLargeError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Largest download accepted for any file (the per-type caps in src/parse/limits.ts are tighter). */
|
|
133
|
+
export const MAX_RESOURCE_BYTES = 100 * 1024 * 1024;
|
|
134
|
+
function headersToObject(headers) {
|
|
135
|
+
const out = {};
|
|
136
|
+
const h = headers;
|
|
137
|
+
if (h && typeof h.forEach === "function") {
|
|
138
|
+
h.forEach((v, k) => {
|
|
139
|
+
out[String(k).toLowerCase()] = String(v);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
function checkDeclaredLength(headers, maxBytes) {
|
|
145
|
+
const declared = Number(headers["content-length"]);
|
|
146
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
147
|
+
throw new ResourceTooLargeError(declared, maxBytes);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Binary-safe GET. Same TLS impersonation as `fetchWithTlsImpersonation`, but keeps
|
|
152
|
+
* the body as bytes so PDFs, Office files, images and media survive intact.
|
|
153
|
+
*/
|
|
154
|
+
export async function fetchResource(url, options = {}) {
|
|
155
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
156
|
+
const maxBytes = options.maxBytes ?? MAX_RESOURCE_BYTES;
|
|
157
|
+
const { res, finalUrl } = await followSafely(url, (hop) => rawGet(hop, { ...getBrowserHeaders(hop), Accept: "*/*" }, timeoutMs, maxBytes));
|
|
158
|
+
if (res.body.byteLength > maxBytes)
|
|
159
|
+
throw new ResourceTooLargeError(res.body.byteLength, maxBytes);
|
|
160
|
+
return {
|
|
161
|
+
status: res.status,
|
|
162
|
+
headers: res.headers,
|
|
163
|
+
contentType: (res.headers["content-type"] || "").split(";")[0].trim().toLowerCase(),
|
|
164
|
+
body: new Uint8Array(res.body),
|
|
165
|
+
url: finalUrl,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Detects whether an HTML response is an empty SPA shell or a Cloudflare/WAF challenge page
|
|
170
|
+
* that requires headless browser / Vercel Sandbox escalation.
|
|
171
|
+
*/
|
|
172
|
+
export function needsBrowserEscalation(status, html, markdown) {
|
|
173
|
+
if (isAntiBotChallenge(html, status)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const lower = html.slice(0, 6000).toLowerCase();
|
|
177
|
+
// Empty SPA shell check (<div id="root"></div> or <div id="app"></div> with almost no text)
|
|
178
|
+
if (markdown.length < 120 &&
|
|
179
|
+
(lower.includes('id="root"') ||
|
|
180
|
+
lower.includes('id="app"') ||
|
|
181
|
+
lower.includes('id="__next"'))) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export declare function decodeHtmlEntities(text: string): string;
|
|
2
|
+
export interface MarkdownOptions {
|
|
3
|
+
retainImages?: "all" | "none";
|
|
4
|
+
/** Default true: prefer <main>/<article> and drop header/footer/nav/aside. */
|
|
5
|
+
onlyMainContent?: boolean;
|
|
6
|
+
/** Simple selectors (`tag`, `.class`, `#id`, `tag.class`). Keep only matching elements. */
|
|
7
|
+
includeTags?: string[];
|
|
8
|
+
/** Same selector syntax. Matching elements are removed. */
|
|
9
|
+
excludeTags?: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function removeElements(html: string, rawSelectors: string[]): string;
|
|
12
|
+
export declare function keepElements(html: string, rawSelectors: string[]): string;
|
|
13
|
+
/**
|
|
14
|
+
* Ultra-fast zero-DOM streaming regex/state-machine HTML-to-Markdown converter.
|
|
15
|
+
* Processes a 200KB HTML document in ~1.2ms (vs 45-120ms for JSDOM + Turndown).
|
|
16
|
+
*/
|
|
17
|
+
export declare function fastHtmlToMarkdown(html: string, baseUrl: string, options?: MarkdownOptions): {
|
|
18
|
+
title: string;
|
|
19
|
+
markdown: string;
|
|
20
|
+
links: string[];
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fast anchor href extractor for high-speed multi-hop BFS crawling.
|
|
24
|
+
*/
|
|
25
|
+
export declare function extractLinksFromHtml(html: string, pageUrl: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Discovers all internal URLs for a website:
|
|
28
|
+
* 1. Checks 24-hour cache first (unless `bypassCache: true`).
|
|
29
|
+
* 2. Probes `robots.txt`, `/sitemap.xml`, `/sitemap_index.xml`, and subpath `sitemap.xml` in parallel.
|
|
30
|
+
* 3. When `sitemap.xml` is missing (or incomplete), performs a full multi-hop concurrent BFS crawl
|
|
31
|
+
* across the entire website (with automatic `@vercel/sandbox` / browser escalation if the entry page is an SPA),
|
|
32
|
+
* and caches both the discovered site map and scraped pages for 24 hours.
|
|
33
|
+
*/
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
const HTML_ENTITIES = {
|
|
2
|
+
"&": "&",
|
|
3
|
+
"<": "<",
|
|
4
|
+
">": ">",
|
|
5
|
+
""": '"',
|
|
6
|
+
"'": "'",
|
|
7
|
+
"'": "'",
|
|
8
|
+
" ": " ",
|
|
9
|
+
"–": "–",
|
|
10
|
+
"—": "\u2014",
|
|
11
|
+
"©": "©",
|
|
12
|
+
"®": "®",
|
|
13
|
+
"™": "™",
|
|
14
|
+
"…": "…",
|
|
15
|
+
};
|
|
16
|
+
export function decodeHtmlEntities(text) {
|
|
17
|
+
return text.replace(/&[a-zA-Z0-9#]+;/g, (entity) => {
|
|
18
|
+
if (HTML_ENTITIES[entity]) {
|
|
19
|
+
return HTML_ENTITIES[entity];
|
|
20
|
+
}
|
|
21
|
+
if (entity.startsWith("&#x") || entity.startsWith("&#X")) {
|
|
22
|
+
const code = parseInt(entity.slice(3, -1), 16);
|
|
23
|
+
return !Number.isNaN(code) ? String.fromCodePoint(code) : entity;
|
|
24
|
+
}
|
|
25
|
+
if (entity.startsWith("&#")) {
|
|
26
|
+
const code = parseInt(entity.slice(2, -1), 10);
|
|
27
|
+
return !Number.isNaN(code) ? String.fromCodePoint(code) : entity;
|
|
28
|
+
}
|
|
29
|
+
return entity;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
|
33
|
+
function parseSelector(raw) {
|
|
34
|
+
const m = raw.trim().match(/^([a-z][a-z0-9-]*)?((?:[.#][\w-]+)*)$/i);
|
|
35
|
+
if (!m || (!m[1] && !m[2]))
|
|
36
|
+
return null;
|
|
37
|
+
const sel = { tag: m[1]?.toLowerCase(), classes: [] };
|
|
38
|
+
for (const part of m[2].match(/[.#][\w-]+/g) ?? []) {
|
|
39
|
+
if (part[0] === "#")
|
|
40
|
+
sel.id = part.slice(1);
|
|
41
|
+
else
|
|
42
|
+
sel.classes.push(part.slice(1));
|
|
43
|
+
}
|
|
44
|
+
return sel;
|
|
45
|
+
}
|
|
46
|
+
function openTagMatches(tagName, attrs, sel) {
|
|
47
|
+
if (sel.tag && sel.tag !== tagName)
|
|
48
|
+
return false;
|
|
49
|
+
if (sel.id) {
|
|
50
|
+
const id = attrs.match(/\bid=["']([^"']*)["']/i)?.[1];
|
|
51
|
+
if (id !== sel.id)
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (sel.classes.length) {
|
|
55
|
+
const cls = (attrs.match(/\bclass=["']([^"']*)["']/i)?.[1] ?? "").split(/\s+/);
|
|
56
|
+
if (!sel.classes.every((c) => cls.includes(c)))
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** Finds balanced `[start, end)` ranges of elements matching any selector (outermost only). */
|
|
62
|
+
function findElementRanges(html, selectors) {
|
|
63
|
+
const ranges = [];
|
|
64
|
+
const openRe = /<([a-z][a-z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/gi;
|
|
65
|
+
let m;
|
|
66
|
+
let skipUntil = 0;
|
|
67
|
+
while ((m = openRe.exec(html)) !== null) {
|
|
68
|
+
if (m.index < skipUntil)
|
|
69
|
+
continue;
|
|
70
|
+
const tag = m[1].toLowerCase();
|
|
71
|
+
if (!selectors.some((sel) => openTagMatches(tag, m[2], sel)))
|
|
72
|
+
continue;
|
|
73
|
+
if (VOID_TAGS.has(tag) || m[2].trimEnd().endsWith("/")) {
|
|
74
|
+
ranges.push([m.index, m.index + m[0].length]);
|
|
75
|
+
skipUntil = m.index + m[0].length;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const re = new RegExp(`<(/?)${tag}(?=[\\s>/])(?:[^>"']|"[^"]*"|'[^']*')*>`, "gi");
|
|
79
|
+
re.lastIndex = m.index + m[0].length;
|
|
80
|
+
let depth = 1;
|
|
81
|
+
let end = html.length;
|
|
82
|
+
let t;
|
|
83
|
+
while ((t = re.exec(html)) !== null) {
|
|
84
|
+
depth += t[1] ? -1 : 1;
|
|
85
|
+
if (depth === 0) {
|
|
86
|
+
end = t.index + t[0].length;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
ranges.push([m.index, end]);
|
|
91
|
+
skipUntil = end;
|
|
92
|
+
}
|
|
93
|
+
return ranges;
|
|
94
|
+
}
|
|
95
|
+
export function removeElements(html, rawSelectors) {
|
|
96
|
+
const sels = rawSelectors.map(parseSelector).filter((x) => !!x);
|
|
97
|
+
if (!sels.length)
|
|
98
|
+
return html;
|
|
99
|
+
const ranges = findElementRanges(html, sels);
|
|
100
|
+
let out = "";
|
|
101
|
+
let pos = 0;
|
|
102
|
+
for (const [a, b] of ranges) {
|
|
103
|
+
out += html.slice(pos, a);
|
|
104
|
+
pos = b;
|
|
105
|
+
}
|
|
106
|
+
return out + html.slice(pos);
|
|
107
|
+
}
|
|
108
|
+
export function keepElements(html, rawSelectors) {
|
|
109
|
+
const sels = rawSelectors.map(parseSelector).filter((x) => !!x);
|
|
110
|
+
if (!sels.length)
|
|
111
|
+
return html;
|
|
112
|
+
const ranges = findElementRanges(html, sels);
|
|
113
|
+
if (!ranges.length)
|
|
114
|
+
return html;
|
|
115
|
+
return ranges.map(([a, b]) => html.slice(a, b)).join("\n");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Ultra-fast zero-DOM streaming regex/state-machine HTML-to-Markdown converter.
|
|
119
|
+
* Processes a 200KB HTML document in ~1.2ms (vs 45-120ms for JSDOM + Turndown).
|
|
120
|
+
*/
|
|
121
|
+
export function fastHtmlToMarkdown(html, baseUrl, options = {}) {
|
|
122
|
+
// 1. Extract <title> or og:title, plus meta description
|
|
123
|
+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) ||
|
|
124
|
+
html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ||
|
|
125
|
+
html.match(/<h1(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/h1>/i);
|
|
126
|
+
const rawTitle = titleMatch ? stripTags(titleMatch[1]).trim() : "";
|
|
127
|
+
const title = decodeHtmlEntities(rawTitle) || baseUrl;
|
|
128
|
+
const metaDescMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ||
|
|
129
|
+
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i) ||
|
|
130
|
+
html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
|
|
131
|
+
const metaDescription = metaDescMatch
|
|
132
|
+
? decodeHtmlEntities(metaDescMatch[1].trim())
|
|
133
|
+
: "";
|
|
134
|
+
// 2. Extract all <a href="..."> links before stripping nav/header/footer
|
|
135
|
+
const linksSet = new Set();
|
|
136
|
+
const linkRegex = /<a\s+[^>]*href=["']([^"'#]+)["'][^>]*>/gi;
|
|
137
|
+
let linkMatch;
|
|
138
|
+
while ((linkMatch = linkRegex.exec(html)) !== null) {
|
|
139
|
+
const href = linkMatch[1].trim();
|
|
140
|
+
if (!href ||
|
|
141
|
+
href.startsWith("javascript:") ||
|
|
142
|
+
href.startsWith("mailto:") ||
|
|
143
|
+
href.startsWith("tel:")) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const resolved = new URL(href, baseUrl);
|
|
148
|
+
resolved.hash = "";
|
|
149
|
+
if (resolved.protocol === "http:" || resolved.protocol === "https:") {
|
|
150
|
+
linksSet.add(resolved.toString());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// ignore malformed URLs
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 3. Focus on <main>/<article> (unless onlyMainContent is false), else <body>
|
|
158
|
+
const onlyMain = options.onlyMainContent !== false;
|
|
159
|
+
let bodyHtml = html;
|
|
160
|
+
if (options.includeTags?.length) {
|
|
161
|
+
bodyHtml = keepElements(html, options.includeTags);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const mainMatch = onlyMain
|
|
165
|
+
? html.match(/<main[^>]*>([\s\S]*?)<\/main>/i) ||
|
|
166
|
+
html.match(/<article[^>]*>([\s\S]*?)<\/article>/i)
|
|
167
|
+
: null;
|
|
168
|
+
if (mainMatch && mainMatch[1].length > 400) {
|
|
169
|
+
bodyHtml = mainMatch[1];
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
173
|
+
if (bodyMatch) {
|
|
174
|
+
bodyHtml = bodyMatch[1];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (options.excludeTags?.length) {
|
|
179
|
+
bodyHtml = removeElements(bodyHtml, options.excludeTags);
|
|
180
|
+
}
|
|
181
|
+
// 4. Strip noise tags: script, style, noscript, svg, iframe, dialog, form, aria-hidden (+ nav chrome when onlyMainContent)
|
|
182
|
+
let cleaned = bodyHtml
|
|
183
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
184
|
+
.replace(/<(script|style|noscript|svg|canvas|template|iframe|dialog|form)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "");
|
|
185
|
+
if (onlyMain) {
|
|
186
|
+
cleaned = cleaned.replace(/<(header|footer|nav|aside)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "");
|
|
187
|
+
}
|
|
188
|
+
cleaned = cleaned.replace(/<([a-z0-9]+)\b(?:[^>"']|"[^"]*"|'[^']*')*\baria-hidden=["']true["'](?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "");
|
|
189
|
+
// 5. Preserve <pre><code> blocks via placeholders so whitespace isn't collapsed
|
|
190
|
+
const codeBlocks = [];
|
|
191
|
+
cleaned = cleaned.replace(/<pre[^>]*>\s*(?:<code[^>]*>)?([\s\S]*?)(?:<\/code>)?\s*<\/pre>/gi, (_full, codeContent) => {
|
|
192
|
+
const idx = codeBlocks.length;
|
|
193
|
+
const decoded = decodeHtmlEntities(stripTags(codeContent)).trim();
|
|
194
|
+
codeBlocks.push(`\n\`\`\`\n${decoded}\n\`\`\`\n`);
|
|
195
|
+
return `__WEBCRAWL_CODE_BLOCK_${idx}__`;
|
|
196
|
+
});
|
|
197
|
+
// 6. Images
|
|
198
|
+
if (options.retainImages === "none") {
|
|
199
|
+
cleaned = cleaned.replace(/<img[^>]*>/gi, "");
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
cleaned = cleaned.replace(/<img\s+[^>]*>/gi, (imgTag) => {
|
|
203
|
+
const srcMatch = imgTag.match(/src=["']([^"']+)["']/i);
|
|
204
|
+
if (!srcMatch) {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i);
|
|
208
|
+
const alt = altMatch ? decodeHtmlEntities(altMatch[1].trim()) : "";
|
|
209
|
+
try {
|
|
210
|
+
const absSrc = new URL(srcMatch[1], baseUrl).toString();
|
|
211
|
+
return ``;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// 7. Links: <a href="...">text</a> -> [text](url)
|
|
219
|
+
cleaned = cleaned.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_full, href, inner) => {
|
|
220
|
+
const linkText = decodeHtmlEntities(stripTags(inner))
|
|
221
|
+
.replace(/\s+/g, " ")
|
|
222
|
+
.trim();
|
|
223
|
+
if (!linkText) {
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
if (href.startsWith("javascript:") || href.startsWith("#")) {
|
|
227
|
+
return linkText;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const absUrl = new URL(href, baseUrl).toString();
|
|
231
|
+
return `[${linkText}](${absUrl})`;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return linkText;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
// 8. Headings h1-h6 (handle > inside quoted attributes like Tailwind class="[&>*]:mb-0")
|
|
238
|
+
cleaned = cleaned.replace(/<h([1-6])(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/h\1>/gi, (_f, level, inner) => {
|
|
239
|
+
const hashes = "#".repeat(Number(level));
|
|
240
|
+
const text = decodeHtmlEntities(stripTags(inner))
|
|
241
|
+
.replace(/\s+/g, " ")
|
|
242
|
+
.trim();
|
|
243
|
+
return text ? `\n\n${hashes} ${text}\n\n` : "";
|
|
244
|
+
});
|
|
245
|
+
// 9. Inline formatting
|
|
246
|
+
cleaned = cleaned
|
|
247
|
+
.replace(/<(strong|b)(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?>([\s\S]*?)<\/\1>/gi, "**$2**")
|
|
248
|
+
.replace(/<(em|i)(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?>([\s\S]*?)<\/\1>/gi, "*$2*")
|
|
249
|
+
.replace(/<code(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/code>/gi, (_f, inner) => {
|
|
250
|
+
const text = decodeHtmlEntities(stripTags(inner)).trim();
|
|
251
|
+
return text ? `\`${text}\`` : "";
|
|
252
|
+
});
|
|
253
|
+
// 10. Lists and paragraphs and tables
|
|
254
|
+
cleaned = cleaned
|
|
255
|
+
.replace(/<li(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/li>/gi, (_f, inner) => {
|
|
256
|
+
const item = decodeHtmlEntities(stripTags(inner))
|
|
257
|
+
.replace(/\s+/g, " ")
|
|
258
|
+
.trim();
|
|
259
|
+
return item ? `\n- ${item}` : "";
|
|
260
|
+
})
|
|
261
|
+
.replace(/<tr(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/tr>/gi, (_f, row) => {
|
|
262
|
+
const cells = [];
|
|
263
|
+
row.replace(/<t[dh](?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/t[dh]>/gi, (_c, cell) => {
|
|
264
|
+
cells.push(decodeHtmlEntities(stripTags(cell)).replace(/\s+/g, " ").trim());
|
|
265
|
+
return "";
|
|
266
|
+
});
|
|
267
|
+
return cells.length > 0 ? `\n| ${cells.join(" | ")} |` : "";
|
|
268
|
+
})
|
|
269
|
+
.replace(/<blockquote(?:[^>"']|"[^"]*"|'[^']*')*>([\s\S]*?)<\/blockquote>/gi, (_f, inner) => {
|
|
270
|
+
const text = decodeHtmlEntities(stripTags(inner))
|
|
271
|
+
.replace(/\s+/g, " ")
|
|
272
|
+
.trim();
|
|
273
|
+
return text ? `\n\n> ${text}\n\n` : "";
|
|
274
|
+
})
|
|
275
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
276
|
+
.replace(/<\/(p|div|section|article|ul|ol|table|dl)>/gi, "\n\n")
|
|
277
|
+
.replace(/<hr\s*\/?>/gi, "\n\n---\n\n");
|
|
278
|
+
// 11. Strip remaining HTML tags and decode entities
|
|
279
|
+
cleaned = decodeHtmlEntities(stripTags(cleaned));
|
|
280
|
+
// 12. Restore code blocks
|
|
281
|
+
cleaned = cleaned.replace(/__WEBCRAWL_CODE_BLOCK_(\d+)__/g, (_f, idx) => codeBlocks[Number(idx)] || "");
|
|
282
|
+
// 13. Normalize whitespace and prepend meta description if not already in body
|
|
283
|
+
let markdown = cleaned
|
|
284
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
285
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
286
|
+
.trim();
|
|
287
|
+
if (metaDescription && !markdown.includes(metaDescription.slice(0, 40))) {
|
|
288
|
+
markdown = `> ${metaDescription}\n\n${markdown}`;
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
title,
|
|
292
|
+
markdown,
|
|
293
|
+
links: Array.from(linksSet),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function stripTags(html) {
|
|
297
|
+
return html.replace(/<(?:[^>"']|"[^"]*"|'[^']*')+>/g, " ");
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Fast anchor href extractor for high-speed multi-hop BFS crawling.
|
|
301
|
+
*/
|
|
302
|
+
export function extractLinksFromHtml(html, pageUrl) {
|
|
303
|
+
const links = [];
|
|
304
|
+
const hrefRegex = /<a\s+[^>]*href=["']([^"'#]+)["'][^>]*>/gi;
|
|
305
|
+
let match;
|
|
306
|
+
while ((match = hrefRegex.exec(html)) !== null) {
|
|
307
|
+
const rawHref = match[1].trim();
|
|
308
|
+
if (!rawHref ||
|
|
309
|
+
rawHref.startsWith("javascript:") ||
|
|
310
|
+
rawHref.startsWith("mailto:") ||
|
|
311
|
+
rawHref.startsWith("tel:")) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const resolved = new URL(decodeHtmlEntities(rawHref), pageUrl);
|
|
316
|
+
if (resolved.protocol === "http:" || resolved.protocol === "https:") {
|
|
317
|
+
resolved.hash = "";
|
|
318
|
+
links.push(resolved.toString());
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// ignore invalid URL
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return links;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Discovers all internal URLs for a website:
|
|
329
|
+
* 1. Checks 24-hour cache first (unless `bypassCache: true`).
|
|
330
|
+
* 2. Probes `robots.txt`, `/sitemap.xml`, `/sitemap_index.xml`, and subpath `sitemap.xml` in parallel.
|
|
331
|
+
* 3. When `sitemap.xml` is missing (or incomplete), performs a full multi-hop concurrent BFS crawl
|
|
332
|
+
* across the entire website (with automatic `@vercel/sandbox` / browser escalation if the entry page is an SPA),
|
|
333
|
+
* and caches both the discovered site map and scraped pages for 24 hours.
|
|
334
|
+
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path scoping for map/crawl: restrict discovered URLs to a subpath and/or
|
|
3
|
+
* include/exclude path patterns. Patterns match the URL path (+ query):
|
|
4
|
+
* "/docs" prefix at a segment boundary ("/docs", "/docs/a", not "/docs-old")
|
|
5
|
+
* "/blog/*" `*` matches any characters
|
|
6
|
+
* "/api/*\/v1$" trailing `$` anchors the end
|
|
7
|
+
*/
|
|
8
|
+
export interface PathScopeOptions {
|
|
9
|
+
includePaths?: string[];
|
|
10
|
+
excludePaths?: string[];
|
|
11
|
+
/** Default true: when the start URL has a path and no includePaths are given, stay under it. */
|
|
12
|
+
restrictToSubpath?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface PathScope {
|
|
15
|
+
allows(url: string): boolean;
|
|
16
|
+
/** Stable string for cache keys. */
|
|
17
|
+
signature: string;
|
|
18
|
+
includes: string[];
|
|
19
|
+
excludes: string[];
|
|
20
|
+
}
|
|
21
|
+
export declare function buildPathScope(startUrl: string, options?: PathScopeOptions): PathScope;
|