@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.
- package/README.md +1 -1
- package/dist/bin.js +5 -7
- package/dist/core/engine/awswaf.d.ts +59 -0
- package/dist/core/engine/awswaf.js +164 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/chrome.d.ts +14 -0
- package/dist/core/engine/chrome.js +140 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +152 -0
- package/dist/core/engine/crawler.js +918 -0
- package/dist/core/engine/fetcher.d.ts +92 -0
- package/dist/core/engine/fetcher.js +494 -0
- package/dist/core/engine/index.d.ts +7 -0
- package/dist/core/engine/index.js +7 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/render.d.ts +41 -0
- package/dist/core/engine/render.js +138 -0
- package/dist/core/engine/safe-url.d.ts +29 -0
- package/dist/core/engine/safe-url.js +109 -0
- package/dist/core/engine/session.d.ts +93 -0
- package/dist/core/engine/session.js +250 -0
- package/dist/core/engine/stealth.d.ts +83 -0
- package/dist/core/engine/stealth.js +222 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +9 -0
- package/dist/core/extract/index.js +9 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/extract/structured.d.ts +14 -0
- package/dist/core/extract/structured.js +144 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +72 -0
- package/dist/core/interact/driver.js +384 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +43 -0
- package/dist/core/interact/vm.js +338 -0
- package/dist/core/local-engine.d.ts +56 -0
- package/dist/core/local-engine.js +667 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +643 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +222 -0
- package/dist/core/search/catalog.d.ts +176 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +719 -0
- package/dist/core/search/providers.d.ts +72 -0
- package/dist/core/search/providers.js +1270 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/dist/format.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/load-local-engine.d.ts +7 -0
- package/dist/load-local-engine.js +22 -0
- package/dist/server.js +3 -0
- package/package.json +11 -3
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { fastHtmlToMarkdown, fetchWithTlsImpersonation, getCachedPage, setCachedPage, sha256, } from "../engine/index.js";
|
|
2
|
+
import { dedupeBusinessListings, dedupeFlightOffers, dedupeHotelListings, dedupePropertyListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, extractBusinessListingsWithScaleDown, extractDomainHost, extractFlightOfferFromSerpResult, extractFlightOffersFromHtml, extractFlightOffersWithScaleDown, extractHotelListingFromSerpResult, extractHotelListingsFromHtml, extractHotelListingsWithScaleDown, extractOfferFromSerpSnippet, extractOffersFromHtml, extractPropertyListingFromSerpResult, extractPropertyListingsFromHtml, extractPropertyListingsWithScaleDown, groupOffersIntoProducts, extractCveIdsFromText, mergeCveRecords, normalizeCveId, sortCveRecords, } from "../extract/index.js";
|
|
3
|
+
import { rankDocuments, resolveCountryDisplayName, } from "../ranker/index.js";
|
|
4
|
+
import { browserEscalationAvailable, escalateToSandboxOrBrowser, } from "../sandbox.js";
|
|
5
|
+
const HOTEL_OTA_HOSTS = [
|
|
6
|
+
"booking.com",
|
|
7
|
+
"agoda.com",
|
|
8
|
+
"hotels.com",
|
|
9
|
+
"expedia.com",
|
|
10
|
+
"expedia.com.au",
|
|
11
|
+
"tripadvisor.com",
|
|
12
|
+
"tripadvisor.com.au",
|
|
13
|
+
"trivago.com",
|
|
14
|
+
"kayak.com",
|
|
15
|
+
"trip.com",
|
|
16
|
+
];
|
|
17
|
+
function isHotelOta(url) {
|
|
18
|
+
return hostMatchesAny(url, HOTEL_OTA_HOSTS);
|
|
19
|
+
}
|
|
20
|
+
import { hostMatchesAny, normalizeHostList, resolveCategory, resolveUserLocation, SEARCH_CATEGORY_CATALOG, } from "./catalog.js";
|
|
21
|
+
import { EMPTY_SEARCH_ERROR, fetchHedgedNativeFederation, fetchJinaSearch, fetchNvdByIds, } from "./providers.js";
|
|
22
|
+
import { runInBackground, upsertCves } from "../extract/cve-cache.js";
|
|
23
|
+
const searchCache = new Map();
|
|
24
|
+
const SEARCH_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours warm edge cache
|
|
25
|
+
/**
|
|
26
|
+
* High-speed Native Multi-Engine Web Search + 5-Stage Document & Decision Model Reranker (`/v1/search` & `/api/v1/search`).
|
|
27
|
+
* Uses Hedged Quorum Racing + Weighted Reciprocal Rank Fusion (W-RRF) + Fielded BM25F+ + ColBERT Subword MaxSim +
|
|
28
|
+
* Vercel AI Gateway Decision Models (`baseten/mammoth-1` & `convaiinnovations/laya`).
|
|
29
|
+
*/
|
|
30
|
+
export async function searchWeb(req) {
|
|
31
|
+
const start = performance.now();
|
|
32
|
+
const limit = Math.min(Math.max(req.limit ?? 5, 1), 25);
|
|
33
|
+
const effectiveQuery = req.query.trim();
|
|
34
|
+
const targetSites = normalizeHostList([
|
|
35
|
+
...(req.filters?.sites ?? []),
|
|
36
|
+
...(req.includeDomains ?? []),
|
|
37
|
+
...(req.filters?.includeDomains ?? []),
|
|
38
|
+
]);
|
|
39
|
+
const excludedHosts = normalizeHostList([
|
|
40
|
+
...(req.excludeDomains ?? []),
|
|
41
|
+
...(req.filters?.excludeDomains ?? []),
|
|
42
|
+
]);
|
|
43
|
+
const userLocation = resolveUserLocation(req);
|
|
44
|
+
const hasGeo = Boolean(userLocation.country || userLocation.city || userLocation.region);
|
|
45
|
+
const category = resolveCategory(req);
|
|
46
|
+
const filters = req.filters;
|
|
47
|
+
const productMode = filters?.productMode !== undefined
|
|
48
|
+
? Boolean(filters.productMode)
|
|
49
|
+
: category === "shopping";
|
|
50
|
+
const allowBrowserEscalation = Boolean(filters?.allowBrowserEscalation);
|
|
51
|
+
const catDef = SEARCH_CATEGORY_CATALOG[category] ?? SEARCH_CATEGORY_CATALOG.general;
|
|
52
|
+
const countryName = resolveCountryDisplayName(userLocation.country) || userLocation.country;
|
|
53
|
+
const localizeBy = filters?.localizeBy && filters.localizeBy !== "auto"
|
|
54
|
+
? filters.localizeBy
|
|
55
|
+
: catDef.localizeBy;
|
|
56
|
+
const qLower = effectiveQuery.toLowerCase();
|
|
57
|
+
const queryAlreadyHasPlace = Boolean(userLocation.city && qLower.includes(userLocation.city.toLowerCase())) || Boolean(countryName && qLower.includes(countryName.toLowerCase()));
|
|
58
|
+
const geoSuffix = localizeBy === "city"
|
|
59
|
+
? userLocation.city || countryName || ""
|
|
60
|
+
: localizeBy === "country"
|
|
61
|
+
? countryName || userLocation.city || ""
|
|
62
|
+
: localizeBy === "region"
|
|
63
|
+
? userLocation.region || countryName || ""
|
|
64
|
+
: "";
|
|
65
|
+
const localizedSearchQuery = geoSuffix && !queryAlreadyHasPlace && hasGeo
|
|
66
|
+
? `${effectiveQuery} ${geoSuffix}`.trim()
|
|
67
|
+
: effectiveQuery;
|
|
68
|
+
const filterContext = {
|
|
69
|
+
category,
|
|
70
|
+
subcategory: req.subcategory,
|
|
71
|
+
preferLocalDomains: filters?.preferLocalDomains ?? catDef.preferLocalDomains,
|
|
72
|
+
boostTerms: filters?.boostTerms,
|
|
73
|
+
mustIncludeTerms: filters?.mustIncludeTerms,
|
|
74
|
+
excludeTerms: filters?.excludeTerms,
|
|
75
|
+
customInstruction: filters?.customInstruction,
|
|
76
|
+
};
|
|
77
|
+
const useDecisionModel = req.rankOptions?.useDecisionModel !== false;
|
|
78
|
+
const decisionModel = req.rankOptions?.decisionModel ?? "auto";
|
|
79
|
+
const cacheKey = JSON.stringify({
|
|
80
|
+
q: localizedSearchQuery,
|
|
81
|
+
country: userLocation.country,
|
|
82
|
+
city: userLocation.city,
|
|
83
|
+
limit,
|
|
84
|
+
tbs: req.tbs,
|
|
85
|
+
category,
|
|
86
|
+
subcategory: req.subcategory,
|
|
87
|
+
filters: req.filters,
|
|
88
|
+
targetSites,
|
|
89
|
+
excludedHosts,
|
|
90
|
+
productMode,
|
|
91
|
+
provider: req.provider ?? "auto",
|
|
92
|
+
useDecisionModel,
|
|
93
|
+
decisionModel,
|
|
94
|
+
scrape: Boolean(req.scrapeOptions?.formats?.length),
|
|
95
|
+
});
|
|
96
|
+
if (!req.bypassCache) {
|
|
97
|
+
const cached = searchCache.get(cacheKey);
|
|
98
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
data: cached.data,
|
|
102
|
+
...(cached.products ? { products: cached.products } : {}),
|
|
103
|
+
...(cached.offers ? { offers: cached.offers } : {}),
|
|
104
|
+
...(cached.listings ? { listings: cached.listings } : {}),
|
|
105
|
+
...(cached.flights ? { flights: cached.flights } : {}),
|
|
106
|
+
...(cached.hotels ? { hotels: cached.hotels } : {}),
|
|
107
|
+
...(cached.businesses ? { businesses: cached.businesses } : {}),
|
|
108
|
+
...(cached.cves ? { cves: cached.cves } : {}),
|
|
109
|
+
provider: cached.provider,
|
|
110
|
+
cached: true,
|
|
111
|
+
latencyMs: Math.round(performance.now() - start),
|
|
112
|
+
...(hasGeo ? { userLocation } : {}),
|
|
113
|
+
rankingMetrics: cached.rankingMetrics,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const perEngineLimit = Math.min(Math.max(limit + 4, 8), 15);
|
|
118
|
+
const quorumTimeoutMs = req.rankOptions?.quorumTimeoutMs ?? 1500;
|
|
119
|
+
// Stage 1: Hedged Multi-Engine Native Federation
|
|
120
|
+
const collectedOffers = [];
|
|
121
|
+
const offerByUrl = new Map();
|
|
122
|
+
const recordOffer = (offer) => {
|
|
123
|
+
if (targetSites.length > 0 &&
|
|
124
|
+
!hostMatchesAny(offer.domain || offer.url, targetSites)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (excludedHosts.length > 0 &&
|
|
128
|
+
hostMatchesAny(offer.domain || offer.url, excludedHosts)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
collectedOffers.push(offer);
|
|
132
|
+
if (offer.url && !offerByUrl.has(offer.url)) {
|
|
133
|
+
offerByUrl.set(offer.url, offer);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
// Security: structured CVE records streamed from the NVD / OSV engines.
|
|
137
|
+
const collectedCves = [];
|
|
138
|
+
const recordCve = (cve) => {
|
|
139
|
+
collectedCves.push(cve);
|
|
140
|
+
};
|
|
141
|
+
// Hotels: a parallel OTA-biased pass so booking sites (photos, nightly rates,
|
|
142
|
+
// review scores) are in the candidate pool, not only travel articles.
|
|
143
|
+
const otaPass = category === "hotels" && targetSites.length === 0
|
|
144
|
+
? fetchHedgedNativeFederation(`${effectiveQuery} booking.com agoda`, `${localizedSearchQuery} booking.com agoda`, perEngineLimit, req, userLocation, quorumTimeoutMs, [], recordOffer).catch(() => [])
|
|
145
|
+
: Promise.resolve([]);
|
|
146
|
+
const [mainCandidates, otaCandidates] = await Promise.all([
|
|
147
|
+
fetchHedgedNativeFederation(effectiveQuery, localizedSearchQuery, perEngineLimit, req, userLocation, quorumTimeoutMs, targetSites, recordOffer, recordCve),
|
|
148
|
+
otaPass,
|
|
149
|
+
]);
|
|
150
|
+
const mainUrls = new Set(mainCandidates.map((c) => c.url));
|
|
151
|
+
const rawCandidatesUnfiltered = [
|
|
152
|
+
...mainCandidates,
|
|
153
|
+
...otaCandidates.filter((c) => c.url && isHotelOta(c.url) && !mainUrls.has(c.url)),
|
|
154
|
+
];
|
|
155
|
+
// Post-fetch host filtering for `sites`, `includeDomains`, and `excludeDomains` (never uses `-site:` which breaks Brave)
|
|
156
|
+
const rawCandidates = rawCandidatesUnfiltered.filter((cand) => {
|
|
157
|
+
if (!cand.url) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (targetSites.length > 0 && !hostMatchesAny(cand.url, targetSites)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
if (excludedHosts.length > 0 && hostMatchesAny(cand.url, excludedHosts)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
// Stage 2: Pre-rank with W-RRF + Lexical BM25F+ so if content scraping is requested,
|
|
169
|
+
// we only scrape the highest-value top candidate URLs.
|
|
170
|
+
const wantsMarkdown = req.scrapeOptions?.formats?.includes("markdown");
|
|
171
|
+
const wantsLinks = req.scrapeOptions?.formats?.includes("links");
|
|
172
|
+
// Enrich any candidates that are ALREADY in our warm 24-hour pageCache for 0ms cost!
|
|
173
|
+
const retainImages = req.scrapeOptions?.retainImages ?? "all";
|
|
174
|
+
for (const cand of rawCandidates) {
|
|
175
|
+
if (!cand.url) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const warm = getCachedPage(cand.url, retainImages);
|
|
179
|
+
if (warm) {
|
|
180
|
+
cand.markdown = warm.markdown;
|
|
181
|
+
cand.links = warm.links;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Stage 3: Optional parallel content scraping (when `scrapeOptions.formats` includes "markdown" or "links")
|
|
185
|
+
if ((wantsMarkdown || wantsLinks) && rawCandidates.length > 0) {
|
|
186
|
+
const preRanked = await rankDocuments(localizedSearchQuery, rawCandidates, {
|
|
187
|
+
topK: limit + 2,
|
|
188
|
+
useDecisionModel: false,
|
|
189
|
+
userLocation,
|
|
190
|
+
filterContext,
|
|
191
|
+
});
|
|
192
|
+
const urlsToScrape = new Set(preRanked.results.slice(0, limit).map((r) => r.url));
|
|
193
|
+
const scrapeTimeoutMs = req.scrapeOptions?.scrapeTimeoutMs ?? 2500;
|
|
194
|
+
await Promise.all(rawCandidates.map(async (item) => {
|
|
195
|
+
if (!item.url || !urlsToScrape.has(item.url) || item.markdown) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const res = await fetchWithTlsImpersonation(item.url, scrapeTimeoutMs);
|
|
200
|
+
if (res.status < 200 || res.status >= 400 || !res.html) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const parsed = fastHtmlToMarkdown(res.html, item.url, {
|
|
204
|
+
retainImages,
|
|
205
|
+
});
|
|
206
|
+
setCachedPage(item.url, retainImages, {
|
|
207
|
+
title: parsed.title || item.title,
|
|
208
|
+
markdown: parsed.markdown,
|
|
209
|
+
links: parsed.links,
|
|
210
|
+
statusCode: res.status,
|
|
211
|
+
contentHash: sha256(parsed.markdown),
|
|
212
|
+
});
|
|
213
|
+
if (wantsMarkdown) {
|
|
214
|
+
item.markdown = parsed.markdown;
|
|
215
|
+
}
|
|
216
|
+
if (wantsLinks) {
|
|
217
|
+
item.links = parsed.links;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Keep SERP snippet if individual page times out
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
// Stage 4 & 5: Full 5-Stage Document & Passage Ranking + Vercel AI Gateway Decision Model (`baseten/mammoth-1` / `convaiinnovations/laya`)
|
|
226
|
+
let rankCandidates = rawCandidates;
|
|
227
|
+
if (filters?.strict &&
|
|
228
|
+
(filters.mustIncludeTerms?.length || filters.excludeTerms?.length)) {
|
|
229
|
+
const must = (filters.mustIncludeTerms ?? [])
|
|
230
|
+
.map((t) => t.trim().toLowerCase())
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
const exclude = (filters.excludeTerms ?? [])
|
|
233
|
+
.map((t) => t.trim().toLowerCase())
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
rankCandidates = rawCandidates.filter((c) => {
|
|
236
|
+
const hay = `${c.title || ""} ${c.description || ""} ${c.url || ""} ${c.markdown || ""}`.toLowerCase();
|
|
237
|
+
return (must.every((t) => hay.includes(t)) &&
|
|
238
|
+
!exclude.some((t) => hay.includes(t)));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// News: evergreen pages (Wikipedia, travel guides) out-score articles on entity
|
|
242
|
+
// queries like "paris". Rank only dated articles when there are enough of them.
|
|
243
|
+
if (category === "news") {
|
|
244
|
+
const dated = rankCandidates.filter((c) => c.publishedDate || c.engine === "google-news-rss");
|
|
245
|
+
if (dated.length >= Math.min(3, limit)) {
|
|
246
|
+
rankCandidates = dated;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const rankOpts = {
|
|
250
|
+
topK: limit,
|
|
251
|
+
useDecisionModel,
|
|
252
|
+
decisionModel,
|
|
253
|
+
decisionTimeoutMs: req.rankOptions?.decisionTimeoutMs ?? 1200,
|
|
254
|
+
decisionTopN: Math.min(limit + 3, 12),
|
|
255
|
+
maxPerDomain: req.rankOptions?.maxPerDomain,
|
|
256
|
+
userLocation,
|
|
257
|
+
filterContext,
|
|
258
|
+
};
|
|
259
|
+
let ranked = await rankDocuments(localizedSearchQuery, rankCandidates, rankOpts);
|
|
260
|
+
// Thin result set after ranking and filtering: ask Jina once, then re-rank the combined pool.
|
|
261
|
+
const minResults = Math.min(3, limit);
|
|
262
|
+
if ((!req.provider || req.provider === "auto") &&
|
|
263
|
+
ranked.results.length < minResults &&
|
|
264
|
+
process.env.JINA_API_KEY &&
|
|
265
|
+
!rankCandidates.some((c) => c.engine === "jina")) {
|
|
266
|
+
const jinaQuery = targetSites.length > 0 && targetSites.length <= 3
|
|
267
|
+
? `${localizedSearchQuery} ${targetSites.map((h) => `site:${h}`).join(" OR ")}`
|
|
268
|
+
: localizedSearchQuery;
|
|
269
|
+
const jina = await fetchJinaSearch(jinaQuery, perEngineLimit, req);
|
|
270
|
+
const filteredJina = jina.filter((c) => (targetSites.length === 0 || hostMatchesAny(c.url, targetSites)) &&
|
|
271
|
+
(excludedHosts.length === 0 || !hostMatchesAny(c.url, excludedHosts)));
|
|
272
|
+
if (filteredJina.length > 0) {
|
|
273
|
+
const reranked = await rankDocuments(localizedSearchQuery, [...rankCandidates, ...filteredJina], rankOpts);
|
|
274
|
+
if (reranked.results.length > ranked.results.length) {
|
|
275
|
+
ranked = reranked;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Stage 6 (Shopping Product Mode): Extract JSON-LD/OpenGraph/SERP offers from top ranked pages when needed,
|
|
280
|
+
// with optional browser escalation on blocked candidate pages (`allowBrowserEscalation: true`).
|
|
281
|
+
if (productMode) {
|
|
282
|
+
for (const doc of ranked.results) {
|
|
283
|
+
if (!offerByUrl.has(doc.url)) {
|
|
284
|
+
const snippetOffer = extractOfferFromSerpSnippet(doc.title, doc.description, doc.url, userLocation.country);
|
|
285
|
+
if (snippetOffer) {
|
|
286
|
+
recordOffer(snippetOffer);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Only probe top candidate pages for JSON-LD / OpenGraph if we still need more offers (< limit)
|
|
291
|
+
const candidatesMissingOffer = collectedOffers.length >= limit
|
|
292
|
+
? []
|
|
293
|
+
: ranked.results.filter((doc) => !offerByUrl.has(doc.url)).slice(0, 3);
|
|
294
|
+
if (candidatesMissingOffer.length > 0) {
|
|
295
|
+
await Promise.all(candidatesMissingOffer.map(async (doc) => {
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetchWithTlsImpersonation(doc.url, 1800);
|
|
298
|
+
let html = res.html || "";
|
|
299
|
+
if ((!html || res.status === 403 || res.status === 503) &&
|
|
300
|
+
allowBrowserEscalation) {
|
|
301
|
+
const esc = await escalateToSandboxOrBrowser(doc.url, {
|
|
302
|
+
timeoutMs: 4500,
|
|
303
|
+
});
|
|
304
|
+
if (esc?.html) {
|
|
305
|
+
html = esc.html;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (html) {
|
|
309
|
+
const pageOffers = extractOffersFromHtml(html, doc.url, doc.title, userLocation.country);
|
|
310
|
+
for (const o of pageOffers) {
|
|
311
|
+
recordOffer(o);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// Best-effort extraction
|
|
317
|
+
}
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const products = productMode
|
|
322
|
+
? groupOffersIntoProducts(collectedOffers, effectiveQuery, limit)
|
|
323
|
+
: undefined;
|
|
324
|
+
const isRealEstate = category === "real-estate";
|
|
325
|
+
const collectedListings = [];
|
|
326
|
+
const listingsByUrl = new Map();
|
|
327
|
+
if (isRealEstate) {
|
|
328
|
+
const seenPortalUrls = new Set();
|
|
329
|
+
const seenDomains = new Set();
|
|
330
|
+
const portalCandidates = [];
|
|
331
|
+
for (const doc of ranked.results.slice(0, Math.min(limit, 8))) {
|
|
332
|
+
seenPortalUrls.add(doc.url);
|
|
333
|
+
const dom = extractDomainHost(doc.url);
|
|
334
|
+
if (dom) {
|
|
335
|
+
seenDomains.add(dom);
|
|
336
|
+
}
|
|
337
|
+
portalCandidates.push(doc);
|
|
338
|
+
}
|
|
339
|
+
// Include additional organic SERP candidates from distinct domains so if 1-2 top domains
|
|
340
|
+
// return 403/CAPTCHA on cloud datacenter IPs, other organic results still yield listings.
|
|
341
|
+
for (const cand of rankCandidates) {
|
|
342
|
+
if (portalCandidates.length >= 10) {
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
const dom = extractDomainHost(cand.url);
|
|
346
|
+
if (!seenPortalUrls.has(cand.url) && dom && !seenDomains.has(dom)) {
|
|
347
|
+
seenPortalUrls.add(cand.url);
|
|
348
|
+
seenDomains.add(dom);
|
|
349
|
+
portalCandidates.push(cand);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const scrapedMarkdowns = [];
|
|
353
|
+
await Promise.all(portalCandidates.map(async (doc) => {
|
|
354
|
+
const pageListings = [];
|
|
355
|
+
try {
|
|
356
|
+
const fetched = await fetchWithTlsImpersonation(doc.url, 2600);
|
|
357
|
+
if (fetched.status >= 200 &&
|
|
358
|
+
fetched.status < 400 &&
|
|
359
|
+
fetched.html.length > 500) {
|
|
360
|
+
const extracted = extractPropertyListingsFromHtml(fetched.html, doc.url, userLocation.country);
|
|
361
|
+
pageListings.push(...extracted);
|
|
362
|
+
if (extracted.length === 0) {
|
|
363
|
+
const md = fastHtmlToMarkdown(fetched.html, doc.url, {
|
|
364
|
+
retainImages: "none",
|
|
365
|
+
}).markdown;
|
|
366
|
+
if (md && md.length > 120) {
|
|
367
|
+
scrapedMarkdowns.push({ url: doc.url, markdown: md });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Best-effort extraction
|
|
374
|
+
}
|
|
375
|
+
if (pageListings.length === 0) {
|
|
376
|
+
const fallback = extractPropertyListingFromSerpResult(doc.title, doc.description, doc.url, userLocation.country);
|
|
377
|
+
if (fallback) {
|
|
378
|
+
pageListings.push(fallback);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (pageListings.length > 0) {
|
|
382
|
+
listingsByUrl.set(doc.url, pageListings.slice(0, 10));
|
|
383
|
+
collectedListings.push(...pageListings);
|
|
384
|
+
}
|
|
385
|
+
}));
|
|
386
|
+
// Tier-2 fallback: if deterministic JSON-state / JSON-LD extraction yielded fewer than 3 listings,
|
|
387
|
+
// run `scaledown/scaledown-extract-1` ($0.05/1M input, $0 output) over scraped page markdown + SERP snippets.
|
|
388
|
+
if (collectedListings.length < 3) {
|
|
389
|
+
const combinedContext = [
|
|
390
|
+
...scrapedMarkdowns
|
|
391
|
+
.slice(0, 3)
|
|
392
|
+
.map((m) => `Source URL: ${m.url}\n${m.markdown.slice(0, 3500)}`),
|
|
393
|
+
...rankCandidates
|
|
394
|
+
.slice(0, 10)
|
|
395
|
+
.map((c) => `Result: ${c.title} | ${c.url}\n${c.description}`),
|
|
396
|
+
].join("\n\n");
|
|
397
|
+
const primaryUrl = scrapedMarkdowns[0]?.url ||
|
|
398
|
+
ranked.results[0]?.url ||
|
|
399
|
+
"https://webcrawl.app";
|
|
400
|
+
const scaledownListings = await extractPropertyListingsWithScaleDown(combinedContext, primaryUrl, userLocation.country, 3000);
|
|
401
|
+
if (scaledownListings.length > 0) {
|
|
402
|
+
collectedListings.push(...scaledownListings);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const dedupedListings = isRealEstate
|
|
407
|
+
? dedupePropertyListings(collectedListings, Math.max(limit * 5, 25))
|
|
408
|
+
: undefined;
|
|
409
|
+
// Stage 6.5 (Flights, Hotels, Business Vertical Extraction)
|
|
410
|
+
const isFlights = category === "flights";
|
|
411
|
+
const isHotels = category === "hotels";
|
|
412
|
+
const isBusiness = category === "business";
|
|
413
|
+
const collectedFlights = [];
|
|
414
|
+
const flightsByUrl = new Map();
|
|
415
|
+
const collectedHotels = [];
|
|
416
|
+
const hotelsByUrl = new Map();
|
|
417
|
+
const collectedBusinesses = [];
|
|
418
|
+
const businessesByUrl = new Map();
|
|
419
|
+
if (isFlights || isHotels || isBusiness) {
|
|
420
|
+
const seenUrls = new Set();
|
|
421
|
+
const seenDomains = new Set();
|
|
422
|
+
const verticalCandidates = [];
|
|
423
|
+
// Hotels: booking sites carry photos, prices and ratings for many stays on one
|
|
424
|
+
// page, so pull them into the probe set even when they rank below articles.
|
|
425
|
+
if (isHotels) {
|
|
426
|
+
const otaFirst = [...ranked.results, ...rankCandidates].filter((c) => c.url && isHotelOta(c.url));
|
|
427
|
+
for (const cand of otaFirst) {
|
|
428
|
+
if (verticalCandidates.length >= 4) {
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
const dom = extractDomainHost(cand.url);
|
|
432
|
+
if (!seenUrls.has(cand.url) && dom && !seenDomains.has(dom)) {
|
|
433
|
+
seenUrls.add(cand.url);
|
|
434
|
+
seenDomains.add(dom);
|
|
435
|
+
verticalCandidates.push(cand);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
for (const doc of ranked.results.slice(0, Math.min(limit, 8))) {
|
|
440
|
+
if (seenUrls.has(doc.url)) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
seenUrls.add(doc.url);
|
|
444
|
+
const dom = extractDomainHost(doc.url);
|
|
445
|
+
if (dom) {
|
|
446
|
+
seenDomains.add(dom);
|
|
447
|
+
}
|
|
448
|
+
verticalCandidates.push(doc);
|
|
449
|
+
}
|
|
450
|
+
for (const cand of rankCandidates) {
|
|
451
|
+
if (verticalCandidates.length >= 10) {
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
const dom = extractDomainHost(cand.url);
|
|
455
|
+
if (!seenUrls.has(cand.url) && dom && !seenDomains.has(dom)) {
|
|
456
|
+
seenUrls.add(cand.url);
|
|
457
|
+
seenDomains.add(dom);
|
|
458
|
+
verticalCandidates.push(cand);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const scrapedMarkdowns = [];
|
|
462
|
+
await Promise.all(verticalCandidates.map(async (doc) => {
|
|
463
|
+
try {
|
|
464
|
+
const fetched = await fetchWithTlsImpersonation(doc.url, 2400);
|
|
465
|
+
// Blocked (403/429/202 challenge) or JS-only shell on a booking site:
|
|
466
|
+
// retry once through the browser tier inside a fixed budget.
|
|
467
|
+
if (isHotels &&
|
|
468
|
+
isHotelOta(doc.url) &&
|
|
469
|
+
(allowBrowserEscalation || browserEscalationAvailable()) &&
|
|
470
|
+
(fetched.status === 202 ||
|
|
471
|
+
fetched.status === 403 ||
|
|
472
|
+
fetched.status === 429 ||
|
|
473
|
+
fetched.status === 503 ||
|
|
474
|
+
!/data-testid="title"|application\/ld\+json/i.test(fetched.html))) {
|
|
475
|
+
const esc = await escalateToSandboxOrBrowser(doc.url, {
|
|
476
|
+
timeoutMs: 6000,
|
|
477
|
+
}).catch(() => null);
|
|
478
|
+
if (esc?.html && esc.html.length > 500) {
|
|
479
|
+
fetched.status = 200;
|
|
480
|
+
fetched.html = esc.html;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (fetched.status >= 200 &&
|
|
484
|
+
fetched.status < 400 &&
|
|
485
|
+
fetched.html.length > 500) {
|
|
486
|
+
if (isFlights) {
|
|
487
|
+
const extracted = extractFlightOffersFromHtml(fetched.html, doc.url, userLocation.country, effectiveQuery);
|
|
488
|
+
if (extracted.length > 0) {
|
|
489
|
+
flightsByUrl.set(doc.url, extracted.slice(0, 10));
|
|
490
|
+
collectedFlights.push(...extracted);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else if (isHotels) {
|
|
495
|
+
const extracted = extractHotelListingsFromHtml(fetched.html, doc.url, userLocation.country);
|
|
496
|
+
if (extracted.length > 0) {
|
|
497
|
+
hotelsByUrl.set(doc.url, extracted.slice(0, 10));
|
|
498
|
+
collectedHotels.push(...extracted);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
else if (isBusiness) {
|
|
503
|
+
const extracted = extractBusinessListingsFromHtml(fetched.html, doc.url, userLocation.country);
|
|
504
|
+
if (extracted.length > 0) {
|
|
505
|
+
businessesByUrl.set(doc.url, extracted.slice(0, 10));
|
|
506
|
+
collectedBusinesses.push(...extracted);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const md = fastHtmlToMarkdown(fetched.html, doc.url, {
|
|
511
|
+
retainImages: "none",
|
|
512
|
+
}).markdown;
|
|
513
|
+
if (md && md.length > 120) {
|
|
514
|
+
scrapedMarkdowns.push({ url: doc.url, markdown: md });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// Best-effort extraction
|
|
520
|
+
}
|
|
521
|
+
// SERP snippet fallback
|
|
522
|
+
if (isFlights) {
|
|
523
|
+
const fallback = extractFlightOfferFromSerpResult(doc.title, doc.description, doc.url, userLocation.country, effectiveQuery);
|
|
524
|
+
if (fallback) {
|
|
525
|
+
flightsByUrl.set(doc.url, [fallback]);
|
|
526
|
+
collectedFlights.push(fallback);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (isHotels) {
|
|
530
|
+
const fallback = extractHotelListingFromSerpResult(doc.title, doc.description, doc.url, userLocation.country);
|
|
531
|
+
if (fallback) {
|
|
532
|
+
hotelsByUrl.set(doc.url, [fallback]);
|
|
533
|
+
collectedHotels.push(fallback);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else if (isBusiness) {
|
|
537
|
+
const fallback = extractBusinessListingFromSerpResult(doc.title, doc.description, doc.url, userLocation.country);
|
|
538
|
+
if (fallback) {
|
|
539
|
+
businessesByUrl.set(doc.url, [fallback]);
|
|
540
|
+
collectedBusinesses.push(fallback);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}));
|
|
544
|
+
const combinedContext = [
|
|
545
|
+
...scrapedMarkdowns
|
|
546
|
+
.slice(0, 3)
|
|
547
|
+
.map((m) => `Source URL: ${m.url}\n${m.markdown.slice(0, 3500)}`),
|
|
548
|
+
...rankCandidates
|
|
549
|
+
.slice(0, 10)
|
|
550
|
+
.map((c) => `Result: ${c.title} | ${c.url}\n${c.description}`),
|
|
551
|
+
].join("\n\n");
|
|
552
|
+
const primaryUrl = scrapedMarkdowns[0]?.url ||
|
|
553
|
+
ranked.results[0]?.url ||
|
|
554
|
+
"https://webcrawl.app";
|
|
555
|
+
if (isFlights && collectedFlights.length < 3) {
|
|
556
|
+
const sdFlights = await extractFlightOffersWithScaleDown(combinedContext, primaryUrl, userLocation.country, 3000);
|
|
557
|
+
if (sdFlights.length > 0) {
|
|
558
|
+
collectedFlights.push(...sdFlights);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (isHotels && collectedHotels.length < 3) {
|
|
562
|
+
const sdHotels = await extractHotelListingsWithScaleDown(combinedContext, primaryUrl, userLocation.country, 3000);
|
|
563
|
+
if (sdHotels.length > 0) {
|
|
564
|
+
collectedHotels.push(...sdHotels);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
else if (isBusiness && collectedBusinesses.length < 3) {
|
|
568
|
+
const sdBusinesses = await extractBusinessListingsWithScaleDown(combinedContext, primaryUrl, userLocation.country, 3000);
|
|
569
|
+
if (sdBusinesses.length > 0) {
|
|
570
|
+
collectedBusinesses.push(...sdBusinesses);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const dedupedFlights = isFlights
|
|
575
|
+
? dedupeFlightOffers(collectedFlights, Math.max(limit * 5, 25))
|
|
576
|
+
: undefined;
|
|
577
|
+
const dedupedHotels = isHotels
|
|
578
|
+
? dedupeHotelListings(collectedHotels, Math.max(limit * 5, 25))
|
|
579
|
+
: undefined;
|
|
580
|
+
const dedupedBusinesses = isBusiness
|
|
581
|
+
? dedupeBusinessListings(collectedBusinesses, Math.max(limit * 5, 25))
|
|
582
|
+
: undefined;
|
|
583
|
+
// Stage 6.6 (Security: structured CVE records)
|
|
584
|
+
// API records first; then any CVE id a top-ranked page mentions that the APIs
|
|
585
|
+
// did not return gets one NVD lookup (capped), so write-ups resolve to records.
|
|
586
|
+
const isSecurity = category === "security";
|
|
587
|
+
let dedupedCves;
|
|
588
|
+
const cvesByUrl = new Map();
|
|
589
|
+
if (isSecurity) {
|
|
590
|
+
const queryIds = extractCveIdsFromText(effectiveQuery, 5);
|
|
591
|
+
const known = new Set(collectedCves.flatMap((c) => [c.id, ...(c.aliases ?? [])].map(normalizeCveId)));
|
|
592
|
+
const pageIds = new Map();
|
|
593
|
+
for (const doc of ranked.results.slice(0, Math.max(limit, 5))) {
|
|
594
|
+
const ids = extractCveIdsFromText(`${doc.title} ${doc.description} ${doc.markdown?.slice(0, 20000) ?? ""}`, 6);
|
|
595
|
+
if (ids.length)
|
|
596
|
+
pageIds.set(doc.url, ids);
|
|
597
|
+
}
|
|
598
|
+
const missing = [
|
|
599
|
+
...new Set([...queryIds, ...[...pageIds.values()].flat()]),
|
|
600
|
+
].filter((id) => !known.has(id));
|
|
601
|
+
if (missing.length > 0) {
|
|
602
|
+
collectedCves.push(...(await fetchNvdByIds(missing, 3)));
|
|
603
|
+
}
|
|
604
|
+
dedupedCves = sortCveRecords(mergeCveRecords(collectedCves, Math.max(limit * 5, 25)), queryIds);
|
|
605
|
+
// Incremental cache: store the merged NVD + OSV records after the response
|
|
606
|
+
// (unchanged rows are skipped by the upsert's IS DISTINCT FROM guard).
|
|
607
|
+
runInBackground(upsertCves(dedupedCves));
|
|
608
|
+
const byId = new Map();
|
|
609
|
+
for (const c of dedupedCves) {
|
|
610
|
+
for (const n of [c.id, ...(c.aliases ?? [])])
|
|
611
|
+
byId.set(normalizeCveId(n), c);
|
|
612
|
+
}
|
|
613
|
+
for (const c of dedupedCves) {
|
|
614
|
+
cvesByUrl.set(c.url, [c]);
|
|
615
|
+
}
|
|
616
|
+
for (const [url, ids] of pageIds) {
|
|
617
|
+
const recs = [
|
|
618
|
+
...new Set(ids.map((id) => byId.get(id)).filter((c) => !!c)),
|
|
619
|
+
];
|
|
620
|
+
if (recs.length && !cvesByUrl.has(url))
|
|
621
|
+
cvesByUrl.set(url, recs.slice(0, 5));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const finalItems = ranked.results.map((doc) => {
|
|
625
|
+
const matchedOffer = offerByUrl.get(doc.url);
|
|
626
|
+
const matchedListings = listingsByUrl.get(doc.url);
|
|
627
|
+
const matchedFlights = flightsByUrl.get(doc.url);
|
|
628
|
+
const matchedHotels = hotelsByUrl.get(doc.url);
|
|
629
|
+
const matchedBusinesses = businessesByUrl.get(doc.url);
|
|
630
|
+
const matchedCves = cvesByUrl.get(doc.url);
|
|
631
|
+
return {
|
|
632
|
+
title: doc.title,
|
|
633
|
+
url: doc.url,
|
|
634
|
+
canonicalUrl: doc.canonicalUrl,
|
|
635
|
+
description: doc.description,
|
|
636
|
+
...(wantsMarkdown && doc.markdown ? { markdown: doc.markdown } : {}),
|
|
637
|
+
...(wantsLinks && doc.links ? { links: doc.links } : {}),
|
|
638
|
+
...(doc.publishedDate ? { publishedDate: doc.publishedDate } : {}),
|
|
639
|
+
score: doc.score,
|
|
640
|
+
rank: doc.rank,
|
|
641
|
+
sources: doc.sources,
|
|
642
|
+
highlights: doc.highlights,
|
|
643
|
+
scoreBreakdown: doc.scoreBreakdown,
|
|
644
|
+
...(matchedOffer ? { offer: matchedOffer } : {}),
|
|
645
|
+
...(matchedListings?.length ? { listings: matchedListings } : {}),
|
|
646
|
+
...(matchedFlights?.length ? { flights: matchedFlights } : {}),
|
|
647
|
+
...(matchedHotels?.length ? { hotels: matchedHotels } : {}),
|
|
648
|
+
...(matchedBusinesses?.length ? { businesses: matchedBusinesses } : {}),
|
|
649
|
+
...(matchedCves?.length ? { cves: matchedCves } : {}),
|
|
650
|
+
};
|
|
651
|
+
});
|
|
652
|
+
// Charge the fallback surcharge only when a Jina result actually reached the caller.
|
|
653
|
+
// An explicitly chosen provider is not a fallback.
|
|
654
|
+
const usedJinaFallback = (!req.provider || req.provider === "auto") &&
|
|
655
|
+
finalItems.some((item) => item.sources?.includes("jina"));
|
|
656
|
+
const engines = ranked.rankingMetrics.enginesContributing;
|
|
657
|
+
const provider = engines.length > 1
|
|
658
|
+
? "native-federated"
|
|
659
|
+
: engines.includes("serper")
|
|
660
|
+
? "serper"
|
|
661
|
+
: engines.includes("jina")
|
|
662
|
+
? "jina"
|
|
663
|
+
: engines.includes("brave-api") || engines.includes("brave-native")
|
|
664
|
+
? "brave"
|
|
665
|
+
: engines.includes("ddg-lite")
|
|
666
|
+
? "ddg-edge"
|
|
667
|
+
: "native-federated";
|
|
668
|
+
const hasHits = finalItems.length > 0 ||
|
|
669
|
+
(products && products.length > 0) ||
|
|
670
|
+
(dedupedListings && dedupedListings.length > 0) ||
|
|
671
|
+
(dedupedFlights && dedupedFlights.length > 0) ||
|
|
672
|
+
(dedupedHotels && dedupedHotels.length > 0) ||
|
|
673
|
+
(dedupedBusinesses && dedupedBusinesses.length > 0) ||
|
|
674
|
+
(dedupedCves && dedupedCves.length > 0);
|
|
675
|
+
if (hasHits) {
|
|
676
|
+
searchCache.set(cacheKey, {
|
|
677
|
+
data: finalItems,
|
|
678
|
+
...(products ? { products } : {}),
|
|
679
|
+
...(productMode ? { offers: collectedOffers } : {}),
|
|
680
|
+
...(dedupedListings ? { listings: dedupedListings } : {}),
|
|
681
|
+
...(dedupedFlights ? { flights: dedupedFlights } : {}),
|
|
682
|
+
...(dedupedHotels ? { hotels: dedupedHotels } : {}),
|
|
683
|
+
...(dedupedBusinesses ? { businesses: dedupedBusinesses } : {}),
|
|
684
|
+
...(dedupedCves ? { cves: dedupedCves } : {}),
|
|
685
|
+
provider,
|
|
686
|
+
rankingMetrics: ranked.rankingMetrics,
|
|
687
|
+
expiresAt: Date.now() + SEARCH_CACHE_TTL_MS,
|
|
688
|
+
});
|
|
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
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
success: true,
|
|
704
|
+
data: finalItems,
|
|
705
|
+
...(products ? { products } : {}),
|
|
706
|
+
...(productMode ? { offers: collectedOffers } : {}),
|
|
707
|
+
...(dedupedListings ? { listings: dedupedListings } : {}),
|
|
708
|
+
...(dedupedFlights ? { flights: dedupedFlights } : {}),
|
|
709
|
+
...(dedupedHotels ? { hotels: dedupedHotels } : {}),
|
|
710
|
+
...(dedupedBusinesses ? { businesses: dedupedBusinesses } : {}),
|
|
711
|
+
...(dedupedCves ? { cves: dedupedCves } : {}),
|
|
712
|
+
provider,
|
|
713
|
+
...(usedJinaFallback ? { fallback: "jina" } : {}),
|
|
714
|
+
cached: false,
|
|
715
|
+
latencyMs: Math.round(performance.now() - start),
|
|
716
|
+
...(hasGeo ? { userLocation } : {}),
|
|
717
|
+
rankingMetrics: ranked.rankingMetrics,
|
|
718
|
+
};
|
|
719
|
+
}
|