@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,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. */
@@ -9,7 +9,7 @@
9
9
  * Every community improvement to extractors, parsers, or targets in `src/` immediately
10
10
  * powers both this local engine (`npx webcrawl`) and the hosted `webcrawl.app` API.
11
11
  */
12
- import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls, renderViaLocalChrome, setCachedPage, sha256, } from "./engine.js";
12
+ import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls, renderViaLocalChromeDetailed, setCachedPage, sha256, } from "./engine.js";
13
13
  import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
14
14
  import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
15
15
  import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
@@ -20,7 +20,7 @@ import { parseYouTubeCached, youtubeVideoId } from "./parse/youtube.js";
20
20
  import { rankDocuments } from "./ranker.js";
21
21
  import { getSearchCategoriesCatalog, searchWeb } from "./search.js";
22
22
  import { inspectTarget, listTargets, resolveTarget } from "./targets.js";
23
- import { hasCustomExtractRequest } from "./extract/structured.js";
23
+ import { hasCustomExtractRequest, isEmptyExtractResult, } from "./extract/structured.js";
24
24
  /** Lowercased hostname of a URL (memo key for the Tier R render memo). */
25
25
  function safeDomainOf(url) {
26
26
  try {
@@ -53,6 +53,34 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs, reason) {
53
53
  evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
54
54
  };
55
55
  }
56
+ /**
57
+ * JS page whose renderer failed. `success: false` so callers never treat the
58
+ * unrendered nav shell as content (v1.4.3 smoke test: 114 chars of nav with
59
+ * success:true after Chrome hung on --dump-dom).
60
+ */
61
+ export function jsRenderFailedResult(url, statusCode, tier, latencyMs, reason) {
62
+ return {
63
+ success: false,
64
+ mode: "local",
65
+ url,
66
+ blocked: false,
67
+ needsJs: true,
68
+ statusCode,
69
+ error: reason,
70
+ title: url,
71
+ markdown: "",
72
+ links: [],
73
+ data: { url, title: url, markdown: "", links: [] },
74
+ evidence: {
75
+ tier,
76
+ blocked: false,
77
+ needsJs: true,
78
+ statusCode,
79
+ latencyMs,
80
+ limitations: [reason],
81
+ },
82
+ };
83
+ }
56
84
  /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
57
85
  export const MIN_CACHEABLE_MARKDOWN = 200;
58
86
  /** Attach source HTML when the caller asked for the `html` format. */
@@ -82,33 +110,55 @@ function emptyPageError(statusCode) {
82
110
  "The site may be blocking automated clients or serving a client-rendered shell. " +
83
111
  "Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
84
112
  }
113
+ function defaultJsRenderError() {
114
+ return ("Page needs JavaScript rendering; local Chrome was not available or produced no content. " +
115
+ "Install Chrome (see `webcrawl doctor`), point WEBCRAWL_CHROME_PATH at chrome-headless-shell " +
116
+ "(`npx playwright install chromium-headless-shell`), run `webcrawl login` and retry with `--mode cloud`, " +
117
+ "or self-host a render provider via WEBCRAWL_RENDER_URL.");
118
+ }
85
119
  /**
86
- * JS / challenge shell → render provider, then local Chrome dump-dom, then stealth.
87
- * Returns null when no renderer produced real content.
120
+ * JS / challenge shell → render provider, then local Chrome (CDP, then dump-dom),
121
+ * then stealth. Returns null when no renderer produced real content.
88
122
  */
89
123
  async function escalateJsRender(url) {
124
+ const errors = [];
90
125
  if (isRenderProviderEnabled()) {
91
126
  const rendered = await renderViaProvider(url);
92
127
  if (rendered && rendered.markdown.trim().length >= 40) {
93
128
  return {
94
- title: rendered.title,
95
- markdown: rendered.markdown,
96
- html: "",
97
- links: [],
98
- tier: "render",
129
+ page: {
130
+ title: rendered.title,
131
+ markdown: rendered.markdown,
132
+ html: "",
133
+ links: [],
134
+ tier: "render",
135
+ },
99
136
  };
100
137
  }
138
+ errors.push("render provider produced no content");
101
139
  }
102
140
  if (isLocalChromeAvailable()) {
103
- const html = await renderViaLocalChrome(url);
104
- if (html && html.length > 100) {
105
- const parsed = fastHtmlToMarkdown(html, url);
141
+ const chrome = await renderViaLocalChromeDetailed(url);
142
+ if (chrome.html && chrome.html.length > 100) {
143
+ const parsed = fastHtmlToMarkdown(chrome.html, url);
106
144
  if (parsed.markdown.trim().length > 40 &&
107
- !isAntiBotChallenge(html, 200) &&
145
+ !isAntiBotChallenge(chrome.html, 200) &&
108
146
  !isChallengeMarkdown(parsed.title, parsed.markdown)) {
109
- return { ...parsed, html, tier: "browser" };
147
+ return { page: { ...parsed, html: chrome.html, tier: "browser" } };
110
148
  }
149
+ errors.push(chrome.method
150
+ ? `${chrome.method} rendered HTML but markdown was still a shell`
151
+ : "local Chrome rendered HTML but markdown was still a shell");
152
+ }
153
+ else if (chrome.error) {
154
+ errors.push(chrome.error);
111
155
  }
156
+ else {
157
+ errors.push("local Chrome produced no content");
158
+ }
159
+ }
160
+ else {
161
+ errors.push("no local Chrome/Chromium binary");
112
162
  }
113
163
  try {
114
164
  const { stealthAvailable } = await import("./engine/stealth.js");
@@ -119,19 +169,22 @@ async function escalateJsRender(url) {
119
169
  esc.markdown.trim().length > 40 &&
120
170
  !isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
121
171
  return {
122
- title: esc.title,
123
- markdown: esc.markdown,
124
- html: esc.html,
125
- links: esc.links,
126
- tier: esc.tier,
172
+ page: {
173
+ title: esc.title,
174
+ markdown: esc.markdown,
175
+ html: esc.html,
176
+ links: esc.links,
177
+ tier: esc.tier,
178
+ },
127
179
  };
128
180
  }
181
+ errors.push("stealth browser produced no content");
129
182
  }
130
183
  }
131
184
  catch {
132
185
  /* stealth optional */
133
186
  }
134
- return null;
187
+ return { page: null, error: errors.filter(Boolean).join("; ") || defaultJsRenderError() };
135
188
  }
136
189
  /**
137
190
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
@@ -329,12 +382,15 @@ export function createLocalEngineClient() {
329
382
  const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
330
383
  const nativeTier = nativeScrapeTier();
331
384
  // JS shell / challenge: memoize the domain and escalate to a renderer
332
- // (hosted provider, local Chrome dump-dom, then stealth). Never cache
333
- // the unrendered shell.
385
+ // (hosted provider, local Chrome CDP, then stealth). Never cache
386
+ // the unrendered shell, and never report the shell as success:true.
334
387
  if (needsRender) {
335
388
  await markDomainRenderNeeded(renderDomain);
336
389
  // 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
337
- const rendered = blocked && fetched.status >= 400 ? null : await escalateJsRender(targetUrl);
390
+ const escalated = blocked && fetched.status >= 400
391
+ ? { page: null, error: undefined }
392
+ : await escalateJsRender(targetUrl);
393
+ const rendered = escalated.page;
338
394
  if (rendered) {
339
395
  const renderHash = sha256(rendered.markdown);
340
396
  if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
@@ -369,43 +425,10 @@ export function createLocalEngineClient() {
369
425
  },
370
426
  }, rendered.html, p.formats);
371
427
  }
372
- if (blocked || !markdown.trim()) {
373
- return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
428
+ if (blocked) {
429
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
374
430
  }
375
- const targetInspection = await inspectTarget({
376
- url: targetUrl,
377
- html,
378
- markdown,
379
- });
380
- return withOptionalHtml({
381
- success: true,
382
- mode: "local",
383
- url: targetUrl,
384
- title: title || targetInspection.title || targetUrl,
385
- markdown: targetInspection.markdown || markdown,
386
- links,
387
- data: {
388
- url: targetUrl,
389
- title: title || targetInspection.title || targetUrl,
390
- markdown: targetInspection.markdown || markdown,
391
- links,
392
- structured: targetInspection.structured,
393
- },
394
- evidence: {
395
- ...targetInspection.evidence,
396
- tier: nativeTier,
397
- contentHash: sha256(markdown || html),
398
- latencyMs: Date.now() - start,
399
- limitations: [
400
- ...(Array.isArray(targetInspection.evidence?.limitations)
401
- ? targetInspection.evidence.limitations
402
- : []),
403
- "Page needs JS rendering; local Chrome was not available or produced no content. " +
404
- "Install Chrome (see `webcrawl doctor`), run `webcrawl login` and retry with `--mode cloud`, " +
405
- "or self-host a render provider via WEBCRAWL_RENDER_URL.",
406
- ],
407
- },
408
- }, html, p.formats);
431
+ return jsRenderFailedResult(targetUrl, fetched.status, nativeTier, Date.now() - start, escalated.error || (!markdown.trim() ? emptyPageError(fetched.status) : defaultJsRenderError()));
409
432
  }
410
433
  if (blocked || !markdown.trim()) {
411
434
  return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
@@ -557,22 +580,38 @@ export function createLocalEngineClient() {
557
580
  formats: ["markdown", "html"],
558
581
  noCache: true,
559
582
  }));
560
- const markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
561
- const html = (scraped.data && scraped.data.html) || scraped.html || "";
583
+ let markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
584
+ let html = (scraped.data && scraped.data.html) || scraped.html || "";
562
585
  if (scraped.blocked || scraped.success === false) {
563
- return {
564
- success: false,
565
- blocked: true,
566
- mode: "local",
567
- url: targetUrl,
568
- data: {},
569
- error: scraped.error || emptyPageError(200),
570
- evidence: {
571
- tier: lastFetchTier(),
572
- sourceUrl: targetUrl,
573
- limitations: [String(scraped.error || emptyPageError(200))],
574
- },
575
- };
586
+ // Bot-blocked pages stay failures. A JS shell whose Chrome render failed
587
+ // can still yield structured data from JSON islands once script tags are
588
+ // stripped for card matching — never from `document.write` templates.
589
+ if (scraped.needsJs && scraped.blocked !== true) {
590
+ try {
591
+ const fetched = await fetchWithTlsImpersonation(targetUrl);
592
+ html = fetched.html || html;
593
+ const parsedShell = fastHtmlToMarkdown(html, targetUrl);
594
+ markdown = parsedShell.markdown || markdown;
595
+ }
596
+ catch {
597
+ /* keep whatever scrape returned */
598
+ }
599
+ }
600
+ else {
601
+ return {
602
+ success: false,
603
+ blocked: true,
604
+ mode: "local",
605
+ url: targetUrl,
606
+ data: {},
607
+ error: scraped.error || emptyPageError(200),
608
+ evidence: {
609
+ tier: lastFetchTier(),
610
+ sourceUrl: targetUrl,
611
+ limitations: [String(scraped.error || emptyPageError(200))],
612
+ },
613
+ };
614
+ }
576
615
  }
577
616
  const targetInspection = await inspectTarget({
578
617
  url: targetUrl,
@@ -587,15 +626,35 @@ export function createLocalEngineClient() {
587
626
  bypassCache: true,
588
627
  });
589
628
  const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
629
+ const data = extractOutputData(extracted, {
630
+ prompt: p.prompt,
631
+ schema: p.schema,
632
+ target: targetInspection.structured,
633
+ });
634
+ if (hasCustomExtractRequest(p.prompt, p.schema) && isEmptyExtractResult(data)) {
635
+ const error = "Extraction produced no data matching the prompt/schema. " +
636
+ "If this is a JS-rendered page, install Chrome (see `webcrawl doctor`) and retry; " +
637
+ "empty objects are not reported as success.";
638
+ return {
639
+ success: false,
640
+ mode: "local",
641
+ url: targetUrl,
642
+ data: {},
643
+ error,
644
+ evidence: {
645
+ ...targetInspection.evidence,
646
+ limitations: [
647
+ ...(targetInspection.evidence?.limitations ?? []),
648
+ error,
649
+ ],
650
+ },
651
+ };
652
+ }
590
653
  return {
591
654
  success: true,
592
655
  mode: "local",
593
656
  url: targetUrl,
594
- data: extractOutputData(extracted, {
595
- prompt: p.prompt,
596
- schema: p.schema,
597
- target: targetInspection.structured,
598
- }),
657
+ data,
599
658
  evidence: targetInspection.evidence,
600
659
  };
601
660
  },
@@ -52,7 +52,7 @@ export interface SearchRequest {
52
52
  bypassCache?: boolean;
53
53
  /**
54
54
  * Pick one search provider and skip the default multi-engine pipeline.
55
- * `auto` (default) uses the free engines and falls back to Jina when results are thin.
55
+ * `auto` (default) uses the free engines and falls back to a keyed provider when results are thin.
56
56
  */
57
57
  provider?: SearchProviderChoice;
58
58
  rankOptions?: {
@@ -11,7 +11,7 @@ export const SEARCH_PROVIDER_ENV_KEYS = {
11
11
  export const SEARCH_CATEGORY_CATALOG = {
12
12
  general: {
13
13
  description: "Broad web search across general-purpose engines.",
14
- engines: ["brave-native", "ddg-lite", "wikipedia"],
14
+ engines: ["brave-native", "ddg-lite", "wikipedia", "hn-algolia", "bing-rss"],
15
15
  localizeBy: "none",
16
16
  preferLocalDomains: false,
17
17
  subcategories: {