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 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
- 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,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
- 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)) {
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
- setCachedPage(targetUrl, "none", {
203
- title,
204
- markdown,
205
- links,
206
- statusCode: fetched.status,
207
- contentHash,
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: p.limit ?? 100,
424
+ limit: needle ? Math.max(limit, MAP_FILTER_DISCOVER_LIMIT) : limit,
374
425
  });
375
- const needle = p.search?.trim().toLowerCase();
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.0",
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.0",
131
- "@webcrawl/curl-impersonate": "^1.4.0",
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",