webcrawl 1.4.3 → 1.4.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/README.md +2 -2
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +25 -1
- package/dist/core/engine/chrome-cdp.d.ts +29 -0
- package/dist/core/engine/chrome-cdp.js +339 -0
- package/dist/core/engine/chrome-launch.d.ts +49 -0
- package/dist/core/engine/chrome-launch.js +244 -0
- package/dist/core/engine/chrome.d.ts +26 -7
- package/dist/core/engine/chrome.js +155 -78
- package/dist/core/extract/index.d.ts +1 -1
- package/dist/core/extract/index.js +1 -1
- package/dist/core/extract/structured.d.ts +19 -0
- package/dist/core/extract/structured.js +192 -20
- package/dist/core/local-engine.d.ts +6 -0
- package/dist/core/local-engine.js +138 -79
- package/dist/core/search/catalog.d.ts +1 -1
- package/dist/core/search/catalog.js +1 -1
- package/dist/core/search/pipeline.js +5 -3
- package/dist/core/search/providers.d.ts +15 -3
- package/dist/core/search/providers.js +177 -39
- package/dist/core/targets/registry.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/setup.js +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| Target | Returns | Requirements |
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| **Websites & JS SPAs** (`web`) | Clean Markdown, semantic headings, tables, code blocks, links, OpenGraph/JSON-LD, and SHA-256 content hashes | Zero-key local (`impit` TLS impersonation + local Chrome) or [`webcrawl.app`](https://webcrawl.app) cloud stealth browsers |
|
|
17
|
-
| **PDF & Office Docs** (`pdf-office`) | Page-numbered Markdown, tables, slide notes, and automatic OCR on scanned PDF pages (`pagesOcr` + honest `pagesSkipped`) | Built-in
|
|
17
|
+
| **PDF & Office Docs** (`pdf-office`) | Page-numbered Markdown, tables, slide notes, and automatic OCR on scanned PDF pages (`pagesOcr` + honest `pagesSkipped`) | Built-in PDF inspector plus `pdf-parse`, `mammoth`, `officeparser`, and `tesseract.js` |
|
|
18
18
|
| **YouTube & Media** (`youtube-media`) | Timestamped transcript Markdown, video title/channel/duration metadata, and spoken audio transcription | Built-in zero-key YouTube multi-client caption extractor; optional audio transcription provider for raw MP3/MP4 |
|
|
19
19
|
| **Shopping & Products** (`shopping`) | Multi-retailer price comparison, currency-normalized offers, stock status, ratings, review counts, and shipping | Zero-key local HTML/JSON-LD product extractor (`-c shopping`); optional `SERPER_API_KEY` |
|
|
20
20
|
| **Flights & Hotels** (`travel`) | Structured flight routes (`origin`, `destination`, `airline`, `stops`, `price`) and hotel nightly rates, stars, and guest ratings | Zero-key local search + schema.org extractor (`-c flights` / `-c hotels`) |
|
|
@@ -101,7 +101,7 @@ Webcrawl Anything uses the **exact same TypeScript engine (`src/`)** locally and
|
|
|
101
101
|
| **Core Engine & Target Plugins** | 100% shared (`src/engine`, `src/targets`, `src/extract`) | 100% shared |
|
|
102
102
|
| **PDF, Office, YouTube & CVE Parsers** | Built-in local execution | Hosted + background async workers (`/v1/runs/:id`) |
|
|
103
103
|
| **Anti-Bot & Cloudflare Bypass** | Local `impit` TLS + local Chrome | Managed residential stealth browser fleet + Cloudflare rendering |
|
|
104
|
-
| **Search Fan-Out** | Built-in DuckDuckGo/Bing
|
|
104
|
+
| **Search Fan-Out** | Built-in DuckDuckGo/Bing plus optional API keys | Managed multi-provider fan-out |
|
|
105
105
|
| **Global Content Cache** | Local in-memory LRU | Sub-50ms global SHA-256 content-addressed cache |
|
|
106
106
|
|
|
107
107
|
To connect your CLI and MCP server to [`webcrawl.app`](https://webcrawl.app) cloud:
|
package/dist/cli.d.ts
CHANGED
|
@@ -17,7 +17,9 @@ export interface Deps {
|
|
|
17
17
|
/** Working directory override for `webcrawl setup` tests. */
|
|
18
18
|
cwd?: string;
|
|
19
19
|
}
|
|
20
|
-
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 monitor [subcmd] Watch pages/sites: create <url...>, list, get <id>, check <id>, pause <id>, resume <id>, delete <id>\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 --scope <s|u|w> Monitor scope: page (default), urls, or site\n --interval <mins> Monitor check interval in minutes (default 60)\n --watch-for <text> What to watch for (e.g. \"Pricing tiers and discounts\")\n --condition <expr> Trigger rule expression (e.g. 'price < 50', 'content contains \"In Stock\"')\n --notify-email <e> Alert email address when triggered (or 'true' for account email)\n --webhook <url> Alert webhook URL when triggered\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";
|
|
20
|
+
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 monitor [subcmd] Watch pages/sites: create <url...>, list, get <id>, check <id>, pause <id>, resume <id>, delete <id>\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 --scope <s|u|w> Monitor scope: page (default), urls, or site\n --interval <mins> Monitor check interval in minutes (default 60)\n --watch-for <text> What to watch for (e.g. \"Pricing tiers and discounts\")\n --condition <expr> Trigger rule expression (e.g. 'price < 50', 'content contains \"In Stock\"')\n --notify-email <e> Alert email address when triggered (or 'true' for account email)\n --webhook <url> Alert webhook URL when triggered\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 -v, --version Print the installed package version\n -h, --help\n\nConfig order: flags, then WEBCRAWL_API_KEY / WEBCRAWL_URL / WEBCRAWL_MODE, then ~/.config/webcrawl/config.json";
|
|
21
|
+
/** Installed `webcrawl` version, read from package.json at runtime. */
|
|
22
|
+
export declare function packageVersion(): string;
|
|
21
23
|
/** Self-contained (no network assets) page served on the loopback callback. */
|
|
22
24
|
export declare function callbackPage(opts: {
|
|
23
25
|
kind: "success" | "error" | "info";
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,9 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
5
6
|
import { createInterface } from "node:readline";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { parseArgs } from "node:util";
|
|
7
9
|
import { WebcrawlClient, WebcrawlError, configPath, describeError, readConfigFile, resolveConfig, writeConfigFile, } from "@webcrawl/client";
|
|
8
10
|
import { formatCrawl, formatCredits, formatMap, formatMonitor, formatRerank, formatScrape, formatSearch, formatTargets, } from "./format.js";
|
|
@@ -45,12 +47,14 @@ Options
|
|
|
45
47
|
--webhook <url> Alert webhook URL when triggered
|
|
46
48
|
--agent <name> Target agent for setup: claude, cursor, codex, gemini, windsurf, or all (repeatable)
|
|
47
49
|
--schema-profile MCP schema profile for setup: compact (default) or full
|
|
50
|
+
-v, --version Print the installed package version
|
|
48
51
|
-h, --help
|
|
49
52
|
|
|
50
53
|
Config order: flags, then WEBCRAWL_API_KEY / WEBCRAWL_URL / WEBCRAWL_MODE, then ~/.config/webcrawl/config.json`;
|
|
51
54
|
const OPTIONS = {
|
|
52
55
|
json: { type: "boolean" },
|
|
53
56
|
help: { type: "boolean", short: "h" },
|
|
57
|
+
version: { type: "boolean", short: "v" },
|
|
54
58
|
"api-key": { type: "string" },
|
|
55
59
|
url: { type: "string" },
|
|
56
60
|
mode: { type: "string" },
|
|
@@ -84,6 +88,15 @@ const OPTIONS = {
|
|
|
84
88
|
};
|
|
85
89
|
class UsageError extends Error {
|
|
86
90
|
}
|
|
91
|
+
/** Installed `webcrawl` version, read from package.json at runtime. */
|
|
92
|
+
export function packageVersion() {
|
|
93
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
94
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
95
|
+
if (!pkg.version) {
|
|
96
|
+
throw new Error("package.json is missing a version field");
|
|
97
|
+
}
|
|
98
|
+
return pkg.version;
|
|
99
|
+
}
|
|
87
100
|
function int(v, name) {
|
|
88
101
|
if (v === undefined) {
|
|
89
102
|
return undefined;
|
|
@@ -356,6 +369,10 @@ export async function main(argv, io, deps = {}) {
|
|
|
356
369
|
}
|
|
357
370
|
const { values: v, positionals } = parsed;
|
|
358
371
|
const [cmd, ...rest] = positionals;
|
|
372
|
+
if (v.version) {
|
|
373
|
+
io.out(`${packageVersion()}\n`);
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
359
376
|
if (v.help || !cmd || cmd === "help") {
|
|
360
377
|
io.out(`${HELP}\n`);
|
|
361
378
|
return cmd || v.help ? 0 : 2;
|
|
@@ -467,8 +484,15 @@ export async function main(argv, io, deps = {}) {
|
|
|
467
484
|
}
|
|
468
485
|
}
|
|
469
486
|
const res = await client.extract({ url: need("url"), prompt: v.prompt, schema });
|
|
487
|
+
if (res.success === false && !v.json) {
|
|
488
|
+
const msg = typeof res.error === "string" && res.error.trim()
|
|
489
|
+
? res.error
|
|
490
|
+
: JSON.stringify(res.data ?? res, null, 2);
|
|
491
|
+
io.err(`${msg}\n`);
|
|
492
|
+
return 1;
|
|
493
|
+
}
|
|
470
494
|
emit(res, JSON.stringify(res.data ?? res, null, 2));
|
|
471
|
-
return 0;
|
|
495
|
+
return res.success === false ? 1 : 0;
|
|
472
496
|
}
|
|
473
497
|
case "rerank": {
|
|
474
498
|
const query = need("query");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client for local JS rendering.
|
|
3
|
+
*
|
|
4
|
+
* Drive a real Chrome over CDP instead of dump-dom, wait until the DOM is
|
|
5
|
+
* actually ready, and always bound the session with a hard timeout. No
|
|
6
|
+
* puppeteer/playwright dependency.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { type ChromeHeadlessMode } from "./chrome-launch.js";
|
|
10
|
+
/** Chrome spawned but CDP never became ready. `hung` is true when the process was still alive. */
|
|
11
|
+
export declare class ChromeConnectError extends Error {
|
|
12
|
+
readonly hung: boolean;
|
|
13
|
+
constructor(message: string, hung?: boolean);
|
|
14
|
+
}
|
|
15
|
+
export interface CdpSnapshot {
|
|
16
|
+
html: string;
|
|
17
|
+
title: string;
|
|
18
|
+
textLen: number;
|
|
19
|
+
readyState: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CdpRenderOptions {
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
/** CDP launch/connect budget. Defaults to 4s (`WEBCRAWL_CHROME_CONNECT_TIMEOUT_MS`). */
|
|
24
|
+
connectTimeoutMs?: number;
|
|
25
|
+
headless: ChromeHeadlessMode;
|
|
26
|
+
/** Injected for tests. */
|
|
27
|
+
spawnChrome?: typeof spawn;
|
|
28
|
+
}
|
|
29
|
+
export declare function renderUrlViaCdp(chromePath: string, url: string, opts: CdpRenderOptions): Promise<CdpSnapshot>;
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Chrome DevTools Protocol client for local JS rendering.
|
|
3
|
+
*
|
|
4
|
+
* Drive a real Chrome over CDP instead of dump-dom, wait until the DOM is
|
|
5
|
+
* actually ready, and always bound the session with a hard timeout. No
|
|
6
|
+
* puppeteer/playwright dependency.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { chromeCdpArgs, killProcessGroup, resolveChromeConnectTimeoutMs, } from "./chrome-launch.js";
|
|
13
|
+
/** Chrome spawned but CDP never became ready. `hung` is true when the process was still alive. */
|
|
14
|
+
export class ChromeConnectError extends Error {
|
|
15
|
+
hung;
|
|
16
|
+
constructor(message, hung = false) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ChromeConnectError";
|
|
19
|
+
this.hung = hung;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const SNAPSHOT_JS = `(() => {
|
|
23
|
+
const body = document.body;
|
|
24
|
+
const text = body && typeof body.innerText === "string" ? body.innerText.trim() : "";
|
|
25
|
+
return {
|
|
26
|
+
html: document.documentElement ? document.documentElement.outerHTML : "",
|
|
27
|
+
title: document.title || "",
|
|
28
|
+
textLen: text.length,
|
|
29
|
+
readyState: document.readyState || "",
|
|
30
|
+
};
|
|
31
|
+
})()`;
|
|
32
|
+
/** mutationIdle (200ms quiet) plus readyState complete, capped. */
|
|
33
|
+
const WAIT_FOR_CONTENT_JS = `(async () => {
|
|
34
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
const snap = () => {
|
|
36
|
+
const body = document.body;
|
|
37
|
+
const text = body && typeof body.innerText === "string" ? body.innerText.trim() : "";
|
|
38
|
+
return {
|
|
39
|
+
html: document.documentElement ? document.documentElement.outerHTML : "",
|
|
40
|
+
title: document.title || "",
|
|
41
|
+
textLen: text.length,
|
|
42
|
+
readyState: document.readyState || "",
|
|
43
|
+
elemCount: document.getElementsByTagName("*").length,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
let lastMut = Date.now();
|
|
47
|
+
let observer = null;
|
|
48
|
+
try {
|
|
49
|
+
observer = new MutationObserver(() => { lastMut = Date.now(); });
|
|
50
|
+
if (document.documentElement) {
|
|
51
|
+
observer.observe(document.documentElement, {
|
|
52
|
+
subtree: true,
|
|
53
|
+
childList: true,
|
|
54
|
+
characterData: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch (_) { /* ignore */ }
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
let last = snap();
|
|
60
|
+
while (Date.now() - start < 2500) {
|
|
61
|
+
await sleep(150);
|
|
62
|
+
const now = snap();
|
|
63
|
+
const quiet = Date.now() - lastMut >= 200;
|
|
64
|
+
const grown = now.elemCount > last.elemCount + 2 || now.textLen > last.textLen + 20;
|
|
65
|
+
last = now;
|
|
66
|
+
if (
|
|
67
|
+
now.readyState === "complete" &&
|
|
68
|
+
now.textLen >= 80 &&
|
|
69
|
+
quiet &&
|
|
70
|
+
!grown
|
|
71
|
+
) break;
|
|
72
|
+
}
|
|
73
|
+
try { observer && observer.disconnect(); } catch (_) { /* ignore */ }
|
|
74
|
+
return last;
|
|
75
|
+
})()`;
|
|
76
|
+
function childStillRunning(child) {
|
|
77
|
+
return Boolean(child && child.pid && child.exitCode == null && !child.killed);
|
|
78
|
+
}
|
|
79
|
+
export async function renderUrlViaCdp(chromePath, url, opts) {
|
|
80
|
+
const timeoutMs = Math.max(1_500, opts.timeoutMs);
|
|
81
|
+
const connectTimeoutMs = resolveChromeConnectTimeoutMs(opts.connectTimeoutMs);
|
|
82
|
+
const profile = mkdtempSync(join(tmpdir(), "wc-cdp-"));
|
|
83
|
+
const port = 40000 + Math.floor(Math.random() * 20000);
|
|
84
|
+
const spawnFn = opts.spawnChrome ?? spawn;
|
|
85
|
+
const args = chromeCdpArgs({
|
|
86
|
+
port,
|
|
87
|
+
profile,
|
|
88
|
+
headless: opts.headless,
|
|
89
|
+
binaryPath: chromePath,
|
|
90
|
+
});
|
|
91
|
+
let child;
|
|
92
|
+
let cdpReady = false;
|
|
93
|
+
const ac = new AbortController();
|
|
94
|
+
// Page budget covers navigation after connect; connect has its own deadline.
|
|
95
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
96
|
+
try {
|
|
97
|
+
child = spawnFn(chromePath, args, {
|
|
98
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
99
|
+
detached: true,
|
|
100
|
+
});
|
|
101
|
+
if (!child.pid) {
|
|
102
|
+
throw new Error("Chrome failed to start (no pid)");
|
|
103
|
+
}
|
|
104
|
+
child.once("exit", () => {
|
|
105
|
+
if (!cdpReady && !ac.signal.aborted)
|
|
106
|
+
ac.abort();
|
|
107
|
+
});
|
|
108
|
+
const wsUrl = await waitForPageWebSocket(port, connectTimeoutMs, ac.signal);
|
|
109
|
+
cdpReady = true;
|
|
110
|
+
const snapshot = await withCdpSession(wsUrl, url, timeoutMs, ac.signal);
|
|
111
|
+
if (!snapshot.html || snapshot.html.length < 40) {
|
|
112
|
+
throw new Error("CDP returned empty HTML");
|
|
113
|
+
}
|
|
114
|
+
return snapshot;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (!cdpReady) {
|
|
118
|
+
const hung = childStillRunning(child);
|
|
119
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
120
|
+
throw new ChromeConnectError(hung
|
|
121
|
+
? `Chrome did not respond over CDP within ${connectTimeoutMs}ms`
|
|
122
|
+
: `Chrome CDP failed to start (${detail})`, hung);
|
|
123
|
+
}
|
|
124
|
+
if (ac.signal.aborted) {
|
|
125
|
+
throw new Error(`CDP timed out after ${timeoutMs}ms`);
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
if (child?.pid)
|
|
132
|
+
killProcessGroup(child.pid, child);
|
|
133
|
+
try {
|
|
134
|
+
rmSync(profile, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* leftover profile is in /tmp */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function waitForPageWebSocket(port, connectTimeoutMs, signal) {
|
|
142
|
+
const deadline = Date.now() + connectTimeoutMs;
|
|
143
|
+
let lastErr = "CDP HTTP endpoint never became ready";
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
throw new Error("Chrome CDP timed out while starting");
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
|
149
|
+
signal: AbortSignal.timeout(400),
|
|
150
|
+
});
|
|
151
|
+
if (res.ok) {
|
|
152
|
+
const json = (await res.json());
|
|
153
|
+
const page = (Array.isArray(json) ? json : []).find((t) => t.webSocketDebuggerUrl && (t.type === "page" || !t.type));
|
|
154
|
+
if (page?.webSocketDebuggerUrl)
|
|
155
|
+
return page.webSocketDebuggerUrl;
|
|
156
|
+
lastErr = "CDP /json/list had no page websocket";
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
lastErr = `CDP /json/list HTTP ${res.status}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
164
|
+
}
|
|
165
|
+
await delay(80, signal);
|
|
166
|
+
}
|
|
167
|
+
throw new Error(lastErr);
|
|
168
|
+
}
|
|
169
|
+
async function withCdpSession(wsUrl, url, timeoutMs, signal) {
|
|
170
|
+
const client = await CdpClient.connect(wsUrl, signal);
|
|
171
|
+
const onAbort = () => client.close();
|
|
172
|
+
signal.addEventListener("abort", onAbort);
|
|
173
|
+
try {
|
|
174
|
+
await client.send("Page.enable");
|
|
175
|
+
await client.send("Runtime.enable");
|
|
176
|
+
const load = client.waitForEvent("Page.loadEventFired", Math.min(timeoutMs, 12_000));
|
|
177
|
+
const nav = await client.send("Page.navigate", { url });
|
|
178
|
+
if (nav.errorText) {
|
|
179
|
+
throw new Error(`Page.navigate failed: ${String(nav.errorText)}`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await load;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* take a snapshot anyway if navigation load times out */
|
|
186
|
+
}
|
|
187
|
+
let snapshot = await evaluateSnapshot(client, WAIT_FOR_CONTENT_JS);
|
|
188
|
+
if (!snapshot || snapshot.textLen < 40 || snapshot.html.length < 80) {
|
|
189
|
+
snapshot = await evaluateSnapshot(client, SNAPSHOT_JS);
|
|
190
|
+
}
|
|
191
|
+
if (!snapshot)
|
|
192
|
+
throw new Error("Runtime.evaluate returned no snapshot");
|
|
193
|
+
return snapshot;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
signal.removeEventListener("abort", onAbort);
|
|
197
|
+
client.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function evaluateSnapshot(client, expression) {
|
|
201
|
+
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
202
|
+
const value = result.result?.value;
|
|
203
|
+
if (!value || typeof value.html !== "string")
|
|
204
|
+
return null;
|
|
205
|
+
return {
|
|
206
|
+
html: value.html,
|
|
207
|
+
title: String(value.title ?? ""),
|
|
208
|
+
textLen: Number(value.textLen ?? 0),
|
|
209
|
+
readyState: String(value.readyState ?? ""),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
class CdpClient {
|
|
213
|
+
ws;
|
|
214
|
+
nextId = 0;
|
|
215
|
+
pending = new Map();
|
|
216
|
+
eventWaiters = [];
|
|
217
|
+
constructor(ws) {
|
|
218
|
+
this.ws = ws;
|
|
219
|
+
ws.addEventListener("message", (ev) => {
|
|
220
|
+
let msg;
|
|
221
|
+
try {
|
|
222
|
+
msg = JSON.parse(String(ev.data));
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (msg.id != null) {
|
|
228
|
+
const p = this.pending.get(msg.id);
|
|
229
|
+
if (p) {
|
|
230
|
+
this.pending.delete(msg.id);
|
|
231
|
+
if (msg.error) {
|
|
232
|
+
p.reject(new Error(msg.error.message || `CDP error ${msg.error.code ?? ""}`));
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
p.resolve(msg.result ?? {});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (msg.method) {
|
|
241
|
+
for (const w of [...this.eventWaiters]) {
|
|
242
|
+
if (w.method === msg.method) {
|
|
243
|
+
this.eventWaiters = this.eventWaiters.filter((x) => x !== w);
|
|
244
|
+
w.resolve();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
static connect(url, signal) {
|
|
251
|
+
return new Promise((resolve, reject) => {
|
|
252
|
+
const ws = new WebSocket(url);
|
|
253
|
+
const onAbort = () => {
|
|
254
|
+
try {
|
|
255
|
+
ws.close();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
/* ignore */
|
|
259
|
+
}
|
|
260
|
+
reject(new Error("Chrome CDP websocket timed out"));
|
|
261
|
+
};
|
|
262
|
+
if (signal.aborted) {
|
|
263
|
+
onAbort();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
ws.addEventListener("open", () => {
|
|
268
|
+
signal.removeEventListener("abort", onAbort);
|
|
269
|
+
resolve(new CdpClient(ws));
|
|
270
|
+
});
|
|
271
|
+
ws.addEventListener("error", () => {
|
|
272
|
+
signal.removeEventListener("abort", onAbort);
|
|
273
|
+
reject(new Error("Chrome CDP websocket failed"));
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
send(method, params = {}) {
|
|
278
|
+
const id = ++this.nextId;
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
this.pending.set(id, { resolve, reject });
|
|
281
|
+
try {
|
|
282
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
this.pending.delete(id);
|
|
286
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
waitForEvent(method, timeoutMs) {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
this.eventWaiters = this.eventWaiters.filter((w) => w !== waiter);
|
|
294
|
+
reject(new Error(`Timed out waiting for ${method}`));
|
|
295
|
+
}, timeoutMs);
|
|
296
|
+
const waiter = {
|
|
297
|
+
method,
|
|
298
|
+
resolve: () => {
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
resolve();
|
|
301
|
+
},
|
|
302
|
+
reject: (err) => {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
reject(err);
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
this.eventWaiters.push(waiter);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
close() {
|
|
311
|
+
for (const p of this.pending.values()) {
|
|
312
|
+
p.reject(new Error("CDP session closed"));
|
|
313
|
+
}
|
|
314
|
+
this.pending.clear();
|
|
315
|
+
for (const w of this.eventWaiters) {
|
|
316
|
+
w.reject(new Error("CDP session closed"));
|
|
317
|
+
}
|
|
318
|
+
this.eventWaiters = [];
|
|
319
|
+
try {
|
|
320
|
+
this.ws.close();
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
/* ignore */
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function delay(ms, signal) {
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
if (signal.aborted) {
|
|
330
|
+
reject(new Error("aborted"));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const t = setTimeout(resolve, ms);
|
|
334
|
+
signal.addEventListener("abort", () => {
|
|
335
|
+
clearTimeout(t);
|
|
336
|
+
reject(new Error("aborted"));
|
|
337
|
+
}, { once: true });
|
|
338
|
+
});
|
|
339
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
export declare const DUMP_MAX_BYTES: number;
|
|
3
|
+
/** Page navigation + DOM-wait budget once Chrome is answering over CDP. */
|
|
4
|
+
export declare const DEFAULT_TIMEOUT_MS = 18000;
|
|
5
|
+
/**
|
|
6
|
+
* How long to wait for Chrome to accept CDP after spawn. Kept short and
|
|
7
|
+
* separate from `DEFAULT_TIMEOUT_MS` so a hung binary fails fast while a
|
|
8
|
+
* slow but working JS page (often ~7s) still has time to render.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_CONNECT_TIMEOUT_MS = 4000;
|
|
11
|
+
export declare const VIRTUAL_TIME_MS = 10000;
|
|
12
|
+
/** `opts.timeoutMs`, else `WEBCRAWL_CHROME_TIMEOUT_MS`, else 18s. */
|
|
13
|
+
export declare function resolveChromeTimeoutMs(override?: number): number;
|
|
14
|
+
/** `opts.connectTimeoutMs`, else `WEBCRAWL_CHROME_CONNECT_TIMEOUT_MS`, else 4s. */
|
|
15
|
+
export declare function resolveChromeConnectTimeoutMs(override?: number): number;
|
|
16
|
+
export type ChromeHeadlessMode = "new" | "old" | "shell";
|
|
17
|
+
export declare function killProcessGroup(pid: number | undefined, child?: Pick<ChildProcess, "kill">): void;
|
|
18
|
+
export declare function headlessFlag(mode: ChromeHeadlessMode, binaryPath: string): string[];
|
|
19
|
+
export declare function chromeCdpArgs(opts: {
|
|
20
|
+
port: number;
|
|
21
|
+
profile: string;
|
|
22
|
+
headless: ChromeHeadlessMode;
|
|
23
|
+
binaryPath?: string;
|
|
24
|
+
}): string[];
|
|
25
|
+
export declare function chromeDumpDomArgs(opts: {
|
|
26
|
+
profile: string;
|
|
27
|
+
headless: ChromeHeadlessMode;
|
|
28
|
+
url: string;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
binaryPath?: string;
|
|
31
|
+
}): string[];
|
|
32
|
+
/**
|
|
33
|
+
* First line of Chrome's DevToolsActivePort file is the chosen port when
|
|
34
|
+
* `--remote-debugging-port=0`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseDevToolsActivePort(text: string): number | null;
|
|
37
|
+
export declare function defaultChromeInstallPaths(): string[];
|
|
38
|
+
/** Playwright's chrome-headless-shell, if a previous `playwright install` left it on disk. */
|
|
39
|
+
export declare function findPlaywrightHeadlessShell(): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Path to a real installed Chrome/Chromium, or null.
|
|
42
|
+
* Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
|
|
43
|
+
* defaults (same locations `webcrawl doctor` checks). Env overrides are returned
|
|
44
|
+
* even if the file is missing so tests can point at a stub binary.
|
|
45
|
+
*/
|
|
46
|
+
export declare function installedChromePath(): string | null;
|
|
47
|
+
export declare function isLocalChromeAvailable(): boolean;
|
|
48
|
+
/** Distinct binaries to try, env override first, then system Chrome, then headless-shell. */
|
|
49
|
+
export declare function discoverChromeBinaries(): string[];
|