webcrawl 1.3.2 → 1.3.4

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
@@ -43,8 +43,11 @@ export declare function lastFetchTier(): "impit-tls" | "impersonate" | "proxy-im
43
43
  export declare function lastProxyBytesCount(): number;
44
44
  /**
45
45
  * Residential proxy URL from env, or null when the tier is disabled.
46
- * Accepts a full `DECODO_URL` (`http://user:pass@host:port`) or the split
47
- * `DECODO_USER` + `DECODO_PWD` pair (gateway defaults to `gate.decodo.com:7000`).
46
+ * Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
47
+ * split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
48
+ * host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
49
+ * `DECODO_*` equivalents are honored as a legacy fallback so existing
50
+ * deployments keep working (their default gateway is `gate.decodo.com:7000`).
48
51
  */
49
52
  export declare function residentialProxyUrl(): string | null;
50
53
  /**
@@ -134,17 +134,22 @@ export function lastProxyBytesCount() {
134
134
  }
135
135
  /**
136
136
  * Residential proxy URL from env, or null when the tier is disabled.
137
- * Accepts a full `DECODO_URL` (`http://user:pass@host:port`) or the split
138
- * `DECODO_USER` + `DECODO_PWD` pair (gateway defaults to `gate.decodo.com:7000`).
137
+ * Generic: a full `WEBCRAWL_PROXY_URL` (`http://user:pass@host:port`) or the
138
+ * split `WEBCRAWL_PROXY_USER` + `WEBCRAWL_PROXY_PWD` pair, with the gateway
139
+ * host/port overridable via `WEBCRAWL_PROXY_HOST` / `WEBCRAWL_PROXY_PORT`.
140
+ * `DECODO_*` equivalents are honored as a legacy fallback so existing
141
+ * deployments keep working (their default gateway is `gate.decodo.com:7000`).
139
142
  */
140
143
  export function residentialProxyUrl() {
141
- const direct = process.env.DECODO_URL?.trim();
144
+ const direct = process.env.WEBCRAWL_PROXY_URL?.trim() || process.env.DECODO_URL?.trim();
142
145
  if (direct)
143
146
  return direct;
144
- const user = process.env.DECODO_USER?.trim();
145
- const pwd = process.env.DECODO_PWD?.trim();
147
+ const user = process.env.WEBCRAWL_PROXY_USER?.trim() || process.env.DECODO_USER?.trim();
148
+ const pwd = process.env.WEBCRAWL_PROXY_PWD?.trim() || process.env.DECODO_PWD?.trim();
146
149
  if (user && pwd) {
147
- return `http://${encodeURIComponent(user)}:${encodeURIComponent(pwd)}@gate.decodo.com:7000`;
150
+ const host = process.env.WEBCRAWL_PROXY_HOST?.trim() || "gate.decodo.com";
151
+ const port = process.env.WEBCRAWL_PROXY_PORT?.trim() || "7000";
152
+ return `http://${encodeURIComponent(user)}:${encodeURIComponent(pwd)}@${host}:${port}`;
148
153
  }
149
154
  return null;
150
155
  }
@@ -460,5 +465,11 @@ export function needsBrowserEscalation(status, html, markdown) {
460
465
  lower.includes('id="__next"'))) {
461
466
  return true;
462
467
  }
468
+ // Big-HTML/no-content SPA shell: a very large page (JS bundles / inline
469
+ // state, e.g. StubHub category pages ~3MB) that yields almost no
470
+ // extractable text is serving its real content client-side.
471
+ if (html.length > 250_000 && markdown.length < 500) {
472
+ return true;
473
+ }
463
474
  return false;
464
475
  }
@@ -2,4 +2,5 @@ export * from "./cache.js";
2
2
  export * from "./markdown.js";
3
3
  export * from "./session.js";
4
4
  export * from "./fetcher.js";
5
+ export * from "./render.js";
5
6
  export * from "./crawler.js";
@@ -2,4 +2,5 @@ export * from "./cache.js";
2
2
  export * from "./markdown.js";
3
3
  export * from "./session.js";
4
4
  export * from "./fetcher.js";
5
+ export * from "./render.js";
5
6
  export * from "./crawler.js";
@@ -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
+ }
@@ -38,6 +38,9 @@ export interface ExternalSessionStore {
38
38
  /** Optional persistence for the residential-proxy memo (Tier P1). */
39
39
  markDomainProxyNeeded?(domain: string): Promise<void>;
40
40
  isDomainProxyNeeded?(domain: string): Promise<boolean>;
41
+ /** Optional persistence for the render-provider memo (Tier R). */
42
+ markDomainRenderNeeded?(domain: string): Promise<void>;
43
+ isDomainRenderNeeded?(domain: string): Promise<boolean>;
41
44
  }
42
45
  /** How long a solved session is offered for warm reconnect / cookie replay. */
43
46
  export declare const WARM_TTL_MS: number;
@@ -49,6 +52,8 @@ export declare const WARM_CAP = 4;
49
52
  export declare const CHALLENGE_MEMO_TTL_MS: number;
50
53
  /** How long a domain stays on the residential-proxy memo (Tier P1). */
51
54
  export declare const PROXY_MEMO_TTL_MS: number;
55
+ /** How long a domain stays on the render-provider memo (Tier R). */
56
+ export declare const RENDER_MEMO_TTL_MS: number;
52
57
  export declare function registerExternalSessionStore(store: ExternalSessionStore): void;
53
58
  export interface CookieLike {
54
59
  name: string;
@@ -77,5 +82,12 @@ export declare function isDomainChallenged(domain: string): Promise<boolean>;
77
82
  */
78
83
  export declare function markDomainProxyNeeded(domain: string): Promise<void>;
79
84
  export declare function isDomainProxyNeeded(domain: string): Promise<boolean>;
85
+ /**
86
+ * Marks a domain as needing the render-provider tier (Tier R): the native
87
+ * fetch ladder passes the bot check but the page's content only appears after
88
+ * JS renders, so future runs skip the ladder and go provider-first.
89
+ */
90
+ export declare function markDomainRenderNeeded(domain: string): Promise<void>;
91
+ export declare function isDomainRenderNeeded(domain: string): Promise<boolean>;
80
92
  /** Test helper: resets all in-memory state (external stores are untouched). */
81
93
  export declare function resetSessionStateForTests(): void;
@@ -29,6 +29,8 @@ export const WARM_CAP = 4;
29
29
  export const CHALLENGE_MEMO_TTL_MS = 30 * 60_000;
30
30
  /** How long a domain stays on the residential-proxy memo (Tier P1). */
31
31
  export const PROXY_MEMO_TTL_MS = 60 * 60_000;
32
+ /** How long a domain stays on the render-provider memo (Tier R). */
33
+ export const RENDER_MEMO_TTL_MS = 12 * 60 * 60_000;
32
34
  let external = null;
33
35
  export function registerExternalSessionStore(store) {
34
36
  external = store;
@@ -36,6 +38,7 @@ export function registerExternalSessionStore(store) {
36
38
  const warm = new Map();
37
39
  const challenged = new Map();
38
40
  const proxyNeeded = new Map();
41
+ const renderNeeded = new Map();
39
42
  function reap(now = Date.now()) {
40
43
  for (const [d, s] of warm)
41
44
  if (s.expiresAt <= now)
@@ -46,6 +49,9 @@ function reap(now = Date.now()) {
46
49
  for (const [d, t] of proxyNeeded)
47
50
  if (t <= now)
48
51
  proxyNeeded.delete(d);
52
+ for (const [d, t] of renderNeeded)
53
+ if (t <= now)
54
+ renderNeeded.delete(d);
49
55
  }
50
56
  /** Serializes captured browser cookies into a `Cookie:` header value (drops expired). */
51
57
  export function cookieHeaderFrom(cookies) {
@@ -195,9 +201,50 @@ export async function isDomainProxyNeeded(domain) {
195
201
  }
196
202
  return false;
197
203
  }
204
+ /**
205
+ * Marks a domain as needing the render-provider tier (Tier R): the native
206
+ * fetch ladder passes the bot check but the page's content only appears after
207
+ * JS renders, so future runs skip the ladder and go provider-first.
208
+ */
209
+ export async function markDomainRenderNeeded(domain) {
210
+ if (!domain)
211
+ return;
212
+ renderNeeded.set(domain, Date.now() + RENDER_MEMO_TTL_MS);
213
+ if (external?.markDomainRenderNeeded) {
214
+ try {
215
+ await external.markDomainRenderNeeded(domain);
216
+ }
217
+ catch {
218
+ /* store unavailable */
219
+ }
220
+ }
221
+ }
222
+ export async function isDomainRenderNeeded(domain) {
223
+ if (!domain)
224
+ return false;
225
+ reap();
226
+ const until = renderNeeded.get(domain);
227
+ if (until && until > Date.now())
228
+ return true;
229
+ if (until)
230
+ renderNeeded.delete(domain);
231
+ if (external?.isDomainRenderNeeded) {
232
+ try {
233
+ if (await external.isDomainRenderNeeded(domain)) {
234
+ renderNeeded.set(domain, Date.now() + RENDER_MEMO_TTL_MS);
235
+ return true;
236
+ }
237
+ }
238
+ catch {
239
+ /* store unavailable */
240
+ }
241
+ }
242
+ return false;
243
+ }
198
244
  /** Test helper: resets all in-memory state (external stores are untouched). */
199
245
  export function resetSessionStateForTests() {
200
246
  warm.clear();
201
247
  challenged.clear();
202
248
  proxyNeeded.clear();
249
+ renderNeeded.clear();
203
250
  }
@@ -38,6 +38,15 @@ export type StepAction = Record<string, unknown> & {
38
38
  */
39
39
  export declare function interactBrowserAvailable(): boolean;
40
40
  export declare function interactBackend(): "local" | "sandbox" | "none";
41
+ /**
42
+ * Path to a real, user-installed Chrome when one exists, else null.
43
+ * Anti-bot vendors (DataDome, Akamai) fingerprint the automation build of
44
+ * Chromium and serve degraded pages to it, while the same engine driving a
45
+ * real installed Chrome gets full content — practitioner reports confirm
46
+ * this on DataDome-protected sites. Priority: explicit env override, then
47
+ * the standard per-OS install locations.
48
+ */
49
+ export declare function installedChromePath(): string | null;
41
50
  export declare function startBrowser(opts?: {
42
51
  timeoutMs?: number;
43
52
  userAgent?: string;
@@ -9,7 +9,7 @@
9
9
  * (Local World / `next dev`).
10
10
  */
11
11
  import { execFile, spawn } from "node:child_process";
12
- import { mkdtempSync, writeFileSync } from "node:fs";
12
+ import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import path from "node:path";
15
15
  import { Sandbox } from "@vercel/sandbox";
@@ -131,6 +131,38 @@ async function startWarmSandboxBrowser(domain) {
131
131
  }
132
132
  }
133
133
  /* ------------------------------ local backend ----------------------------- */
134
+ /**
135
+ * Path to a real, user-installed Chrome when one exists, else null.
136
+ * Anti-bot vendors (DataDome, Akamai) fingerprint the automation build of
137
+ * Chromium and serve degraded pages to it, while the same engine driving a
138
+ * real installed Chrome gets full content — practitioner reports confirm
139
+ * this on DataDome-protected sites. Priority: explicit env override, then
140
+ * the standard per-OS install locations.
141
+ */
142
+ export function installedChromePath() {
143
+ if (process.env.INTERACT_CHROME_PATH)
144
+ return process.env.INTERACT_CHROME_PATH;
145
+ const candidates = [];
146
+ if (process.platform === "darwin") {
147
+ candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
148
+ }
149
+ else if (process.platform === "win32") {
150
+ candidates.push(`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`);
151
+ }
152
+ else {
153
+ candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium");
154
+ }
155
+ for (const p of candidates) {
156
+ try {
157
+ if (existsSync(p))
158
+ return p;
159
+ }
160
+ catch {
161
+ /* not installed here */
162
+ }
163
+ }
164
+ return null;
165
+ }
134
166
  async function resolveLocalPlaywright() {
135
167
  const candidates = [
136
168
  process.env.WC_PLAYWRIGHT,
@@ -140,7 +172,10 @@ async function resolveLocalPlaywright() {
140
172
  for (const p of candidates) {
141
173
  try {
142
174
  const mod = (await import(/* webpackIgnore: true */ p));
143
- const chrome = process.env.INTERACT_CHROME_PATH || mod.chromium.executablePath();
175
+ // Prefer a real installed Chrome over playwright's bundled automation
176
+ // Chromium (see installedChromePath); fall back to the bundle when the
177
+ // host has no Chrome (CI, containers, minimal servers).
178
+ const chrome = installedChromePath() || mod.chromium.executablePath();
144
179
  return { playwright: p, chrome };
145
180
  }
146
181
  catch {
@@ -188,6 +188,13 @@ try {
188
188
  // only needs the document, its scripts and the challenge sensor (the
189
189
  // sensor host is never on the ad blocklist). A byte cap aborts the rest
190
190
  // of the page once the billed total blows past the budget.
191
+ // Anti-bot sensor/challenge hosts are ALWAYS allowed, before any block
192
+ // rule: blocking them would make the DataDome / Cloudflare / AWS WAF
193
+ // challenge unsolvable — no cookie is minted, the interstitial loops
194
+ // forever, and the failed sensor POST is itself a bot signal. The
195
+ // sensor payload is a few KB of form-encoded text, so allowing it
196
+ // costs essentially nothing against the byte budget.
197
+ const allowRe = /(api-js\.datadome\.co|js\.datadome\.co|ct\.captcha-delivery\.com|geo\.captcha-delivery\.com|challenges\.cloudflare\.com|[a-z0-9-]+\.awswaf\.com)/i;
191
198
  let proxyBilled = 0;
192
199
  if (action.lightweight || action.proxyUser) {
193
200
  const skip = new Set(action.proxyUser ? ["image", "media", "font", "stylesheet"] : ["image", "media", "font"]);
@@ -197,6 +204,7 @@ try {
197
204
  await page.route("**/*", (r) => {
198
205
  try {
199
206
  const req = r.request();
207
+ if (allowRe.test(req.url())) return r.continue(); // never block a challenge sensor, even past the cap
200
208
  if (proxyBilled > cap) return r.abort();
201
209
  if (skip.has(req.resourceType())) return r.abort();
202
210
  if (adRe.test(req.url())) return r.abort();
@@ -10,7 +10,9 @@
10
10
  * powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
11
11
  */
12
12
  import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
13
- import { fetchResource, lastFetchTier } from "./engine/fetcher.js";
13
+ import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
14
+ import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
15
+ import { fetchResource, lastFetchTier, needsBrowserEscalation } from "./engine/fetcher.js";
14
16
  import { parseFile, publicParseMeta } from "./parse/index.js";
15
17
  import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
16
18
  import { maxDownloadBytes } from "./parse/limits.js";
@@ -18,6 +20,15 @@ import { parseYouTubeCached, youtubeVideoId } from "./parse/youtube.js";
18
20
  import { rankDocuments } from "./ranker.js";
19
21
  import { getSearchCategoriesCatalog, searchWeb } from "./search.js";
20
22
  import { inspectTarget, listTargets, resolveTarget } from "./targets.js";
23
+ /** Lowercased hostname of a URL (memo key for the Tier R render memo). */
24
+ function safeDomainOf(url) {
25
+ try {
26
+ return new URL(url).hostname.toLowerCase();
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ }
21
32
  export function createLocalEngineClient() {
22
33
  return {
23
34
  baseUrl: "local://engine",
@@ -142,7 +153,49 @@ export function createLocalEngineClient() {
142
153
  };
143
154
  }
144
155
  }
145
- // 4. Fetch with TLS impersonation (impit) + target-aware extraction
156
+ // 4. Fetch with TLS impersonation (impit) + target-aware extraction.
157
+ // Tier R: memoized render-needed domains skip the native ladder
158
+ // entirely when a render provider is configured — cache (step 3)
159
+ // already absorbed repeats, so the provider is the next best hop.
160
+ const renderDomain = safeDomainOf(targetUrl);
161
+ if (isRenderProviderEnabled() && (await isDomainRenderNeeded(renderDomain))) {
162
+ const rendered = await renderViaProvider(targetUrl);
163
+ if (rendered) {
164
+ const renderHash = sha256(rendered.markdown);
165
+ setCachedPage(targetUrl, "none", {
166
+ title: rendered.title,
167
+ markdown: rendered.markdown,
168
+ links: [],
169
+ statusCode: 200,
170
+ contentHash: renderHash,
171
+ });
172
+ return {
173
+ success: true,
174
+ mode: "local",
175
+ url: targetUrl,
176
+ title: rendered.title || targetUrl,
177
+ markdown: rendered.markdown,
178
+ links: [],
179
+ data: {
180
+ url: targetUrl,
181
+ title: rendered.title || targetUrl,
182
+ markdown: rendered.markdown,
183
+ links: [],
184
+ },
185
+ evidence: {
186
+ targetId: "render-provider",
187
+ tier: "render",
188
+ contentHash: renderHash,
189
+ latencyMs: Date.now() - start,
190
+ sourceUrl: rendered.sourceUrl,
191
+ matchedSignals: ["render:provider"],
192
+ limitations: rendered.warnings,
193
+ },
194
+ };
195
+ }
196
+ // Provider failed on a memo'd domain: fall through to the native
197
+ // ladder — the memo may be stale or the provider flaky.
198
+ }
146
199
  const fetched = await fetchWithTlsImpersonation(targetUrl);
147
200
  const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
148
201
  const contentHash = sha256(markdown || fetched.html);
@@ -158,6 +211,83 @@ export function createLocalEngineClient() {
158
211
  html: fetched.html,
159
212
  markdown,
160
213
  });
214
+ // Tier R: the native ladder passed the bot check but the page is a
215
+ // JS shell / challenge page the ladder cannot render. Memoize the
216
+ // domain and either render via the provider (when configured) or
217
+ // return the shell with an honest limitation note.
218
+ if (needsBrowserEscalation(fetched.status, fetched.html, markdown)) {
219
+ await markDomainRenderNeeded(renderDomain);
220
+ if (isRenderProviderEnabled()) {
221
+ const rendered = await renderViaProvider(targetUrl);
222
+ if (rendered) {
223
+ const renderHash = sha256(rendered.markdown);
224
+ setCachedPage(targetUrl, "none", {
225
+ title: rendered.title,
226
+ markdown: rendered.markdown,
227
+ links: [],
228
+ statusCode: 200,
229
+ contentHash: renderHash,
230
+ });
231
+ return {
232
+ success: true,
233
+ mode: "local",
234
+ url: targetUrl,
235
+ title: rendered.title || targetUrl,
236
+ markdown: rendered.markdown,
237
+ links: [],
238
+ data: {
239
+ url: targetUrl,
240
+ title: rendered.title || targetUrl,
241
+ markdown: rendered.markdown,
242
+ links: [],
243
+ },
244
+ evidence: {
245
+ targetId: "render-provider",
246
+ tier: "render",
247
+ contentHash: renderHash,
248
+ latencyMs: Date.now() - start,
249
+ sourceUrl: rendered.sourceUrl,
250
+ matchedSignals: ["render:provider"],
251
+ limitations: rendered.warnings,
252
+ },
253
+ };
254
+ }
255
+ }
256
+ const nativeTier = lastFetchTier() === "impersonate"
257
+ ? "impersonate"
258
+ : lastFetchTier() === "proxy-impersonate"
259
+ ? "proxy-impersonate"
260
+ : "impit-tls";
261
+ return {
262
+ success: true,
263
+ mode: "local",
264
+ url: targetUrl,
265
+ title: title || targetInspection.title || targetUrl,
266
+ markdown: targetInspection.markdown || markdown,
267
+ links,
268
+ data: {
269
+ url: targetUrl,
270
+ title: title || targetInspection.title || targetUrl,
271
+ markdown: targetInspection.markdown || markdown,
272
+ links,
273
+ structured: targetInspection.structured,
274
+ },
275
+ evidence: {
276
+ ...targetInspection.evidence,
277
+ tier: nativeTier,
278
+ contentHash,
279
+ latencyMs: Date.now() - start,
280
+ limitations: [
281
+ ...(Array.isArray(targetInspection.evidence?.limitations)
282
+ ? targetInspection.evidence.limitations
283
+ : []),
284
+ "Page needs JS rendering; the local native tiers cannot render it. " +
285
+ "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
286
+ "or self-host a render provider via WEBCRAWL_RENDER_URL.",
287
+ ],
288
+ },
289
+ };
290
+ }
161
291
  return {
162
292
  success: true,
163
293
  mode: "local",
@@ -287,6 +417,49 @@ export function createLocalEngineClient() {
287
417
  async extract(p) {
288
418
  const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
289
419
  const fetched = await fetchWithTlsImpersonation(targetUrl);
420
+ const fastCheck = fastHtmlToMarkdown(fetched.html, targetUrl);
421
+ // Tier R: shell page the native ladder cannot render — retry
422
+ // through scrape() so the render path + cache apply, then extract
423
+ // from the rendered Markdown. Without a provider, surface the
424
+ // login/cloud limitation instead of silently returning {}.
425
+ if (needsBrowserEscalation(fetched.status, fetched.html, fastCheck.markdown)) {
426
+ if (isRenderProviderEnabled()) {
427
+ const scraped = (await this.scrape({ url: targetUrl, noCache: true }));
428
+ const renderedMarkdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
429
+ if (renderedMarkdown.trim().length > 40) {
430
+ const targetInspection = await inspectTarget({
431
+ url: targetUrl,
432
+ markdown: renderedMarkdown,
433
+ });
434
+ const siteData = await extractWebsiteData(targetUrl);
435
+ return {
436
+ success: true,
437
+ mode: "local",
438
+ url: targetUrl,
439
+ data: {
440
+ ...siteData,
441
+ ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
442
+ },
443
+ evidence: targetInspection.evidence,
444
+ };
445
+ }
446
+ }
447
+ return {
448
+ success: true,
449
+ mode: "local",
450
+ url: targetUrl,
451
+ data: {},
452
+ evidence: {
453
+ tier: lastFetchTier(),
454
+ sourceUrl: targetUrl,
455
+ limitations: [
456
+ "Page needs JS rendering; the local native tiers cannot render it. " +
457
+ "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
458
+ "or self-host a render provider via WEBCRAWL_RENDER_URL.",
459
+ ],
460
+ },
461
+ };
462
+ }
290
463
  const targetInspection = await inspectTarget({
291
464
  url: targetUrl,
292
465
  html: fetched.html,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcrawl",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
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.3.2",
131
- "@webcrawl/curl-impersonate": "^1.3.2",
130
+ "@webcrawl/client": "^1.3.4",
131
+ "@webcrawl/curl-impersonate": "^1.3.4",
132
132
  "ai": "^7.0.150",
133
133
  "impit": "^0.14.7",
134
134
  "mammoth": "^1.13.0",