@webcrawl/mcp 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.
@@ -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>;