webcrawl 1.4.2 → 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.
@@ -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, 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
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 } 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,86 @@ export function blockedScrapeResult(url, statusCode, tier, latencyMs) {
51
53
  evidence: { tier, blocked: true, statusCode, latencyMs, limitations: [error] },
52
54
  };
53
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
+ }
54
136
  /**
55
137
  * Apply `map --filter` / `search` after discovery, then honour `-n` / `limit`.
56
138
  * Matching is a case-insensitive substring of the URL. Exported for unit tests.
@@ -90,7 +172,7 @@ export function createLocalEngineClient() {
90
172
  const resolved = resolveTarget({ query, category });
91
173
  return {
92
174
  ...res,
93
- success: true,
175
+ success: res.success !== false,
94
176
  mode: "local",
95
177
  target: {
96
178
  id: resolved.plugin.id,
@@ -161,8 +243,11 @@ export function createLocalEngineClient() {
161
243
  if (!p.noCache) {
162
244
  const cached = await getCachedPageAsync(targetUrl, "none");
163
245
  // 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)) {
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) {
166
251
  const targetInspection = await inspectTarget({
167
252
  url: targetUrl,
168
253
  markdown: cached.markdown,
@@ -235,76 +320,64 @@ export function createLocalEngineClient() {
235
320
  // ladder — the memo may be stale or the provider flaky.
236
321
  }
237
322
  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)) {
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) {
262
335
  await markDomainRenderNeeded(renderDomain);
263
- if (isRenderProviderEnabled()) {
264
- const rendered = await renderViaProvider(targetUrl);
265
- if (rendered) {
266
- 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) {
267
341
  setCachedPage(targetUrl, "none", {
268
342
  title: rendered.title,
269
343
  markdown: rendered.markdown,
270
- links: [],
344
+ links: rendered.links,
271
345
  statusCode: 200,
272
346
  contentHash: renderHash,
273
347
  });
274
- return {
275
- success: true,
276
- 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: {
277
357
  url: targetUrl,
278
358
  title: rendered.title || targetUrl,
279
359
  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
- }
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);
298
371
  }
299
- const nativeTier = lastFetchTier() === "impersonate"
300
- ? "impersonate"
301
- : lastFetchTier() === "proxy-impersonate"
302
- ? "proxy-impersonate"
303
- : "impit-tls";
304
- if (blocked) {
305
- return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start);
372
+ if (blocked || !markdown.trim()) {
373
+ return blockedScrapeResult(targetUrl, fetched.status, nativeTier, Date.now() - start, blocked ? undefined : emptyPageError(fetched.status));
306
374
  }
307
- return {
375
+ const targetInspection = await inspectTarget({
376
+ url: targetUrl,
377
+ html,
378
+ markdown,
379
+ });
380
+ return withOptionalHtml({
308
381
  success: true,
309
382
  mode: "local",
310
383
  url: targetUrl,
@@ -321,20 +394,38 @@ export function createLocalEngineClient() {
321
394
  evidence: {
322
395
  ...targetInspection.evidence,
323
396
  tier: nativeTier,
324
- contentHash,
397
+ contentHash: sha256(markdown || html),
325
398
  latencyMs: Date.now() - start,
326
399
  limitations: [
327
400
  ...(Array.isArray(targetInspection.evidence?.limitations)
328
401
  ? targetInspection.evidence.limitations
329
402
  : []),
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), " +
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`, " +
332
405
  "or self-host a render provider via WEBCRAWL_RENDER_URL.",
333
406
  ],
334
407
  },
335
- };
408
+ }, html, p.formats);
336
409
  }
337
- 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({
338
429
  success: true,
339
430
  mode: "local",
340
431
  url: targetUrl,
@@ -350,15 +441,11 @@ export function createLocalEngineClient() {
350
441
  },
351
442
  evidence: {
352
443
  ...targetInspection.evidence,
353
- tier: lastFetchTier() === "impersonate"
354
- ? "impersonate"
355
- : lastFetchTier() === "proxy-impersonate"
356
- ? "proxy-impersonate"
357
- : "impit-tls",
444
+ tier: nativeTier,
358
445
  contentHash,
359
446
  latencyMs: Date.now() - start,
360
447
  },
361
- };
448
+ }, html, p.formats);
362
449
  },
363
450
  async parse(p) {
364
451
  const url = typeof p.url === "string" ? p.url : undefined;
@@ -464,63 +551,51 @@ export function createLocalEngineClient() {
464
551
  },
465
552
  async extract(p) {
466
553
  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
- };
493
- }
494
- }
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) {
495
563
  return {
496
- success: true,
564
+ success: false,
565
+ blocked: true,
497
566
  mode: "local",
498
567
  url: targetUrl,
499
568
  data: {},
569
+ error: scraped.error || emptyPageError(200),
500
570
  evidence: {
501
571
  tier: lastFetchTier(),
502
572
  sourceUrl: targetUrl,
503
- 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.",
507
- ],
573
+ limitations: [String(scraped.error || emptyPageError(200))],
508
574
  },
509
575
  };
510
576
  }
511
577
  const targetInspection = await inspectTarget({
512
578
  url: targetUrl,
513
- 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,
514
588
  });
515
- const siteData = await extractWebsiteData(targetUrl);
589
+ const { cached: _c, cacheLayer: _l, ...extracted } = siteData;
516
590
  return {
517
591
  success: true,
518
592
  mode: "local",
519
593
  url: targetUrl,
520
- data: {
521
- ...siteData,
522
- ...(targetInspection.structured ? { target: targetInspection.structured } : {}),
523
- },
594
+ data: extractOutputData(extracted, {
595
+ prompt: p.prompt,
596
+ schema: p.schema,
597
+ target: targetInspection.structured,
598
+ }),
524
599
  evidence: targetInspection.evidence,
525
600
  };
526
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;
@@ -219,10 +219,105 @@ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perE
219
219
  }
220
220
  collected.push(...jina);
221
221
  }
222
+ // HTML SERPs (DDG, Brave) are frequently challenge/429 pages. When they
223
+ // yield nothing, fall back to keyless APIs that still work without a key.
224
+ if (collected.length === 0 && (!req.provider || req.provider === "auto")) {
225
+ const extras = await fetchKeylessSearchFallbacks(localizedQuery, perEngineLimit);
226
+ collected.push(...extras);
227
+ }
222
228
  return collected;
223
229
  }
224
230
  /** Below this many unique URLs from the free engines, the Jina fallback runs. */
225
231
  export const JINA_FALLBACK_MIN_URLS = 3;
232
+ /** True when a scraped SERP is a bot-check / captcha rather than results. */
233
+ export function isSerpChallengeHtml(html, status = 200) {
234
+ if (status === 403 || status === 429 || status === 503)
235
+ return true;
236
+ const lower = String(html ?? "").slice(0, 8000).toLowerCase();
237
+ return (lower.includes("bots use duckduckgo") ||
238
+ lower.includes("anomaly-modal") ||
239
+ lower.includes("please complete the following challenge") ||
240
+ lower.includes("schedule a captcha") ||
241
+ lower.includes("flagged as being suspicious") ||
242
+ lower.includes("enable javascript to function"));
243
+ }
244
+ export const EMPTY_SEARCH_ERROR = "Keyless search engines returned no results (DuckDuckGo and Brave often serve a bot challenge). " +
245
+ "Tried Wikipedia, Hacker News, and Bing RSS as well. " +
246
+ "Set BRAVE_SEARCH_API_KEY, SERPER_API_KEY, or JINA_API_KEY, or retry later.";
247
+ /** Parse Bing's `format=rss` web-search feed into candidate docs. */
248
+ export function parseBingWebRss(xml, limit) {
249
+ const items = [];
250
+ const itemRe = /<item>([\s\S]*?)<\/item>/gi;
251
+ let match;
252
+ while ((match = itemRe.exec(xml)) !== null && items.length < limit) {
253
+ const block = match[1];
254
+ const title = decodeHtmlEntities((block.match(/<title>([\s\S]*?)<\/title>/i)?.[1] || "")
255
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
256
+ .trim());
257
+ const link = (block.match(/<link>([\s\S]*?)<\/link>/i)?.[1] || "")
258
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
259
+ .trim();
260
+ const description = decodeHtmlEntities((block.match(/<description>([\s\S]*?)<\/description>/i)?.[1] || "")
261
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
262
+ .replace(/<[^>]+>/g, " ")
263
+ .replace(/\s+/g, " ")
264
+ .trim());
265
+ if (!title || !link.startsWith("http"))
266
+ continue;
267
+ items.push({
268
+ title,
269
+ url: link,
270
+ description: description || title,
271
+ engine: "bing-rss",
272
+ engineRank: items.length + 1,
273
+ });
274
+ }
275
+ return items;
276
+ }
277
+ async function fetchBingWebRss(query, limit) {
278
+ try {
279
+ const rssUrl = `https://www.bing.com/search?format=rss&q=${encodeURIComponent(query)}`;
280
+ const res = await fetch(rssUrl, {
281
+ headers: {
282
+ Accept: "application/rss+xml, application/xml, text/xml;q=0.9, */*;q=0.8",
283
+ "User-Agent": "Mozilla/5.0 (compatible; WebCrawl/1.4; +https://webcrawl.app)",
284
+ },
285
+ signal: AbortSignal.timeout(4000),
286
+ });
287
+ if (!res.ok)
288
+ return [];
289
+ const xml = await res.text();
290
+ if (!xml.includes("<item>"))
291
+ return [];
292
+ return parseBingWebRss(xml, limit);
293
+ }
294
+ catch {
295
+ return [];
296
+ }
297
+ }
298
+ /**
299
+ * Last-resort keyless engines used when DDG/Brave HTML SERPs come back empty.
300
+ * Wikipedia OpenSearch, HN Algolia, and Bing RSS do not require a JS challenge.
301
+ */
302
+ export async function fetchKeylessSearchFallbacks(query, limit) {
303
+ const per = Math.max(limit, 5);
304
+ const [hn, wiki, bing] = await Promise.all([
305
+ fetchHackerNewsAlgolia(query, per),
306
+ fetchWikipediaOpenSearch(query, per),
307
+ fetchBingWebRss(query, per),
308
+ ]);
309
+ const seen = new Set();
310
+ const out = [];
311
+ for (const item of [...hn, ...wiki, ...bing]) {
312
+ if (!item.url || seen.has(item.url))
313
+ continue;
314
+ seen.add(item.url);
315
+ out.push(item);
316
+ if (out.length >= Math.max(limit + 4, 8))
317
+ break;
318
+ }
319
+ return out;
320
+ }
226
321
  export async function fetchJinaSearch(query, limit, req) {
227
322
  const key = process.env.JINA_API_KEY;
228
323
  if (!key) {
@@ -435,7 +530,7 @@ export async function fetchBraveNativeSerp(query, limit, userLocation) {
435
530
  : "";
436
531
  const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}&source=web${countryParam}`;
437
532
  const res = await fetchWithTlsImpersonation(url, 2200);
438
- if (res.status !== 200 || !res.html) {
533
+ if (res.status !== 200 || !res.html || isSerpChallengeHtml(res.html, res.status)) {
439
534
  return [];
440
535
  }
441
536
  const html = res.html;
@@ -545,7 +640,7 @@ async function fetchDuckDuckGoLiteOnly(query, limit, userLocation) {
545
640
  const kl = ddgRegion(countryCode, undefined);
546
641
  const klParam = kl ? `&kl=${encodeURIComponent(kl)}` : "";
547
642
  const liteRes = await fetchWithTlsImpersonation(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}${klParam}`, 2200);
548
- if (liteRes && liteRes.html) {
643
+ if (liteRes && liteRes.html && !isSerpChallengeHtml(liteRes.html, liteRes.status)) {
549
644
  const html = liteRes.html;
550
645
  const linkMatches = Array.from(html.matchAll(/<a\s+[^>]*href=["']([^"']+)["'][^>]*class=["']result-link["'][^>]*>([\s\S]*?)<\/a>|<a\s+[^>]*class=["']result-link["'][^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi));
551
646
  const snippetMatches = Array.from(html.matchAll(/<td\s+[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/gi));