@webcrawl/mcp 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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -7
  3. package/dist/core/engine/awswaf.d.ts +59 -0
  4. package/dist/core/engine/awswaf.js +164 -0
  5. package/dist/core/engine/cache.d.ts +70 -0
  6. package/dist/core/engine/cache.js +297 -0
  7. package/dist/core/engine/chrome.d.ts +14 -0
  8. package/dist/core/engine/chrome.js +140 -0
  9. package/dist/core/engine/crawl-policy.d.ts +36 -0
  10. package/dist/core/engine/crawl-policy.js +134 -0
  11. package/dist/core/engine/crawler.d.ts +152 -0
  12. package/dist/core/engine/crawler.js +918 -0
  13. package/dist/core/engine/fetcher.d.ts +92 -0
  14. package/dist/core/engine/fetcher.js +494 -0
  15. package/dist/core/engine/index.d.ts +7 -0
  16. package/dist/core/engine/index.js +7 -0
  17. package/dist/core/engine/markdown.d.ts +33 -0
  18. package/dist/core/engine/markdown.js +334 -0
  19. package/dist/core/engine/path-scope.d.ts +21 -0
  20. package/dist/core/engine/path-scope.js +88 -0
  21. package/dist/core/engine/render.d.ts +41 -0
  22. package/dist/core/engine/render.js +138 -0
  23. package/dist/core/engine/safe-url.d.ts +29 -0
  24. package/dist/core/engine/safe-url.js +109 -0
  25. package/dist/core/engine/session.d.ts +93 -0
  26. package/dist/core/engine/session.js +250 -0
  27. package/dist/core/engine/stealth.d.ts +83 -0
  28. package/dist/core/engine/stealth.js +222 -0
  29. package/dist/core/engine.d.ts +1 -0
  30. package/dist/core/engine.js +1 -0
  31. package/dist/core/extract/business.d.ts +28 -0
  32. package/dist/core/extract/business.js +344 -0
  33. package/dist/core/extract/currency.d.ts +15 -0
  34. package/dist/core/extract/currency.js +259 -0
  35. package/dist/core/extract/cve-cache.d.ts +62 -0
  36. package/dist/core/extract/cve-cache.js +277 -0
  37. package/dist/core/extract/cve.d.ts +57 -0
  38. package/dist/core/extract/cve.js +397 -0
  39. package/dist/core/extract/dedupe-key.d.ts +12 -0
  40. package/dist/core/extract/dedupe-key.js +36 -0
  41. package/dist/core/extract/flights.d.ts +37 -0
  42. package/dist/core/extract/flights.js +373 -0
  43. package/dist/core/extract/hotels.d.ts +35 -0
  44. package/dist/core/extract/hotels.js +473 -0
  45. package/dist/core/extract/index.d.ts +9 -0
  46. package/dist/core/extract/index.js +9 -0
  47. package/dist/core/extract/real-estate.d.ts +53 -0
  48. package/dist/core/extract/real-estate.js +835 -0
  49. package/dist/core/extract/scaledown.d.ts +14 -0
  50. package/dist/core/extract/scaledown.js +484 -0
  51. package/dist/core/extract/shopping.d.ts +44 -0
  52. package/dist/core/extract/shopping.js +393 -0
  53. package/dist/core/extract/structured.d.ts +14 -0
  54. package/dist/core/extract/structured.js +144 -0
  55. package/dist/core/image-url.d.ts +5 -0
  56. package/dist/core/image-url.js +13 -0
  57. package/dist/core/interact/driver.d.ts +72 -0
  58. package/dist/core/interact/driver.js +384 -0
  59. package/dist/core/interact/remote.d.ts +41 -0
  60. package/dist/core/interact/remote.js +133 -0
  61. package/dist/core/interact/spans.d.ts +46 -0
  62. package/dist/core/interact/spans.js +32 -0
  63. package/dist/core/interact/validate.d.ts +34 -0
  64. package/dist/core/interact/validate.js +135 -0
  65. package/dist/core/interact/vm.d.ts +43 -0
  66. package/dist/core/interact/vm.js +338 -0
  67. package/dist/core/local-engine.d.ts +56 -0
  68. package/dist/core/local-engine.js +667 -0
  69. package/dist/core/parse/detect.d.ts +29 -0
  70. package/dist/core/parse/detect.js +152 -0
  71. package/dist/core/parse/fetch.d.ts +20 -0
  72. package/dist/core/parse/fetch.js +39 -0
  73. package/dist/core/parse/image.d.ts +13 -0
  74. package/dist/core/parse/image.js +100 -0
  75. package/dist/core/parse/index.d.ts +53 -0
  76. package/dist/core/parse/index.js +153 -0
  77. package/dist/core/parse/limits.d.ts +12 -0
  78. package/dist/core/parse/limits.js +29 -0
  79. package/dist/core/parse/media.d.ts +42 -0
  80. package/dist/core/parse/media.js +238 -0
  81. package/dist/core/parse/merge.d.ts +19 -0
  82. package/dist/core/parse/merge.js +48 -0
  83. package/dist/core/parse/ocr.d.ts +25 -0
  84. package/dist/core/parse/ocr.js +121 -0
  85. package/dist/core/parse/office.d.ts +28 -0
  86. package/dist/core/parse/office.js +83 -0
  87. package/dist/core/parse/pdf.d.ts +27 -0
  88. package/dist/core/parse/pdf.js +156 -0
  89. package/dist/core/parse/types.d.ts +38 -0
  90. package/dist/core/parse/types.js +1 -0
  91. package/dist/core/parse/youtube.d.ts +30 -0
  92. package/dist/core/parse/youtube.js +264 -0
  93. package/dist/core/ranker/decision.d.ts +28 -0
  94. package/dist/core/ranker/decision.js +151 -0
  95. package/dist/core/ranker/index.d.ts +5 -0
  96. package/dist/core/ranker/index.js +361 -0
  97. package/dist/core/ranker/lexical.d.ts +82 -0
  98. package/dist/core/ranker/lexical.js +643 -0
  99. package/dist/core/ranker/types.d.ts +90 -0
  100. package/dist/core/ranker/types.js +1 -0
  101. package/dist/core/ranker.d.ts +1 -0
  102. package/dist/core/ranker.js +1 -0
  103. package/dist/core/sandbox.d.ts +29 -0
  104. package/dist/core/sandbox.js +222 -0
  105. package/dist/core/search/catalog.d.ts +176 -0
  106. package/dist/core/search/catalog.js +488 -0
  107. package/dist/core/search/index.d.ts +3 -0
  108. package/dist/core/search/index.js +3 -0
  109. package/dist/core/search/pipeline.d.ts +7 -0
  110. package/dist/core/search/pipeline.js +719 -0
  111. package/dist/core/search/providers.d.ts +72 -0
  112. package/dist/core/search/providers.js +1270 -0
  113. package/dist/core/search.d.ts +1 -0
  114. package/dist/core/search.js +1 -0
  115. package/dist/core/targets/developer.d.ts +44 -0
  116. package/dist/core/targets/developer.js +338 -0
  117. package/dist/core/targets/index.d.ts +3 -0
  118. package/dist/core/targets/index.js +3 -0
  119. package/dist/core/targets/registry.d.ts +32 -0
  120. package/dist/core/targets/registry.js +411 -0
  121. package/dist/core/targets/types.d.ts +72 -0
  122. package/dist/core/targets/types.js +8 -0
  123. package/dist/core/targets.d.ts +1 -0
  124. package/dist/core/targets.js +1 -0
  125. package/dist/format.js +5 -1
  126. package/dist/index.d.ts +1 -0
  127. package/dist/index.js +1 -0
  128. package/dist/load-local-engine.d.ts +7 -0
  129. package/dist/load-local-engine.js +22 -0
  130. package/dist/server.js +3 -0
  131. package/package.json +11 -3
@@ -0,0 +1,1270 @@
1
+ import { decodeHtmlEntities, fetchWithTlsImpersonation, } from "../engine/index.js";
2
+ import { cveSeverityLabel, extractCveIdsFromText, extractDomainHost, extractGhsaIdsFromText, extractOfferFromSerpSnippet, parseBingShoppingHtml, parseNvdResponse, parseOsvVuln, parsePriceString, } from "../extract/index.js";
3
+ import { CVE_STORE_SINCE, getCachedCves, getCachedQuery, refreshStaleCves, rememberQuery, searchCachedCves, } from "../extract/cve-cache.js";
4
+ import { ddgRegion, planEngines, resolveCategory, } from "./catalog.js";
5
+ /**
6
+ * Wikipedia only helps for entity / definition queries ("retrieval augmented
7
+ * generation", "what is rag"). For intent queries ("best X under 500",
8
+ * "A vs B", "how to ...") it injects off-topic pages (songs, unrelated topics).
9
+ */
10
+ export function isEntityQuery(q) {
11
+ const t = q.trim().toLowerCase();
12
+ if (/^(what is|what are|who is|who was|define|meaning of)\b/.test(t)) {
13
+ return true;
14
+ }
15
+ if (/\b(how|why|best|top|vs|versus|cheap|cheapest|review|reviews|price|pricing|near|latest|this week|today|buy|under|over|to)\b|\d/.test(t)) {
16
+ return false;
17
+ }
18
+ return t.split(/\s+/).filter(Boolean).length <= 4;
19
+ }
20
+ /**
21
+ * Stage 1: Hedged Multi-Engine Native Federation with Early-Exit Quorum.
22
+ * Fires all native zero-key search engines concurrently and exits as soon as a fast
23
+ * quorum (at least 2 engines + >= limit results + 80ms grace window) finishes,
24
+ * preventing any single slow engine from inflating tail latency.
25
+ */
26
+ export async function fetchHedgedNativeFederation(rawQuery, localizedQuery, perEngineLimit, req, userLocation, quorumTimeoutMs, targetSites, onOffer, onCve) {
27
+ if (req.provider && req.provider !== "auto") {
28
+ const q = targetSites.length > 0 && targetSites.length <= 3
29
+ ? `${localizedQuery} ${targetSites.map((h) => `site:${h}`).join(" OR ")}`
30
+ : localizedQuery;
31
+ switch (req.provider) {
32
+ case "jina":
33
+ return fetchJinaSearch(q, perEngineLimit, req);
34
+ case "serper":
35
+ return fetchSerperApi(q, perEngineLimit, req);
36
+ case "brave":
37
+ return fetchBraveSearchApi(q, perEngineLimit);
38
+ case "ddg":
39
+ return fetchDuckDuckGoLiteOnly(q, perEngineLimit, userLocation);
40
+ }
41
+ }
42
+ const collected = [];
43
+ let completedPrimaryEngines = 0;
44
+ let totalCompletedEngines = 0;
45
+ const category = resolveCategory(req);
46
+ const plan = planEngines(category, req.filters).filter((e) => e !== "wikipedia" ||
47
+ req.filters?.engines?.includes("wikipedia") ||
48
+ isEntityQuery(rawQuery));
49
+ let completedShoppingEngine = !plan.includes("bing-shopping");
50
+ // News: the quorum must include the actual news engine, not just general web results.
51
+ let completedNewsEngine = !(category === "news" && plan.includes("google-news-rss"));
52
+ if (!completedNewsEngine) {
53
+ quorumTimeoutMs = Math.max(quorumTimeoutMs, 4200);
54
+ }
55
+ // Security: structured CVE records come from NVD (~1.2s per request, two for keyword
56
+ // searches), so the quorum waits for it instead of exiting on web results alone.
57
+ let completedNvdEngine = !plan.includes("nvd");
58
+ if (!completedNvdEngine) {
59
+ quorumTimeoutMs = Math.max(quorumTimeoutMs, 9000);
60
+ }
61
+ // Academic: wait for the ArXiv + OpenAlex scholarly paper APIs before exiting quorum.
62
+ let completedArxivEngine = !(category === "academic" && plan.includes("arxiv"));
63
+ if (!completedArxivEngine) {
64
+ quorumTimeoutMs = Math.max(quorumTimeoutMs, 4500);
65
+ }
66
+ const siteHintQuery = targetSites.length > 0 && targetSites.length <= 3
67
+ ? `${localizedQuery} ${targetSites.join(" ")}`
68
+ : localizedQuery;
69
+ const factories = {
70
+ "bing-shopping": {
71
+ primary: true,
72
+ run: () => fetchBingShoppingNative(rawQuery, perEngineLimit, userLocation, targetSites, onOffer),
73
+ },
74
+ "google-shopping": {
75
+ primary: true,
76
+ run: () => fetchGoogleShoppingProvider(rawQuery, localizedQuery, perEngineLimit, userLocation, targetSites, onOffer),
77
+ },
78
+ "brave-native": {
79
+ primary: true,
80
+ run: () => fetchBraveNativeSerp(siteHintQuery, perEngineLimit, userLocation),
81
+ },
82
+ "ddg-lite": {
83
+ primary: true,
84
+ run: () => fetchDuckDuckGoLiteOnly(siteHintQuery, perEngineLimit, userLocation),
85
+ },
86
+ "google-news-rss": {
87
+ primary: category === "news",
88
+ run: () => fetchGoogleNewsSearchRss(localizedQuery, Math.min(perEngineLimit, category === "shopping" ? 4 : 10), userLocation, req.lang, category === "news" ? 4000 : 2000),
89
+ },
90
+ wikipedia: {
91
+ primary: false,
92
+ run: () => fetchWikipediaOpenSearch(localizedQuery, 2),
93
+ },
94
+ "hn-algolia": {
95
+ primary: false,
96
+ run: () => fetchHackerNewsAlgolia(localizedQuery, 5),
97
+ },
98
+ arxiv: {
99
+ primary: category === "academic",
100
+ run: () => fetchArxivApi(rawQuery, Math.max(perEngineLimit, 8)),
101
+ },
102
+ // Vulnerability databases use the raw query: localisation hints ("in Australia") break id/keyword lookups.
103
+ nvd: { primary: true, run: () => fetchNvdApi(rawQuery, perEngineLimit, onCve) },
104
+ osv: { primary: false, run: () => fetchOsvApi(rawQuery, perEngineLimit, onCve) },
105
+ };
106
+ const tasks = [];
107
+ const timed = (name, p) => {
108
+ if (!process.env.DEBUG_SEARCH_TIMING) {
109
+ return p;
110
+ }
111
+ const t0 = performance.now();
112
+ return p.then((r) => {
113
+ console.log(`[timing] engine=${name} ms=${Math.round(performance.now() - t0)} n=${r.length}`);
114
+ return r;
115
+ }, (e) => {
116
+ console.log(`[timing] engine=${name} ms=${Math.round(performance.now() - t0)} ERR=${String(e?.message ?? e).slice(0, 80)}`);
117
+ throw e;
118
+ });
119
+ };
120
+ for (const name of plan) {
121
+ const f = factories[name];
122
+ if (f) {
123
+ tasks.push({ name, primary: f.primary, promise: timed(name, f.run()) });
124
+ }
125
+ }
126
+ if (process.env.SERPER_API_KEY) {
127
+ tasks.push({
128
+ name: "serper",
129
+ primary: true,
130
+ promise: timed("serper", fetchSerperApi(localizedQuery, perEngineLimit, req)),
131
+ });
132
+ }
133
+ if (process.env.BRAVE_SEARCH_API_KEY) {
134
+ tasks.push({
135
+ name: "brave-api",
136
+ primary: true,
137
+ promise: timed("brave-api", fetchBraveSearchApi(localizedQuery, perEngineLimit)),
138
+ });
139
+ }
140
+ await new Promise((resolve) => {
141
+ let settled = false;
142
+ let graceTimer = null;
143
+ const hardTimer = setTimeout(() => {
144
+ if (!settled) {
145
+ settled = true;
146
+ resolve();
147
+ }
148
+ }, quorumTimeoutMs);
149
+ const checkQuorum = () => {
150
+ if (settled) {
151
+ return;
152
+ }
153
+ if (totalCompletedEngines >= tasks.length) {
154
+ settled = true;
155
+ clearTimeout(hardTimer);
156
+ if (graceTimer) {
157
+ clearTimeout(graceTimer);
158
+ }
159
+ resolve();
160
+ return;
161
+ }
162
+ // Early-exit quorum: at least 1 primary web engine + 2 total engines + enough candidates (and shopping engine finished when in shopping plan)
163
+ if (completedShoppingEngine &&
164
+ completedNewsEngine &&
165
+ completedNvdEngine &&
166
+ completedArxivEngine &&
167
+ completedPrimaryEngines >= 1 &&
168
+ totalCompletedEngines >= 2 &&
169
+ collected.length >= perEngineLimit &&
170
+ !graceTimer) {
171
+ graceTimer = setTimeout(() => {
172
+ if (!settled) {
173
+ settled = true;
174
+ clearTimeout(hardTimer);
175
+ resolve();
176
+ }
177
+ }, 90);
178
+ }
179
+ };
180
+ for (const task of tasks) {
181
+ task.promise
182
+ .then((items) => {
183
+ if (items.length > 0) {
184
+ collected.push(...items);
185
+ if (task.primary) {
186
+ completedPrimaryEngines++;
187
+ }
188
+ }
189
+ })
190
+ .catch(() => {
191
+ // ignore engine error
192
+ })
193
+ .finally(() => {
194
+ if (task.name === "bing-shopping") {
195
+ completedShoppingEngine = true;
196
+ }
197
+ if (task.name === "google-news-rss") {
198
+ completedNewsEngine = true;
199
+ }
200
+ if (task.name === "nvd") {
201
+ completedNvdEngine = true;
202
+ }
203
+ if (task.name === "arxiv") {
204
+ completedArxivEngine = true;
205
+ }
206
+ totalCompletedEngines++;
207
+ checkQuorum();
208
+ });
209
+ }
210
+ });
211
+ // Fallback: the free engines are scraped and can be blocked or rate limited. When they
212
+ // come back thin, fill in from Jina (paid) so the caller still gets an answer.
213
+ const uniqueUrls = new Set(collected.map((c) => c.url).filter(Boolean));
214
+ if (uniqueUrls.size < JINA_FALLBACK_MIN_URLS && process.env.JINA_API_KEY) {
215
+ const t0 = performance.now();
216
+ const jina = await fetchJinaSearch(localizedQuery, perEngineLimit, req);
217
+ if (process.env.DEBUG_SEARCH_TIMING) {
218
+ console.log(`[timing] engine=jina(fallback) ms=${Math.round(performance.now() - t0)} n=${jina.length}`);
219
+ }
220
+ collected.push(...jina);
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
+ }
228
+ return collected;
229
+ }
230
+ /** Below this many unique URLs from the free engines, the Jina fallback runs. */
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
+ }
321
+ export async function fetchJinaSearch(query, limit, req) {
322
+ const key = process.env.JINA_API_KEY;
323
+ if (!key) {
324
+ return [];
325
+ }
326
+ try {
327
+ const res = await fetch("https://s.jina.ai/", {
328
+ method: "POST",
329
+ headers: {
330
+ Authorization: `Bearer ${key}`,
331
+ Accept: "application/json",
332
+ "Content-Type": "application/json",
333
+ // Titles, URLs and descriptions only: page bodies cost more tokens and we rank on snippets.
334
+ "X-Respond-With": "no-content",
335
+ },
336
+ body: JSON.stringify({
337
+ q: query,
338
+ num: limit,
339
+ ...(req?.country ? { gl: req.country } : {}),
340
+ ...(req?.lang ? { hl: req.lang } : {}),
341
+ }),
342
+ signal: AbortSignal.timeout(3500),
343
+ });
344
+ if (!res.ok) {
345
+ return [];
346
+ }
347
+ const json = (await res.json());
348
+ return (json.data || [])
349
+ .filter((item) => item.url)
350
+ .slice(0, limit)
351
+ .map((item, idx) => ({
352
+ title: item.title || "",
353
+ url: item.url || "",
354
+ description: item.description || "",
355
+ publishedDate: item.date,
356
+ engine: "jina",
357
+ engineRank: idx + 1,
358
+ }));
359
+ }
360
+ catch {
361
+ return [];
362
+ }
363
+ }
364
+ /**
365
+ * 0A. Bing Shopping Native Provider (`bing-shopping`) via Impit Chrome TLS impersonation.
366
+ * Parses embedded `CustomData` JSON + `<li class="br-item">` seller/rating cards with zero API keys and zero headless browsers (~400ms).
367
+ * When `targetSites` is set (e.g. `["jbhifi.com.au", "amazon.com.au"]`), queries per-site in parallel so even bot-gated retailers return live prices and direct product URLs.
368
+ */
369
+ export async function fetchBingShoppingNative(rawQuery, limit, userLocation, targetSites, onOffer) {
370
+ try {
371
+ const rawCc = (userLocation?.country || "us").toLowerCase();
372
+ const cc = rawCc === "eu" ? "de" : rawCc === "uk" ? "gb" : rawCc;
373
+ const queries = targetSites && targetSites.length > 0
374
+ ? targetSites.slice(0, 4).map((site) => `${rawQuery} ${site}`)
375
+ : [rawQuery];
376
+ const items = [];
377
+ const seenUrls = new Set();
378
+ await Promise.all(queries.map(async (q) => {
379
+ try {
380
+ const url = `https://www.bing.com/shop?q=${encodeURIComponent(q)}&cc=${encodeURIComponent(cc)}`;
381
+ const res = await fetchWithTlsImpersonation(url, 2400);
382
+ if (res.status !== 200 || !res.html) {
383
+ return;
384
+ }
385
+ const offers = parseBingShoppingHtml(res.html, userLocation?.country);
386
+ for (const offer of offers) {
387
+ onOffer?.(offer);
388
+ if (seenUrls.has(offer.url)) {
389
+ continue;
390
+ }
391
+ seenUrls.add(offer.url);
392
+ const descParts = [
393
+ `${offer.rawPrice} (${offer.currency}) at ${offer.vendor}`,
394
+ offer.rating ? `Rating: ${offer.rating}/5` : "",
395
+ offer.freeShipping ? "Free shipping" : "",
396
+ offer.title,
397
+ ].filter(Boolean);
398
+ items.push({
399
+ title: `${offer.title} - ${offer.rawPrice} (${offer.vendor})`,
400
+ url: offer.url,
401
+ description: descParts.join(" · "),
402
+ engine: "bing-shopping",
403
+ engineRank: items.length + 1,
404
+ });
405
+ }
406
+ }
407
+ catch {
408
+ // Ignore individual site query timeout
409
+ }
410
+ }));
411
+ return items.slice(0, Math.max(limit * 2, 16));
412
+ }
413
+ catch {
414
+ return [];
415
+ }
416
+ }
417
+ /**
418
+ * 0B. Google Shopping Provider (`google-shopping`).
419
+ * Tier 1: Serper `/shopping` endpoint when `SERPER_API_KEY` is configured.
420
+ * Tier 2 (Zero-key fallback): Shopping-intent Brave query ("<query> buy price") with snippet price extraction.
421
+ */
422
+ export async function fetchGoogleShoppingProvider(rawQuery, localizedQuery, limit, userLocation, targetSites, onOffer) {
423
+ const serperKey = process.env.SERPER_API_KEY;
424
+ if (serperKey) {
425
+ try {
426
+ const gl = (userLocation?.country || "us").toLowerCase();
427
+ const q = targetSites && targetSites.length > 0
428
+ ? `${rawQuery} ${targetSites.slice(0, 3).join(" OR ")}`
429
+ : rawQuery;
430
+ const res = await fetch("https://google.serper.dev/shopping", {
431
+ method: "POST",
432
+ headers: {
433
+ "X-API-KEY": serperKey,
434
+ "Content-Type": "application/json",
435
+ },
436
+ body: JSON.stringify({ q, gl, num: Math.min(limit * 2, 20) }),
437
+ signal: AbortSignal.timeout(2200),
438
+ });
439
+ if (res.ok) {
440
+ const json = (await res.json());
441
+ const items = [];
442
+ for (const entry of json.shopping ?? []) {
443
+ if (!entry.link || !entry.title) {
444
+ continue;
445
+ }
446
+ const parsedPrice = parsePriceString(entry.price, userLocation?.country);
447
+ const domain = extractDomainHost(entry.link);
448
+ if (parsedPrice && domain) {
449
+ onOffer?.({
450
+ title: entry.title,
451
+ vendor: entry.source || domain,
452
+ domain,
453
+ url: entry.link,
454
+ price: parsedPrice.price,
455
+ rawPrice: parsedPrice.rawPrice,
456
+ currency: parsedPrice.currency,
457
+ availability: "in_stock",
458
+ ...(entry.rating ? { rating: entry.rating } : {}),
459
+ ...(entry.ratingCount ? { reviewCount: entry.ratingCount } : {}),
460
+ ...(entry.delivery
461
+ ? {
462
+ freeShipping: /free/i.test(entry.delivery),
463
+ shippingNote: entry.delivery,
464
+ }
465
+ : {}),
466
+ ...(entry.imageUrl ? { imageUrl: entry.imageUrl } : {}),
467
+ source: "google-shopping",
468
+ });
469
+ }
470
+ items.push({
471
+ title: entry.price
472
+ ? `${entry.title} - ${entry.price}`
473
+ : entry.title,
474
+ url: entry.link,
475
+ description: [
476
+ entry.price,
477
+ entry.source,
478
+ entry.delivery,
479
+ entry.title,
480
+ ]
481
+ .filter(Boolean)
482
+ .join(" · "),
483
+ engine: "google-shopping",
484
+ engineRank: items.length + 1,
485
+ });
486
+ }
487
+ if (items.length > 0) {
488
+ return items.slice(0, limit);
489
+ }
490
+ }
491
+ }
492
+ catch {
493
+ // Fall through to zero-key shopping query
494
+ }
495
+ }
496
+ // Zero-key shopping intent query to capture price-aggregator and retailer pages
497
+ try {
498
+ const shoppingQuery = targetSites && targetSites.length > 0
499
+ ? `${rawQuery} ${targetSites.slice(0, 3).join(" ")} price`
500
+ : `${localizedQuery} price buy`;
501
+ const [braveCandidates, ddgCandidates] = await Promise.all([
502
+ fetchBraveNativeSerp(shoppingQuery, limit, userLocation),
503
+ fetchDuckDuckGoLiteOnly(shoppingQuery, limit, userLocation),
504
+ ]);
505
+ const candidates = [...braveCandidates, ...ddgCandidates];
506
+ for (const c of candidates) {
507
+ c.engine = "google-shopping";
508
+ const snippetOffer = extractOfferFromSerpSnippet(c.title, c.description, c.url, userLocation?.country);
509
+ if (snippetOffer) {
510
+ onOffer?.(snippetOffer);
511
+ }
512
+ }
513
+ return candidates;
514
+ }
515
+ catch {
516
+ return [];
517
+ }
518
+ }
519
+ /**
520
+ * 1. Brave Search Native HTML parser via Impit Chrome TLS impersonation (`search.brave.com/search`).
521
+ * Independent web index with zero API keys required.
522
+ */
523
+ export async function fetchBraveNativeSerp(query, limit, userLocation) {
524
+ try {
525
+ const rawCc = (userLocation?.country || "").toLowerCase();
526
+ // Brave Search supports specific 2-letter country codes; map 'eu' or unsupported codes safely
527
+ const braveCc = rawCc === "eu" ? "" : rawCc === "uk" ? "gb" : rawCc;
528
+ const countryParam = braveCc && braveCc.length === 2
529
+ ? `&country=${encodeURIComponent(braveCc)}`
530
+ : "";
531
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}&source=web${countryParam}`;
532
+ const res = await fetchWithTlsImpersonation(url, 2200);
533
+ if (res.status !== 200 || !res.html || isSerpChallengeHtml(res.html, res.status)) {
534
+ return [];
535
+ }
536
+ const html = res.html;
537
+ const items = [];
538
+ const seen = new Set();
539
+ // Brave organic web results use `data-type="web"` blocks or `<a href="https://..." class="... l1 ...">`
540
+ const snippetBlocks = html.split(/data-type=["']web["']/i).slice(1);
541
+ for (const block of snippetBlocks) {
542
+ if (items.length >= limit) {
543
+ break;
544
+ }
545
+ const linkMatch = block.match(/<a[^>]+href=["'](https?:\/\/(?!(?:search|cdn\.search|imgs\.search)\.brave\.com)[^"']+)["'][^>]*>([\s\S]*?)<\/a>/i);
546
+ if (!linkMatch) {
547
+ continue;
548
+ }
549
+ const rawUrl = linkMatch[1].trim();
550
+ if (seen.has(rawUrl)) {
551
+ continue;
552
+ }
553
+ const titleMatch = block.match(/class=["'][^"']*title[^"']*["'][^>]*>([\s\S]*?)<\/div>/i) ||
554
+ block.match(/<div[^>]+class=["'][^"']*heading[^"']*["'][^>]*>([\s\S]*?)<\/div>/i);
555
+ const rawTitle = titleMatch ? titleMatch[1] : linkMatch[2];
556
+ const title = decodeHtmlEntities(rawTitle
557
+ .replace(/<[^>]+>/g, " ")
558
+ .replace(/\s+/g, " ")
559
+ .trim());
560
+ const descMatch = block.match(/class=["'][^"']*snippet-description[^"']*["'][^>]*>([\s\S]*?)<\/div>/i) ||
561
+ block.match(/class=["'][^"']*generic-snippet[^"']*["'][^>]*>([\s\S]*?)<\/div>/i);
562
+ const description = decodeHtmlEntities((descMatch ? descMatch[1] : "")
563
+ .replace(/<[^>]+>/g, " ")
564
+ .replace(/\s+/g, " ")
565
+ .trim());
566
+ if (title && rawUrl.startsWith("http")) {
567
+ seen.add(rawUrl);
568
+ items.push({
569
+ title,
570
+ url: rawUrl,
571
+ description: description || title,
572
+ engine: "brave-native",
573
+ engineRank: items.length + 1,
574
+ });
575
+ }
576
+ }
577
+ // Fallback generic anchor + description extraction if Brave's DOM class varied
578
+ if (items.length === 0) {
579
+ const anchorRegex = /<a[^>]+href=["'](https?:\/\/(?!(?:search|cdn\.search|imgs\.search)\.brave\.com)[^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
580
+ let m;
581
+ while ((m = anchorRegex.exec(html)) !== null && items.length < limit) {
582
+ const linkUrl = m[1].trim();
583
+ if (seen.has(linkUrl)) {
584
+ continue;
585
+ }
586
+ const text = decodeHtmlEntities(m[2]
587
+ .replace(/<[^>]+>/g, " ")
588
+ .replace(/\s+/g, " ")
589
+ .trim());
590
+ if (text.length < 12 ||
591
+ /^(Ask|All|Images|News|Videos|Maps|Goggles)$/i.test(text)) {
592
+ continue;
593
+ }
594
+ seen.add(linkUrl);
595
+ items.push({
596
+ title: text,
597
+ url: linkUrl,
598
+ description: text,
599
+ engine: "brave-native",
600
+ engineRank: items.length + 1,
601
+ });
602
+ }
603
+ }
604
+ return items;
605
+ }
606
+ catch {
607
+ return [];
608
+ }
609
+ }
610
+ /**
611
+ * 2. DuckDuckGo Lite / HTML SERP via Impit Chrome TLS impersonation.
612
+ */
613
+ async function fetchDuckDuckGoLiteOnly(query, limit, userLocation) {
614
+ const seenUrls = new Set();
615
+ const items = [];
616
+ const addResult = (title, url, description) => {
617
+ const cleanUrl = unwrapRedirectUrl(url);
618
+ if (!cleanUrl.startsWith("http") ||
619
+ cleanUrl.includes("duckduckgo.com/y.js") ||
620
+ seenUrls.has(cleanUrl)) {
621
+ return;
622
+ }
623
+ seenUrls.add(cleanUrl);
624
+ items.push({
625
+ title: decodeHtmlEntities(title
626
+ .replace(/<[^>]+>/g, "")
627
+ .replace(/\s+/g, " ")
628
+ .trim()),
629
+ url: cleanUrl,
630
+ description: decodeHtmlEntities(description
631
+ .replace(/<[^>]+>/g, "")
632
+ .replace(/\s+/g, " ")
633
+ .trim()),
634
+ engine: "ddg-lite",
635
+ engineRank: items.length + 1,
636
+ });
637
+ };
638
+ try {
639
+ const countryCode = (userLocation?.country || "").toUpperCase();
640
+ const kl = ddgRegion(countryCode, undefined);
641
+ const klParam = kl ? `&kl=${encodeURIComponent(kl)}` : "";
642
+ const liteRes = await fetchWithTlsImpersonation(`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}${klParam}`, 2200);
643
+ if (liteRes && liteRes.html && !isSerpChallengeHtml(liteRes.html, liteRes.status)) {
644
+ const html = liteRes.html;
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));
646
+ const snippetMatches = Array.from(html.matchAll(/<td\s+[^>]*class=["']result-snippet["'][^>]*>([\s\S]*?)<\/td>/gi));
647
+ for (let i = 0; i < linkMatches.length && items.length < limit; i++) {
648
+ const m = linkMatches[i];
649
+ const rawUrl = m[1] || m[3] || "";
650
+ const rawTitle = m[2] || m[4] || "";
651
+ const rawSnippet = snippetMatches[i]?.[1] || "";
652
+ addResult(rawTitle, rawUrl, rawSnippet);
653
+ }
654
+ }
655
+ }
656
+ catch {
657
+ // ignore
658
+ }
659
+ return items;
660
+ }
661
+ /**
662
+ * 3. Google News Search RSS (`news.google.com/rss/search`) with dynamic edition localization (`gl`, `hl`, `ceid`).
663
+ */
664
+ export async function fetchGoogleNewsSearchRss(query, limit, userLocation, lang = "en", timeoutMs = 2000) {
665
+ try {
666
+ const rawGl = (userLocation?.country || "US").toUpperCase();
667
+ const gl = rawGl === "EU" || rawGl === "UK" ? "GB" : rawGl;
668
+ const cleanLang = (lang || "en").toLowerCase().split("-")[0];
669
+ const hl = `${cleanLang}-${gl}`;
670
+ const ceid = `${gl}:${cleanLang}`;
671
+ const rssUrl = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=${encodeURIComponent(hl)}&gl=${encodeURIComponent(gl)}&ceid=${encodeURIComponent(ceid)}`;
672
+ const res = await fetchWithTlsImpersonation(rssUrl, timeoutMs);
673
+ if (res.status !== 200 || !res.html.includes("<item>")) {
674
+ return [];
675
+ }
676
+ const items = [];
677
+ const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
678
+ let match;
679
+ while ((match = itemRegex.exec(res.html)) !== null &&
680
+ items.length < limit) {
681
+ const block = match[1];
682
+ const title = decodeHtmlEntities((block.match(/<title>([\s\S]*?)<\/title>/i)?.[1] || "")
683
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
684
+ .trim());
685
+ const link = (block.match(/<link>([\s\S]*?)<\/link>/i)?.[1] || "").trim();
686
+ const source = decodeHtmlEntities((block.match(/<source[^>]*>([\s\S]*?)<\/source>/i)?.[1] || "").trim());
687
+ const pubDate = (block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i)?.[1] || "").trim();
688
+ const rawDesc = decodeHtmlEntities(block.match(/<description>([\s\S]*?)<\/description>/i)?.[1] || "")
689
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
690
+ .replace(/<[^>]+>/g, " ")
691
+ .replace(/\s+/g, " ")
692
+ .trim();
693
+ if (title && link) {
694
+ items.push({
695
+ title,
696
+ url: link,
697
+ description: [source ? `[${source}]` : "", pubDate, rawDesc]
698
+ .filter(Boolean)
699
+ .join(" - "),
700
+ publishedDate: pubDate || undefined,
701
+ engine: "google-news-rss",
702
+ engineRank: items.length + 1,
703
+ });
704
+ }
705
+ }
706
+ return items;
707
+ }
708
+ catch {
709
+ return [];
710
+ }
711
+ }
712
+ /**
713
+ * 4. Wikipedia OpenSearch API (`en.wikipedia.org/w/api.php`) for high-authority entity grounding.
714
+ */
715
+ async function fetchWikipediaOpenSearch(query, limit) {
716
+ try {
717
+ const cleanQuery = query.replace(/\([^)]*\)|-?site:\S+/g, "").trim();
718
+ if (!cleanQuery) {
719
+ return [];
720
+ }
721
+ const url = `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(cleanQuery)}&srlimit=${limit}&format=json`;
722
+ const res = await fetch(url, {
723
+ headers: {
724
+ "User-Agent": "WebCrawl/1.0 (https://webcrawl.app; search@webcrawl.app)",
725
+ Accept: "application/json",
726
+ },
727
+ signal: AbortSignal.timeout(1400),
728
+ });
729
+ if (!res.ok) {
730
+ return [];
731
+ }
732
+ const json = (await res.json());
733
+ const hits = json.query?.search || [];
734
+ return hits.slice(0, limit).map((hit, idx) => {
735
+ const title = (hit.title || "").trim();
736
+ const slug = encodeURIComponent(title.replace(/\s+/g, "_"));
737
+ const snippet = decodeHtmlEntities((hit.snippet || "")
738
+ .replace(/<[^>]+>/g, " ")
739
+ .replace(/\s+/g, " ")
740
+ .trim());
741
+ return {
742
+ title: `${title} - Wikipedia`,
743
+ url: `https://en.wikipedia.org/wiki/${slug}`,
744
+ description: snippet,
745
+ publishedDate: hit.timestamp,
746
+ engine: "wikipedia",
747
+ engineRank: idx + 1,
748
+ };
749
+ });
750
+ }
751
+ catch {
752
+ return [];
753
+ }
754
+ }
755
+ /**
756
+ * 5. HackerNews Algolia API (`hn.algolia.com/api/v1/search`) for ultra-fast tech & developer search.
757
+ */
758
+ async function fetchHackerNewsAlgolia(query, limit) {
759
+ try {
760
+ const cleanQuery = query.replace(/\([^)]*\)|-?site:\S+/g, "").trim();
761
+ const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(cleanQuery)}&tags=story&hitsPerPage=${limit}`;
762
+ const res = await fetch(url, {
763
+ headers: { Accept: "application/json" },
764
+ signal: AbortSignal.timeout(1400),
765
+ });
766
+ if (!res.ok) {
767
+ return [];
768
+ }
769
+ const json = (await res.json());
770
+ const items = [];
771
+ for (const hit of json.hits || []) {
772
+ if (items.length >= limit) {
773
+ break;
774
+ }
775
+ const targetUrl = hit.url ||
776
+ (hit.objectID
777
+ ? `https://news.ycombinator.com/item?id=${hit.objectID}`
778
+ : "");
779
+ if (!targetUrl || !hit.title) {
780
+ continue;
781
+ }
782
+ items.push({
783
+ title: hit.title,
784
+ url: targetUrl,
785
+ description: `${hit.title} (Hacker News: ${hit.points ?? 0} points, ${hit.num_comments ?? 0} comments)`,
786
+ publishedDate: hit.created_at,
787
+ engine: "hn-algolia",
788
+ engineRank: items.length + 1,
789
+ });
790
+ }
791
+ return items;
792
+ }
793
+ catch {
794
+ return [];
795
+ }
796
+ }
797
+ /**
798
+ * 6. ArXiv API (`export.arxiv.org/api/query`) + OpenAlex Scholarly Works API
799
+ * for scientific, ML/AI, biomedical, and peer-reviewed research paper search.
800
+ */
801
+ export function buildArxivSearchQuery(rawQuery) {
802
+ const clean = rawQuery
803
+ .replace(/\([^)]*\)|-?site:\S+/g, " ")
804
+ .replace(/["'`]+/g, " ")
805
+ .replace(/\s+/g, " ")
806
+ .trim();
807
+ if (!clean) {
808
+ return "all:machine+learning";
809
+ }
810
+ const arxivIdMatch = clean.match(/\b(\d{4}\.\d{4,5}(?:v\d+)?)\b/i);
811
+ if (arxivIdMatch) {
812
+ return `id:${encodeURIComponent(arxivIdMatch[1])}`;
813
+ }
814
+ const stop = new Set(["the", "and", "for", "with", "from", "paper", "papers", "arxiv", "research"]);
815
+ const words = clean
816
+ .split(/\s+/)
817
+ .map((w) => w.replace(/[^a-zA-Z0-9.-]/g, ""))
818
+ .filter((w) => w.length >= 2 && !stop.has(w.toLowerCase()))
819
+ .slice(0, 6);
820
+ if (words.length <= 1) {
821
+ return `all:${encodeURIComponent(words[0] || clean)}`;
822
+ }
823
+ return words.map((w) => `all:${encodeURIComponent(w)}`).join("+AND+");
824
+ }
825
+ async function fetchOpenAlexPapers(query, limit) {
826
+ try {
827
+ const clean = query.replace(/\([^)]*\)|-?site:\S+/g, " ").replace(/\s+/g, " ").trim();
828
+ if (!clean) {
829
+ return [];
830
+ }
831
+ const url = `https://api.openalex.org/works?search=${encodeURIComponent(clean)}&per-page=${Math.min(limit, 8)}`;
832
+ const res = await fetch(url, {
833
+ headers: { "user-agent": "webcrawl-anything/1.2 (mailto:support@webcrawl.app)" },
834
+ signal: AbortSignal.timeout(2800),
835
+ });
836
+ if (!res.ok) {
837
+ return [];
838
+ }
839
+ const data = (await res.json());
840
+ const works = Array.isArray(data.results) ? data.results : [];
841
+ const items = [];
842
+ for (const w of works) {
843
+ if (items.length >= limit) {
844
+ break;
845
+ }
846
+ const title = (w.title || "").replace(/\s+/g, " ").trim();
847
+ const targetUrl = w.primary_location?.landing_page_url ||
848
+ w.open_access?.oa_url ||
849
+ w.doi ||
850
+ "";
851
+ if (!title || !targetUrl) {
852
+ continue;
853
+ }
854
+ const authors = (w.authorships || [])
855
+ .map((a) => a.author?.display_name)
856
+ .filter((n) => Boolean(n))
857
+ .slice(0, 4)
858
+ .join(", ");
859
+ let abstractText = "";
860
+ if (w.abstract_inverted_index && typeof w.abstract_inverted_index === "object") {
861
+ const pairs = [];
862
+ for (const [word, positions] of Object.entries(w.abstract_inverted_index)) {
863
+ if (Array.isArray(positions)) {
864
+ for (const pos of positions) {
865
+ pairs.push([pos, word]);
866
+ }
867
+ }
868
+ }
869
+ pairs.sort((a, b) => a[0] - b[0]);
870
+ abstractText = pairs.map((p) => p[1]).join(" ");
871
+ }
872
+ const venue = w.primary_location?.source?.display_name;
873
+ const pdfUrl = w.primary_location?.pdf_url || w.open_access?.oa_url;
874
+ const metaParts = [
875
+ authors ? `Authors: ${authors}` : "",
876
+ venue ? `Venue: ${venue}` : "",
877
+ typeof w.cited_by_count === "number" ? `Citations: ${w.cited_by_count}` : "",
878
+ pdfUrl ? `PDF: ${pdfUrl}` : "",
879
+ ].filter(Boolean);
880
+ const description = [metaParts.join(" · "), abstractText]
881
+ .filter(Boolean)
882
+ .join(" — ")
883
+ .slice(0, 460);
884
+ items.push({
885
+ title: `[Paper] ${title}`,
886
+ url: targetUrl,
887
+ description: description || title,
888
+ publishedDate: w.publication_date ||
889
+ (w.publication_year ? `${w.publication_year}-01-01` : undefined),
890
+ engine: "arxiv",
891
+ engineRank: items.length + 1,
892
+ });
893
+ }
894
+ return items;
895
+ }
896
+ catch {
897
+ return [];
898
+ }
899
+ }
900
+ export async function fetchArxivApi(query, limit) {
901
+ const arxivPromise = (async () => {
902
+ try {
903
+ const searchExpr = buildArxivSearchQuery(query);
904
+ const url = `https://export.arxiv.org/api/query?search_query=${searchExpr}&start=0&max_results=${Math.max(limit, 6)}`;
905
+ const res = await fetch(url, {
906
+ headers: { "user-agent": "webcrawl-anything/1.2 (https://webcrawl.app)" },
907
+ signal: AbortSignal.timeout(3500),
908
+ });
909
+ if (!res.ok) {
910
+ return [];
911
+ }
912
+ const xml = await res.text();
913
+ const entries = xml.match(/<entry>([\s\S]*?)<\/entry>/gi) || [];
914
+ const items = [];
915
+ for (const entry of entries) {
916
+ if (items.length >= limit) {
917
+ break;
918
+ }
919
+ const title = decodeHtmlEntities((entry.match(/<title>([\s\S]*?)<\/title>/i)?.[1] || "")
920
+ .replace(/\s+/g, " ")
921
+ .trim());
922
+ const rawIdUrl = (entry.match(/<id>([\s\S]*?)<\/id>/i)?.[1] || "").trim();
923
+ const idUrl = rawIdUrl.replace(/^http:\/\/arxiv\.org\//i, "https://arxiv.org/");
924
+ const summary = decodeHtmlEntities((entry.match(/<summary>([\s\S]*?)<\/summary>/i)?.[1] || "")
925
+ .replace(/\s+/g, " ")
926
+ .trim());
927
+ const published = (entry.match(/<published>([\s\S]*?)<\/published>/i)?.[1] || "").trim();
928
+ const authorMatches = Array.from(entry.matchAll(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/gi))
929
+ .map((m) => decodeHtmlEntities(m[1].trim()))
930
+ .filter(Boolean)
931
+ .slice(0, 4);
932
+ const pdfLink = entry.match(/<link[^>]+title=["']pdf["'][^>]+href=["']([^"']+)["']/i)?.[1] ||
933
+ idUrl.replace("/abs/", "/pdf/");
934
+ if (title && idUrl) {
935
+ const metaPrefix = [
936
+ authorMatches.length ? `Authors: ${authorMatches.join(", ")}` : "",
937
+ pdfLink ? `PDF: ${pdfLink.replace(/^http:\/\//i, "https://")}` : "",
938
+ ]
939
+ .filter(Boolean)
940
+ .join(" · ");
941
+ items.push({
942
+ title: `[arXiv] ${title}`,
943
+ url: idUrl,
944
+ description: (metaPrefix ? `${metaPrefix} — ${summary}` : summary).slice(0, 460),
945
+ publishedDate: published || undefined,
946
+ engine: "arxiv",
947
+ engineRank: items.length + 1,
948
+ });
949
+ }
950
+ }
951
+ return items;
952
+ }
953
+ catch {
954
+ return [];
955
+ }
956
+ })();
957
+ const [arxivItems, openAlexItems] = await Promise.all([
958
+ arxivPromise,
959
+ fetchOpenAlexPapers(query, Math.max(4, Math.ceil(limit / 2))),
960
+ ]);
961
+ const combined = [];
962
+ const seen = new Set();
963
+ for (const item of [...arxivItems, ...openAlexItems]) {
964
+ const key = item.url.toLowerCase().replace(/v\d+$/, "");
965
+ if (seen.has(key)) {
966
+ continue;
967
+ }
968
+ seen.add(key);
969
+ combined.push({ ...item, engineRank: combined.length + 1 });
970
+ }
971
+ return combined.slice(0, Math.max(limit, 8));
972
+ }
973
+ const NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0";
974
+ const NVD_TIMEOUT_MS = 4000;
975
+ function nvdHeaders() {
976
+ const key = process.env.NVD_API_KEY;
977
+ return key ? { apiKey: key } : {};
978
+ }
979
+ /** Words that describe the search, not the product, stripped before an NVD keyword search. */
980
+ const VULN_NOISE = /\b(cves?|vulns?|vulnerabilit(?:y|ies)|security|advisor(?:y|ies)|exploits?|exploited|critical|high|latest|recent|new|known|list|of|for|in|the|and|bugs?|issues?|patch(?:es)?|20\d\d)\b/gi;
981
+ export function nvdKeywordFromQuery(query) {
982
+ return query
983
+ .replace(/\([^)]*\)|-?site:\S+/g, " ")
984
+ .replace(VULN_NOISE, " ")
985
+ .replace(/[^\w.\- ]+/g, " ")
986
+ .replace(/\s+/g, " ")
987
+ .trim();
988
+ }
989
+ function cveToCandidate(r, engine, rank) {
990
+ const label = cveSeverityLabel(r);
991
+ const product = r.affected?.[0]
992
+ ? `${r.affected[0].vendor ? `${r.affected[0].vendor} ` : ""}${r.affected[0].product}`
993
+ : undefined;
994
+ return {
995
+ title: [r.id, label ? `(${label})` : "", product ? `- ${product}` : "", r.title && !product ? `- ${r.title}` : ""]
996
+ .filter(Boolean)
997
+ .join(" "),
998
+ url: r.url,
999
+ description: r.summary.slice(0, 420),
1000
+ publishedDate: r.published,
1001
+ engine,
1002
+ engineRank: rank,
1003
+ };
1004
+ }
1005
+ /**
1006
+ * Exact CVE ids, cache first: hits come from Postgres (stale ones refresh in the
1007
+ * background), and only misses go to NVD, one request each, capped at `cap`.
1008
+ * Used for query ids and page-mentioned ids. Write-back happens in the pipeline
1009
+ * once NVD and OSV records are merged.
1010
+ */
1011
+ export async function fetchNvdByIds(ids, cap = 3) {
1012
+ if (ids.length === 0)
1013
+ return [];
1014
+ const cached = await getCachedCves(ids);
1015
+ const have = new Set(cached.records.flatMap((c) => [c.id, ...(c.aliases ?? [])].map((x) => x.toUpperCase())));
1016
+ const missing = ids.filter((id) => !have.has(id.toUpperCase()));
1017
+ refreshStaleCves(cached.stale, cached.records, (s) => fetchNvdLiveByIds(s, s.length));
1018
+ const live = missing.length ? await fetchNvdLiveByIds(missing, cap) : [];
1019
+ return [...cached.records, ...live];
1020
+ }
1021
+ async function fetchNvdLiveByIds(ids, cap) {
1022
+ const out = [];
1023
+ await Promise.all(ids.slice(0, cap).map(async (id) => {
1024
+ try {
1025
+ const res = await fetch(`${NVD_API}?cveId=${encodeURIComponent(id)}`, {
1026
+ headers: nvdHeaders(),
1027
+ signal: AbortSignal.timeout(NVD_TIMEOUT_MS),
1028
+ });
1029
+ if (res.ok)
1030
+ out.push(...parseNvdResponse(await res.json()));
1031
+ }
1032
+ catch {
1033
+ // rate limited or slow: the caller falls back to OSV + web results
1034
+ }
1035
+ }));
1036
+ return out;
1037
+ }
1038
+ /**
1039
+ * 7. NVD CVE API 2.0. Exact ids use `cveId`; anything else uses `keywordSearch`.
1040
+ * NVD returns keyword matches oldest-first, so when there are more matches than we
1041
+ * want, a second request fetches the newest page (`startIndex = total - n`).
1042
+ */
1043
+ async function fetchNvdApi(query, limit, onCve) {
1044
+ try {
1045
+ const ids = extractCveIdsFromText(query, 5);
1046
+ let records = [];
1047
+ if (ids.length > 0) {
1048
+ records = await fetchNvdByIds(ids, 5);
1049
+ }
1050
+ else {
1051
+ const kw = nvdKeywordFromQuery(query);
1052
+ if (!kw)
1053
+ return [];
1054
+ const n = Math.min(Math.max(limit, 5), 20);
1055
+ records = (await cachedKeywordResult(kw)) ?? (await fetchNvdKeywordLive(kw, n));
1056
+ }
1057
+ records.forEach((r) => onCve?.(r));
1058
+ return records.slice(0, limit).map((r, i) => cveToCandidate(r, "nvd", i + 1));
1059
+ }
1060
+ catch {
1061
+ return [];
1062
+ }
1063
+ }
1064
+ const byPublishedDesc = (a, b) => (b.published ?? "").localeCompare(a.published ?? "");
1065
+ /**
1066
+ * Keyword answered within QUERY_TTL_MS: replay its id list from the cache. Returns
1067
+ * null (go live) unless nearly every id is still cached, so a partial row set never
1068
+ * masks NVD's full answer. Ids outside the stored window (pre-2020) are never cached,
1069
+ * so they count as expected misses.
1070
+ */
1071
+ async function cachedKeywordResult(kw) {
1072
+ const ids = await getCachedQuery(kw);
1073
+ if (!ids)
1074
+ return null;
1075
+ const storable = ids.filter((id) => Number(id.match(/^CVE-(\d{4})-/)?.[1]) >= CVE_STORE_SINCE);
1076
+ if (storable.length === 0)
1077
+ return ids.length === 0 ? [] : null;
1078
+ const cached = await getCachedCves(storable);
1079
+ if (cached.records.length < Math.ceil(storable.length * 0.8))
1080
+ return null;
1081
+ refreshStaleCves(cached.stale, cached.records, (s) => fetchNvdLiveByIds(s, s.length));
1082
+ return cached.records.sort(byPublishedDesc);
1083
+ }
1084
+ /** NVD keywordSearch (newest page), remembered per keyword; FTS over the cache if NVD fails. */
1085
+ async function fetchNvdKeywordLive(kw, n) {
1086
+ const base = `${NVD_API}?keywordSearch=${encodeURIComponent(kw)}`;
1087
+ const first = await fetch(`${base}&resultsPerPage=${n}`, {
1088
+ headers: nvdHeaders(),
1089
+ signal: AbortSignal.timeout(NVD_TIMEOUT_MS),
1090
+ }).catch(() => null);
1091
+ if (!first?.ok) {
1092
+ // Rate limited (403/503) or slow: serve what earlier searches cached.
1093
+ return (await searchCachedCves(kw, n)).sort(byPublishedDesc);
1094
+ }
1095
+ const body = await first.json();
1096
+ const total = Number(body?.totalResults ?? 0);
1097
+ let records = parseNvdResponse(body);
1098
+ if (total > n) {
1099
+ const latest = await fetch(`${base}&resultsPerPage=${n}&startIndex=${total - n}`, {
1100
+ headers: nvdHeaders(),
1101
+ signal: AbortSignal.timeout(NVD_TIMEOUT_MS),
1102
+ }).catch(() => null);
1103
+ if (latest?.ok)
1104
+ records = parseNvdResponse(await latest.json());
1105
+ }
1106
+ records.sort(byPublishedDesc);
1107
+ rememberQuery(kw, records.map((r) => r.id));
1108
+ return records;
1109
+ }
1110
+ const OSV_ECOSYSTEMS = {
1111
+ npm: "npm",
1112
+ node: "npm",
1113
+ pypi: "PyPI",
1114
+ pip: "PyPI",
1115
+ python: "PyPI",
1116
+ go: "Go",
1117
+ golang: "Go",
1118
+ maven: "Maven",
1119
+ java: "Maven",
1120
+ cargo: "crates.io",
1121
+ crates: "crates.io",
1122
+ rust: "crates.io",
1123
+ rubygems: "RubyGems",
1124
+ gem: "RubyGems",
1125
+ ruby: "RubyGems",
1126
+ nuget: "NuGet",
1127
+ packagist: "Packagist",
1128
+ composer: "Packagist",
1129
+ php: "Packagist",
1130
+ hex: "Hex",
1131
+ pub: "Pub",
1132
+ };
1133
+ /** "lodash npm vulnerabilities" -> { name: "lodash", ecosystem: "npm" } */
1134
+ export function osvPackageFromQuery(query) {
1135
+ const words = nvdKeywordFromQuery(query).split(" ").filter(Boolean);
1136
+ const ecoIdx = words.findIndex((w) => OSV_ECOSYSTEMS[w.toLowerCase()]);
1137
+ if (ecoIdx < 0)
1138
+ return null;
1139
+ const name = words.filter((_, i) => i !== ecoIdx && !/^(package|library|module|crate)s?$/i.test(words[i]))[0];
1140
+ return name ? { name, ecosystem: OSV_ECOSYSTEMS[words[ecoIdx].toLowerCase()] } : null;
1141
+ }
1142
+ /**
1143
+ * 8. OSV (`api.osv.dev`): exact CVE/GHSA ids, or `package + ecosystem` queries.
1144
+ * Adds ecosystem package ranges and fixed versions that NVD's CPE data lacks.
1145
+ */
1146
+ async function fetchOsvApi(query, limit, onCve) {
1147
+ try {
1148
+ const ids = [...extractCveIdsFromText(query, 3), ...extractGhsaIdsFromText(query, 3)];
1149
+ let records = [];
1150
+ if (ids.length > 0) {
1151
+ const got = await Promise.all(ids.slice(0, 4).map(async (id) => {
1152
+ const res = await fetch(`https://api.osv.dev/v1/vulns/${encodeURIComponent(id)}`, {
1153
+ signal: AbortSignal.timeout(3000),
1154
+ }).catch(() => null);
1155
+ return res?.ok ? parseOsvVuln(await res.json()) : null;
1156
+ }));
1157
+ records = got.filter((r) => r !== null);
1158
+ }
1159
+ else {
1160
+ const pkg = osvPackageFromQuery(query);
1161
+ if (!pkg)
1162
+ return [];
1163
+ const res = await fetch("https://api.osv.dev/v1/query", {
1164
+ method: "POST",
1165
+ headers: { "content-type": "application/json" },
1166
+ body: JSON.stringify({ package: pkg }),
1167
+ signal: AbortSignal.timeout(3000),
1168
+ });
1169
+ if (!res.ok)
1170
+ return [];
1171
+ const body = await res.json();
1172
+ records = (body?.vulns ?? [])
1173
+ .map((v) => parseOsvVuln(v))
1174
+ .filter((r) => r !== null)
1175
+ .sort((a, b) => (b.published ?? "").localeCompare(a.published ?? ""))
1176
+ .slice(0, Math.max(limit, 10));
1177
+ }
1178
+ records.forEach((r) => onCve?.(r));
1179
+ return records.slice(0, limit).map((r, i) => cveToCandidate(r, "osv", i + 1));
1180
+ }
1181
+ catch {
1182
+ return [];
1183
+ }
1184
+ }
1185
+ async function fetchSerperApi(effectiveQuery, limit, req) {
1186
+ try {
1187
+ const res = await fetch("https://google.serper.dev/search", {
1188
+ method: "POST",
1189
+ headers: {
1190
+ "X-API-KEY": process.env.SERPER_API_KEY || "",
1191
+ "Content-Type": "application/json",
1192
+ },
1193
+ body: JSON.stringify({
1194
+ q: effectiveQuery,
1195
+ num: limit,
1196
+ ...(req.country ? { gl: req.country } : {}),
1197
+ ...(req.lang ? { hl: req.lang } : {}),
1198
+ ...(req.tbs ? { tbs: req.tbs } : {}),
1199
+ }),
1200
+ signal: AbortSignal.timeout(2000),
1201
+ });
1202
+ if (!res.ok) {
1203
+ return [];
1204
+ }
1205
+ const json = (await res.json());
1206
+ return (json.organic || []).slice(0, limit).map((item, idx) => ({
1207
+ title: item.title || "",
1208
+ url: item.link || "",
1209
+ description: item.snippet || "",
1210
+ publishedDate: item.date,
1211
+ engine: "serper",
1212
+ engineRank: idx + 1,
1213
+ }));
1214
+ }
1215
+ catch {
1216
+ return [];
1217
+ }
1218
+ }
1219
+ async function fetchBraveSearchApi(effectiveQuery, limit) {
1220
+ try {
1221
+ const u = new URL("https://api.search.brave.com/res/v1/web/search");
1222
+ u.searchParams.set("q", effectiveQuery);
1223
+ u.searchParams.set("count", String(limit));
1224
+ const res = await fetch(u.toString(), {
1225
+ headers: {
1226
+ Accept: "application/json",
1227
+ "X-Subscription-Token": process.env.BRAVE_SEARCH_API_KEY || "",
1228
+ },
1229
+ signal: AbortSignal.timeout(2000),
1230
+ });
1231
+ if (!res.ok) {
1232
+ return [];
1233
+ }
1234
+ const json = (await res.json());
1235
+ return (json.web?.results || []).slice(0, limit).map((item, idx) => ({
1236
+ title: item.title || "",
1237
+ url: item.url || "",
1238
+ description: item.description || "",
1239
+ publishedDate: item.page_age,
1240
+ engine: "brave-api",
1241
+ engineRank: idx + 1,
1242
+ }));
1243
+ }
1244
+ catch {
1245
+ return [];
1246
+ }
1247
+ }
1248
+ function unwrapRedirectUrl(rawUrl) {
1249
+ let cleaned = rawUrl.replace(/&amp;/g, "&").trim();
1250
+ if (cleaned.startsWith("//")) {
1251
+ cleaned = `https:${cleaned}`;
1252
+ }
1253
+ if (cleaned.includes("duckduckgo.com/y.js") ||
1254
+ cleaned.includes("duckduckgo.com/duckduckgo-help-pages")) {
1255
+ return "";
1256
+ }
1257
+ if (cleaned.includes("uddg=")) {
1258
+ try {
1259
+ const parsed = new URL(cleaned, "https://duckduckgo.com");
1260
+ const uddg = parsed.searchParams.get("uddg");
1261
+ if (uddg) {
1262
+ return decodeURIComponent(uddg);
1263
+ }
1264
+ }
1265
+ catch {
1266
+ // ignore
1267
+ }
1268
+ }
1269
+ return cleaned;
1270
+ }