@webcrawl/mcp 1.4.1 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +9 -0
- package/dist/core/extract/index.js +9 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +72 -0
- package/dist/core/interact/driver.js +384 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +643 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +222 -0
- package/dist/core/search/catalog.d.ts +176 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/dist/format.js +8 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +7 -0
- package/package.json +11 -3
|
@@ -0,0 +1,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;
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
function toRegExp(pattern) {
|
|
9
|
+
const p = pattern.trim();
|
|
10
|
+
const anchored = p.endsWith("$");
|
|
11
|
+
const raw = anchored ? p.slice(0, -1) : p;
|
|
12
|
+
const hasWildcard = raw.includes("*");
|
|
13
|
+
const body = raw
|
|
14
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
15
|
+
.replace(/\*/g, ".*");
|
|
16
|
+
if (anchored)
|
|
17
|
+
return new RegExp(`^${body}$`);
|
|
18
|
+
if (hasWildcard)
|
|
19
|
+
return new RegExp(`^${body}`);
|
|
20
|
+
// Plain prefix: match at a segment boundary.
|
|
21
|
+
const trimmed = body.replace(/\/+$/, "");
|
|
22
|
+
return new RegExp(`^${trimmed}(?:/|$|\\?)`);
|
|
23
|
+
}
|
|
24
|
+
function normalizePattern(p) {
|
|
25
|
+
let s = String(p ?? "").trim();
|
|
26
|
+
if (!s)
|
|
27
|
+
return null;
|
|
28
|
+
// Accept full URLs by taking their path.
|
|
29
|
+
if (/^https?:\/\//i.test(s)) {
|
|
30
|
+
try {
|
|
31
|
+
s = new URL(s).pathname;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!s.startsWith("/") && !s.startsWith("*"))
|
|
38
|
+
s = `/${s}`;
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
export function buildPathScope(startUrl, options = {}) {
|
|
42
|
+
const clean = (list) => (list ?? []).map(normalizePattern).filter((x) => !!x).slice(0, 100);
|
|
43
|
+
let includes = clean(options.includePaths);
|
|
44
|
+
const excludes = clean(options.excludePaths);
|
|
45
|
+
if (includes.length === 0 && options.restrictToSubpath !== false) {
|
|
46
|
+
try {
|
|
47
|
+
const path = new URL(startUrl).pathname.replace(/\/+$/, "");
|
|
48
|
+
if (path && path !== "")
|
|
49
|
+
includes = [path];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ignore invalid start URL
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const inc = includes.map(toRegExp);
|
|
56
|
+
const exc = excludes.map(toRegExp);
|
|
57
|
+
let startKey = "";
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(startUrl);
|
|
60
|
+
startKey = (u.pathname.replace(/\/+$/, "") || "/") + u.search;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
includes,
|
|
67
|
+
excludes,
|
|
68
|
+
signature: `inc=${includes.join(",")}|exc=${excludes.join(",")}`,
|
|
69
|
+
allows(url) {
|
|
70
|
+
let key;
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(url);
|
|
73
|
+
key = (u.pathname || "/") + u.search;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
// The start URL itself is always allowed.
|
|
79
|
+
if ((key.replace(/\/+$/, "") || "/") === startKey)
|
|
80
|
+
return true;
|
|
81
|
+
if (exc.some((r) => r.test(key)))
|
|
82
|
+
return false;
|
|
83
|
+
if (inc.length && !inc.some((r) => r.test(key)))
|
|
84
|
+
return false;
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic render-provider tier (Tier R): a server-side JS-rendering provider
|
|
3
|
+
* for pages the native fetch ladder cannot render (JS SPAs, challenge shells).
|
|
4
|
+
*
|
|
5
|
+
* Same pattern as the residential-proxy tier: the provider URL comes from
|
|
6
|
+
* `WEBCRAWL_RENDER_URL` and is unset in the open-source repo — `null` disables
|
|
7
|
+
* the tier entirely, so the OSS build never requires a provider. The hosted
|
|
8
|
+
* webcrawl.app server sets the env (and the optional key), so cloud-mode calls
|
|
9
|
+
* render JS pages through the provider while local zero-key mode degrades to
|
|
10
|
+
* the `webcrawl login` prompt.
|
|
11
|
+
*
|
|
12
|
+
* The wire format parsed here is the de-facto standard "reader" text format
|
|
13
|
+
* (`Title:` / `URL Source:` / `Markdown Content:` headers), so any compatible
|
|
14
|
+
* provider works without code changes.
|
|
15
|
+
*/
|
|
16
|
+
/** Render-provider endpoint base URL, or null when the tier is disabled. */
|
|
17
|
+
export declare function renderProviderUrl(): string | null;
|
|
18
|
+
/** Optional bearer key for the render provider (hosted tier). */
|
|
19
|
+
export declare function renderProviderKey(): string | null;
|
|
20
|
+
/** Whether the render-provider tier is configured and usable at all. */
|
|
21
|
+
export declare function isRenderProviderEnabled(): boolean;
|
|
22
|
+
export interface RenderedPage {
|
|
23
|
+
title: string;
|
|
24
|
+
markdown: string;
|
|
25
|
+
sourceUrl: string;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
/** Splits a reader-format response body into its header fields and markdown. */
|
|
29
|
+
export declare function parseRenderedResponse(body: string): {
|
|
30
|
+
title: string;
|
|
31
|
+
sourceUrl: string;
|
|
32
|
+
markdown: string;
|
|
33
|
+
};
|
|
34
|
+
/** Test helper: clears the in-memory rate-limit window (external env untouched). */
|
|
35
|
+
export declare function resetRenderStateForTests(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Renders `url` through the configured render provider. Returns null when the
|
|
38
|
+
* tier is disabled, rate-limited, times out, or the provider itself reports a
|
|
39
|
+
* failed render — the caller then keeps its native result.
|
|
40
|
+
*/
|
|
41
|
+
export declare function renderViaProvider(url: string): Promise<RenderedPage | null>;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic render-provider tier (Tier R): a server-side JS-rendering provider
|
|
3
|
+
* for pages the native fetch ladder cannot render (JS SPAs, challenge shells).
|
|
4
|
+
*
|
|
5
|
+
* Same pattern as the residential-proxy tier: the provider URL comes from
|
|
6
|
+
* `WEBCRAWL_RENDER_URL` and is unset in the open-source repo — `null` disables
|
|
7
|
+
* the tier entirely, so the OSS build never requires a provider. The hosted
|
|
8
|
+
* webcrawl.app server sets the env (and the optional key), so cloud-mode calls
|
|
9
|
+
* render JS pages through the provider while local zero-key mode degrades to
|
|
10
|
+
* the `webcrawl login` prompt.
|
|
11
|
+
*
|
|
12
|
+
* The wire format parsed here is the de-facto standard "reader" text format
|
|
13
|
+
* (`Title:` / `URL Source:` / `Markdown Content:` headers), so any compatible
|
|
14
|
+
* provider works without code changes.
|
|
15
|
+
*/
|
|
16
|
+
/** Render-provider endpoint base URL, or null when the tier is disabled. */
|
|
17
|
+
export function renderProviderUrl() {
|
|
18
|
+
const url = (process.env.WEBCRAWL_RENDER_URL ?? "").trim();
|
|
19
|
+
return url.length > 0 ? url : null;
|
|
20
|
+
}
|
|
21
|
+
/** Optional bearer key for the render provider (hosted tier). */
|
|
22
|
+
export function renderProviderKey() {
|
|
23
|
+
const key = (process.env.WEBCRAWL_RENDER_KEY ?? "").trim();
|
|
24
|
+
return key.length > 0 ? key : null;
|
|
25
|
+
}
|
|
26
|
+
/** Whether the render-provider tier is configured and usable at all. */
|
|
27
|
+
export function isRenderProviderEnabled() {
|
|
28
|
+
return renderProviderUrl() !== null;
|
|
29
|
+
}
|
|
30
|
+
/** Default provider rate limit (requests/min) when no key is configured. */
|
|
31
|
+
const KEYLESS_RATE_LIMIT_PER_MIN = 15;
|
|
32
|
+
/** Provider rate limit (requests/min) assumed with a key configured. */
|
|
33
|
+
const KEYED_RATE_LIMIT_PER_MIN = 60;
|
|
34
|
+
/** Hard timeout for a single render request. */
|
|
35
|
+
const RENDER_TIMEOUT_MS = 45_000;
|
|
36
|
+
/** Max bytes accepted from the provider (matches the fetch ladder cap). */
|
|
37
|
+
const RENDER_MAX_BYTES = 2 * 1024 * 1024;
|
|
38
|
+
const rateWindow = [];
|
|
39
|
+
function withinRateLimit() {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
while (rateWindow.length > 0 && rateWindow[0] <= now - 60_000)
|
|
42
|
+
rateWindow.shift();
|
|
43
|
+
const limit = renderProviderKey() ? KEYED_RATE_LIMIT_PER_MIN : KEYLESS_RATE_LIMIT_PER_MIN;
|
|
44
|
+
if (rateWindow.length >= limit)
|
|
45
|
+
return false;
|
|
46
|
+
rateWindow.push(now);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Failure markers providers prepend/emit when the target page could not be
|
|
51
|
+
* rendered (challenge, CAPTCHA, upstream error). A matching response is not
|
|
52
|
+
* rendered content and must not be returned as such.
|
|
53
|
+
*/
|
|
54
|
+
const RENDER_FAILURE_MARKERS = [
|
|
55
|
+
"target url returned error",
|
|
56
|
+
"may be requiring captcha",
|
|
57
|
+
"maybe requiring captcha",
|
|
58
|
+
"please make sure you are authorized",
|
|
59
|
+
];
|
|
60
|
+
/** Splits a reader-format response body into its header fields and markdown. */
|
|
61
|
+
export function parseRenderedResponse(body) {
|
|
62
|
+
const lines = body.split("\n");
|
|
63
|
+
let title = "";
|
|
64
|
+
let sourceUrl = "";
|
|
65
|
+
let markdownStart = lines.length;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (line.startsWith("Title:")) {
|
|
69
|
+
title = line.slice("Title:".length).trim();
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (line.startsWith("URL Source:")) {
|
|
73
|
+
sourceUrl = line.slice("URL Source:".length).trim();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (/^Markdown Content:?\s*$/.test(line)) {
|
|
77
|
+
markdownStart = i + 1;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
let markdown = lines.slice(markdownStart).join("\n").trim();
|
|
82
|
+
if (markdownStart === lines.length && title) {
|
|
83
|
+
// No "Markdown Content:" separator: treat the whole body after the header
|
|
84
|
+
// block as markdown (some providers omit the separator).
|
|
85
|
+
markdown = lines
|
|
86
|
+
.filter((l) => !l.startsWith("Title:") && !l.startsWith("URL Source:"))
|
|
87
|
+
.join("\n")
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
return { title, sourceUrl, markdown };
|
|
91
|
+
}
|
|
92
|
+
/** Test helper: clears the in-memory rate-limit window (external env untouched). */
|
|
93
|
+
export function resetRenderStateForTests() {
|
|
94
|
+
rateWindow.length = 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Renders `url` through the configured render provider. Returns null when the
|
|
98
|
+
* tier is disabled, rate-limited, times out, or the provider itself reports a
|
|
99
|
+
* failed render — the caller then keeps its native result.
|
|
100
|
+
*/
|
|
101
|
+
export async function renderViaProvider(url) {
|
|
102
|
+
const base = renderProviderUrl();
|
|
103
|
+
if (!base)
|
|
104
|
+
return null;
|
|
105
|
+
if (!withinRateLimit())
|
|
106
|
+
return null;
|
|
107
|
+
const key = renderProviderKey();
|
|
108
|
+
const headers = {
|
|
109
|
+
Accept: "text/plain",
|
|
110
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
111
|
+
};
|
|
112
|
+
let res;
|
|
113
|
+
try {
|
|
114
|
+
res = await fetch(`${base}${url}`, {
|
|
115
|
+
headers,
|
|
116
|
+
signal: AbortSignal.timeout(RENDER_TIMEOUT_MS),
|
|
117
|
+
redirect: "follow",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null; // network error / timeout / DNS — keep native result
|
|
122
|
+
}
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
return null;
|
|
125
|
+
const body = (await res.text()).slice(0, RENDER_MAX_BYTES);
|
|
126
|
+
const lower = body.slice(0, 2000).toLowerCase();
|
|
127
|
+
if (RENDER_FAILURE_MARKERS.some((m) => lower.includes(m)))
|
|
128
|
+
return null;
|
|
129
|
+
const { title, sourceUrl, markdown } = parseRenderedResponse(body);
|
|
130
|
+
if (markdown.trim().length < 40)
|
|
131
|
+
return null; // nothing real came back
|
|
132
|
+
return {
|
|
133
|
+
title,
|
|
134
|
+
markdown,
|
|
135
|
+
sourceUrl: sourceUrl || url,
|
|
136
|
+
warnings: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare class UnsafeUrlError extends Error {
|
|
2
|
+
constructor(message?: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function isPrivateAddress(ip: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True for 198.18.0.0/15, the benchmarking range that fake-IP proxies
|
|
7
|
+
* (Clash, Surge, sing-box, some VPN clients) hand out for every hostname.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isFakeIpAddress(ip: string): boolean;
|
|
10
|
+
export declare const FAKE_IP_HINT: string;
|
|
11
|
+
export type HostResolver = (host: string) => Promise<string[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
14
|
+
* only to public addresses. Call it before every request and every redirect hop.
|
|
15
|
+
*/
|
|
16
|
+
export declare function assertSafeUrl(raw: string, resolve?: HostResolver): Promise<URL>;
|
|
17
|
+
export declare const MAX_REDIRECTS = 8;
|
|
18
|
+
export interface HopResponse {
|
|
19
|
+
status: number;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs `send` for `url` and follows redirects manually, validating every hop
|
|
24
|
+
* with `assertSafeUrl`. `send` must NOT follow redirects itself.
|
|
25
|
+
*/
|
|
26
|
+
export declare function followSafely<T extends HopResponse>(url: string, send: (hopUrl: string) => Promise<T>, resolve?: HostResolver): Promise<{
|
|
27
|
+
res: T;
|
|
28
|
+
finalUrl: string;
|
|
29
|
+
}>;
|