webcrawl 1.2.4 → 1.2.5
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/dist/cli.js +3 -2
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -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 +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -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/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -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 +8 -0
- package/dist/core/extract/index.js +8 -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/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -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 +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -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 +642 -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 +182 -0
- package/dist/core/search/catalog.d.ts +174 -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 +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -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/package.json +82 -4
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { extractDomainHost, parsePriceString } from "./currency.js";
|
|
2
|
+
function normalizeAvailability(raw) {
|
|
3
|
+
if (!raw) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const s = raw.toLowerCase();
|
|
7
|
+
if (s.includes("instock") ||
|
|
8
|
+
s.includes("in_stock") ||
|
|
9
|
+
s.includes("in stock")) {
|
|
10
|
+
return "in_stock";
|
|
11
|
+
}
|
|
12
|
+
if (s.includes("outofstock") ||
|
|
13
|
+
s.includes("out_of_stock") ||
|
|
14
|
+
s.includes("sold out")) {
|
|
15
|
+
return "out_of_stock";
|
|
16
|
+
}
|
|
17
|
+
if (s.includes("preorder") ||
|
|
18
|
+
s.includes("pre-order") ||
|
|
19
|
+
s.includes("backorder")) {
|
|
20
|
+
return "preorder";
|
|
21
|
+
}
|
|
22
|
+
return "unknown";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extract structured ProductOffer items from Bing Shopping HTML (`CustomData` JSON + `<li class="br-item">` seller/rating DOM).
|
|
26
|
+
*/
|
|
27
|
+
export function parseBingShoppingHtml(html, countryCode) {
|
|
28
|
+
if (!html) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const sellerByOfferId = new Map();
|
|
32
|
+
const cardRegex = /<li[^>]+class=["'][^"']*\bbr-item\b[^"']*["'][^>]*data-offerId=["']([^"']+)["']([\s\S]*?)<\/li>/gi;
|
|
33
|
+
let cardMatch;
|
|
34
|
+
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
|
35
|
+
const offerId = cardMatch[1];
|
|
36
|
+
const body = cardMatch[2];
|
|
37
|
+
const sellerRaw = (body.match(/class=["'][^"']*\bbr-seller(?:\s|["'])[^>]*>([\s\S]*?)<\/div>/i) ||
|
|
38
|
+
body.match(/class=["']l2_seller_lgfb[^"']*["'][^>]*title=["']([^"']+)["']/i) ||
|
|
39
|
+
[])[1]
|
|
40
|
+
?.replace(/<[^>]+>/g, "")
|
|
41
|
+
.trim() || undefined;
|
|
42
|
+
const ratingStr = (body.match(/class=["']rating-num["'][^>]*>([\d.]+)<\/div>/i) || [])[1];
|
|
43
|
+
const rating = ratingStr ? Number.parseFloat(ratingStr) : undefined;
|
|
44
|
+
const freeShipping = /Free shipping/i.test(body);
|
|
45
|
+
sellerByOfferId.set(offerId, {
|
|
46
|
+
seller: sellerRaw,
|
|
47
|
+
rating: Number.isFinite(rating) ? rating : undefined,
|
|
48
|
+
freeShipping,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const offers = [];
|
|
52
|
+
const seenKey = new Set();
|
|
53
|
+
const customDataRegex = /"CustomData":"(\{\\u0022MediaUrl\\u0022:[\s\S]*?\})"/g;
|
|
54
|
+
let m;
|
|
55
|
+
while ((m = customDataRegex.exec(html)) !== null) {
|
|
56
|
+
try {
|
|
57
|
+
const unescaped = JSON.parse(`"${m[1]}"`);
|
|
58
|
+
const obj = JSON.parse(unescaped);
|
|
59
|
+
if (!obj.PageUrl || !obj.ToolTip || !obj.Price) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const parsedPrice = parsePriceString(obj.Price, countryCode);
|
|
63
|
+
if (!parsedPrice) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const domain = extractDomainHost(obj.PageUrl);
|
|
67
|
+
if (!domain) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const extra = sellerByOfferId.get(String(obj.GlobalOfferId || "")) || {};
|
|
71
|
+
const vendor = extra.seller || domain;
|
|
72
|
+
const dedupeKey = `${domain}::${parsedPrice.price}::${obj.ToolTip.slice(0, 48).toLowerCase()}`;
|
|
73
|
+
if (seenKey.has(dedupeKey)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
seenKey.add(dedupeKey);
|
|
77
|
+
offers.push({
|
|
78
|
+
title: obj.ToolTip.trim(),
|
|
79
|
+
vendor,
|
|
80
|
+
domain,
|
|
81
|
+
url: obj.PageUrl,
|
|
82
|
+
price: parsedPrice.price,
|
|
83
|
+
rawPrice: parsedPrice.rawPrice,
|
|
84
|
+
currency: parsedPrice.currency,
|
|
85
|
+
availability: "in_stock",
|
|
86
|
+
...(extra.rating
|
|
87
|
+
? { rating: extra.rating }
|
|
88
|
+
: obj.Stars && obj.Stars > 0
|
|
89
|
+
? { rating: obj.Stars }
|
|
90
|
+
: {}),
|
|
91
|
+
...(extra.freeShipping
|
|
92
|
+
? { freeShipping: true, shippingNote: "Free shipping" }
|
|
93
|
+
: {}),
|
|
94
|
+
...(obj.MediaUrl ? { imageUrl: obj.MediaUrl } : {}),
|
|
95
|
+
source: "bing-shopping",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Ignore malformed CustomData block
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return offers;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract structured ProductOffer items from arbitrary retailer or price-aggregator HTML
|
|
106
|
+
* using Schema.org JSON-LD (`Product`, `Offer`, `AggregateOffer`), OpenGraph `product:price:*`, and microdata.
|
|
107
|
+
*/
|
|
108
|
+
export function extractOffersFromHtml(html, pageUrl, fallbackTitle, countryCode) {
|
|
109
|
+
if (!html || !pageUrl) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const domain = extractDomainHost(pageUrl);
|
|
113
|
+
if (!domain) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const offers = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const pushOffer = (candidate) => {
|
|
119
|
+
const d = candidate.domain || extractDomainHost(candidate.url) || domain;
|
|
120
|
+
const key = `${d}::${candidate.price}::${candidate.url}`;
|
|
121
|
+
if (seen.has(key)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
seen.add(key);
|
|
125
|
+
offers.push({ ...candidate, domain: d });
|
|
126
|
+
};
|
|
127
|
+
// 1. Schema.org JSON-LD (<script type="application/ld+json">)
|
|
128
|
+
const ldRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
129
|
+
let ldMatch;
|
|
130
|
+
while ((ldMatch = ldRegex.exec(html)) !== null) {
|
|
131
|
+
const rawJson = ldMatch[1].trim();
|
|
132
|
+
if (!rawJson) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(rawJson);
|
|
137
|
+
const visit = (node, inheritedTitle, inheritedRating) => {
|
|
138
|
+
if (!node || typeof node !== "object") {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(node)) {
|
|
142
|
+
for (const item of node) {
|
|
143
|
+
visit(item, inheritedTitle, inheritedRating);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const rec = node;
|
|
148
|
+
if (Array.isArray(rec["@graph"])) {
|
|
149
|
+
visit(rec["@graph"], inheritedTitle, inheritedRating);
|
|
150
|
+
}
|
|
151
|
+
const typeVal = String(rec["@type"] || "").toLowerCase();
|
|
152
|
+
const nodeName = typeof rec.name === "string" && rec.name.trim()
|
|
153
|
+
? rec.name.trim()
|
|
154
|
+
: inheritedTitle || fallbackTitle || domain;
|
|
155
|
+
const aggRatingObj = rec.aggregateRating;
|
|
156
|
+
const ratingVal = aggRatingObj &&
|
|
157
|
+
(typeof aggRatingObj.ratingValue === "number" ||
|
|
158
|
+
typeof aggRatingObj.ratingValue === "string")
|
|
159
|
+
? Number.parseFloat(String(aggRatingObj.ratingValue))
|
|
160
|
+
: inheritedRating;
|
|
161
|
+
if (typeVal.includes("product") && rec.offers) {
|
|
162
|
+
visit(rec.offers, nodeName, Number.isFinite(ratingVal) ? ratingVal : undefined);
|
|
163
|
+
}
|
|
164
|
+
else if (typeVal.includes("offer") ||
|
|
165
|
+
rec.price !== undefined ||
|
|
166
|
+
rec.lowPrice !== undefined) {
|
|
167
|
+
const rawPrice = (rec.price ?? rec.lowPrice);
|
|
168
|
+
const priceCurrency = typeof rec.priceCurrency === "string"
|
|
169
|
+
? rec.priceCurrency
|
|
170
|
+
: undefined;
|
|
171
|
+
const p = parsePriceString(rawPrice, countryCode, priceCurrency);
|
|
172
|
+
if (p) {
|
|
173
|
+
const offerUrl = typeof rec.url === "string" && rec.url.startsWith("http")
|
|
174
|
+
? rec.url
|
|
175
|
+
: pageUrl;
|
|
176
|
+
const sellerObj = rec.seller;
|
|
177
|
+
const sellerName = sellerObj &&
|
|
178
|
+
typeof sellerObj.name === "string" &&
|
|
179
|
+
sellerObj.name.trim()
|
|
180
|
+
? sellerObj.name.trim()
|
|
181
|
+
: extractDomainHost(offerUrl) || domain;
|
|
182
|
+
pushOffer({
|
|
183
|
+
title: nodeName,
|
|
184
|
+
vendor: sellerName,
|
|
185
|
+
url: offerUrl,
|
|
186
|
+
price: p.price,
|
|
187
|
+
rawPrice: p.rawPrice,
|
|
188
|
+
currency: p.currency,
|
|
189
|
+
availability: normalizeAvailability(typeof rec.availability === "string"
|
|
190
|
+
? rec.availability
|
|
191
|
+
: undefined),
|
|
192
|
+
...(Number.isFinite(ratingVal) ? { rating: ratingVal } : {}),
|
|
193
|
+
source: "json-ld",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(rec.offers)) {
|
|
197
|
+
visit(rec.offers, nodeName, ratingVal);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
visit(parsed, fallbackTitle);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// Ignore malformed JSON-LD script
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 2. OpenGraph / meta product price tags
|
|
208
|
+
if (offers.length === 0) {
|
|
209
|
+
const ogAmount = (html.match(/<meta[^>]+(?:property|name)=["'](?:product|og):price:amount["'][^>]+content=["']([^"']+)["']/i) ||
|
|
210
|
+
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:product|og):price:amount["']/i) ||
|
|
211
|
+
html.match(/itemprop=["']price["'][^>]+content=["']([^"']+)["']/i) ||
|
|
212
|
+
[])[1];
|
|
213
|
+
const ogCurrency = (html.match(/<meta[^>]+(?:property|name)=["'](?:product|og):price:currency["'][^>]+content=["']([^"']+)["']/i) ||
|
|
214
|
+
html.match(/itemprop=["']priceCurrency["'][^>]+content=["']([^"']+)["']/i) ||
|
|
215
|
+
[])[1];
|
|
216
|
+
if (ogAmount) {
|
|
217
|
+
const p = parsePriceString(ogAmount, countryCode, ogCurrency);
|
|
218
|
+
if (p) {
|
|
219
|
+
pushOffer({
|
|
220
|
+
title: fallbackTitle || domain,
|
|
221
|
+
vendor: domain,
|
|
222
|
+
url: pageUrl,
|
|
223
|
+
price: p.price,
|
|
224
|
+
rawPrice: p.rawPrice,
|
|
225
|
+
currency: p.currency,
|
|
226
|
+
source: "opengraph",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return offers;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Extract fallback price offer from a SERP result title/description when a snippet explicitly states a price
|
|
235
|
+
* (e.g. "Buy Sony WH-1000XM5 from £173.49", "Sony WH-1000XM5 - $399.00 at JB Hi-Fi").
|
|
236
|
+
*/
|
|
237
|
+
export function extractOfferFromSerpSnippet(title, description, url, countryCode) {
|
|
238
|
+
const combined = `${title} ${description}`;
|
|
239
|
+
const prefixMatch = combined.match(/(?:from\s+|price\s+|now\s+|sale\s+|ab\s+)?((?:A\$|AU\$|NZ\$|CA\$|US\$|S\$|SG\$|HK\$|NT\$|Rs\.?|INR|SGD|EUR|GBP|USD|VND|RM|Rp\.?|\$|£|€|¥|₹|₫|฿|₱|₩)\s?\d[\d,.]*(?:\s*(?:crores?|cr|lakhs?|lacs?|tỷ|triệu))?)/i);
|
|
240
|
+
const suffixMatch = !prefixMatch
|
|
241
|
+
? combined.match(/(\d[\d,.]*\s*(?:€|£|₫|₹|zł|kr|CHF|EUR|GBP|INR|SGD|VND|THB|MYR|IDR))\b/i)
|
|
242
|
+
: null;
|
|
243
|
+
const matchedRaw = prefixMatch?.[1] || suffixMatch?.[1];
|
|
244
|
+
if (!matchedRaw) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const parsed = parsePriceString(matchedRaw, countryCode);
|
|
248
|
+
if (!parsed) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const domain = extractDomainHost(url);
|
|
252
|
+
if (!domain) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
title: title.trim(),
|
|
257
|
+
vendor: domain,
|
|
258
|
+
domain,
|
|
259
|
+
url,
|
|
260
|
+
price: parsed.price,
|
|
261
|
+
rawPrice: parsed.rawPrice,
|
|
262
|
+
currency: parsed.currency,
|
|
263
|
+
source: "serp-snippet",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function normalizeProductNameTokens(title) {
|
|
267
|
+
const stop = new Set([
|
|
268
|
+
"the",
|
|
269
|
+
"and",
|
|
270
|
+
"with",
|
|
271
|
+
"for",
|
|
272
|
+
"from",
|
|
273
|
+
"buy",
|
|
274
|
+
"new",
|
|
275
|
+
"brand",
|
|
276
|
+
"sealed",
|
|
277
|
+
"official",
|
|
278
|
+
"free",
|
|
279
|
+
"shipping",
|
|
280
|
+
"delivery",
|
|
281
|
+
"sale",
|
|
282
|
+
"deal",
|
|
283
|
+
"deals",
|
|
284
|
+
"price",
|
|
285
|
+
"prices",
|
|
286
|
+
"online",
|
|
287
|
+
"australia",
|
|
288
|
+
"uk",
|
|
289
|
+
"usa",
|
|
290
|
+
]);
|
|
291
|
+
return title
|
|
292
|
+
.toLowerCase()
|
|
293
|
+
.replace(/[^a-z0-9+]+/g, " ")
|
|
294
|
+
.split(/\s+/)
|
|
295
|
+
.filter((t) => t.length >= 2 && !stop.has(t));
|
|
296
|
+
}
|
|
297
|
+
function tokenJaccard(a, b) {
|
|
298
|
+
if (a.length === 0 || b.length === 0) {
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
const setA = new Set(a);
|
|
302
|
+
const setB = new Set(b);
|
|
303
|
+
let inter = 0;
|
|
304
|
+
for (const t of setA) {
|
|
305
|
+
if (setB.has(t)) {
|
|
306
|
+
inter++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const union = setA.size + setB.size - inter;
|
|
310
|
+
return union > 0 ? inter / union : 0;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Group extracted ProductOffer items into normalized AggregatedProduct clusters and compute `bestOffer` (lowest in-stock price).
|
|
314
|
+
*/
|
|
315
|
+
export function groupOffersIntoProducts(offers, query, maxProducts = 10) {
|
|
316
|
+
if (offers.length === 0) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const queryTokens = normalizeProductNameTokens(query);
|
|
320
|
+
// Deduplicate identical (domain, price, url) offers
|
|
321
|
+
const uniqueOffers = [];
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
for (const o of offers) {
|
|
324
|
+
const key = `${o.domain}::${o.price}::${o.url}`;
|
|
325
|
+
if (seen.has(key)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
seen.add(key);
|
|
329
|
+
uniqueOffers.push(o);
|
|
330
|
+
}
|
|
331
|
+
const clusters = [];
|
|
332
|
+
for (const offer of uniqueOffers) {
|
|
333
|
+
const tokens = normalizeProductNameTokens(offer.title);
|
|
334
|
+
let bestCluster = null;
|
|
335
|
+
let bestSim = 0;
|
|
336
|
+
for (const c of clusters) {
|
|
337
|
+
const sim = tokenJaccard(tokens, c.tokens);
|
|
338
|
+
if (sim > bestSim) {
|
|
339
|
+
bestSim = sim;
|
|
340
|
+
bestCluster = c;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (bestCluster && bestSim >= 0.42) {
|
|
344
|
+
bestCluster.offers.push(offer);
|
|
345
|
+
if (offer.title.length < bestCluster.name.length &&
|
|
346
|
+
offer.title.length >= 12) {
|
|
347
|
+
bestCluster.name = offer.title;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
clusters.push({
|
|
352
|
+
name: offer.title,
|
|
353
|
+
tokens,
|
|
354
|
+
offers: [offer],
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const products = clusters.map((c) => {
|
|
359
|
+
const sorted = [...c.offers].sort((a, b) => {
|
|
360
|
+
const aOut = a.availability === "out_of_stock" ? 1 : 0;
|
|
361
|
+
const bOut = b.availability === "out_of_stock" ? 1 : 0;
|
|
362
|
+
if (aOut !== bOut) {
|
|
363
|
+
return aOut - bOut;
|
|
364
|
+
}
|
|
365
|
+
return a.price - b.price;
|
|
366
|
+
});
|
|
367
|
+
const bestOffer = sorted[0];
|
|
368
|
+
const prices = sorted.map((o) => o.price);
|
|
369
|
+
const vendors = Array.from(new Set(sorted.map((o) => o.vendor)));
|
|
370
|
+
return {
|
|
371
|
+
name: c.name,
|
|
372
|
+
bestOffer,
|
|
373
|
+
minPrice: Math.min(...prices),
|
|
374
|
+
maxPrice: Math.max(...prices),
|
|
375
|
+
currency: bestOffer.currency,
|
|
376
|
+
offerCount: sorted.length,
|
|
377
|
+
vendors,
|
|
378
|
+
offers: sorted,
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
// Sort clusters by relevance to query tokens + multi-vendor offer count + lowest price
|
|
382
|
+
products.sort((a, b) => {
|
|
383
|
+
const relA = tokenJaccard(normalizeProductNameTokens(a.name), queryTokens);
|
|
384
|
+
const relB = tokenJaccard(normalizeProductNameTokens(b.name), queryTokens);
|
|
385
|
+
const scoreA = relA * 2 + Math.min(a.offerCount, 6) * 0.25;
|
|
386
|
+
const scoreB = relB * 2 + Math.min(b.offerCount, 6) * 0.25;
|
|
387
|
+
if (Math.abs(scoreB - scoreA) > 0.15) {
|
|
388
|
+
return scoreB - scoreA;
|
|
389
|
+
}
|
|
390
|
+
return a.minPrice - b.minPrice;
|
|
391
|
+
});
|
|
392
|
+
return products.slice(0, maxProducts);
|
|
393
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe helper (no Node imports): turn an external image URL into our
|
|
3
|
+
* same-origin `/img` proxy URL. See `image-proxy.ts` for the server side.
|
|
4
|
+
*/
|
|
5
|
+
export function proxiedImage(src) {
|
|
6
|
+
if (!src) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
if (src.startsWith("/") && !src.startsWith("//")) {
|
|
10
|
+
return src; // already local
|
|
11
|
+
}
|
|
12
|
+
return `/img?url=${encodeURIComponent(src)}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CDP_PORT, type StepResultLine } from "./vm.js";
|
|
2
|
+
import { type RemoteProvider, type RemoteSessionCost } from "./remote.js";
|
|
3
|
+
/** Where STEP_SCRIPT runs when the browser itself is hosted by a remote provider. */
|
|
4
|
+
export type StepRunner = {
|
|
5
|
+
kind: "sandbox";
|
|
6
|
+
sandboxId: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "local";
|
|
9
|
+
scriptPath: string;
|
|
10
|
+
playwright: string;
|
|
11
|
+
};
|
|
12
|
+
export type BrowserHandle = {
|
|
13
|
+
kind: "sandbox";
|
|
14
|
+
sandboxId: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "local";
|
|
17
|
+
cdp: string;
|
|
18
|
+
pid: number;
|
|
19
|
+
scriptPath: string;
|
|
20
|
+
playwright: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "remote";
|
|
23
|
+
provider: RemoteProvider;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
cdpUrl: string;
|
|
26
|
+
/** Headers the CDP websocket needs (e.g. Cloudflare bearer auth). */
|
|
27
|
+
cdpHeaders?: Record<string, string>;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
runner: StepRunner;
|
|
30
|
+
};
|
|
31
|
+
export type StepAction = Record<string, unknown> & {
|
|
32
|
+
type: string;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* True when some browser backend can run. Checked before starting a run so we never fake success.
|
|
36
|
+
* On Vercel (VERCEL=1) the OIDC token is not in env at request time: it arrives in the
|
|
37
|
+
* `x-vercel-oidc-token` request header, which @vercel/sandbox reads via @vercel/oidc.
|
|
38
|
+
*/
|
|
39
|
+
export declare function interactBrowserAvailable(): boolean;
|
|
40
|
+
export declare function interactBackend(): "local" | "sandbox" | "none";
|
|
41
|
+
export declare function startBrowser(opts?: {
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
userAgent?: string;
|
|
44
|
+
remote?: RemoteProvider | null;
|
|
45
|
+
}): Promise<BrowserHandle>;
|
|
46
|
+
export declare function runBrowserStep(handle: BrowserHandle, action: StepAction): Promise<StepResultLine>;
|
|
47
|
+
/** Stops the browser. For a remote provider, returns what the session cost (null when unknown). */
|
|
48
|
+
export declare function stopBrowser(handle: BrowserHandle): Promise<RemoteSessionCost | null>;
|
|
49
|
+
export { CDP_PORT };
|