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
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
/** Page navigation + DOM-wait budget once Chrome is answering over CDP. */
|
|
9
|
+
export const DEFAULT_TIMEOUT_MS = 18_000;
|
|
10
|
+
/**
|
|
11
|
+
* How long to wait for Chrome to accept CDP after spawn. Kept short and
|
|
12
|
+
* separate from `DEFAULT_TIMEOUT_MS` so a hung binary fails fast while a
|
|
13
|
+
* slow but working JS page (often ~7s) still has time to render.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;
|
|
16
|
+
export const VIRTUAL_TIME_MS = 10_000;
|
|
17
|
+
function envPositiveMs(name, fallback, min) {
|
|
18
|
+
const raw = process.env[name];
|
|
19
|
+
if (raw == null || raw.trim() === "")
|
|
20
|
+
return fallback;
|
|
21
|
+
const n = Number(raw);
|
|
22
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
23
|
+
return fallback;
|
|
24
|
+
return Math.max(min, n);
|
|
25
|
+
}
|
|
26
|
+
/** `opts.timeoutMs`, else `WEBCRAWL_CHROME_TIMEOUT_MS`, else 18s. */
|
|
27
|
+
export function resolveChromeTimeoutMs(override) {
|
|
28
|
+
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
|
29
|
+
return Math.max(1_500, override);
|
|
30
|
+
}
|
|
31
|
+
return envPositiveMs("WEBCRAWL_CHROME_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, 1_500);
|
|
32
|
+
}
|
|
33
|
+
/** `opts.connectTimeoutMs`, else `WEBCRAWL_CHROME_CONNECT_TIMEOUT_MS`, else 4s. */
|
|
34
|
+
export function resolveChromeConnectTimeoutMs(override) {
|
|
35
|
+
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
|
36
|
+
return Math.max(500, override);
|
|
37
|
+
}
|
|
38
|
+
return envPositiveMs("WEBCRAWL_CHROME_CONNECT_TIMEOUT_MS", DEFAULT_CONNECT_TIMEOUT_MS, 500);
|
|
39
|
+
}
|
|
40
|
+
export function killProcessGroup(pid, child) {
|
|
41
|
+
if (pid && pid > 0) {
|
|
42
|
+
try {
|
|
43
|
+
process.kill(-pid, "SIGKILL");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* process group kill is Unix-only; fall through */
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, "SIGKILL");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* already gone */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
child?.kill("SIGKILL");
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* already gone */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const COMMON_CHROME_ARGS = [
|
|
65
|
+
"--no-sandbox",
|
|
66
|
+
"--disable-gpu",
|
|
67
|
+
"--disable-dev-shm-usage",
|
|
68
|
+
"--no-first-run",
|
|
69
|
+
"--no-default-browser-check",
|
|
70
|
+
"--disable-crash-reporter",
|
|
71
|
+
"--disable-breakpad",
|
|
72
|
+
"--disable-background-networking",
|
|
73
|
+
"--disable-sync",
|
|
74
|
+
"--disable-extensions",
|
|
75
|
+
"--disable-popup-blocking",
|
|
76
|
+
"--mute-audio",
|
|
77
|
+
"--hide-scrollbars",
|
|
78
|
+
"--disable-blink-features=AutomationControlled",
|
|
79
|
+
];
|
|
80
|
+
export function headlessFlag(mode, binaryPath) {
|
|
81
|
+
const isShell = /headless[-_]?shell/i.test(binaryPath);
|
|
82
|
+
if (isShell || mode === "shell") {
|
|
83
|
+
// chrome-headless-shell is already a headless binary; --headless=new hangs
|
|
84
|
+
// on some Chrome 156 installs (the v1.4.3 smoke-test failure).
|
|
85
|
+
return ["--headless"];
|
|
86
|
+
}
|
|
87
|
+
if (mode === "old")
|
|
88
|
+
return ["--headless=old"];
|
|
89
|
+
return ["--headless=new"];
|
|
90
|
+
}
|
|
91
|
+
export function chromeCdpArgs(opts) {
|
|
92
|
+
return [
|
|
93
|
+
...headlessFlag(opts.headless, opts.binaryPath ?? ""),
|
|
94
|
+
...COMMON_CHROME_ARGS,
|
|
95
|
+
`--remote-debugging-port=${opts.port}`,
|
|
96
|
+
"--remote-debugging-address=127.0.0.1",
|
|
97
|
+
`--user-data-dir=${opts.profile}`,
|
|
98
|
+
`--window-size=1280,800`,
|
|
99
|
+
"about:blank",
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
export function chromeDumpDomArgs(opts) {
|
|
103
|
+
return [
|
|
104
|
+
...headlessFlag(opts.headless, opts.binaryPath ?? ""),
|
|
105
|
+
...COMMON_CHROME_ARGS,
|
|
106
|
+
`--user-data-dir=${opts.profile}`,
|
|
107
|
+
`--virtual-time-budget=${VIRTUAL_TIME_MS}`,
|
|
108
|
+
`--timeout=${opts.timeoutMs}`,
|
|
109
|
+
"--dump-dom",
|
|
110
|
+
opts.url,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* First line of Chrome's DevToolsActivePort file is the chosen port when
|
|
115
|
+
* `--remote-debugging-port=0`.
|
|
116
|
+
*/
|
|
117
|
+
export function parseDevToolsActivePort(text) {
|
|
118
|
+
const line = String(text ?? "")
|
|
119
|
+
.split(/\r?\n/)
|
|
120
|
+
.map((s) => s.trim())
|
|
121
|
+
.find(Boolean);
|
|
122
|
+
if (!line)
|
|
123
|
+
return null;
|
|
124
|
+
const port = Number(line);
|
|
125
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
126
|
+
}
|
|
127
|
+
function envChromePath() {
|
|
128
|
+
const fromEnv = process.env.WEBCRAWL_CHROME_PATH?.trim() ||
|
|
129
|
+
process.env.INTERACT_CHROME_PATH?.trim() ||
|
|
130
|
+
process.env.CHROME_PATH?.trim();
|
|
131
|
+
return fromEnv || null;
|
|
132
|
+
}
|
|
133
|
+
export function defaultChromeInstallPaths() {
|
|
134
|
+
if (process.platform === "darwin") {
|
|
135
|
+
return [
|
|
136
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
137
|
+
`${process.env.HOME || homedir()}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
|
|
138
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
if (process.platform === "win32") {
|
|
142
|
+
return [
|
|
143
|
+
`${process.env["ProgramFiles"] || "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
144
|
+
`${process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
return [
|
|
148
|
+
"/usr/bin/google-chrome",
|
|
149
|
+
"/usr/bin/google-chrome-stable",
|
|
150
|
+
"/usr/bin/chromium-browser",
|
|
151
|
+
"/usr/bin/chromium",
|
|
152
|
+
"/snap/bin/chromium",
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
/** Playwright's chrome-headless-shell, if a previous `playwright install` left it on disk. */
|
|
156
|
+
export function findPlaywrightHeadlessShell() {
|
|
157
|
+
const roots = [
|
|
158
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
159
|
+
join(homedir(), ".cache", "ms-playwright"),
|
|
160
|
+
join(homedir(), "Library", "Caches", "ms-playwright"),
|
|
161
|
+
process.env.LOCALAPPDATA
|
|
162
|
+
? join(process.env.LOCALAPPDATA, "ms-playwright")
|
|
163
|
+
: "",
|
|
164
|
+
].filter(Boolean);
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const root of roots) {
|
|
167
|
+
if (!existsSync(root))
|
|
168
|
+
continue;
|
|
169
|
+
let entries = [];
|
|
170
|
+
try {
|
|
171
|
+
entries = readdirSync(root);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
for (const name of entries) {
|
|
177
|
+
if (!name.startsWith("chromium_headless_shell") && !name.startsWith("chromium-headless-shell")) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const dir = join(root, name);
|
|
181
|
+
const candidates = [
|
|
182
|
+
join(dir, "chrome-headless-shell-linux64", "chrome-headless-shell"),
|
|
183
|
+
join(dir, "chrome-headless-shell-linux-arm64", "chrome-headless-shell"),
|
|
184
|
+
join(dir, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
|
|
185
|
+
join(dir, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
|
|
186
|
+
join(dir, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
|
|
187
|
+
];
|
|
188
|
+
for (const c of candidates) {
|
|
189
|
+
if (existsSync(c))
|
|
190
|
+
out.push(c);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Path to a real installed Chrome/Chromium, or null.
|
|
198
|
+
* Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
|
|
199
|
+
* defaults (same locations `webcrawl doctor` checks). Env overrides are returned
|
|
200
|
+
* even if the file is missing so tests can point at a stub binary.
|
|
201
|
+
*/
|
|
202
|
+
export function installedChromePath() {
|
|
203
|
+
const fromEnv = envChromePath();
|
|
204
|
+
if (fromEnv)
|
|
205
|
+
return fromEnv;
|
|
206
|
+
for (const p of defaultChromeInstallPaths()) {
|
|
207
|
+
try {
|
|
208
|
+
if (p && existsSync(p))
|
|
209
|
+
return p;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* not installed here */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const shells = findPlaywrightHeadlessShell();
|
|
216
|
+
return shells[0] ?? null;
|
|
217
|
+
}
|
|
218
|
+
export function isLocalChromeAvailable() {
|
|
219
|
+
return installedChromePath() !== null;
|
|
220
|
+
}
|
|
221
|
+
/** Distinct binaries to try, env override first, then system Chrome, then headless-shell. */
|
|
222
|
+
export function discoverChromeBinaries() {
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
const out = [];
|
|
225
|
+
const add = (p) => {
|
|
226
|
+
if (!p || seen.has(p))
|
|
227
|
+
return;
|
|
228
|
+
seen.add(p);
|
|
229
|
+
out.push(p);
|
|
230
|
+
};
|
|
231
|
+
add(envChromePath());
|
|
232
|
+
for (const p of defaultChromeInstallPaths()) {
|
|
233
|
+
try {
|
|
234
|
+
if (p && existsSync(p))
|
|
235
|
+
add(p);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* skip */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const p of findPlaywrightHeadlessShell())
|
|
242
|
+
add(p);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, resolveChromeConnectTimeoutMs, resolveChromeTimeoutMs, type ChromeHeadlessMode, } from "./chrome-launch.js";
|
|
2
|
+
export { ChromeConnectError } from "./chrome-cdp.js";
|
|
3
|
+
export interface ChromeRenderAttempt {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
method: string;
|
|
6
|
+
ms: number;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ChromeRenderResult {
|
|
10
|
+
html: string | null;
|
|
11
|
+
method?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
attempts: ChromeRenderAttempt[];
|
|
14
|
+
}
|
|
8
15
|
/**
|
|
9
16
|
* Render `url` in local Chrome and return the hydrated HTML, or null when
|
|
10
17
|
* Chrome is missing, times out, or produces nothing.
|
|
11
18
|
*/
|
|
12
19
|
export declare function renderViaLocalChrome(url: string, opts?: {
|
|
13
20
|
timeoutMs?: number;
|
|
21
|
+
connectTimeoutMs?: number;
|
|
22
|
+
binaries?: string[];
|
|
14
23
|
}): Promise<string | null>;
|
|
24
|
+
/**
|
|
25
|
+
* Same as `renderViaLocalChrome` but with the attempt log and an actionable
|
|
26
|
+
* error when every driver fails. Callers that need to report honest JS-render
|
|
27
|
+
* failures should use this.
|
|
28
|
+
*/
|
|
29
|
+
export declare function renderViaLocalChromeDetailed(url: string, opts?: {
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
connectTimeoutMs?: number;
|
|
32
|
+
binaries?: string[];
|
|
33
|
+
}): Promise<ChromeRenderResult>;
|
|
@@ -1,131 +1,208 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Chrome
|
|
2
|
+
* Local Chrome renderer (JS pages the native fetch ladder cannot hydrate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Primary driver is Chrome DevTools Protocol:
|
|
5
|
+
* launch headless Chrome, navigate, wait until the DOM is content-ready
|
|
6
|
+
* (load + 200ms mutation-idle), then read `documentElement.outerHTML`.
|
|
7
|
+
*
|
|
8
|
+
* dump-dom is a last-resort fallback: Chrome 156 hangs on dump-dom with a
|
|
9
|
+
* fresh profile, which used to look like a successful scrape of the
|
|
10
|
+
* unrendered nav shell. Every attempt has a hard timeout and a process-group
|
|
11
|
+
* kill. Playwright's chrome-headless-shell is discovered automatically when
|
|
12
|
+
* present.
|
|
8
13
|
*/
|
|
9
14
|
import { spawn } from "node:child_process";
|
|
10
|
-
import {
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
16
|
import { tmpdir } from "node:os";
|
|
12
17
|
import { join } from "node:path";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
process.env.CHROME_PATH?.trim();
|
|
25
|
-
if (fromEnv)
|
|
26
|
-
return fromEnv;
|
|
27
|
-
const candidates = [];
|
|
28
|
-
if (process.platform === "darwin") {
|
|
29
|
-
candidates.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", `${process.env.HOME || ""}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`);
|
|
30
|
-
}
|
|
31
|
-
else if (process.platform === "win32") {
|
|
32
|
-
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`);
|
|
18
|
+
import { ChromeConnectError, renderUrlViaCdp } from "./chrome-cdp.js";
|
|
19
|
+
import { DUMP_MAX_BYTES, chromeDumpDomArgs, discoverChromeBinaries, killProcessGroup, resolveChromeConnectTimeoutMs, resolveChromeTimeoutMs, } from "./chrome-launch.js";
|
|
20
|
+
export { DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, resolveChromeConnectTimeoutMs, resolveChromeTimeoutMs, } from "./chrome-launch.js";
|
|
21
|
+
export { ChromeConnectError } from "./chrome-cdp.js";
|
|
22
|
+
function attemptPlan(binary) {
|
|
23
|
+
const isShell = /headless[-_]?shell/i.test(binary);
|
|
24
|
+
if (isShell) {
|
|
25
|
+
return [
|
|
26
|
+
{ driver: "cdp", headless: "shell" },
|
|
27
|
+
{ driver: "dump-dom", headless: "shell" },
|
|
28
|
+
];
|
|
33
29
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return p;
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
/* not installed here */
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return null;
|
|
30
|
+
return [
|
|
31
|
+
{ driver: "cdp", headless: "new" },
|
|
32
|
+
{ driver: "cdp", headless: "old" },
|
|
33
|
+
{ driver: "dump-dom", headless: "old" },
|
|
34
|
+
{ driver: "dump-dom", headless: "new" },
|
|
35
|
+
];
|
|
47
36
|
}
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
function methodName(binary, kind) {
|
|
38
|
+
const base = binary.split(/[/\\]/).pop() || binary;
|
|
39
|
+
return `${kind.driver}/${kind.headless} (${base})`;
|
|
50
40
|
}
|
|
51
41
|
/**
|
|
52
42
|
* Render `url` in local Chrome and return the hydrated HTML, or null when
|
|
53
43
|
* Chrome is missing, times out, or produces nothing.
|
|
54
44
|
*/
|
|
55
45
|
export async function renderViaLocalChrome(url, opts = {}) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
const result = await renderViaLocalChromeDetailed(url, opts);
|
|
47
|
+
return result.html;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Same as `renderViaLocalChrome` but with the attempt log and an actionable
|
|
51
|
+
* error when every driver fails. Callers that need to report honest JS-render
|
|
52
|
+
* failures should use this.
|
|
53
|
+
*/
|
|
54
|
+
export async function renderViaLocalChromeDetailed(url, opts = {}) {
|
|
55
|
+
const binaries = opts.binaries?.length ? opts.binaries : discoverChromeBinaries();
|
|
56
|
+
if (binaries.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
html: null,
|
|
59
|
+
error: "No local Chrome/Chromium binary found. Install Chrome, set WEBCRAWL_CHROME_PATH, " +
|
|
60
|
+
"or run `npx playwright install chromium-headless-shell`. See `webcrawl doctor`.",
|
|
61
|
+
attempts: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const budgetMs = resolveChromeTimeoutMs(opts.timeoutMs);
|
|
65
|
+
const connectTimeoutMs = resolveChromeConnectTimeoutMs(opts.connectTimeoutMs);
|
|
66
|
+
const started = Date.now();
|
|
67
|
+
const attempts = [];
|
|
68
|
+
let lastError = "local Chrome produced no content";
|
|
69
|
+
for (const binary of binaries) {
|
|
70
|
+
let hungConnect = false;
|
|
71
|
+
for (const kind of attemptPlan(binary)) {
|
|
72
|
+
const remaining = budgetMs - (Date.now() - started);
|
|
73
|
+
if (remaining < 1_200)
|
|
74
|
+
break;
|
|
75
|
+
// A process that launched but never answered CDP will hang dump-dom too.
|
|
76
|
+
if (kind.driver === "dump-dom" && hungConnect)
|
|
77
|
+
continue;
|
|
78
|
+
const method = methodName(binary, kind);
|
|
79
|
+
const attemptBudget = kind.driver === "cdp"
|
|
80
|
+
? Math.min(remaining, kind.headless === "new" ? 12_000 : 8_000)
|
|
81
|
+
: Math.min(remaining, 6_000);
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
const html = kind.driver === "cdp"
|
|
85
|
+
? (await renderUrlViaCdp(binary, url, {
|
|
86
|
+
timeoutMs: attemptBudget,
|
|
87
|
+
connectTimeoutMs: Math.min(connectTimeoutMs, remaining),
|
|
88
|
+
headless: kind.headless,
|
|
89
|
+
})).html
|
|
90
|
+
: await dumpDom(binary, url, kind.headless, attemptBudget);
|
|
91
|
+
const ms = Date.now() - t0;
|
|
92
|
+
if (html && html.length > 100) {
|
|
93
|
+
attempts.push({ ok: true, method, ms });
|
|
94
|
+
return { html, method, attempts };
|
|
95
|
+
}
|
|
96
|
+
lastError = `${method} returned empty HTML`;
|
|
97
|
+
attempts.push({ ok: false, method, ms, error: lastError });
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
const ms = Date.now() - t0;
|
|
101
|
+
lastError = `${method}: ${err instanceof Error ? err.message : String(err)}`;
|
|
102
|
+
attempts.push({ ok: false, method, ms, error: lastError });
|
|
103
|
+
if (err instanceof ChromeConnectError && err.hung)
|
|
104
|
+
hungConnect = true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
html: null,
|
|
110
|
+
error: `JavaScript rendering failed (${lastError}). ` +
|
|
111
|
+
"Chrome hung, timed out, or produced no content. Install Chrome, point WEBCRAWL_CHROME_PATH " +
|
|
112
|
+
"at chrome-headless-shell (`npx playwright install chromium-headless-shell`), " +
|
|
113
|
+
"run `webcrawl login` and retry with `--mode cloud`, or set WEBCRAWL_RENDER_URL.",
|
|
114
|
+
attempts,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function dumpDom(chrome, url, headless, timeoutMs) {
|
|
60
118
|
let profile = "";
|
|
61
119
|
try {
|
|
62
120
|
profile = mkdtempSync(join(tmpdir(), "wc-chrome-"));
|
|
63
121
|
}
|
|
64
122
|
catch {
|
|
65
|
-
|
|
123
|
+
throw new Error("could not create Chrome profile directory");
|
|
66
124
|
}
|
|
67
|
-
const args =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"--disable-gpu",
|
|
71
|
-
"--disable-dev-shm-usage",
|
|
72
|
-
"--no-first-run",
|
|
73
|
-
"--no-default-browser-check",
|
|
74
|
-
"--disable-crash-reporter",
|
|
75
|
-
"--disable-breakpad",
|
|
76
|
-
`--user-data-dir=${profile}`,
|
|
77
|
-
`--virtual-time-budget=${VIRTUAL_TIME_MS}`,
|
|
78
|
-
`--timeout=${timeoutMs}`,
|
|
79
|
-
"--dump-dom",
|
|
125
|
+
const args = chromeDumpDomArgs({
|
|
126
|
+
profile,
|
|
127
|
+
headless,
|
|
80
128
|
url,
|
|
81
|
-
|
|
129
|
+
timeoutMs,
|
|
130
|
+
binaryPath: chrome,
|
|
131
|
+
});
|
|
82
132
|
try {
|
|
83
|
-
return await new Promise((resolve) => {
|
|
133
|
+
return await new Promise((resolve, reject) => {
|
|
84
134
|
let settled = false;
|
|
85
|
-
const done = (value) => {
|
|
135
|
+
const done = (err, value) => {
|
|
86
136
|
if (settled)
|
|
87
137
|
return;
|
|
88
138
|
settled = true;
|
|
89
|
-
|
|
139
|
+
if (err)
|
|
140
|
+
reject(err);
|
|
141
|
+
else
|
|
142
|
+
resolve(value ?? "");
|
|
90
143
|
};
|
|
91
144
|
let child;
|
|
92
145
|
try {
|
|
93
|
-
child = spawn(chrome, args, {
|
|
146
|
+
child = spawn(chrome, args, {
|
|
147
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
148
|
+
detached: true,
|
|
149
|
+
});
|
|
94
150
|
}
|
|
95
|
-
catch {
|
|
96
|
-
done(
|
|
151
|
+
catch (err) {
|
|
152
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
97
153
|
return;
|
|
98
154
|
}
|
|
99
155
|
const chunks = [];
|
|
100
156
|
let bytes = 0;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
157
|
+
let idle = null;
|
|
158
|
+
const finish = (err) => {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (idle)
|
|
161
|
+
clearTimeout(idle);
|
|
162
|
+
killProcessGroup(child.pid, child);
|
|
163
|
+
if (err) {
|
|
164
|
+
done(err);
|
|
165
|
+
return;
|
|
107
166
|
}
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
168
|
+
done(null, text.trim());
|
|
169
|
+
};
|
|
170
|
+
const timer = setTimeout(() => {
|
|
171
|
+
if (bytes > 100)
|
|
172
|
+
finish(null);
|
|
173
|
+
else
|
|
174
|
+
finish(new Error(`dump-dom timed out after ${timeoutMs}ms`));
|
|
175
|
+
}, timeoutMs);
|
|
110
176
|
child.stdout?.on("data", (c) => {
|
|
111
177
|
if (bytes >= DUMP_MAX_BYTES)
|
|
112
178
|
return;
|
|
113
179
|
const take = c.subarray(0, DUMP_MAX_BYTES - bytes);
|
|
114
180
|
chunks.push(take);
|
|
115
181
|
bytes += take.length;
|
|
182
|
+
// Chrome 156 often prints the DOM then never exits (`--dump-dom` hang).
|
|
183
|
+
if (idle)
|
|
184
|
+
clearTimeout(idle);
|
|
185
|
+
idle = setTimeout(() => {
|
|
186
|
+
if (bytes > 40)
|
|
187
|
+
finish(null);
|
|
188
|
+
}, 400);
|
|
116
189
|
});
|
|
117
|
-
child.on("error", () => {
|
|
190
|
+
child.on("error", (err) => {
|
|
118
191
|
clearTimeout(timer);
|
|
119
|
-
|
|
192
|
+
if (idle)
|
|
193
|
+
clearTimeout(idle);
|
|
194
|
+
done(err);
|
|
120
195
|
});
|
|
121
196
|
child.on("close", () => {
|
|
122
197
|
clearTimeout(timer);
|
|
198
|
+
if (idle)
|
|
199
|
+
clearTimeout(idle);
|
|
123
200
|
if (bytes === 0) {
|
|
124
|
-
done(
|
|
201
|
+
done(new Error("dump-dom produced no stdout"));
|
|
125
202
|
return;
|
|
126
203
|
}
|
|
127
204
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
128
|
-
done(text.trim().length > 0 ? text :
|
|
205
|
+
done(null, text.trim().length > 0 ? text : "");
|
|
129
206
|
});
|
|
130
207
|
});
|
|
131
208
|
}
|
|
@@ -6,4 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
|
|
|
6
6
|
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, type BusinessListing, } from "./business.js";
|
|
7
7
|
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, type CveAffected, type CveRecord, type CveSeverity, } from "./cve.js";
|
|
8
8
|
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
-
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
|
|
9
|
+
export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
|
|
@@ -6,4 +6,4 @@ export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelLis
|
|
|
6
6
|
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, } from "./business.js";
|
|
7
7
|
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, } from "./cve.js";
|
|
8
8
|
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
-
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, } from "./structured.js";
|
|
9
|
+
export { coerceExtractSchema, extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, isEmptyExtractResult, isJsTemplateJunk, nestedQuoteAuthorFields, normalizeExtractSchema, shapeQuoteAuthors, stripNonContentHtml, wantsQuoteAuthorExtract, } from "./structured.js";
|
|
@@ -2,8 +2,27 @@ export interface QuoteAuthor {
|
|
|
2
2
|
text: string;
|
|
3
3
|
author: string;
|
|
4
4
|
}
|
|
5
|
+
/** A bare property map or shorthand array becomes a JSON Schema. */
|
|
6
|
+
export declare function coerceExtractSchema(node: unknown): Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Root-array wrap: a top-level array becomes
|
|
9
|
+
* `{ type: object, properties: { items: <array> } }`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeExtractSchema(schema?: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
12
|
+
export declare function schemaProperties(schema?: Record<string, unknown>): Record<string, unknown>;
|
|
5
13
|
export declare function hasCustomExtractRequest(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
14
|
+
/** Nested object/array field names that look like quote+author records. */
|
|
15
|
+
export declare function nestedQuoteAuthorFields(schema?: Record<string, unknown>): {
|
|
16
|
+
arrayPath: string[];
|
|
17
|
+
textKey: string;
|
|
18
|
+
authorKey: string;
|
|
19
|
+
} | null;
|
|
6
20
|
export declare function wantsQuoteAuthorExtract(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
21
|
+
/** Strip script/style/template (including unclosed tags) before reading visible DOM. */
|
|
22
|
+
export declare function stripNonContentHtml(html: string): string;
|
|
23
|
+
/** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
|
|
24
|
+
export declare function isJsTemplateJunk(text: string): boolean;
|
|
25
|
+
export declare function isEmptyExtractResult(data: Record<string, unknown> | null | undefined): boolean;
|
|
7
26
|
/** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
|
|
8
27
|
export declare function extractQuoteAuthorPairs(html: string, markdown?: string): QuoteAuthor[];
|
|
9
28
|
export declare function shapeQuoteAuthors(pairs: QuoteAuthor[], schema?: Record<string, unknown>): Record<string, unknown>;
|