webcrawl 1.4.0 → 1.4.2
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/bin.js +0 -0
- package/dist/cli.js +6 -1
- package/dist/core/engine/safe-url.d.ts +6 -0
- package/dist/core/engine/safe-url.js +19 -2
- package/dist/core/local-engine.d.ts +11 -0
- package/dist/core/local-engine.js +63 -15
- package/dist/format.js +3 -0
- package/package.json +3 -3
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -434,8 +434,13 @@ export async function main(argv, io, deps = {}) {
|
|
|
434
434
|
}
|
|
435
435
|
case "scrape": {
|
|
436
436
|
const res = await client.scrape({ url: need("url"), formats: ["markdown"], noCache: v["no-cache"] });
|
|
437
|
+
// Blocked pages come back as success:false: report on stderr and exit 1, not empty content.
|
|
438
|
+
if (res.success === false && !v.json) {
|
|
439
|
+
io.err(`${formatScrape(res)}\n`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
437
442
|
emit(res, formatScrape(res));
|
|
438
|
-
return 0;
|
|
443
|
+
return res.success === false ? 1 : 0;
|
|
439
444
|
}
|
|
440
445
|
case "map": {
|
|
441
446
|
const res = await client.map({ url: need("url"), search: v.filter, limit: int(v.limit, "--limit") });
|
|
@@ -2,6 +2,12 @@ export declare class UnsafeUrlError extends Error {
|
|
|
2
2
|
constructor(message?: string);
|
|
3
3
|
}
|
|
4
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;
|
|
5
11
|
export type HostResolver = (host: string) => Promise<string[]>;
|
|
6
12
|
/**
|
|
7
13
|
* Throws `UnsafeUrlError` unless `raw` is an http(s) URL whose host resolves
|
|
@@ -35,6 +35,20 @@ export function isPrivateAddress(ip) {
|
|
|
35
35
|
}
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* True for 198.18.0.0/15, the benchmarking range that fake-IP proxies
|
|
40
|
+
* (Clash, Surge, sing-box, some VPN clients) hand out for every hostname.
|
|
41
|
+
*/
|
|
42
|
+
export function isFakeIpAddress(ip) {
|
|
43
|
+
const s = ip.toLowerCase().replace(/^::ffff:/, "");
|
|
44
|
+
if (isIP(s) !== 4)
|
|
45
|
+
return false;
|
|
46
|
+
const [a, b] = s.split(".").map(Number);
|
|
47
|
+
return a === 198 && (b === 18 || b === 19);
|
|
48
|
+
}
|
|
49
|
+
export const FAKE_IP_HINT = "That address isn't publicly reachable: it resolved to 198.18.x.x, which fake-IP proxies " +
|
|
50
|
+
"(Clash, Surge, sing-box, some VPNs) return for every site. Switch the proxy to redir-host/real-IP " +
|
|
51
|
+
"DNS mode, or set WEBCRAWL_ALLOW_PRIVATE_URLS=true for local runs you trust.";
|
|
38
52
|
const defaultResolve = async (h) => (await lookup(h, { all: true })).map((r) => r.address);
|
|
39
53
|
/** Local development escape hatch (never set in production). */
|
|
40
54
|
function allowPrivate() {
|
|
@@ -67,8 +81,11 @@ export async function assertSafeUrl(raw, resolve = defaultResolve) {
|
|
|
67
81
|
const addrs = isIP(host) ? [host] : await resolve(host).catch(() => []);
|
|
68
82
|
if (addrs.length === 0)
|
|
69
83
|
throw new UnsafeUrlError("We couldn't find that website");
|
|
70
|
-
if (addrs.some(isPrivateAddress))
|
|
71
|
-
|
|
84
|
+
if (addrs.some(isPrivateAddress)) {
|
|
85
|
+
// Every address in the fake-IP range means a local proxy rewrote DNS,
|
|
86
|
+
// not that the site is internal: say so and name the workaround.
|
|
87
|
+
throw new UnsafeUrlError(addrs.every(isFakeIpAddress) ? FAKE_IP_HINT : undefined);
|
|
88
|
+
}
|
|
72
89
|
return u;
|
|
73
90
|
}
|
|
74
91
|
export const MAX_REDIRECTS = 8;
|
|
@@ -32,4 +32,15 @@ export interface LocalEngineBackend {
|
|
|
32
32
|
targets(): Promise<Record<string, unknown>>;
|
|
33
33
|
usage(): Promise<Record<string, unknown>>;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
37
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
38
|
+
* for content. Exported for unit tests.
|
|
39
|
+
*/
|
|
40
|
+
export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number): Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
43
|
+
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
44
|
+
*/
|
|
45
|
+
export declare function filterMappedLinks(links: string[], search?: string, limit?: number): string[];
|
|
35
46
|
export declare function createLocalEngineClient(): LocalEngineBackend;
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* Every community improvement to extractors, parsers, or targets in `src/` immediately
|
|
10
10
|
* powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
|
|
11
11
|
*/
|
|
12
|
-
import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
|
|
12
|
+
import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
|
|
13
13
|
import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
|
|
14
14
|
import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
|
|
15
|
-
import { fetchResource, lastFetchTier, needsBrowserEscalation } from "./engine/fetcher.js";
|
|
15
|
+
import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
|
|
16
16
|
import { parseFile, publicParseMeta } from "./parse/index.js";
|
|
17
17
|
import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
|
|
18
18
|
import { maxDownloadBytes } from "./parse/limits.js";
|
|
@@ -29,6 +29,42 @@ function safeDomainOf(url) {
|
|
|
29
29
|
return "";
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Scrape result for a page the site refused (bot check, 403/429/503).
|
|
34
|
+
* `success: false` with no markdown, so callers never mistake the block page
|
|
35
|
+
* for content. Exported for unit tests.
|
|
36
|
+
*/
|
|
37
|
+
export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
|
|
38
|
+
const error = `Blocked by the site's bot check (HTTP ${statusCode}). No content was returned. ` +
|
|
39
|
+
"Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.";
|
|
40
|
+
return {
|
|
41
|
+
success: false,
|
|
42
|
+
mode: "local",
|
|
43
|
+
url,
|
|
44
|
+
blocked: true,
|
|
45
|
+
statusCode,
|
|
46
|
+
error,
|
|
47
|
+
title: url,
|
|
48
|
+
markdown: "",
|
|
49
|
+
links: [],
|
|
50
|
+
data: { url, title: url, markdown: "", links: [] },
|
|
51
|
+
evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
|
|
56
|
+
* Matching is a case-insensitive substring of the URL. Exported for unit tests.
|
|
57
|
+
*/
|
|
58
|
+
export function filterMappedLinks(links, search, limit = 100) {
|
|
59
|
+
const cap = Math.max(1, limit);
|
|
60
|
+
const needle = search?.trim().toLowerCase();
|
|
61
|
+
const filtered = needle
|
|
62
|
+
? links.filter((l) => l.toLowerCase().includes(needle))
|
|
63
|
+
: links;
|
|
64
|
+
return filtered.slice(0, cap);
|
|
65
|
+
}
|
|
66
|
+
/** How many URLs to discover before applying `--filter`, so a small `-n` cannot hide matches. */
|
|
67
|
+
const MAP_FILTER_DISCOVER_LIMIT = 5000;
|
|
32
68
|
export function createLocalEngineClient() {
|
|
33
69
|
return {
|
|
34
70
|
baseUrl: "local://engine",
|
|
@@ -124,7 +160,9 @@ export function createLocalEngineClient() {
|
|
|
124
160
|
// 3. Check local cache first
|
|
125
161
|
if (!p.noCache) {
|
|
126
162
|
const cached = await getCachedPageAsync(targetUrl, "none");
|
|
127
|
-
|
|
163
|
+
// Entries written before blocked pages stopped being cached may hold a
|
|
164
|
+
// bot-check page: treat those as a miss and fetch again.
|
|
165
|
+
if (cached && !isChallengeMarkdown(cached.title, cached.markdown)) {
|
|
128
166
|
const targetInspection = await inspectTarget({
|
|
129
167
|
url: targetUrl,
|
|
130
168
|
markdown: cached.markdown,
|
|
@@ -199,13 +237,18 @@ export function createLocalEngineClient() {
|
|
|
199
237
|
const fetched = await fetchWithTlsImpersonation(targetUrl);
|
|
200
238
|
const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
|
|
201
239
|
const contentHash = sha256(markdown || fetched.html);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
// A bot-check / 403 / 429 / 503 response is not the page: never cache it,
|
|
241
|
+
// or every retry for the next 24h would get the block page back.
|
|
242
|
+
const blocked = isAntiBotChallenge(fetched.html, fetched.status);
|
|
243
|
+
if (!blocked) {
|
|
244
|
+
setCachedPage(targetUrl, "none", {
|
|
245
|
+
title,
|
|
246
|
+
markdown,
|
|
247
|
+
links,
|
|
248
|
+
statusCode: fetched.status,
|
|
249
|
+
contentHash,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
209
252
|
const targetInspection = await inspectTarget({
|
|
210
253
|
url: targetUrl,
|
|
211
254
|
html: fetched.html,
|
|
@@ -258,6 +301,9 @@ export function createLocalEngineClient() {
|
|
|
258
301
|
: lastFetchTier() === "proxy-impersonate"
|
|
259
302
|
? "proxy-impersonate"
|
|
260
303
|
: "impit-tls";
|
|
304
|
+
if (blocked) {
|
|
305
|
+
return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
|
|
306
|
+
}
|
|
261
307
|
return {
|
|
262
308
|
success: true,
|
|
263
309
|
mode: "local",
|
|
@@ -369,13 +415,15 @@ export function createLocalEngineClient() {
|
|
|
369
415
|
},
|
|
370
416
|
async map(p) {
|
|
371
417
|
const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
|
|
418
|
+
const limit = Math.max(1, p.limit ?? 100);
|
|
419
|
+
const needle = p.search?.trim();
|
|
420
|
+
// Discover first (widely when filtering), then apply search and limit so
|
|
421
|
+
// `map --filter webhooks -n 10` can still find /webhooks/* URLs that are
|
|
422
|
+
// not among the first 10 sitemap entries (#11).
|
|
372
423
|
const mapped = await mapWebsiteUrls(targetUrl, {
|
|
373
|
-
limit:
|
|
424
|
+
limit: needle ? Math.max(limit, MAP_FILTER_DISCOVER_LIMIT) : limit,
|
|
374
425
|
});
|
|
375
|
-
const
|
|
376
|
-
const links = needle
|
|
377
|
-
? mapped.links.filter((l) => l.toLowerCase().includes(needle))
|
|
378
|
-
: mapped.links;
|
|
426
|
+
const links = filterMappedLinks(mapped.links, needle, limit);
|
|
379
427
|
return {
|
|
380
428
|
success: true,
|
|
381
429
|
mode: "local",
|
package/dist/format.js
CHANGED
|
@@ -52,6 +52,9 @@ export function formatSearch(res) {
|
|
|
52
52
|
return parts.length ? parts.join("\n\n") : "No results.";
|
|
53
53
|
}
|
|
54
54
|
export function formatScrape(res) {
|
|
55
|
+
if (res.success === false && str(res.error)) {
|
|
56
|
+
return `Error: ${str(res.error)}`;
|
|
57
|
+
}
|
|
55
58
|
const d = isRec(res.data) ? res.data : res;
|
|
56
59
|
return str(d.markdown) || str(d.content) || "(no content returned)";
|
|
57
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webcrawl",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Webcrawl Anything CLI & Core Engine — crawl, search, and extract structured evidence from any website, SPA, PDF, Office file, YouTube video, GitHub repo, ArXiv paper, or CVE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -127,8 +127,8 @@
|
|
|
127
127
|
"@ai-sdk/gateway": "^4.0.123",
|
|
128
128
|
"@firecrawl/pdf-inspector": "^1.25.2",
|
|
129
129
|
"@vercel/sandbox": "^1.0.0",
|
|
130
|
-
"@webcrawl/client": "^1.4.
|
|
131
|
-
"@webcrawl/curl-impersonate": "^1.4.
|
|
130
|
+
"@webcrawl/client": "^1.4.2",
|
|
131
|
+
"@webcrawl/curl-impersonate": "^1.4.2",
|
|
132
132
|
"ai": "^7.0.150",
|
|
133
133
|
"impit": "^0.14.7",
|
|
134
134
|
"mammoth": "^1.13.0",
|