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.
@@ -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
- * Path to a real installed Chrome/Chromium, or null.
3
- * Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
4
- * defaults (same locations `webcrawl doctor` checks).
5
- */
6
- export declare function installedChromePath(): string | null;
7
- export declare function isLocalChromeAvailable(): boolean;
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 dump-dom renderer (JS pages the native fetch ladder cannot hydrate).
2
+ * Local Chrome renderer (JS pages the native fetch ladder cannot hydrate).
3
3
  *
4
- * Uses a user-installed Chrome/Chromium binary — the same one `webcrawl doctor`
5
- * reports — with `--dump-dom` + virtual time so `document.write` / client-rendered
6
- * content appears in the HTML. Playwright is not required; this is the zero-key
7
- * local JS tier.
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 { existsSync, mkdtempSync, rmSync } from "node:fs";
15
+ import { mkdtempSync, rmSync } from "node:fs";
11
16
  import { tmpdir } from "node:os";
12
17
  import { join } from "node:path";
13
- const DUMP_MAX_BYTES = 2 * 1024 * 1024;
14
- const DEFAULT_TIMEOUT_MS = 20_000;
15
- const VIRTUAL_TIME_MS = 10_000;
16
- /**
17
- * Path to a real installed Chrome/Chromium, or null.
18
- * Priority: WEBCRAWL_CHROME_PATH, INTERACT_CHROME_PATH, CHROME_PATH, then per-OS
19
- * defaults (same locations `webcrawl doctor` checks).
20
- */
21
- export function installedChromePath() {
22
- const fromEnv = process.env.WEBCRAWL_CHROME_PATH?.trim() ||
23
- process.env.INTERACT_CHROME_PATH?.trim() ||
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
- else {
35
- candidates.push("/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser", "/usr/bin/chromium", "/snap/bin/chromium");
36
- }
37
- for (const p of candidates) {
38
- try {
39
- if (p && existsSync(p))
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
- export function isLocalChromeAvailable() {
49
- return installedChromePath() !== null;
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 chrome = installedChromePath();
57
- if (!chrome)
58
- return null;
59
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
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
- return null;
123
+ throw new Error("could not create Chrome profile directory");
66
124
  }
67
- const args = [
68
- "--headless=new",
69
- "--no-sandbox",
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
- resolve(value);
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, { stdio: ["ignore", "pipe", "pipe"] });
146
+ child = spawn(chrome, args, {
147
+ stdio: ["ignore", "pipe", "pipe"],
148
+ detached: true,
149
+ });
94
150
  }
95
- catch {
96
- done(null);
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
- const timer = setTimeout(() => {
102
- try {
103
- child.kill("SIGKILL");
104
- }
105
- catch {
106
- /* already gone */
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
- done(null);
109
- }, timeoutMs + 2_000);
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
- done(null);
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(null);
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 : null);
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>;