@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.
@@ -1,131 +1,199 @@
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 { renderUrlViaCdp } from "./chrome-cdp.js";
19
+ import { DEFAULT_TIMEOUT_MS, DUMP_MAX_BYTES, chromeDumpDomArgs, discoverChromeBinaries, killProcessGroup, } from "./chrome-launch.js";
20
+ export { DEFAULT_TIMEOUT_MS, VIRTUAL_TIME_MS, discoverChromeBinaries, findPlaywrightHeadlessShell, installedChromePath, isLocalChromeAvailable, parseDevToolsActivePort, chromeCdpArgs, chromeDumpDomArgs, killProcessGroup, } from "./chrome-launch.js";
21
+ function attemptPlan(binary) {
22
+ const isShell = /headless[-_]?shell/i.test(binary);
23
+ if (isShell) {
24
+ return [
25
+ { driver: "cdp", headless: "shell" },
26
+ { driver: "dump-dom", headless: "shell" },
27
+ ];
33
28
  }
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;
29
+ return [
30
+ { driver: "cdp", headless: "new" },
31
+ { driver: "cdp", headless: "old" },
32
+ { driver: "dump-dom", headless: "old" },
33
+ { driver: "dump-dom", headless: "new" },
34
+ ];
47
35
  }
48
- export function isLocalChromeAvailable() {
49
- return installedChromePath() !== null;
36
+ function methodName(binary, kind) {
37
+ const base = binary.split(/[/\\]/).pop() || binary;
38
+ return `${kind.driver}/${kind.headless} (${base})`;
50
39
  }
51
40
  /**
52
41
  * Render `url` in local Chrome and return the hydrated HTML, or null when
53
42
  * Chrome is missing, times out, or produces nothing.
54
43
  */
55
44
  export async function renderViaLocalChrome(url, opts = {}) {
56
- const chrome = installedChromePath();
57
- if (!chrome)
58
- return null;
59
- const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
45
+ const result = await renderViaLocalChromeDetailed(url, opts);
46
+ return result.html;
47
+ }
48
+ /**
49
+ * Same as `renderViaLocalChrome` but with the attempt log and an actionable
50
+ * error when every driver fails. Callers that need to report honest JS-render
51
+ * failures should use this.
52
+ */
53
+ export async function renderViaLocalChromeDetailed(url, opts = {}) {
54
+ const binaries = discoverChromeBinaries();
55
+ if (binaries.length === 0) {
56
+ return {
57
+ html: null,
58
+ error: "No local Chrome/Chromium binary found. Install Chrome, set WEBCRAWL_CHROME_PATH, " +
59
+ "or run `npx playwright install chromium-headless-shell`. See `webcrawl doctor`.",
60
+ attempts: [],
61
+ };
62
+ }
63
+ const budgetMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
64
+ const started = Date.now();
65
+ const attempts = [];
66
+ let lastError = "local Chrome produced no content";
67
+ for (const binary of binaries) {
68
+ for (const kind of attemptPlan(binary)) {
69
+ const remaining = budgetMs - (Date.now() - started);
70
+ if (remaining < 1_200)
71
+ break;
72
+ const method = methodName(binary, kind);
73
+ const attemptBudget = kind.driver === "cdp"
74
+ ? Math.min(remaining, kind.headless === "new" ? 12_000 : 8_000)
75
+ : Math.min(remaining, 6_000);
76
+ const t0 = Date.now();
77
+ try {
78
+ const html = kind.driver === "cdp"
79
+ ? (await renderUrlViaCdp(binary, url, {
80
+ timeoutMs: attemptBudget,
81
+ headless: kind.headless,
82
+ })).html
83
+ : await dumpDom(binary, url, kind.headless, attemptBudget);
84
+ const ms = Date.now() - t0;
85
+ if (html && html.length > 100) {
86
+ attempts.push({ ok: true, method, ms });
87
+ return { html, method, attempts };
88
+ }
89
+ lastError = `${method} returned empty HTML`;
90
+ attempts.push({ ok: false, method, ms, error: lastError });
91
+ }
92
+ catch (err) {
93
+ const ms = Date.now() - t0;
94
+ lastError = `${method}: ${err instanceof Error ? err.message : String(err)}`;
95
+ attempts.push({ ok: false, method, ms, error: lastError });
96
+ }
97
+ }
98
+ }
99
+ return {
100
+ html: null,
101
+ error: `JavaScript rendering failed (${lastError}). ` +
102
+ "Chrome hung, timed out, or produced no content. Install Chrome, point WEBCRAWL_CHROME_PATH " +
103
+ "at chrome-headless-shell (`npx playwright install chromium-headless-shell`), " +
104
+ "run `webcrawl login` and retry with `--mode cloud`, or set WEBCRAWL_RENDER_URL.",
105
+ attempts,
106
+ };
107
+ }
108
+ async function dumpDom(chrome, url, headless, timeoutMs) {
60
109
  let profile = "";
61
110
  try {
62
111
  profile = mkdtempSync(join(tmpdir(), "wc-chrome-"));
63
112
  }
64
113
  catch {
65
- return null;
114
+ throw new Error("could not create Chrome profile directory");
66
115
  }
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",
116
+ const args = chromeDumpDomArgs({
117
+ profile,
118
+ headless,
80
119
  url,
81
- ];
120
+ timeoutMs,
121
+ binaryPath: chrome,
122
+ });
82
123
  try {
83
- return await new Promise((resolve) => {
124
+ return await new Promise((resolve, reject) => {
84
125
  let settled = false;
85
- const done = (value) => {
126
+ const done = (err, value) => {
86
127
  if (settled)
87
128
  return;
88
129
  settled = true;
89
- resolve(value);
130
+ if (err)
131
+ reject(err);
132
+ else
133
+ resolve(value ?? "");
90
134
  };
91
135
  let child;
92
136
  try {
93
- child = spawn(chrome, args, { stdio: ["ignore", "pipe", "pipe"] });
137
+ child = spawn(chrome, args, {
138
+ stdio: ["ignore", "pipe", "pipe"],
139
+ detached: true,
140
+ });
94
141
  }
95
- catch {
96
- done(null);
142
+ catch (err) {
143
+ done(err instanceof Error ? err : new Error(String(err)));
97
144
  return;
98
145
  }
99
146
  const chunks = [];
100
147
  let bytes = 0;
101
- const timer = setTimeout(() => {
102
- try {
103
- child.kill("SIGKILL");
104
- }
105
- catch {
106
- /* already gone */
148
+ let idle = null;
149
+ const finish = (err) => {
150
+ clearTimeout(timer);
151
+ if (idle)
152
+ clearTimeout(idle);
153
+ killProcessGroup(child.pid, child);
154
+ if (err) {
155
+ done(err);
156
+ return;
107
157
  }
108
- done(null);
109
- }, timeoutMs + 2_000);
158
+ const text = Buffer.concat(chunks).toString("utf8");
159
+ done(null, text.trim());
160
+ };
161
+ const timer = setTimeout(() => {
162
+ if (bytes > 100)
163
+ finish(null);
164
+ else
165
+ finish(new Error(`dump-dom timed out after ${timeoutMs}ms`));
166
+ }, timeoutMs);
110
167
  child.stdout?.on("data", (c) => {
111
168
  if (bytes >= DUMP_MAX_BYTES)
112
169
  return;
113
170
  const take = c.subarray(0, DUMP_MAX_BYTES - bytes);
114
171
  chunks.push(take);
115
172
  bytes += take.length;
173
+ // Chrome 156 often prints the DOM then never exits (`--dump-dom` hang).
174
+ if (idle)
175
+ clearTimeout(idle);
176
+ idle = setTimeout(() => {
177
+ if (bytes > 40)
178
+ finish(null);
179
+ }, 400);
116
180
  });
117
- child.on("error", () => {
181
+ child.on("error", (err) => {
118
182
  clearTimeout(timer);
119
- done(null);
183
+ if (idle)
184
+ clearTimeout(idle);
185
+ done(err);
120
186
  });
121
187
  child.on("close", () => {
122
188
  clearTimeout(timer);
189
+ if (idle)
190
+ clearTimeout(idle);
123
191
  if (bytes === 0) {
124
- done(null);
192
+ done(new Error("dump-dom produced no stdout"));
125
193
  return;
126
194
  }
127
195
  const text = Buffer.concat(chunks).toString("utf8");
128
- done(text.trim().length > 0 ? text : null);
196
+ done(null, text.trim().length > 0 ? text : "");
129
197
  });
130
198
  });
131
199
  }
@@ -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>;
@@ -1,16 +1,73 @@
1
1
  /**
2
2
  * Prompt/schema-aware structured extraction used by `extractWebsiteData`.
3
3
  * Deterministic (no LLM): quote/author cards, embedded JSON, and schema shaping.
4
+ *
5
+ * Nested objects/arrays follow a JSON-schema walk: shorthand
6
+ * `{items:[{quote,author}]}` is coerced to a real object/array schema,
7
+ * root arrays are wrapped as `{items: ...}`, and empty results are detectable.
4
8
  */
5
9
  import { decodeHtmlEntities } from "../engine/markdown.js";
6
- function schemaProperties(schema) {
10
+ const CACHE_META_KEYS = new Set(["cached", "cacheLayer"]);
11
+ function isPlainObject(v) {
12
+ return !!v && typeof v === "object" && !Array.isArray(v);
13
+ }
14
+ /** A bare property map or shorthand array becomes a JSON Schema. */
15
+ export function coerceExtractSchema(node) {
16
+ if (Array.isArray(node)) {
17
+ const item = node.length > 0 ? coerceExtractSchema(node[0]) : { type: "object" };
18
+ return { type: "array", items: item };
19
+ }
20
+ if (typeof node === "string") {
21
+ const t = node.trim() || "string";
22
+ return { type: t };
23
+ }
24
+ if (!isPlainObject(node)) {
25
+ return { type: "string" };
26
+ }
27
+ if (typeof node.type === "string" || Array.isArray(node.type)) {
28
+ const out = { ...node };
29
+ if (isPlainObject(out.properties)) {
30
+ out.properties = Object.fromEntries(Object.entries(out.properties).map(([k, v]) => [k, coerceExtractSchema(v)]));
31
+ }
32
+ if (out.items !== undefined) {
33
+ out.items = coerceExtractSchema(out.items);
34
+ }
35
+ return out;
36
+ }
37
+ const properties = Object.fromEntries(Object.entries(node).map(([k, v]) => [k, coerceExtractSchema(v)]));
38
+ return {
39
+ type: "object",
40
+ properties,
41
+ required: Object.keys(properties),
42
+ additionalProperties: false,
43
+ };
44
+ }
45
+ /**
46
+ * Root-array wrap: a top-level array becomes
47
+ * `{ type: object, properties: { items: <array> } }`.
48
+ */
49
+ export function normalizeExtractSchema(schema) {
7
50
  if (!schema || typeof schema !== "object")
51
+ return undefined;
52
+ let coerced = coerceExtractSchema(schema);
53
+ if (coerced.type === "array") {
54
+ coerced = {
55
+ type: "object",
56
+ properties: { items: coerced },
57
+ required: ["items"],
58
+ additionalProperties: false,
59
+ };
60
+ }
61
+ return coerced;
62
+ }
63
+ export function schemaProperties(schema) {
64
+ const normalized = normalizeExtractSchema(schema);
65
+ if (!normalized)
8
66
  return {};
9
- const props = schema.properties;
10
- if (props && typeof props === "object" && !Array.isArray(props)) {
67
+ const props = normalized.properties;
68
+ if (isPlainObject(props))
11
69
  return props;
12
- }
13
- return schema;
70
+ return normalized;
14
71
  }
15
72
  export function hasCustomExtractRequest(prompt, schema) {
16
73
  if (prompt && prompt.trim())
@@ -18,7 +75,58 @@ export function hasCustomExtractRequest(prompt, schema) {
18
75
  return Object.keys(schemaProperties(schema)).length > 0;
19
76
  }
20
77
  function blobForIntent(prompt, schema) {
21
- return `${prompt ?? ""} ${Object.keys(schemaProperties(schema)).join(" ")}`.toLowerCase();
78
+ const keys = [];
79
+ const walk = (node) => {
80
+ if (Array.isArray(node)) {
81
+ for (const n of node)
82
+ walk(n);
83
+ return;
84
+ }
85
+ if (!isPlainObject(node))
86
+ return;
87
+ keys.push(...Object.keys(node));
88
+ if (isPlainObject(node.properties))
89
+ walk(node.properties);
90
+ if (node.items)
91
+ walk(node.items);
92
+ };
93
+ walk(schema);
94
+ walk(schemaProperties(schema));
95
+ return `${prompt ?? ""} ${keys.join(" ")}`.toLowerCase();
96
+ }
97
+ /** Nested object/array field names that look like quote+author records. */
98
+ export function nestedQuoteAuthorFields(schema) {
99
+ const normalized = normalizeExtractSchema(schema);
100
+ if (!normalized)
101
+ return null;
102
+ const visit = (node, path) => {
103
+ if (node.type === "array" && node.items) {
104
+ const items = coerceExtractSchema(node.items);
105
+ const itemProps = isPlainObject(items.properties) ? items.properties : {};
106
+ const itemKeys = Object.keys(itemProps);
107
+ const textKey = itemKeys.find((k) => /quote|text|content|body/i.test(k));
108
+ const authorKey = itemKeys.find((k) => /author|by|name/i.test(k));
109
+ if (textKey && authorKey) {
110
+ return { arrayPath: path, textKey, authorKey };
111
+ }
112
+ if (items.type === "object" && isPlainObject(items.properties)) {
113
+ const nested = visit(items, path);
114
+ if (nested)
115
+ return nested;
116
+ }
117
+ }
118
+ const props = isPlainObject(node.properties) ? node.properties : {};
119
+ for (const [key, value] of Object.entries(props)) {
120
+ if (!isPlainObject(value) && !Array.isArray(value))
121
+ continue;
122
+ const child = coerceExtractSchema(value);
123
+ const found = visit(child, [...path, key]);
124
+ if (found)
125
+ return found;
126
+ }
127
+ return null;
128
+ };
129
+ return visit(normalized, []);
22
130
  }
23
131
  export function wantsQuoteAuthorExtract(prompt, schema) {
24
132
  const blob = blobForIntent(prompt, schema);
@@ -27,8 +135,44 @@ export function wantsQuoteAuthorExtract(prompt, schema) {
27
135
  const props = schemaProperties(schema);
28
136
  if (props.quotes || props.quote || props.authors)
29
137
  return true;
138
+ if (nestedQuoteAuthorFields(schema))
139
+ return true;
30
140
  return false;
31
141
  }
142
+ /** Strip script/style/template (including unclosed tags) before reading visible DOM. */
143
+ export function stripNonContentHtml(html) {
144
+ return String(html ?? "")
145
+ .replace(/<!--[\s\S]*?-->/g, "")
146
+ .replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*?<\/\1>/gi, "")
147
+ .replace(/<(script|style|noscript|svg|canvas|template|iframe)(?:[^>"']|"[^"]*"|'[^']*')*>[\s\S]*/gi, "");
148
+ }
149
+ /** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
150
+ export function isJsTemplateJunk(text) {
151
+ const t = String(text ?? "");
152
+ return (/\+\s*d\s*\[/i.test(t) ||
153
+ /['"]\s*\+\s*d/i.test(t) ||
154
+ /\bd\s*\[\s*['"]text['"]\s*\]/.test(t) ||
155
+ /\bd\s*\[\s*['"]author['"]\s*\]/.test(t) ||
156
+ /document\.write/i.test(t));
157
+ }
158
+ export function isEmptyExtractResult(data) {
159
+ if (!data || typeof data !== "object")
160
+ return true;
161
+ const keys = Object.keys(data).filter((k) => !CACHE_META_KEYS.has(k));
162
+ if (keys.length === 0)
163
+ return true;
164
+ return keys.every((k) => {
165
+ const v = data[k];
166
+ if (v == null || v === "")
167
+ return true;
168
+ if (Array.isArray(v))
169
+ return v.length === 0;
170
+ if (typeof v === "object" && !Array.isArray(v)) {
171
+ return isEmptyExtractResult(v);
172
+ }
173
+ return false;
174
+ });
175
+ }
32
176
  function cleanText(raw) {
33
177
  return decodeHtmlEntities(raw.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
34
178
  }
@@ -37,6 +181,8 @@ function pushPair(out, text, author) {
37
181
  const a = cleanText(author);
38
182
  if (t.length < 8 || a.length < 2)
39
183
  return;
184
+ if (isJsTemplateJunk(t) || isJsTemplateJunk(a))
185
+ return;
40
186
  if (out.some((p) => p.text === t && p.author === a))
41
187
  return;
42
188
  out.push({ text: t, author: a });
@@ -44,7 +190,8 @@ function pushPair(out, text, author) {
44
190
  /** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
45
191
  export function extractQuoteAuthorPairs(html, markdown = "") {
46
192
  const out = [];
47
- const dataAssign = html.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
193
+ const source = String(html ?? "");
194
+ const dataAssign = source.match(/var\s+data\s*=\s*(\[[\s\S]*?\]);/);
48
195
  if (dataAssign) {
49
196
  try {
50
197
  const parsed = JSON.parse(dataAssign[1]);
@@ -60,9 +207,10 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
60
207
  /* not JSON */
61
208
  }
62
209
  }
210
+ const visible = stripNonContentHtml(source);
63
211
  const cardRe = /<(?:div|figure|blockquote|li)[^>]*class=["'][^"']*\bquote\b[^"']*["'][^>]*>([\s\S]*?)<\/(?:div|figure|blockquote|li)>/gi;
64
212
  let card;
65
- while ((card = cardRe.exec(html)) !== null) {
213
+ while ((card = cardRe.exec(visible)) !== null) {
66
214
  const block = card[1];
67
215
  const text = block.match(/<(?:span|div|p|q)[^>]*class=["'][^"']*\btext\b[^"']*["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
68
216
  block.match(/itemprop=["']text["'][^>]*>([\s\S]*?)<\//i)?.[1] ||
@@ -75,7 +223,7 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
75
223
  }
76
224
  const microRe = /itemprop=["']text["'][^>]*>([\s\S]*?)<\/[^>]+>[\s\S]{0,400}?itemprop=["']author["'][^>]*>([\s\S]*?)<\//gi;
77
225
  let micro;
78
- while ((micro = microRe.exec(html)) !== null) {
226
+ while ((micro = microRe.exec(visible)) !== null) {
79
227
  pushPair(out, micro[1], micro[2]);
80
228
  }
81
229
  if (out.length === 0 && markdown) {
@@ -88,6 +236,14 @@ export function extractQuoteAuthorPairs(html, markdown = "") {
88
236
  return out;
89
237
  }
90
238
  function itemFieldNames(schema) {
239
+ const nested = nestedQuoteAuthorFields(schema);
240
+ if (nested && nested.arrayPath.length > 0) {
241
+ return {
242
+ arrayKey: nested.arrayPath[nested.arrayPath.length - 1] || "items",
243
+ textKey: nested.textKey,
244
+ authorKey: nested.authorKey,
245
+ };
246
+ }
91
247
  const props = schemaProperties(schema);
92
248
  const keys = Object.keys(props);
93
249
  const arrayKey = keys.find((k) => {
@@ -96,8 +252,7 @@ function itemFieldNames(schema) {
96
252
  return /quotes?|items|results|entries/i.test(k);
97
253
  const t = p.type;
98
254
  return t === "array" || /quotes?|items|results|entries/i.test(k);
99
- }) ||
100
- (keys.length === 0 ? "quotes" : undefined);
255
+ }) || (keys.length === 0 ? "quotes" : undefined);
101
256
  const itemSchema = arrayKey && props[arrayKey] && typeof props[arrayKey] === "object"
102
257
  ? props[arrayKey]
103
258
  : undefined;
@@ -107,6 +262,22 @@ function itemFieldNames(schema) {
107
262
  const authorKey = itemKeys.find((k) => /author|by|name/i.test(k)) ?? "author";
108
263
  return { arrayKey: arrayKey ?? "quotes", textKey, authorKey };
109
264
  }
265
+ function assignAtPath(root, path, value) {
266
+ if (path.length === 0) {
267
+ return isPlainObject(value) ? { ...root, ...value } : root;
268
+ }
269
+ let cursor = root;
270
+ for (let i = 0; i < path.length - 1; i++) {
271
+ const key = path[i];
272
+ const next = cursor[key];
273
+ if (!isPlainObject(next)) {
274
+ cursor[key] = {};
275
+ }
276
+ cursor = cursor[key];
277
+ }
278
+ cursor[path[path.length - 1]] = value;
279
+ return root;
280
+ }
110
281
  export function shapeQuoteAuthors(pairs, schema) {
111
282
  const props = schemaProperties(schema);
112
283
  const keys = Object.keys(props);
@@ -119,10 +290,13 @@ export function shapeQuoteAuthors(pairs, schema) {
119
290
  [authorArr]: pairs.map((p) => p.author),
120
291
  };
121
292
  }
293
+ const nested = nestedQuoteAuthorFields(schema);
122
294
  const { arrayKey, textKey, authorKey } = itemFieldNames(schema);
123
- return {
124
- [arrayKey]: pairs.map((p) => ({ [textKey]: p.text, [authorKey]: p.author })),
125
- };
295
+ const rows = pairs.map((p) => ({ [textKey]: p.text, [authorKey]: p.author }));
296
+ if (nested && nested.arrayPath.length > 0) {
297
+ return assignAtPath({}, nested.arrayPath, rows);
298
+ }
299
+ return { [arrayKey]: rows };
126
300
  }
127
301
  /**
128
302
  * Deterministic extract when the caller passed a prompt and/or JSON schema.
@@ -131,13 +305,11 @@ export function shapeQuoteAuthors(pairs, schema) {
131
305
  export function extractByPromptAndSchema(html, markdown, prompt, schema) {
132
306
  if (!hasCustomExtractRequest(prompt, schema))
133
307
  return null;
134
- if (wantsQuoteAuthorExtract(prompt, schema)) {
135
- const pairs = extractQuoteAuthorPairs(html, markdown);
136
- if (pairs.length > 0)
137
- return shapeQuoteAuthors(pairs, schema);
138
- }
139
308
  const pairs = extractQuoteAuthorPairs(html, markdown);
140
- if (pairs.length > 0 && /\bquotes?\b/i.test(blobForIntent(prompt, schema))) {
309
+ if (wantsQuoteAuthorExtract(prompt, schema) && pairs.length > 0) {
310
+ return shapeQuoteAuthors(pairs, schema);
311
+ }
312
+ if (pairs.length > 0 && /\b(quotes?|items|results|entries)\b/i.test(blobForIntent(prompt, schema))) {
141
313
  return shapeQuoteAuthors(pairs, schema);
142
314
  }
143
315
  return null;
@@ -38,6 +38,12 @@ export interface LocalEngineBackend {
38
38
  * for content. Exported for unit tests.
39
39
  */
40
40
  export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
41
+ /**
42
+ * JS page whose renderer failed. `success: false` so callers never treat the
43
+ * unrendered nav shell as content (v1.4.3 smoke test: 114 chars of nav with
44
+ * success:true after Chrome hung on --dump-dom).
45
+ */
46
+ export declare function jsRenderFailedResult(url: string, statusCode: number, tier: string, latencyMs: number, reason: string): Record<string, unknown>;
41
47
  /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
42
48
  export declare const MIN_CACHEABLE_MARKDOWN = 200;
43
49
  /** Attach source HTML when the caller asked for the `html` format. */