webcrawl 1.3.3 → 1.3.5
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.d.ts +1 -1
- package/dist/cli.js +9 -9
- package/dist/core/engine/fetcher.js +6 -0
- package/dist/core/engine/index.d.ts +1 -0
- package/dist/core/engine/index.js +1 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/session.d.ts +12 -0
- package/dist/core/engine/session.js +47 -0
- package/dist/core/local-engine.js +175 -2
- package/package.json +3 -3
package/dist/cli.d.ts
CHANGED
|
@@ -15,5 +15,5 @@ export interface Deps {
|
|
|
15
15
|
/** Working directory override for `webcrawl setup` tests. */
|
|
16
16
|
cwd?: string;
|
|
17
17
|
}
|
|
18
|
-
export declare const HELP = "Webcrawl Anything: search, scrape, parse and crawl any target on the internet\n\nUsage: webcrawl <command> [options]\n\nCommands\n search <query> Search the web or verticals. -c category, --sub, -n limit, --site <domain>\n scrape <url> Inspect a URL (website, SPA, PDF, Office, YouTube, GitHub, ArXiv, llms.txt)\n map <url> List a site's URLs. --filter <text>, -n limit\n crawl <url> Read several pages. -n max pages (default 5, max 25)\n extract <url> Structured JSON. --prompt <text> or --schema <file.json>\n rerank <query> Rank documents. --doc <text> (repeatable), --file <path>, or lines on stdin. -n top\n targets List all supported crawl & search targets and their requirements\n categories List search categories and vertical filters\n setup Register Webcrawl MCP server and Agent Skill (Claude, Cursor, Codex, Gemini, Windsurf)\n doctor Check local crawl engine, parsers, stealth browser, and cloud configuration\n login / signup Create an account or sign in (--email <addr> [--code <6-digit>],
|
|
18
|
+
export declare const HELP = "Webcrawl Anything: search, scrape, parse and crawl any target on the internet\n\nUsage: webcrawl <command> [options]\n\nCommands\n search <query> Search the web or verticals. -c category, --sub, -n limit, --site <domain>\n scrape <url> Inspect a URL (website, SPA, PDF, Office, YouTube, GitHub, ArXiv, llms.txt)\n map <url> List a site's URLs. --filter <text>, -n limit\n crawl <url> Read several pages. -n max pages (default 5, max 25)\n extract <url> Structured JSON. --prompt <text> or --schema <file.json>\n rerank <query> Rank documents. --doc <text> (repeatable), --file <path>, or lines on stdin. -n top\n targets List all supported crawl & search targets and their requirements\n categories List search categories and vertical filters\n setup Register Webcrawl MCP server and Agent Skill (Claude, Cursor, Codex, Gemini, Windsurf)\n doctor Check local crawl engine, parsers, stealth browser, and cloud configuration\n login / signup Create an account or sign in in your browser (default), or --email <addr> [--code <6-digit>], or --key <key>\n whoami Show execution mode (local vs cloud), account email, key source, and credit balance\n\nOptions\n --mode <m> Execution mode: auto (default: local without key, cloud with key), local, or cloud\n --email <addr> Email address for CLI account creation / OTP sign-in (assigns 1,000 free credits)\n --code <6-digit> 6-digit email verification code from `webcrawl login --email <addr>`\n --name <name> Optional display name when creating an account with `--email`\n --web Sign in via browser OAuth (default for login; no need to pass it)\n --json Print the raw JSON response\n --api-key <key> Override WEBCRAWL_API_KEY\n --url <base> Override WEBCRAWL_URL (default https://webcrawl.app)\n --country, --city Where to search from (default: this machine's timezone)\n --no-cache Skip cached pages (scrape)\n --agent <name> Target agent for setup: claude, cursor, codex, gemini, windsurf, or all (repeatable)\n --schema-profile MCP schema profile for setup: compact (default) or full\n -h, --help\n\nConfig order: flags, then WEBCRAWL_API_KEY / WEBCRAWL_URL / WEBCRAWL_MODE, then ~/.config/webcrawl/config.json";
|
|
19
19
|
export declare function main(argv: string[], io: Io, deps?: Deps): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,7 @@ Commands
|
|
|
22
22
|
categories List search categories and vertical filters
|
|
23
23
|
setup Register Webcrawl MCP server and Agent Skill (Claude, Cursor, Codex, Gemini, Windsurf)
|
|
24
24
|
doctor Check local crawl engine, parsers, stealth browser, and cloud configuration
|
|
25
|
-
login / signup Create an account or sign in (--email <addr> [--code <6-digit>],
|
|
25
|
+
login / signup Create an account or sign in in your browser (default), or --email <addr> [--code <6-digit>], or --key <key>
|
|
26
26
|
whoami Show execution mode (local vs cloud), account email, key source, and credit balance
|
|
27
27
|
|
|
28
28
|
Options
|
|
@@ -30,7 +30,7 @@ Options
|
|
|
30
30
|
--email <addr> Email address for CLI account creation / OTP sign-in (assigns 1,000 free credits)
|
|
31
31
|
--code <6-digit> 6-digit email verification code from \`webcrawl login --email <addr>\`
|
|
32
32
|
--name <name> Optional display name when creating an account with \`--email\`
|
|
33
|
-
--web Sign in
|
|
33
|
+
--web Sign in via browser OAuth (default for login; no need to pass it)
|
|
34
34
|
--json Print the raw JSON response
|
|
35
35
|
--api-key <key> Override WEBCRAWL_API_KEY
|
|
36
36
|
--url <base> Override WEBCRAWL_URL (default https://webcrawl.app)
|
|
@@ -302,7 +302,7 @@ export async function main(argv, io, deps = {}) {
|
|
|
302
302
|
" webcrawl login --email <you@domain.com>",
|
|
303
303
|
" webcrawl login --email <you@domain.com> --code <6-digit-code>",
|
|
304
304
|
" 2. Browser OAuth redirect:",
|
|
305
|
-
" webcrawl login
|
|
305
|
+
" webcrawl login",
|
|
306
306
|
" 3. Or run in zero-key local mode:",
|
|
307
307
|
` webcrawl --mode local ${cmd} ${rest.join(" ")}`,
|
|
308
308
|
].join("\n");
|
|
@@ -314,7 +314,7 @@ export async function main(argv, io, deps = {}) {
|
|
|
314
314
|
loginCommands: {
|
|
315
315
|
sendOtp: "webcrawl login --email <you@domain.com>",
|
|
316
316
|
verifyOtp: "webcrawl login --email <you@domain.com> --code <6-digit-code>",
|
|
317
|
-
browserRedirect: "webcrawl login
|
|
317
|
+
browserRedirect: "webcrawl login",
|
|
318
318
|
signupUrl: `${cfg.baseUrl}/?signup=cli`,
|
|
319
319
|
},
|
|
320
320
|
}, null, 2)}\n`);
|
|
@@ -447,8 +447,8 @@ export async function main(argv, io, deps = {}) {
|
|
|
447
447
|
fetch: deps.fetch,
|
|
448
448
|
userAgent: "webcrawl-cli/1.2",
|
|
449
449
|
});
|
|
450
|
-
// 1. Browser OAuth PKCE redirect (`webcrawl login
|
|
451
|
-
if (v.web) {
|
|
450
|
+
// 1. Browser OAuth PKCE redirect (default for `webcrawl login`; `--web` kept for compatibility)
|
|
451
|
+
if (v.web || (!v.email && !v.key)) {
|
|
452
452
|
const webRes = await runWebOAuthLogin(cfg.baseUrl, deps.fetch ?? globalThis.fetch, io);
|
|
453
453
|
const existing = readConfigFile(file);
|
|
454
454
|
writeConfigFile({ ...existing, apiKey: webRes.apiKey, ...(v.url ? { baseUrl: v.url } : {}) }, file);
|
|
@@ -524,9 +524,9 @@ export async function main(argv, io, deps = {}) {
|
|
|
524
524
|
return 0;
|
|
525
525
|
}
|
|
526
526
|
// 3. Direct API key (`webcrawl login --key wc_live_...` or interactive paste)
|
|
527
|
-
const key =
|
|
527
|
+
const key = v.key?.trim() ?? (await readKeyInteractively(io)).trim();
|
|
528
528
|
if (!key) {
|
|
529
|
-
throw new UsageError("No key provided. Pass --key <wc_live_
|
|
529
|
+
throw new UsageError("No key provided. Pass --key <wc_live_...> or --email <you@domain.com>, or run `webcrawl login` to sign in via your browser.");
|
|
530
530
|
}
|
|
531
531
|
const existing = readConfigFile(file);
|
|
532
532
|
writeConfigFile({ ...existing, apiKey: key, ...(v.url ? { baseUrl: v.url } : {}) }, file);
|
|
@@ -538,7 +538,7 @@ export async function main(argv, io, deps = {}) {
|
|
|
538
538
|
const lines = [
|
|
539
539
|
`endpoint: ${cfg.baseUrl} (${cfg.source.baseUrl})`,
|
|
540
540
|
`mode: ${cfg.effectiveMode} (${cfg.source.mode})`,
|
|
541
|
-
`key: ${masked}${cfg.apiKey ? ` (${cfg.source.apiKey})` : ". Run `webcrawl login` (`--email <you@domain.com>`
|
|
541
|
+
`key: ${masked}${cfg.apiKey ? ` (${cfg.source.apiKey})` : ". Run `webcrawl login` to sign in via your browser (or `--email <you@domain.com>`), or set WEBCRAWL_API_KEY (local mode works with zero key)."}`,
|
|
542
542
|
];
|
|
543
543
|
if (cfg.apiKey) {
|
|
544
544
|
const u = await client.usage();
|
|
@@ -465,5 +465,11 @@ export function needsBrowserEscalation(status, html, markdown) {
|
|
|
465
465
|
lower.includes('id="__next"'))) {
|
|
466
466
|
return true;
|
|
467
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
|
+
}
|
|
468
474
|
return false;
|
|
469
475
|
}
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
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.
|
|
3
|
+
"version": "1.3.5",
|
|
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.
|
|
131
|
-
"@webcrawl/curl-impersonate": "^1.3.
|
|
130
|
+
"@webcrawl/client": "^1.3.5",
|
|
131
|
+
"@webcrawl/curl-impersonate": "^1.3.5",
|
|
132
132
|
"ai": "^7.0.150",
|
|
133
133
|
"impit": "^0.14.7",
|
|
134
134
|
"mammoth": "^1.13.0",
|