@webcrawl/mcp 1.4.2 → 1.4.4
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-cdp.d.ts +22 -0
- package/dist/core/engine/chrome-cdp.js +318 -0
- package/dist/core/engine/chrome-launch.d.ts +38 -0
- package/dist/core/engine/chrome-launch.js +214 -0
- package/dist/core/engine/chrome.d.ts +28 -0
- package/dist/core/engine/chrome.js +208 -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 +33 -0
- package/dist/core/extract/structured.js +316 -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 +62 -0
- package/dist/core/local-engine.js +726 -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 +721 -0
- package/dist/core/search/providers.d.ts +84 -0
- package/dist/core/search/providers.js +1408 -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 +6 -0
- package/package.json +11 -3
|
@@ -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,33 @@
|
|
|
1
|
+
export interface QuoteAuthor {
|
|
2
|
+
text: string;
|
|
3
|
+
author: string;
|
|
4
|
+
}
|
|
5
|
+
/** A bare property map or shorthand array becomes a JSON Schema. */
|
|
6
|
+
export declare function coerceExtractSchema(node: unknown): Record<string, unknown>;
|
|
7
|
+
/**
|
|
8
|
+
* Root-array wrap: a top-level array becomes
|
|
9
|
+
* `{ type: object, properties: { items: <array> } }`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeExtractSchema(schema?: Record<string, unknown>): Record<string, unknown> | undefined;
|
|
12
|
+
export declare function schemaProperties(schema?: Record<string, unknown>): Record<string, unknown>;
|
|
13
|
+
export declare function hasCustomExtractRequest(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
14
|
+
/** Nested object/array field names that look like quote+author records. */
|
|
15
|
+
export declare function nestedQuoteAuthorFields(schema?: Record<string, unknown>): {
|
|
16
|
+
arrayPath: string[];
|
|
17
|
+
textKey: string;
|
|
18
|
+
authorKey: string;
|
|
19
|
+
} | null;
|
|
20
|
+
export declare function wantsQuoteAuthorExtract(prompt?: string, schema?: Record<string, unknown>): boolean;
|
|
21
|
+
/** Strip script/style/template (including unclosed tags) before reading visible DOM. */
|
|
22
|
+
export declare function stripNonContentHtml(html: string): string;
|
|
23
|
+
/** Inline `document.write("... " + d['text'] + ...)` leftovers, not real quotes. */
|
|
24
|
+
export declare function isJsTemplateJunk(text: string): boolean;
|
|
25
|
+
export declare function isEmptyExtractResult(data: Record<string, unknown> | null | undefined): boolean;
|
|
26
|
+
/** Pull quote/author pairs from HTML (cards, microdata, `var data = [...]`) and markdown. */
|
|
27
|
+
export declare function extractQuoteAuthorPairs(html: string, markdown?: string): QuoteAuthor[];
|
|
28
|
+
export declare function shapeQuoteAuthors(pairs: QuoteAuthor[], schema?: Record<string, unknown>): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic extract when the caller passed a prompt and/or JSON schema.
|
|
31
|
+
* Returns null when this page/request has nothing this extractor can fill.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractByPromptAndSchema(html: string, markdown: string, prompt?: string, schema?: Record<string, unknown>): Record<string, unknown> | null;
|