webcrawl 1.4.1 → 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/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
- throw new UnsafeUrlError();
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,6 +32,12 @@ 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>;
35
41
  /**
36
42
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
37
43
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -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,28 @@ 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
+ }
32
54
  /**
33
55
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
34
56
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -138,7 +160,9 @@ export function createLocalEngineClient() {
138
160
  // 3. Check local cache first
139
161
  if (!p.noCache) {
140
162
  const cached = await getCachedPageAsync(targetUrl, "none");
141
- if (cached) {
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)) {
142
166
  const targetInspection = await inspectTarget({
143
167
  url: targetUrl,
144
168
  markdown: cached.markdown,
@@ -213,13 +237,18 @@ export function createLocalEngineClient() {
213
237
  const fetched = await fetchWithTlsImpersonation(targetUrl);
214
238
  const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
215
239
  const contentHash = sha256(markdown || fetched.html);
216
- setCachedPage(targetUrl, "none", {
217
- title,
218
- markdown,
219
- links,
220
- statusCode: fetched.status,
221
- contentHash,
222
- });
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
+ }
223
252
  const targetInspection = await inspectTarget({
224
253
  url: targetUrl,
225
254
  html: fetched.html,
@@ -272,6 +301,9 @@ export function createLocalEngineClient() {
272
301
  : lastFetchTier() === "proxy-impersonate"
273
302
  ? "proxy-impersonate"
274
303
  : "impit-tls";
304
+ if (blocked) {
305
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
306
+ }
275
307
  return {
276
308
  success: true,
277
309
  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.1",
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.1",
131
- "@webcrawl/curl-impersonate": "^1.4.1",
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",