webcrawl 1.4.1 → 1.4.3

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.
@@ -32,6 +32,22 @@ export interface LocalEngineBackend {
32
32
  targets(): Promise<Record<string, unknown>>;
33
33
  usage(): Promise<Record<string, unknown>>;
34
34
  }
35
+ /**
36
+ * Scrape result for a page the site refused (bot check, 403/429/503).
37
+ * `success: false` with no markdown, so callers never mistake the block page
38
+ * for content. Exported for unit tests.
39
+ */
40
+ export declare function blockedScrapeResult(url: string, statusCode: number, tier: string, latencyMs: number, reason?: string): Record<string, unknown>;
41
+ /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
42
+ export declare const MIN_CACHEABLE_MARKDOWN = 200;
43
+ /** Attach source HTML when the caller asked for the `html` format. */
44
+ export declare function withOptionalHtml(result: Record<string, unknown>, html: string | undefined, formats?: Array<"markdown" | "links" | "html">): Record<string, unknown>;
45
+ /** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
46
+ export declare function extractOutputData(extracted: Record<string, unknown>, opts: {
47
+ prompt?: string;
48
+ schema?: Record<string, unknown>;
49
+ target?: unknown;
50
+ }): Record<string, unknown>;
35
51
  /**
36
52
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
37
53
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -9,10 +9,10 @@
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, mapWebsiteUrls, setCachedPage, sha256, } from "./engine.js";
12
+ import { extractWebsiteData, fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPageAsync, isChallengeMarkdown, isLocalChromeAvailable, mapWebsiteUrls, renderViaLocalChrome, setCachedPage, sha256, } from "./engine.js";
13
13
  import { isDomainRenderNeeded, markDomainRenderNeeded, } from "./engine/session.js";
14
14
  import { isRenderProviderEnabled, renderViaProvider } from "./engine/render.js";
15
- import { fetchResource, lastFetchTier, needsBrowserEscalation } from "./engine/fetcher.js";
15
+ import { fetchResource, isAntiBotChallenge, lastFetchTier, needsBrowserEscalation, } from "./engine/fetcher.js";
16
16
  import { parseFile, publicParseMeta } from "./parse/index.js";
17
17
  import { isBinaryContentType, looksLikeBinaryUrl } from "./parse/fetch.js";
18
18
  import { maxDownloadBytes } from "./parse/limits.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 } 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 {
@@ -29,6 +30,109 @@ function safeDomainOf(url) {
29
30
  return "";
30
31
  }
31
32
  }
33
+ /**
34
+ * Scrape result for a page the site refused (bot check, 403/429/503).
35
+ * `success: false` with no markdown, so callers never mistake the block page
36
+ * for content. Exported for unit tests.
37
+ */
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.");
42
+ return {
43
+ success: false,
44
+ mode: "local",
45
+ url,
46
+ blocked: true,
47
+ statusCode,
48
+ error,
49
+ title: url,
50
+ markdown: "",
51
+ links: [],
52
+ data: { url, title: url, markdown: "", links: [] },
53
+ evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
54
+ };
55
+ }
56
+ /** Shells shorter than this are unrendered JS / empty pages and must not be cached as content. */
57
+ export const MIN_CACHEABLE_MARKDOWN = 200;
58
+ /** Attach source HTML when the caller asked for the `html` format. */
59
+ export function withOptionalHtml(result, html, formats) {
60
+ if (!formats?.includes("html") || !html)
61
+ return result;
62
+ const data = result.data && typeof result.data === "object" && !Array.isArray(result.data)
63
+ ? result.data
64
+ : {};
65
+ return { ...result, html, data: { ...data, html } };
66
+ }
67
+ /** Structured extract payload: custom prompt/schema → only those fields, no `target` sidecar. */
68
+ export function extractOutputData(extracted, opts) {
69
+ if (hasCustomExtractRequest(opts.prompt, opts.schema))
70
+ return extracted;
71
+ return {
72
+ ...extracted,
73
+ ...(opts.target ? { target: opts.target } : {}),
74
+ };
75
+ }
76
+ function nativeScrapeTier() {
77
+ const t = lastFetchTier();
78
+ return t === "impersonate" || t === "proxy-impersonate" ? t : "impit-tls";
79
+ }
80
+ function emptyPageError(statusCode) {
81
+ return (`Page returned no readable content (HTTP ${statusCode}). ` +
82
+ "The site may be blocking automated clients or serving a client-rendered shell. " +
83
+ "Retry with `--mode cloud` (hosted stealth browser), or self-host a render provider via WEBCRAWL_RENDER_URL.");
84
+ }
85
+ /**
86
+ * JS / challenge shell → render provider, then local Chrome dump-dom, then stealth.
87
+ * Returns null when no renderer produced real content.
88
+ */
89
+ async function escalateJsRender(url) {
90
+ if (isRenderProviderEnabled()) {
91
+ const rendered = await renderViaProvider(url);
92
+ if (rendered && rendered.markdown.trim().length >= 40) {
93
+ return {
94
+ title: rendered.title,
95
+ markdown: rendered.markdown,
96
+ html: "",
97
+ links: [],
98
+ tier: "render",
99
+ };
100
+ }
101
+ }
102
+ if (isLocalChromeAvailable()) {
103
+ const html = await renderViaLocalChrome(url);
104
+ if (html && html.length > 100) {
105
+ const parsed = fastHtmlToMarkdown(html, url);
106
+ if (parsed.markdown.trim().length > 40 &&
107
+ !isAntiBotChallenge(html, 200) &&
108
+ !isChallengeMarkdown(parsed.title, parsed.markdown)) {
109
+ return { ...parsed, html, tier: "browser" };
110
+ }
111
+ }
112
+ }
113
+ try {
114
+ const { stealthAvailable } = await import("./engine/stealth.js");
115
+ const { escalateToSandboxOrBrowser, isChallengeResult } = await import("./sandbox.js");
116
+ if (await stealthAvailable()) {
117
+ const esc = await escalateToSandboxOrBrowser(url, { browser: true, timeoutMs: 25_000 });
118
+ if (esc &&
119
+ esc.markdown.trim().length > 40 &&
120
+ !isChallengeResult(esc.html, esc.markdown, esc.statusCode)) {
121
+ return {
122
+ title: esc.title,
123
+ markdown: esc.markdown,
124
+ html: esc.html,
125
+ links: esc.links,
126
+ tier: esc.tier,
127
+ };
128
+ }
129
+ }
130
+ }
131
+ catch {
132
+ /* stealth optional */
133
+ }
134
+ return null;
135
+ }
32
136
  /**
33
137
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
34
138
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -68,7 +172,7 @@ export function createLocalEngineClient() {
68
172
  const resolved = resolveTarget({ query, category });
69
173
  return {
70
174
  ...res,
71
- success: true,
175
+ success: res.success !== false,
72
176
  mode: "local",
73
177
  target: {
74
178
  id: resolved.plugin.id,
@@ -138,7 +242,12 @@ export function createLocalEngineClient() {
138
242
  // 3. Check local cache first
139
243
  if (!p.noCache) {
140
244
  const cached = await getCachedPageAsync(targetUrl, "none");
141
- if (cached) {
245
+ // Entries written before blocked pages stopped being cached may hold a
246
+ // bot-check page: treat those as a miss and fetch again. Thin shells
247
+ // (header-only JS pages) must also miss so local Chrome can hydrate them.
248
+ if (cached &&
249
+ !isChallengeMarkdown(cached.title, cached.markdown) &&
250
+ cached.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
142
251
  const targetInspection = await inspectTarget({
143
252
  url: targetUrl,
144
253
  markdown: cached.markdown,
@@ -211,68 +320,64 @@ export function createLocalEngineClient() {
211
320
  // ladder — the memo may be stale or the provider flaky.
212
321
  }
213
322
  const fetched = await fetchWithTlsImpersonation(targetUrl);
214
- const { title, markdown, links } = fastHtmlToMarkdown(fetched.html, targetUrl);
215
- const contentHash = sha256(markdown || fetched.html);
216
- setCachedPage(targetUrl, "none", {
217
- title,
218
- markdown,
219
- links,
220
- statusCode: fetched.status,
221
- contentHash,
222
- });
223
- const targetInspection = await inspectTarget({
224
- url: targetUrl,
225
- html: fetched.html,
226
- markdown,
227
- });
228
- // Tier R: the native ladder passed the bot check but the page is a
229
- // JS shell / challenge page the ladder cannot render. Memoize the
230
- // domain and either render via the provider (when configured) or
231
- // return the shell with an honest limitation note.
232
- if (needsBrowserEscalation(fetched.status, fetched.html, markdown)) {
323
+ const parsed = fastHtmlToMarkdown(fetched.html, targetUrl);
324
+ let title = parsed.title;
325
+ let markdown = parsed.markdown;
326
+ let links = parsed.links;
327
+ let html = fetched.html;
328
+ const blocked = isAntiBotChallenge(html, fetched.status);
329
+ const needsRender = needsBrowserEscalation(fetched.status, html, markdown);
330
+ const nativeTier = nativeScrapeTier();
331
+ // 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.
334
+ if (needsRender) {
233
335
  await markDomainRenderNeeded(renderDomain);
234
- if (isRenderProviderEnabled()) {
235
- const rendered = await renderViaProvider(targetUrl);
236
- if (rendered) {
237
- const renderHash = sha256(rendered.markdown);
336
+ // 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);
338
+ if (rendered) {
339
+ const renderHash = sha256(rendered.markdown);
340
+ if (rendered.markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
238
341
  setCachedPage(targetUrl, "none", {
239
342
  title: rendered.title,
240
343
  markdown: rendered.markdown,
241
- links: [],
344
+ links: rendered.links,
242
345
  statusCode: 200,
243
346
  contentHash: renderHash,
244
347
  });
245
- return {
246
- success: true,
247
- mode: "local",
348
+ }
349
+ return withOptionalHtml({
350
+ success: true,
351
+ mode: "local",
352
+ url: targetUrl,
353
+ title: rendered.title || targetUrl,
354
+ markdown: rendered.markdown,
355
+ links: rendered.links,
356
+ data: {
248
357
  url: targetUrl,
249
358
  title: rendered.title || targetUrl,
250
359
  markdown: rendered.markdown,
251
- links: [],
252
- data: {
253
- url: targetUrl,
254
- title: rendered.title || targetUrl,
255
- markdown: rendered.markdown,
256
- links: [],
257
- },
258
- evidence: {
259
- targetId: "render-provider",
260
- tier: "render",
261
- contentHash: renderHash,
262
- latencyMs: Date.now() - start,
263
- sourceUrl: rendered.sourceUrl,
264
- matchedSignals: ["render:provider"],
265
- limitations: rendered.warnings,
266
- },
267
- };
268
- }
360
+ links: rendered.links,
361
+ },
362
+ evidence: {
363
+ targetId: rendered.tier === "render" ? "render-provider" : "web-spa",
364
+ tier: rendered.tier,
365
+ contentHash: renderHash,
366
+ latencyMs: Date.now() - start,
367
+ sourceUrl: targetUrl,
368
+ matchedSignals: rendered.tier === "browser" ? ["render:local-chrome"] : [`render:${rendered.tier}`],
369
+ },
370
+ }, rendered.html, p.formats);
269
371
  }
270
- const nativeTier = lastFetchTier() === "impersonate"
271
- ? "impersonate"
272
- : lastFetchTier() === "proxy-impersonate"
273
- ? "proxy-impersonate"
274
- : "impit-tls";
275
- return {
372
+ if (blocked || !markdown.trim()) {
373
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
374
+ }
375
+ const targetInspection = await inspectTarget({
376
+ url: targetUrl,
377
+ html,
378
+ markdown,
379
+ });
380
+ return withOptionalHtml({
276
381
  success: true,
277
382
  mode: "local",
278
383
  url: targetUrl,
@@ -289,20 +394,38 @@ export function createLocalEngineClient() {
289
394
  evidence: {
290
395
  ...targetInspection.evidence,
291
396
  tier: nativeTier,
292
- contentHash,
397
+ contentHash: sha256(markdown || html),
293
398
  latencyMs: Date.now() - start,
294
399
  limitations: [
295
400
  ...(Array.isArray(targetInspection.evidence?.limitations)
296
401
  ? targetInspection.evidence.limitations
297
402
  : []),
298
- "Page needs JS rendering; the local native tiers cannot render it. " +
299
- "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
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`, " +
300
405
  "or self-host a render provider via WEBCRAWL_RENDER_URL.",
301
406
  ],
302
407
  },
303
- };
408
+ }, html, p.formats);
304
409
  }
305
- return {
410
+ if (blocked || !markdown.trim()) {
411
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
412
+ }
413
+ const contentHash = sha256(markdown || html);
414
+ if (markdown.trim().length >= MIN_CACHEABLE_MARKDOWN) {
415
+ setCachedPage(targetUrl, "none", {
416
+ title,
417
+ markdown,
418
+ links,
419
+ statusCode: fetched.status,
420
+ contentHash,
421
+ });
422
+ }
423
+ const targetInspection = await inspectTarget({
424
+ url: targetUrl,
425
+ html,
426
+ markdown,
427
+ });
428
+ return withOptionalHtml({
306
429
  success: true,
307
430
  mode: "local",
308
431
  url: targetUrl,
@@ -318,15 +441,11 @@ export function createLocalEngineClient() {
318
441
  },
319
442
  evidence: {
320
443
  ...targetInspection.evidence,
321
- tier: lastFetchTier() === "impersonate"
322
- ? "impersonate"
323
- : lastFetchTier() === "proxy-impersonate"
324
- ? "proxy-impersonate"
325
- : "impit-tls",
444
+ tier: nativeTier,
326
445
  contentHash,
327
446
  latencyMs: Date.now() - start,
328
447
  },
329
- };
448
+ }, html, p.formats);
330
449
  },
331
450
  async parse(p) {
332
451
  const url = typeof p.url === "string" ? p.url : undefined;
@@ -432,63 +551,51 @@ export function createLocalEngineClient() {
432
551
  },
433
552
  async extract(p) {
434
553
  const targetUrl = /^https?:\/\//i.test(p.url) ? p.url : `https://${p.url}`;
435
- const fetched = await fetchWithTlsImpersonation(targetUrl);
436
- const fastCheck = fastHtmlToMarkdown(fetched.html, targetUrl);
437
- // Tier R: shell page the native ladder cannot render — retry
438
- // through scrape() so the render path + cache apply, then extract
439
- // from the rendered Markdown. Without a provider, surface the
440
- // login/cloud limitation instead of silently returning {}.
441
- if (needsBrowserEscalation(fetched.status, fetched.html, fastCheck.markdown)) {
442
- if (isRenderProviderEnabled()) {
443
- const scraped = (await this.scrape({ url: targetUrl, noCache: true }));
444
- const renderedMarkdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
445
- if (renderedMarkdown.trim().length > 40) {
446
- const targetInspection = await inspectTarget({
447
- url: targetUrl,
448
- markdown: renderedMarkdown,
449
- });
450
- const siteData = await extractWebsiteData(targetUrl);
451
- return {
452
- success: true,
453
- mode: "local",
454
- url: targetUrl,
455
- data: {
456
- ...siteData,
457
- ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
458
- },
459
- evidence: targetInspection.evidence,
460
- };
461
- }
462
- }
554
+ // Scrape first (local Chrome / render provider) so JS pages yield rendered HTML, not the shell.
555
+ const scraped = (await this.scrape({
556
+ url: targetUrl,
557
+ formats: ["markdown", "html"],
558
+ noCache: true,
559
+ }));
560
+ const markdown = (scraped.data && scraped.data.markdown) || scraped.markdown || "";
561
+ const html = (scraped.data && scraped.data.html) || scraped.html || "";
562
+ if (scraped.blocked || scraped.success === false) {
463
563
  return {
464
- success: true,
564
+ success: false,
565
+ blocked: true,
465
566
  mode: "local",
466
567
  url: targetUrl,
467
568
  data: {},
569
+ error: scraped.error || emptyPageError(200),
468
570
  evidence: {
469
571
  tier: lastFetchTier(),
470
572
  sourceUrl: targetUrl,
471
- limitations: [
472
- "Page needs JS rendering; the local native tiers cannot render it. " +
473
- "Run `webcrawl login` and retry with `--mode cloud` (rendering runs in the hosted tier), " +
474
- "or self-host a render provider via WEBCRAWL_RENDER_URL.",
475
- ],
573
+ limitations: [String(scraped.error || emptyPageError(200))],
476
574
  },
477
575
  };
478
576
  }
479
577
  const targetInspection = await inspectTarget({
480
578
  url: targetUrl,
481
- html: fetched.html,
579
+ html,
580
+ markdown,
581
+ });
582
+ const siteData = await extractWebsiteData(targetUrl, {
583
+ prompt: p.prompt,
584
+ schema: p.schema,
585
+ markdown,
586
+ html,
587
+ bypassCache: true,
482
588
  });
483
- const siteData = await extractWebsiteData(targetUrl);
589
+ const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
484
590
  return {
485
591
  success: true,
486
592
  mode: "local",
487
593
  url: targetUrl,
488
- data: {
489
- ...siteData,
490
- ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
491
- },
594
+ data: extractOutputData(extracted, {
595
+ prompt: p.prompt,
596
+ schema: p.schema,
597
+ target: targetInspection.structured,
598
+ }),
492
599
  evidence: targetInspection.evidence,
493
600
  };
494
601
  },
@@ -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;
@@ -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;
@@ -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
@@ -653,23 +653,26 @@ export async function searchWeb(req) {
653
653
  // An explicitly chosen provider is not a fallback.
654
654
  const usedJinaFallback = (!req.provider || req.provider === "auto") &&
655
655
  finalItems.some((item) => item.sources?.includes("jina"));
656
- const provider = ranked.rankingMetrics.enginesContributing.length > 1
656
+ const engines = ranked.rankingMetrics.enginesContributing;
657
+ const provider = engines.length > 1
657
658
  ? "native-federated"
658
- : ranked.rankingMetrics.enginesContributing.includes("serper")
659
+ : engines.includes("serper")
659
660
  ? "serper"
660
- : ranked.rankingMetrics.enginesContributing.includes("jina")
661
+ : engines.includes("jina")
661
662
  ? "jina"
662
- : ranked.rankingMetrics.enginesContributing.includes("brave-api") ||
663
- ranked.rankingMetrics.enginesContributing.includes("brave-native")
663
+ : engines.includes("brave-api") || engines.includes("brave-native")
664
664
  ? "brave"
665
- : "ddg-edge";
666
- if (finalItems.length > 0 ||
665
+ : engines.includes("ddg-lite")
666
+ ? "ddg-edge"
667
+ : "native-federated";
668
+ const hasHits = finalItems.length > 0 ||
667
669
  (products && products.length > 0) ||
668
670
  (dedupedListings && dedupedListings.length > 0) ||
669
671
  (dedupedFlights && dedupedFlights.length > 0) ||
670
672
  (dedupedHotels && dedupedHotels.length > 0) ||
671
673
  (dedupedBusinesses && dedupedBusinesses.length > 0) ||
672
- (dedupedCves && dedupedCves.length > 0)) {
674
+ (dedupedCves && dedupedCves.length > 0);
675
+ if (hasHits) {
673
676
  searchCache.set(cacheKey, {
674
677
  data: finalItems,
675
678
  ...(products ? { products } : {}),
@@ -684,6 +687,18 @@ export async function searchWeb(req) {
684
687
  expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
685
688
  });
686
689
  }
690
+ if (!hasHits) {
691
+ return {
692
+ success: false,
693
+ data: [],
694
+ provider,
695
+ error: EMPTY_SEARCH_ERROR,
696
+ cached: false,
697
+ latencyMs: Math.round(performance.now() - start),
698
+ ...(hasGeo ? { userLocation } : {}),
699
+ rankingMetrics: ranked.rankingMetrics,
700
+ };
701
+ }
687
702
  return {
688
703
  success: true,
689
704
  data: finalItems,
@@ -16,6 +16,16 @@ export declare function isEntityQuery(q: string): boolean;
16
16
  export declare function fetchHedgedNativeFederation(rawQuery: string, localizedQuery: string, perEngineLimit: number, req: SearchRequest, userLocation: UserLocationContext, quorumTimeoutMs: number, targetSites: string[], onOffer: (offer: ProductOffer) => void, onCve?: (cve: CveRecord) => void): Promise<RawCandidateDocument[]>;
17
17
  /** Below this many unique URLs from the free engines, the Jina fallback runs. */
18
18
  export declare const JINA_FALLBACK_MIN_URLS = 3;
19
+ /** True when a scraped SERP is a bot-check / captcha rather than results. */
20
+ export declare function isSerpChallengeHtml(html: string, status?: number): boolean;
21
+ export declare const EMPTY_SEARCH_ERROR: string;
22
+ /** Parse Bing's `format=rss` web-search feed into candidate docs. */
23
+ export declare function parseBingWebRss(xml: string, limit: number): RawCandidateDocument[];
24
+ /**
25
+ * Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty.
26
+ * Wikipedia OpenSearch, HN Algolia, and Bing RSS do not require a JS challenge.
27
+ */
28
+ export declare function fetchKeylessSearchFallbacks(query: string, limit: number): Promise<RawCandidateDocument[]>;
19
29
  export declare function fetchJinaSearch(query: string, limit: number, req?: {
20
30
  country?: string;
21
31
  lang?: string;