@webcrawl/mcp 1.4.3 → 1.4.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/core/engine/chrome-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +21 -7
- package/dist/core/engine/chrome.js +146 -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/server.js +3 -0
- package/package.json +3 -3
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
export interface CdpSnapshot {
|
|
11
|
+
html: string;
|
|
12
|
+
title: string;
|
|
13
|
+
textLen: number;
|
|
14
|
+
readyState: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CdpRenderOptions {
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
headless: ChromeHeadlessMode;
|
|
19
|
+
/** Injected for tests. */
|
|
20
|
+
spawnChrome?: typeof spawn;
|
|
21
|
+
}
|
|
22
|
+
export declare function renderUrlViaCdp(chromePath: string, url: string, opts: CdpRenderOptions): Promise<CdpSnapshot>;
|
|
@@ -0,0 +1,318 @@
|
|
|
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 } from "./chrome-launch.js";
|
|
13
|
+
const SNAPSHOT_JS = `(() => {
|
|
14
|
+
const body = document.body;
|
|
15
|
+
const text = body && typeof body.innerText === "string" ? body.innerText.trim() : "";
|
|
16
|
+
return {
|
|
17
|
+
html: document.documentElement ? document.documentElement.outerHTML : "",
|
|
18
|
+
title: document.title || "",
|
|
19
|
+
textLen: text.length,
|
|
20
|
+
readyState: document.readyState || "",
|
|
21
|
+
};
|
|
22
|
+
})()`;
|
|
23
|
+
/** mutationIdle (200ms quiet) plus readyState complete, capped. */
|
|
24
|
+
const WAIT_FOR_CONTENT_JS = `(async () => {
|
|
25
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
const snap = () => {
|
|
27
|
+
const body = document.body;
|
|
28
|
+
const text = body && typeof body.innerText === "string" ? body.innerText.trim() : "";
|
|
29
|
+
return {
|
|
30
|
+
html: document.documentElement ? document.documentElement.outerHTML : "",
|
|
31
|
+
title: document.title || "",
|
|
32
|
+
textLen: text.length,
|
|
33
|
+
readyState: document.readyState || "",
|
|
34
|
+
elemCount: document.getElementsByTagName("*").length,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
let lastMut = Date.now();
|
|
38
|
+
let observer = null;
|
|
39
|
+
try {
|
|
40
|
+
observer = new MutationObserver(() => { lastMut = Date.now(); });
|
|
41
|
+
if (document.documentElement) {
|
|
42
|
+
observer.observe(document.documentElement, {
|
|
43
|
+
subtree: true,
|
|
44
|
+
childList: true,
|
|
45
|
+
characterData: true,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} catch (_) { /* ignore */ }
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
let last = snap();
|
|
51
|
+
while (Date.now() - start < 2500) {
|
|
52
|
+
await sleep(150);
|
|
53
|
+
const now = snap();
|
|
54
|
+
const quiet = Date.now() - lastMut >= 200;
|
|
55
|
+
const grown = now.elemCount > last.elemCount + 2 || now.textLen > last.textLen + 20;
|
|
56
|
+
last = now;
|
|
57
|
+
if (
|
|
58
|
+
now.readyState === "complete" &&
|
|
59
|
+
now.textLen >= 80 &&
|
|
60
|
+
quiet &&
|
|
61
|
+
!grown
|
|
62
|
+
) break;
|
|
63
|
+
}
|
|
64
|
+
try { observer && observer.disconnect(); } catch (_) { /* ignore */ }
|
|
65
|
+
return last;
|
|
66
|
+
})()`;
|
|
67
|
+
export async function renderUrlViaCdp(chromePath, url, opts) {
|
|
68
|
+
const timeoutMs = Math.max(1_500, opts.timeoutMs);
|
|
69
|
+
const profile = mkdtempSync(join(tmpdir(), "wc-cdp-"));
|
|
70
|
+
const port = 40000 + Math.floor(Math.random() * 20000);
|
|
71
|
+
const spawnFn = opts.spawnChrome ?? spawn;
|
|
72
|
+
const args = chromeCdpArgs({
|
|
73
|
+
port,
|
|
74
|
+
profile,
|
|
75
|
+
headless: opts.headless,
|
|
76
|
+
binaryPath: chromePath,
|
|
77
|
+
});
|
|
78
|
+
let child;
|
|
79
|
+
const ac = new AbortController();
|
|
80
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
81
|
+
try {
|
|
82
|
+
child = spawnFn(chromePath, args, {
|
|
83
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
84
|
+
detached: true,
|
|
85
|
+
});
|
|
86
|
+
if (!child.pid) {
|
|
87
|
+
throw new Error("Chrome failed to start (no pid)");
|
|
88
|
+
}
|
|
89
|
+
let cdpReady = false;
|
|
90
|
+
child.once("exit", () => {
|
|
91
|
+
if (!cdpReady && !ac.signal.aborted)
|
|
92
|
+
ac.abort();
|
|
93
|
+
});
|
|
94
|
+
const wsUrl = await waitForPageWebSocket(port, timeoutMs, ac.signal);
|
|
95
|
+
cdpReady = true;
|
|
96
|
+
const snapshot = await withCdpSession(wsUrl, url, timeoutMs, ac.signal);
|
|
97
|
+
if (!snapshot.html || snapshot.html.length < 40) {
|
|
98
|
+
throw new Error("CDP returned empty HTML");
|
|
99
|
+
}
|
|
100
|
+
return snapshot;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (ac.signal.aborted) {
|
|
104
|
+
throw new Error(`CDP timed out after ${timeoutMs}ms`);
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (child?.pid)
|
|
111
|
+
killProcessGroup(child.pid, child);
|
|
112
|
+
try {
|
|
113
|
+
rmSync(profile, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* leftover profile is in /tmp */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function waitForPageWebSocket(port, timeoutMs, signal) {
|
|
121
|
+
const deadline = Date.now() + Math.min(timeoutMs, 8_000);
|
|
122
|
+
let lastErr = "CDP HTTP endpoint never became ready";
|
|
123
|
+
while (Date.now() < deadline) {
|
|
124
|
+
if (signal.aborted)
|
|
125
|
+
throw new Error("Chrome CDP timed out while starting");
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
|
128
|
+
signal: AbortSignal.timeout(400),
|
|
129
|
+
});
|
|
130
|
+
if (res.ok) {
|
|
131
|
+
const json = (await res.json());
|
|
132
|
+
const page = (Array.isArray(json) ? json : []).find((t) => t.webSocketDebuggerUrl && (t.type === "page" || !t.type));
|
|
133
|
+
if (page?.webSocketDebuggerUrl)
|
|
134
|
+
return page.webSocketDebuggerUrl;
|
|
135
|
+
lastErr = "CDP /json/list had no page websocket";
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
lastErr = `CDP /json/list HTTP ${res.status}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
143
|
+
}
|
|
144
|
+
await delay(80, signal);
|
|
145
|
+
}
|
|
146
|
+
throw new Error(lastErr);
|
|
147
|
+
}
|
|
148
|
+
async function withCdpSession(wsUrl, url, timeoutMs, signal) {
|
|
149
|
+
const client = await CdpClient.connect(wsUrl, signal);
|
|
150
|
+
const onAbort = () => client.close();
|
|
151
|
+
signal.addEventListener("abort", onAbort);
|
|
152
|
+
try {
|
|
153
|
+
await client.send("Page.enable");
|
|
154
|
+
await client.send("Runtime.enable");
|
|
155
|
+
const load = client.waitForEvent("Page.loadEventFired", Math.min(timeoutMs, 12_000));
|
|
156
|
+
const nav = await client.send("Page.navigate", { url });
|
|
157
|
+
if (nav.errorText) {
|
|
158
|
+
throw new Error(`Page.navigate failed: ${String(nav.errorText)}`);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await load;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* take a snapshot anyway if navigation load times out */
|
|
165
|
+
}
|
|
166
|
+
let snapshot = await evaluateSnapshot(client, WAIT_FOR_CONTENT_JS);
|
|
167
|
+
if (!snapshot || snapshot.textLen < 40 || snapshot.html.length < 80) {
|
|
168
|
+
snapshot = await evaluateSnapshot(client, SNAPSHOT_JS);
|
|
169
|
+
}
|
|
170
|
+
if (!snapshot)
|
|
171
|
+
throw new Error("Runtime.evaluate returned no snapshot");
|
|
172
|
+
return snapshot;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
signal.removeEventListener("abort", onAbort);
|
|
176
|
+
client.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function evaluateSnapshot(client, expression) {
|
|
180
|
+
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
|
181
|
+
const value = result.result?.value;
|
|
182
|
+
if (!value || typeof value.html !== "string")
|
|
183
|
+
return null;
|
|
184
|
+
return {
|
|
185
|
+
html: value.html,
|
|
186
|
+
title: String(value.title ?? ""),
|
|
187
|
+
textLen: Number(value.textLen ?? 0),
|
|
188
|
+
readyState: String(value.readyState ?? ""),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
class CdpClient {
|
|
192
|
+
ws;
|
|
193
|
+
nextId = 0;
|
|
194
|
+
pending = new Map();
|
|
195
|
+
eventWaiters = [];
|
|
196
|
+
constructor(ws) {
|
|
197
|
+
this.ws = ws;
|
|
198
|
+
ws.addEventListener("message", (ev) => {
|
|
199
|
+
let msg;
|
|
200
|
+
try {
|
|
201
|
+
msg = JSON.parse(String(ev.data));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (msg.id != null) {
|
|
207
|
+
const p = this.pending.get(msg.id);
|
|
208
|
+
if (p) {
|
|
209
|
+
this.pending.delete(msg.id);
|
|
210
|
+
if (msg.error) {
|
|
211
|
+
p.reject(new Error(msg.error.message || `CDP error ${msg.error.code ?? ""}`));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
p.resolve(msg.result ?? {});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (msg.method) {
|
|
220
|
+
for (const w of [...this.eventWaiters]) {
|
|
221
|
+
if (w.method === msg.method) {
|
|
222
|
+
this.eventWaiters = this.eventWaiters.filter((x) => x !== w);
|
|
223
|
+
w.resolve();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
static connect(url, signal) {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const ws = new WebSocket(url);
|
|
232
|
+
const onAbort = () => {
|
|
233
|
+
try {
|
|
234
|
+
ws.close();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
239
|
+
reject(new Error("Chrome CDP websocket timed out"));
|
|
240
|
+
};
|
|
241
|
+
if (signal.aborted) {
|
|
242
|
+
onAbort();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
246
|
+
ws.addEventListener("open", () => {
|
|
247
|
+
signal.removeEventListener("abort", onAbort);
|
|
248
|
+
resolve(new CdpClient(ws));
|
|
249
|
+
});
|
|
250
|
+
ws.addEventListener("error", () => {
|
|
251
|
+
signal.removeEventListener("abort", onAbort);
|
|
252
|
+
reject(new Error("Chrome CDP websocket failed"));
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
send(method, params = {}) {
|
|
257
|
+
const id = ++this.nextId;
|
|
258
|
+
return new Promise((resolve, reject) => {
|
|
259
|
+
this.pending.set(id, { resolve, reject });
|
|
260
|
+
try {
|
|
261
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
this.pending.delete(id);
|
|
265
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
waitForEvent(method, timeoutMs) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const timer = setTimeout(() => {
|
|
272
|
+
this.eventWaiters = this.eventWaiters.filter((w) => w !== waiter);
|
|
273
|
+
reject(new Error(`Timed out waiting for ${method}`));
|
|
274
|
+
}, timeoutMs);
|
|
275
|
+
const waiter = {
|
|
276
|
+
method,
|
|
277
|
+
resolve: () => {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
resolve();
|
|
280
|
+
},
|
|
281
|
+
reject: (err) => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
reject(err);
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
this.eventWaiters.push(waiter);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
close() {
|
|
290
|
+
for (const p of this.pending.values()) {
|
|
291
|
+
p.reject(new Error("CDP session closed"));
|
|
292
|
+
}
|
|
293
|
+
this.pending.clear();
|
|
294
|
+
for (const w of this.eventWaiters) {
|
|
295
|
+
w.reject(new Error("CDP session closed"));
|
|
296
|
+
}
|
|
297
|
+
this.eventWaiters = [];
|
|
298
|
+
try {
|
|
299
|
+
this.ws.close();
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
/* ignore */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function delay(ms, signal) {
|
|
307
|
+
return new Promise((resolve, reject) => {
|
|
308
|
+
if (signal.aborted) {
|
|
309
|
+
reject(new Error("aborted"));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const t = setTimeout(resolve, ms);
|
|
313
|
+
signal.addEventListener("abort", () => {
|
|
314
|
+
clearTimeout(t);
|
|
315
|
+
reject(new Error("aborted"));
|
|
316
|
+
}, { once: true });
|
|
317
|
+
});
|
|
318
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
export declare const DUMP_MAX_BYTES: number;
|
|
3
|
+
export declare const DEFAULT_TIMEOUT_MS = 18000;
|
|
4
|
+
export declare const VIRTUAL_TIME_MS = 10000;
|
|
5
|
+
export type ChromeHeadlessMode = "new" | "old" | "shell";
|
|
6
|
+
export declare function killProcessGroup(pid: number | undefined, child?: Pick<ChildProcess, "kill">): void;
|
|
7
|
+
export declare function headlessFlag(mode: ChromeHeadlessMode, binaryPath: string): string[];
|
|
8
|
+
export declare function chromeCdpArgs(opts: {
|
|
9
|
+
port: number;
|
|
10
|
+
profile: string;
|
|
11
|
+
headless: ChromeHeadlessMode;
|
|
12
|
+
binaryPath?: string;
|
|
13
|
+
}): string[];
|
|
14
|
+
export declare function chromeDumpDomArgs(opts: {
|
|
15
|
+
profile: string;
|
|
16
|
+
headless: ChromeHeadlessMode;
|
|
17
|
+
url: string;
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
binaryPath?: string;
|
|
20
|
+
}): string[];
|
|
21
|
+
/**
|
|
22
|
+
* First line of Chrome's DevToolsActivePort file is the chosen port when
|
|
23
|
+
* `--remote-debugging-port=0`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseDevToolsActivePort(text: string): number | null;
|
|
26
|
+
export declare function defaultChromeInstallPaths(): string[];
|
|
27
|
+
/** Playwright's chrome-headless-shell, if a previous `playwright install` left it on disk. */
|
|
28
|
+
export declare function findPlaywrightHeadlessShell(): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Path to a real installed Chrome/Chromium, or null.
|
|
31
|
+
* Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
|
|
32
|
+
* defaults (same locations `webcrawl doctor` checks). Env overrides are returned
|
|
33
|
+
* even if the file is missing so tests can point at a stub binary.
|
|
34
|
+
*/
|
|
35
|
+
export declare function installedChromePath(): string | null;
|
|
36
|
+
export declare function isLocalChromeAvailable(): boolean;
|
|
37
|
+
/** Distinct binaries to try, env override first, then system Chrome, then headless-shell. */
|
|
38
|
+
export declare function discoverChromeBinaries(): string[];
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome process helpers shared by the CDP driver and the dump-dom fallback.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
export const DUMP_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
export const DEFAULT_TIMEOUT_MS = 18_000;
|
|
9
|
+
export const VIRTUAL_TIME_MS = 10_000;
|
|
10
|
+
export function killProcessGroup(pid, child) {
|
|
11
|
+
if (pid && pid > 0) {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(-pid, "SIGKILL");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* process group kill is Unix-only; fall through */
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, "SIGKILL");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* already gone */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
child?.kill("SIGKILL");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* already gone */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const COMMON_CHROME_ARGS = [
|
|
35
|
+
"--no-sandbox",
|
|
36
|
+
"--disable-gpu",
|
|
37
|
+
"--disable-dev-shm-usage",
|
|
38
|
+
"--no-first-run",
|
|
39
|
+
"--no-default-browser-check",
|
|
40
|
+
"--disable-crash-reporter",
|
|
41
|
+
"--disable-breakpad",
|
|
42
|
+
"--disable-background-networking",
|
|
43
|
+
"--disable-sync",
|
|
44
|
+
"--disable-extensions",
|
|
45
|
+
"--disable-popup-blocking",
|
|
46
|
+
"--mute-audio",
|
|
47
|
+
"--hide-scrollbars",
|
|
48
|
+
"--disable-blink-features=AutomationControlled",
|
|
49
|
+
];
|
|
50
|
+
export function headlessFlag(mode, binaryPath) {
|
|
51
|
+
const isShell = /headless[-_]?shell/i.test(binaryPath);
|
|
52
|
+
if (isShell || mode === "shell") {
|
|
53
|
+
// chrome-headless-shell is already a headless binary; --headless=new hangs
|
|
54
|
+
// on some Chrome 156 installs (the v1.4.3 smoke-test failure).
|
|
55
|
+
return ["--headless"];
|
|
56
|
+
}
|
|
57
|
+
if (mode === "old")
|
|
58
|
+
return ["--headless=old"];
|
|
59
|
+
return ["--headless=new"];
|
|
60
|
+
}
|
|
61
|
+
export function chromeCdpArgs(opts) {
|
|
62
|
+
return [
|
|
63
|
+
...headlessFlag(opts.headless, opts.binaryPath ?? ""),
|
|
64
|
+
...COMMON_CHROME_ARGS,
|
|
65
|
+
`--remote-debugging-port=${opts.port}`,
|
|
66
|
+
"--remote-debugging-address=127.0.0.1",
|
|
67
|
+
`--user-data-dir=${opts.profile}`,
|
|
68
|
+
`--window-size=1280,800`,
|
|
69
|
+
"about:blank",
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
export function chromeDumpDomArgs(opts) {
|
|
73
|
+
return [
|
|
74
|
+
...headlessFlag(opts.headless, opts.binaryPath ?? ""),
|
|
75
|
+
...COMMON_CHROME_ARGS,
|
|
76
|
+
`--user-data-dir=${opts.profile}`,
|
|
77
|
+
`--virtual-time-budget=${VIRTUAL_TIME_MS}`,
|
|
78
|
+
`--timeout=${opts.timeoutMs}`,
|
|
79
|
+
"--dump-dom",
|
|
80
|
+
opts.url,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* First line of Chrome's DevToolsActivePort file is the chosen port when
|
|
85
|
+
* `--remote-debugging-port=0`.
|
|
86
|
+
*/
|
|
87
|
+
export function parseDevToolsActivePort(text) {
|
|
88
|
+
const line = String(text ?? "")
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.find(Boolean);
|
|
92
|
+
if (!line)
|
|
93
|
+
return null;
|
|
94
|
+
const port = Number(line);
|
|
95
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
96
|
+
}
|
|
97
|
+
function envChromePath() {
|
|
98
|
+
const fromEnv = process.env.WEBCRAWL_CHROME_PATH?.trim() ||
|
|
99
|
+
process.env.INTERACT_CHROME_PATH?.trim() ||
|
|
100
|
+
process.env.CHROME_PATH?.trim();
|
|
101
|
+
return fromEnv || null;
|
|
102
|
+
}
|
|
103
|
+
export function defaultChromeInstallPaths() {
|
|
104
|
+
if (process.platform === "darwin") {
|
|
105
|
+
return [
|
|
106
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
107
|
+
`${process.env.HOME || homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
108
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
if (process.platform === "win32") {
|
|
112
|
+
return [
|
|
113
|
+
`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
114
|
+
`${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
return [
|
|
118
|
+
"/usr/bin/google-chrome",
|
|
119
|
+
"/usr/bin/google-chrome-stable",
|
|
120
|
+
"/usr/bin/chromium-browser",
|
|
121
|
+
"/usr/bin/chromium",
|
|
122
|
+
"/snap/bin/chromium",
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
/** Playwright's chrome-headless-shell, if a previous `playwright install` left it on disk. */
|
|
126
|
+
export function findPlaywrightHeadlessShell() {
|
|
127
|
+
const roots = [
|
|
128
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
129
|
+
join(homedir(), ".cache", "ms-playwright"),
|
|
130
|
+
join(homedir(), "Library", "Caches", "ms-playwright"),
|
|
131
|
+
process.env.LOCALAPPDATA
|
|
132
|
+
? join(process.env.LOCALAPPDATA, "ms-playwright")
|
|
133
|
+
: "",
|
|
134
|
+
].filter(Boolean);
|
|
135
|
+
const out = [];
|
|
136
|
+
for (const root of roots) {
|
|
137
|
+
if (!existsSync(root))
|
|
138
|
+
continue;
|
|
139
|
+
let entries = [];
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(root);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const name of entries) {
|
|
147
|
+
if (!name.startsWith("chromium_headless_shell") && !name.startsWith("chromium-headless-shell")) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const dir = join(root, name);
|
|
151
|
+
const candidates = [
|
|
152
|
+
join(dir, "chrome-headless-shell-linux64", "chrome-headless-shell"),
|
|
153
|
+
join(dir, "chrome-headless-shell-linux-arm64", "chrome-headless-shell"),
|
|
154
|
+
join(dir, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
|
|
155
|
+
join(dir, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
|
|
156
|
+
join(dir, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
|
|
157
|
+
];
|
|
158
|
+
for (const c of candidates) {
|
|
159
|
+
if (existsSync(c))
|
|
160
|
+
out.push(c);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Path to a real installed Chrome/Chromium, or null.
|
|
168
|
+
* Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
|
|
169
|
+
* defaults (same locations `webcrawl doctor` checks). Env overrides are returned
|
|
170
|
+
* even if the file is missing so tests can point at a stub binary.
|
|
171
|
+
*/
|
|
172
|
+
export function installedChromePath() {
|
|
173
|
+
const fromEnv = envChromePath();
|
|
174
|
+
if (fromEnv)
|
|
175
|
+
return fromEnv;
|
|
176
|
+
for (const p of defaultChromeInstallPaths()) {
|
|
177
|
+
try {
|
|
178
|
+
if (p && existsSync(p))
|
|
179
|
+
return p;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* not installed here */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const shells = findPlaywrightHeadlessShell();
|
|
186
|
+
return shells[0] ?? null;
|
|
187
|
+
}
|
|
188
|
+
export function isLocalChromeAvailable() {
|
|
189
|
+
return installedChromePath() !== null;
|
|
190
|
+
}
|
|
191
|
+
/** Distinct binaries to try, env override first, then system Chrome, then headless-shell. */
|
|
192
|
+
export function discoverChromeBinaries() {
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const out = [];
|
|
195
|
+
const add = (p) => {
|
|
196
|
+
if (!p || seen.has(p))
|
|
197
|
+
return;
|
|
198
|
+
seen.add(p);
|
|
199
|
+
out.push(p);
|
|
200
|
+
};
|
|
201
|
+
add(envChromePath());
|
|
202
|
+
for (const p of defaultChromeInstallPaths()) {
|
|
203
|
+
try {
|
|
204
|
+
if (p && existsSync(p))
|
|
205
|
+
add(p);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* skip */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const p of findPlaywrightHeadlessShell())
|
|
212
|
+
add(p);
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export { DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, type ChromeHeadlessMode, } from "./chrome-launch.js";
|
|
2
|
+
export interface ChromeRenderAttempt {
|
|
3
|
+
ok: boolean;
|
|
4
|
+
method: string;
|
|
5
|
+
ms: number;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ChromeRenderResult {
|
|
9
|
+
html: string | null;
|
|
10
|
+
method?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
attempts: ChromeRenderAttempt[];
|
|
13
|
+
}
|
|
8
14
|
/**
|
|
9
15
|
* Render `url` in local Chrome and return the hydrated HTML, or null when
|
|
10
16
|
* Chrome is missing, times out, or produces nothing.
|
|
@@ -12,3 +18,11 @@ export declare function isLocalChromeAvailable(): boolean;
|
|
|
12
18
|
export declare function renderViaLocalChrome(url: string, opts?: {
|
|
13
19
|
timeoutMs?: number;
|
|
14
20
|
}): Promise<string | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Same as `renderViaLocalChrome` but with the attempt log and an actionable
|
|
23
|
+
* error when every driver fails. Callers that need to report honest JS-render
|
|
24
|
+
* failures should use this.
|
|
25
|
+
*/
|
|
26
|
+
export declare function renderViaLocalChromeDetailed(url: string, opts?: {
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): Promise<ChromeRenderResult>;
|