webcrawl 1.4.2 → 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.
@@ -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, mapWebsiteUrls, 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,6 +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, isEmptyExtractResult, } from "./extract/structured.js";
23
24
  /** Lowercased hostname of a URL (memo key for the Tier R render memo). */
24
25
  function safeDomainOf(url) {
25
26
  try {
@@ -34,9 +35,10 @@ function safeDomainOf(url) {
34
35
  * `success: false` with no markdown, so callers never mistake the block page
35
36
  * for content. Exported for unit tests.
36
37
  */
37
- export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
38
- const error = `Blocked by the site's bot check (HTTP ${statusCode}). No content was returned. ` +
39
- "Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.";
38
+ export function blockedScrapeResult(url, statusCode, tier, latencyMs, reason) {
39
+ const error = reason ??
40
+ (`Blocked by the site's bot check (HTTP ${statusCode}). No content was returned. ` +
41
+ "Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
40
42
  return {
41
43
  success: false,
42
44
  mode: "local",
@@ -51,6 +53,139 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
51
53
  evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
52
54
  };
53
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
+ }
84
+ /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
85
+ export const MIN_CACHEABLE_MARKDOWN = 200;
86
+ /** Attach source HTML when the caller asked for the `html` format. */
87
+ export function withOptionalHtml(result, html, formats) {
88
+ if (!formats?.includes("html") || !html)
89
+ return result;
90
+ const data = result.data && typeof result.data === "object" && !Array.isArray(result.data)
91
+ ? result.data
92
+ : {};
93
+ return { ...result, html, data: { ...data, html } };
94
+ }
95
+ /** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
96
+ export function extractOutputData(extracted, opts) {
97
+ if (hasCustomExtractRequest(opts.prompt, opts.schema))
98
+ return extracted;
99
+ return {
100
+ ...extracted,
101
+ ...(opts.target ? { target: opts.target } : {}),
102
+ };
103
+ }
104
+ function nativeScrapeTier() {
105
+ const t = lastFetchTier();
106
+ return t === "impersonate" || t === "proxy-impersonate" ? t : "impit-tls";
107
+ }
108
+ function emptyPageError(statusCode) {
109
+ return (`Page returned no readable content (HTTP ${statusCode}). ` +
110
+ "The site may be blocking automated clients or serving a client-rendered shell. " +
111
+ "Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
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
+ }
119
+ /**
120
+ * JS / challenge shell → render provider, then local Chrome (CDP, then dump-dom),
121
+ * then stealth. Returns null when no renderer produced real content.
122
+ */
123
+ async function escalateJsRender(url) {
124
+ const errors = [];
125
+ if (isRenderProviderEnabled()) {
126
+ const rendered = await renderViaProvider(url);
127
+ if (rendered && rendered.markdown.trim().length >= 40) {
128
+ return {
129
+ page: {
130
+ title: rendered.title,
131
+ markdown: rendered.markdown,
132
+ html: "",
133
+ links: [],
134
+ tier: "render",
135
+ },
136
+ };
137
+ }
138
+ errors.push("render provider produced no content");
139
+ }
140
+ if (isLocalChromeAvailable()) {
141
+ const chrome = await renderViaLocalChromeDetailed(url);
142
+ if (chrome.html && chrome.html.length > 100) {
143
+ const parsed = fastHtmlToMarkdown(chrome.html, url);
144
+ if (parsed.markdown.trim().length > 40 &&
145
+ !isAntiBotChallenge(chrome.html, 200) &&
146
+ !isChallengeMarkdown(parsed.title, parsed.markdown)) {
147
+ return { page: { ...parsed, html: chrome.html, tier: "browser" } };
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);
155
+ }
156
+ else {
157
+ errors.push("local Chrome produced no content");
158
+ }
159
+ }
160
+ else {
161
+ errors.push("no local Chrome/Chromium binary");
162
+ }
163
+ try {
164
+ const { stealthAvailable } = await import("./engine/stealth.js");
165
+ const { escalateToSandboxOrBrowser, isChallengeResult } = await import("./sandbox.js");
166
+ if (await stealthAvailable()) {
167
+ const esc = await escalateToSandboxOrBrowser(url, { browser: true, timeoutMs: 25_000 });
168
+ if (esc &&
169
+ esc.markdown.trim().length > 40 &&
170
+ !isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
171
+ return {
172
+ page: {
173
+ title: esc.title,
174
+ markdown: esc.markdown,
175
+ html: esc.html,
176
+ links: esc.links,
177
+ tier: esc.tier,
178
+ },
179
+ };
180
+ }
181
+ errors.push("stealth browser produced no content");
182
+ }
183
+ }
184
+ catch {
185
+ /* stealth optional */
186
+ }
187
+ return { page: null, error: errors.filter(Boolean).join("; ") || defaultJsRenderError() };
188
+ }
54
189
  /**
55
190
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
56
191
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -90,7 +225,7 @@ export function createLocalEngineClient() {
90
225
  const resolved = resolveTarget({ query, category });
91
226
  return {
92
227
  ...res,
93
- success: true,
228
+ success: res.success !== false,
94
229
  mode: "local",
95
230
  target: {
96
231
  id: resolved.plugin.id,
@@ -161,8 +296,11 @@ export function createLocalEngineClient() {
161
296
  if (!p.noCache) {
162
297
  const cached = await getCachedPageAsync(targetUrl, "none");
163
298
  // Entries written before blocked pages stopped being cached may hold a
164
- // bot-check page: treat those as a miss and fetch again.
165
- if (cached && !isChallengeMarkdown(cached.title, cached.markdown)) {
299
+ // bot-check page: treat those as a miss and fetch again. Thin shells
300
+ // (header-only JS pages) must also miss so local Chrome can hydrate them.
301
+ if (cached &&
302
+ !isChallengeMarkdown(cached.title, cached.markdown) &&
303
+ cached.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
166
304
  const targetInspection = await inspectTarget({
167
305
  url: targetUrl,
168
306
  markdown: cached.markdown,
@@ -235,106 +373,82 @@ export function createLocalEngineClient() {
235
373
  // ladder — the memo may be stale or the provider flaky.
236
374
  }
237
375
  const fetched = await fetchWithTlsImpersonation(targetUrl);
238
- const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
239
- const contentHash = sha256(markdown || fetched.html);
240
- // A bot-check / 403 / 429 / 503 response is not the page: never cache it,
241
- // or every retry for the next 24h would get the block page back.
242
- const blocked = isAntiBotChallenge(fetched.html, fetched.status);
243
- if (!blocked) {
244
- setCachedPage(targetUrl, "none", {
245
- title,
246
- markdown,
247
- links,
248
- statusCode: fetched.status,
249
- contentHash,
250
- });
251
- }
252
- const targetInspection = await inspectTarget({
253
- url: targetUrl,
254
- html: fetched.html,
255
- markdown,
256
- });
257
- // Tier R: the native ladder passed the bot check but the page is a
258
- // JS shell / challenge page the ladder cannot render. Memoize the
259
- // domain and either render via the provider (when configured) or
260
- // return the shell with an honest limitation note.
261
- if (needsBrowserEscalation(fetched.status, fetched.html, markdown)) {
376
+ const parsed = fastHtmlToMarkdown(fetched.html, targetUrl);
377
+ let title = parsed.title;
378
+ let markdown = parsed.markdown;
379
+ let links = parsed.links;
380
+ let html = fetched.html;
381
+ const blocked = isAntiBotChallenge(html, fetched.status);
382
+ const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
383
+ const nativeTier = nativeScrapeTier();
384
+ // JS shell / challenge: memoize the domain and escalate to a renderer
385
+ // (hosted provider, local Chrome CDP, then stealth). Never cache
386
+ // the unrendered shell, and never report the shell as success:true.
387
+ if (needsRender) {
262
388
  await markDomainRenderNeeded(renderDomain);
263
- if (isRenderProviderEnabled()) {
264
- const rendered = await renderViaProvider(targetUrl);
265
- if (rendered) {
266
- const renderHash = sha256(rendered.markdown);
389
+ // 403/429/503 is already a refusal — don't spend a Chrome launch on the block page.
390
+ const escalated = blocked && fetched.status >= 400
391
+ ? { page: null, error: undefined }
392
+ : await escalateJsRender(targetUrl);
393
+ const rendered = escalated.page;
394
+ if (rendered) {
395
+ const renderHash = sha256(rendered.markdown);
396
+ if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
267
397
  setCachedPage(targetUrl, "none", {
268
398
  title: rendered.title,
269
399
  markdown: rendered.markdown,
270
- links: [],
400
+ links: rendered.links,
271
401
  statusCode: 200,
272
402
  contentHash: renderHash,
273
403
  });
274
- return {
275
- success: true,
276
- mode: "local",
404
+ }
405
+ return withOptionalHtml({
406
+ success: true,
407
+ mode: "local",
408
+ url: targetUrl,
409
+ title: rendered.title || targetUrl,
410
+ markdown: rendered.markdown,
411
+ links: rendered.links,
412
+ data: {
277
413
  url: targetUrl,
278
414
  title: rendered.title || targetUrl,
279
415
  markdown: rendered.markdown,
280
- links: [],
281
- data: {
282
- url: targetUrl,
283
- title: rendered.title || targetUrl,
284
- markdown: rendered.markdown,
285
- links: [],
286
- },
287
- evidence: {
288
- targetId: "render-provider",
289
- tier: "render",
290
- contentHash: renderHash,
291
- latencyMs: Date.now() - start,
292
- sourceUrl: rendered.sourceUrl,
293
- matchedSignals: ["render:provider"],
294
- limitations: rendered.warnings,
295
- },
296
- };
297
- }
416
+ links: rendered.links,
417
+ },
418
+ evidence: {
419
+ targetId: rendered.tier === "render" ? "render-provider" : "web-spa",
420
+ tier: rendered.tier,
421
+ contentHash: renderHash,
422
+ latencyMs: Date.now() - start,
423
+ sourceUrl: targetUrl,
424
+ matchedSignals: rendered.tier === "browser" ? ["render:local-chrome"] : [`render:${rendered.tier}`],
425
+ },
426
+ }, rendered.html, p.formats);
298
427
  }
299
- const nativeTier = lastFetchTier() === "impersonate"
300
- ? "impersonate"
301
- : lastFetchTier() === "proxy-impersonate"
302
- ? "proxy-impersonate"
303
- : "impit-tls";
304
428
  if (blocked) {
305
429
  return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
306
430
  }
307
- return {
308
- success: true,
309
- mode: "local",
310
- url: targetUrl,
311
- title: title || targetInspection.title || targetUrl,
312
- markdown: targetInspection.markdown || markdown,
431
+ return jsRenderFailedResult(targetUrl, fetched.status, nativeTier, Date.now() - start, escalated.error || (!markdown.trim() ? emptyPageError(fetched.status) : defaultJsRenderError()));
432
+ }
433
+ if (blocked || !markdown.trim()) {
434
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
435
+ }
436
+ const contentHash = sha256(markdown || html);
437
+ if (markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
438
+ setCachedPage(targetUrl, "none", {
439
+ title,
440
+ markdown,
313
441
  links,
314
- data: {
315
- url: targetUrl,
316
- title: title || targetInspection.title || targetUrl,
317
- markdown: targetInspection.markdown || markdown,
318
- links,
319
- structured: targetInspection.structured,
320
- },
321
- evidence: {
322
- ...targetInspection.evidence,
323
- tier: nativeTier,
324
- contentHash,
325
- latencyMs: Date.now() - start,
326
- limitations: [
327
- ...(Array.isArray(targetInspection.evidence?.limitations)
328
- ? targetInspection.evidence.limitations
329
- : []),
330
- "Page needs JS rendering; the local native tiers cannot render it. " +
331
- "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
332
- "or self-host a render provider via WEBCRAWL_RENDER_URL.",
333
- ],
334
- },
335
- };
442
+ statusCode: fetched.status,
443
+ contentHash,
444
+ });
336
445
  }
337
- return {
446
+ const targetInspection = await inspectTarget({
447
+ url: targetUrl,
448
+ html,
449
+ markdown,
450
+ });
451
+ return withOptionalHtml({
338
452
  success: true,
339
453
  mode: "local",
340
454
  url: targetUrl,
@@ -350,15 +464,11 @@ export function createLocalEngineClient() {
350
464
  },
351
465
  evidence: {
352
466
  ...targetInspection.evidence,
353
- tier: lastFetchTier() === "impersonate"
354
- ? "impersonate"
355
- : lastFetchTier() === "proxy-impersonate"
356
- ? "proxy-impersonate"
357
- : "impit-tls",
467
+ tier: nativeTier,
358
468
  contentHash,
359
469
  latencyMs: Date.now() - start,
360
470
  },
361
- };
471
+ }, html, p.formats);
362
472
  },
363
473
  async parse(p) {
364
474
  const url = typeof p.url === "string" ? p.url : undefined;
@@ -464,63 +574,87 @@ export function createLocalEngineClient() {
464
574
  },
465
575
  async extract(p) {
466
576
  const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
467
- const fetched = await fetchWithTlsImpersonation(targetUrl);
468
- const fastCheck = fastHtmlToMarkdown(fetched.html, targetUrl);
469
- // Tier R: shell page the native ladder cannot render — retry
470
- // through scrape() so the render path + cache apply, then extract
471
- // from the rendered Markdown. Without a provider, surface the
472
- // login/cloud limitation instead of silently returning {}.
473
- if (needsBrowserEscalation(fetched.status, fetched.html, fastCheck.markdown)) {
474
- if (isRenderProviderEnabled()) {
475
- const scraped = (await this.scrape({ url: targetUrl, noCache: true }));
476
- const renderedMarkdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
477
- if (renderedMarkdown.trim().length > 40) {
478
- const targetInspection = await inspectTarget({
479
- url: targetUrl,
480
- markdown: renderedMarkdown,
481
- });
482
- const siteData = await extractWebsiteData(targetUrl);
483
- return {
484
- success: true,
485
- mode: "local",
486
- url: targetUrl,
487
- data: {
488
- ...siteData,
489
- ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
490
- },
491
- evidence: targetInspection.evidence,
492
- };
577
+ // Scrape first (local Chrome / render provider) so JS pages yield rendered HTML, not the shell.
578
+ const scraped = (await this.scrape({
579
+ url: targetUrl,
580
+ formats: ["markdown", "html"],
581
+ noCache: true,
582
+ }));
583
+ let markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
584
+ let html = (scraped.data && scraped.data.html) || scraped.html || "";
585
+ if (scraped.blocked || scraped.success === false) {
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;
493
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
+ };
494
614
  }
615
+ }
616
+ const targetInspection = await inspectTarget({
617
+ url: targetUrl,
618
+ html,
619
+ markdown,
620
+ });
621
+ const siteData = await extractWebsiteData(targetUrl, {
622
+ prompt: p.prompt,
623
+ schema: p.schema,
624
+ markdown,
625
+ html,
626
+ bypassCache: true,
627
+ });
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.";
495
638
  return {
496
- success: true,
639
+ success: false,
497
640
  mode: "local",
498
641
  url: targetUrl,
499
642
  data: {},
643
+ error,
500
644
  evidence: {
501
- tier: lastFetchTier(),
502
- sourceUrl: targetUrl,
645
+ ...targetInspection.evidence,
503
646
  limitations: [
504
- "Page needs JS rendering; the local native tiers cannot render it. " +
505
- "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
506
- "or self-host a render provider via WEBCRAWL_RENDER_URL.",
647
+ ...(targetInspection.evidence?.limitations ?? []),
648
+ error,
507
649
  ],
508
650
  },
509
651
  };
510
652
  }
511
- const targetInspection = await inspectTarget({
512
- url: targetUrl,
513
- html: fetched.html,
514
- });
515
- const siteData = await extractWebsiteData(targetUrl);
516
653
  return {
517
654
  success: true,
518
655
  mode: "local",
519
656
  url: targetUrl,
520
- data: {
521
- ...siteData,
522
- ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
523
- },
657
+ data,
524
658
  evidence: targetInspection.evidence,
525
659
  };
526
660
  },
@@ -6,6 +6,7 @@ const ENGINE_WEIGHTS = {
6
6
  jina: 1.3,
7
7
  "brave-native": 1.25,
8
8
  "ddg-lite": 1.15,
9
+ "bing-rss": 1.12,
9
10
  "google-news-rss": 1.05,
10
11
  wikipedia: 1.1,
11
12
  "hn-algolia": 1.0,
@@ -1,4 +1,4 @@
1
- export type SearchEngineSource = "bing-shopping" | "google-shopping" | "brave-native" | "ddg-lite" | "google-news-rss" | "wikipedia" | "hn-algolia" | "arxiv" | "nvd" | "osv" | "serper" | "brave-api" | "jina";
1
+ export type SearchEngineSource = "bing-shopping" | "google-shopping" | "brave-native" | "ddg-lite" | "bing-rss" | "google-news-rss" | "wikipedia" | "hn-algolia" | "arxiv" | "nvd" | "osv" | "serper" | "brave-api" | "jina";
2
2
  export type DecisionModelId = "baseten/mammoth-1" | "baseten/elephant-1" | "convaiinnovations/laya" | "auto" | "none";
3
3
  export interface RawCandidateDocument {
4
4
  title: string;
@@ -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?: {
@@ -107,6 +107,8 @@ export interface SearchResponse {
107
107
  /** Structured CVE records from NVD and OSV: CVSS score and severity, CWE, affected products and versions, fixed versions, CISA KEV flag, dates and references (populated when category === "security") */
108
108
  cves?: CveRecord[];
109
109
  provider: "native-federated" | "brave" | "serper" | "jina" | "ddg-edge";
110
+ /** Set when a keyless search returns no hits (engines blocked or empty). */
111
+ error?: string;
110
112
  /** Set when the primary engines came back thin and a paid fallback engine filled in. */
111
113
  fallback?: "jina";
112
114
  cached?: boolean;
@@ -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: {
@@ -18,7 +18,7 @@ function isHotelOta(url) {
18
18
  return hostMatchesAny(url, HOTEL_OTA_HOSTS);
19
19
  }
20
20
  import { hostMatchesAny, normalizeHostList, resolveCategory, resolveUserLocation, SEARCH_CATEGORY_CATALOG, } from "./catalog.js";
21
- import { fetchHedgedNativeFederation, fetchJinaSearch, fetchNvdByIds, } from "./providers.js";
21
+ import { EMPTY_SEARCH_ERROR, fetchHedgedNativeFederation, fetchJinaSearch, fetchNvdByIds, } from "./providers.js";
22
22
  import { runInBackground, upsertCves } from "../extract/cve-cache.js";
23
23
  const searchCache = new Map();
24
24
  const SEARCH_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours warm edge cache
@@ -115,7 +115,9 @@ export async function searchWeb(req) {
115
115
  }
116
116
  }
117
117
  const perEngineLimit = Math.min(Math.max(limit + 4, 8), 15);
118
- const quorumTimeoutMs = req.rankOptions?.quorumTimeoutMs ?? 1500;
118
+ // Must outlast DDG/Brave impersonation (2200ms) so a slow SERP is not cut off
119
+ // after Wikipedia's 2 hits — the v1.4.3 keyless-search failure mode.
120
+ const quorumTimeoutMs = req.rankOptions?.quorumTimeoutMs ?? 2800;
119
121
  // Stage 1: Hedged Multi-Engine Native Federation
120
122
  const collectedOffers = [];
121
123
  const offerByUrl = new Map();
@@ -257,7 +259,7 @@ export async function searchWeb(req) {
257
259
  filterContext,
258
260
  };
259
261
  let ranked = await rankDocuments(localizedSearchQuery, rankCandidates, rankOpts);
260
- // Thin result set after ranking and filtering: ask Jina once, then re-rank the combined pool.
262
+ // Thin result set after ranking and filtering: ask the keyed search provider once, then re-rank.
261
263
  const minResults = Math.min(3, limit);
262
264
  if ((!req.provider || req.provider === "auto") &&
263
265
  ranked.results.length < minResults &&
@@ -649,27 +651,30 @@ export async function searchWeb(req) {
649
651
  ...(matchedCves?.length ? { cves: matchedCves } : {}),
650
652
  };
651
653
  });
652
- // Charge the fallback surcharge only when a Jina result actually reached the caller.
654
+ // Charge the fallback surcharge only when a keyed-provider result actually reached the caller.
653
655
  // An explicitly chosen provider is not a fallback.
654
656
  const usedJinaFallback = (!req.provider || req.provider === "auto") &&
655
657
  finalItems.some((item) => item.sources?.includes("jina"));
656
- const provider = ranked.rankingMetrics.enginesContributing.length > 1
658
+ const engines = ranked.rankingMetrics.enginesContributing;
659
+ const provider = engines.length > 1
657
660
  ? "native-federated"
658
- : ranked.rankingMetrics.enginesContributing.includes("serper")
661
+ : engines.includes("serper")
659
662
  ? "serper"
660
- : ranked.rankingMetrics.enginesContributing.includes("jina")
663
+ : engines.includes("jina")
661
664
  ? "jina"
662
- : ranked.rankingMetrics.enginesContributing.includes("brave-api") ||
663
- ranked.rankingMetrics.enginesContributing.includes("brave-native")
665
+ : engines.includes("brave-api") || engines.includes("brave-native")
664
666
  ? "brave"
665
- : "ddg-edge";
666
- if (finalItems.length > 0 ||
667
+ : engines.includes("ddg-lite")
668
+ ? "ddg-edge"
669
+ : "native-federated";
670
+ const hasHits = finalItems.length > 0 ||
667
671
  (products && products.length > 0) ||
668
672
  (dedupedListings && dedupedListings.length > 0) ||
669
673
  (dedupedFlights && dedupedFlights.length > 0) ||
670
674
  (dedupedHotels && dedupedHotels.length > 0) ||
671
675
  (dedupedBusinesses && dedupedBusinesses.length > 0) ||
672
- (dedupedCves && dedupedCves.length > 0)) {
676
+ (dedupedCves && dedupedCves.length > 0);
677
+ if (hasHits) {
673
678
  searchCache.set(cacheKey, {
674
679
  data: finalItems,
675
680
  ...(products ? { products } : {}),
@@ -684,6 +689,18 @@ export async function searchWeb(req) {
684
689
  expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
685
690
  });
686
691
  }
692
+ if (!hasHits) {
693
+ return {
694
+ success: false,
695
+ data: [],
696
+ provider,
697
+ error: EMPTY_SEARCH_ERROR,
698
+ cached: false,
699
+ latencyMs: Math.round(performance.now() - start),
700
+ ...(hasGeo ? { userLocation } : {}),
701
+ rankingMetrics: ranked.rankingMetrics,
702
+ };
703
+ }
687
704
  return {
688
705
  success: true,
689
706
  data: finalItems,