@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,473 @@
|
|
|
1
|
+
import { extractDomainHost, parsePriceString, resolveCurrencyCode, } from "./currency.js";
|
|
2
|
+
import { normalizeIdentity, resultDedupeKey } from "./dedupe-key.js";
|
|
3
|
+
import { ldImage, resolveAbsoluteUrl } from "./real-estate.js";
|
|
4
|
+
const HOTEL_TYPES = new Set([
|
|
5
|
+
"hotel",
|
|
6
|
+
"lodgingbusiness",
|
|
7
|
+
"resort",
|
|
8
|
+
"hostel",
|
|
9
|
+
"motel",
|
|
10
|
+
"bedandbreakfast",
|
|
11
|
+
"vacationrental",
|
|
12
|
+
"hotelroom",
|
|
13
|
+
"suite",
|
|
14
|
+
"accommodation",
|
|
15
|
+
]);
|
|
16
|
+
export function parseHotelPrice(rawPrice, countryCode, explicitCurrency) {
|
|
17
|
+
const defaultCurrency = resolveCurrencyCode(explicitCurrency, countryCode);
|
|
18
|
+
if (typeof rawPrice === "number" &&
|
|
19
|
+
Number.isFinite(rawPrice) &&
|
|
20
|
+
rawPrice >= 15 &&
|
|
21
|
+
rawPrice <= 25_000) {
|
|
22
|
+
const rounded = Math.round(rawPrice);
|
|
23
|
+
return {
|
|
24
|
+
pricePerNight: rounded,
|
|
25
|
+
priceText: `${defaultCurrency} $${rounded.toLocaleString("en-US")}/night`,
|
|
26
|
+
currency: defaultCurrency,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const text = String(rawPrice || "").trim();
|
|
30
|
+
if (!text) {
|
|
31
|
+
return { priceText: "Check Availability", currency: defaultCurrency };
|
|
32
|
+
}
|
|
33
|
+
const parsed = parsePriceString(text, countryCode, explicitCurrency, 25_000);
|
|
34
|
+
if (parsed && parsed.price >= 15) {
|
|
35
|
+
const rounded = Math.round(parsed.price);
|
|
36
|
+
return {
|
|
37
|
+
pricePerNight: rounded,
|
|
38
|
+
priceText: `${parsed.currency} $${rounded.toLocaleString("en-US")}/night`,
|
|
39
|
+
currency: parsed.currency,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { priceText: text, currency: defaultCurrency };
|
|
43
|
+
}
|
|
44
|
+
function extractAddressFields(addr) {
|
|
45
|
+
if (!addr) {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
if (typeof addr === "string") {
|
|
49
|
+
return { address: addr.trim() };
|
|
50
|
+
}
|
|
51
|
+
if (typeof addr === "object") {
|
|
52
|
+
const a = addr;
|
|
53
|
+
const street = typeof a.streetAddress === "string" ? a.streetAddress.trim() : "";
|
|
54
|
+
const city = typeof a.addressLocality === "string"
|
|
55
|
+
? a.addressLocality.trim()
|
|
56
|
+
: typeof a.city === "string"
|
|
57
|
+
? a.city.trim()
|
|
58
|
+
: "";
|
|
59
|
+
const region = typeof a.addressRegion === "string" ? a.addressRegion.trim() : "";
|
|
60
|
+
const country = typeof a.addressCountry === "string"
|
|
61
|
+
? a.addressCountry.trim()
|
|
62
|
+
: typeof a.addressCountry
|
|
63
|
+
?.name === "string"
|
|
64
|
+
? String(a.addressCountry.name).trim()
|
|
65
|
+
: undefined;
|
|
66
|
+
const parts = [street, city, region].filter(Boolean);
|
|
67
|
+
return {
|
|
68
|
+
address: parts.length > 0 ? parts.join(", ") : undefined,
|
|
69
|
+
city: city || undefined,
|
|
70
|
+
country,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
function walkHotelJsonNode(node, pageUrl, portal, countryCode, extractedVia, out, depth = 0) {
|
|
76
|
+
if (depth > 12 || out.length >= 45 || !node || typeof node !== "object") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(node)) {
|
|
80
|
+
for (const item of node) {
|
|
81
|
+
walkHotelJsonNode(item, pageUrl, portal, countryCode, extractedVia, out, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const obj = node;
|
|
86
|
+
const rawType = String(obj["@type"] || obj.type || "").toLowerCase();
|
|
87
|
+
const name = typeof obj.name === "string"
|
|
88
|
+
? obj.name.trim()
|
|
89
|
+
: typeof obj.hotelName === "string"
|
|
90
|
+
? obj.hotelName.trim()
|
|
91
|
+
: typeof obj.propertyName === "string"
|
|
92
|
+
? obj.propertyName.trim()
|
|
93
|
+
: "";
|
|
94
|
+
const offersObj = obj.offers;
|
|
95
|
+
const rawPriceVal = obj.pricePerNight ??
|
|
96
|
+
obj.nightlyPrice ??
|
|
97
|
+
obj.minPrice ??
|
|
98
|
+
obj.lowPrice ??
|
|
99
|
+
obj.price ??
|
|
100
|
+
offersObj?.price ??
|
|
101
|
+
offersObj?.lowPrice ??
|
|
102
|
+
obj.priceRange;
|
|
103
|
+
const rawCurrency = (typeof obj.priceCurrency === "string" ? obj.priceCurrency : undefined) ||
|
|
104
|
+
(typeof obj.currency === "string" ? obj.currency : undefined) ||
|
|
105
|
+
(typeof offersObj?.priceCurrency === "string"
|
|
106
|
+
? offersObj.priceCurrency
|
|
107
|
+
: undefined);
|
|
108
|
+
const starRatingObj = obj.starRating;
|
|
109
|
+
const aggRatingObj = obj.aggregateRating;
|
|
110
|
+
const starRatingRaw = starRatingObj?.ratingValue ?? obj.starRating ?? obj.stars ?? obj.hotelClass;
|
|
111
|
+
const starRatingNum = typeof starRatingRaw === "number" || typeof starRatingRaw === "string"
|
|
112
|
+
? Number.parseFloat(String(starRatingRaw))
|
|
113
|
+
: undefined;
|
|
114
|
+
const guestRatingRaw = aggRatingObj?.ratingValue ??
|
|
115
|
+
obj.guestRating ??
|
|
116
|
+
obj.reviewScore ??
|
|
117
|
+
obj.rating;
|
|
118
|
+
const guestRatingNum = typeof guestRatingRaw === "number" || typeof guestRatingRaw === "string"
|
|
119
|
+
? Number.parseFloat(String(guestRatingRaw))
|
|
120
|
+
: undefined;
|
|
121
|
+
const reviewCountRaw = aggRatingObj?.reviewCount ??
|
|
122
|
+
aggRatingObj?.ratingCount ??
|
|
123
|
+
obj.reviewCount ??
|
|
124
|
+
obj.numberOfReviews;
|
|
125
|
+
const reviewCountNum = typeof reviewCountRaw === "number" || typeof reviewCountRaw === "string"
|
|
126
|
+
? Number.parseInt(String(reviewCountRaw).replace(/,/g, ""), 10)
|
|
127
|
+
: undefined;
|
|
128
|
+
const isSiteOrArticleNode = /^(website|webpage|organization|corporation|article|newsarticle|blogposting|breadcrumblist|faqpage|searchaction)$/i.test(rawType);
|
|
129
|
+
const isHotel = !isSiteOrArticleNode &&
|
|
130
|
+
(HOTEL_TYPES.has(rawType) ||
|
|
131
|
+
(name.length >= 4 &&
|
|
132
|
+
name.length <= 120 &&
|
|
133
|
+
!/^(luxury\s+hotels?|best\s+hotels?|hotels?\s+in\b|cheap\s+hotels?)/i.test(name) &&
|
|
134
|
+
(/\b(hotel|resort|suites?|inn|lodge|motel|hostel|palace|hyatt|marriott|hilton|sheraton|intercontinental|novotel|sofitel|ibis|pullman|westin|radisson|ritz|four\s+seasons|holiday\s+inn|crowne\s+plaza|mercure|shangri-la|capella|langham)\b/i.test(name) ||
|
|
135
|
+
Boolean(obj.starRating !== undefined ||
|
|
136
|
+
obj.pricePerNight !== undefined ||
|
|
137
|
+
obj.nightlyPrice !== undefined ||
|
|
138
|
+
obj.hotelId !== undefined)) &&
|
|
139
|
+
(rawPriceVal !== undefined ||
|
|
140
|
+
guestRatingNum !== undefined ||
|
|
141
|
+
starRatingNum !== undefined ||
|
|
142
|
+
obj.address !== undefined)));
|
|
143
|
+
if (isHotel && name) {
|
|
144
|
+
const parsedPrice = parseHotelPrice(rawPriceVal, countryCode, rawCurrency);
|
|
145
|
+
const addrInfo = extractAddressFields(obj.address || obj.location);
|
|
146
|
+
const geoObj = obj.geo;
|
|
147
|
+
const lat = typeof geoObj?.latitude === "number"
|
|
148
|
+
? geoObj.latitude
|
|
149
|
+
: typeof obj.latitude === "number"
|
|
150
|
+
? obj.latitude
|
|
151
|
+
: undefined;
|
|
152
|
+
const lng = typeof geoObj?.longitude === "number"
|
|
153
|
+
? geoObj.longitude
|
|
154
|
+
: typeof obj.longitude === "number"
|
|
155
|
+
? obj.longitude
|
|
156
|
+
: undefined;
|
|
157
|
+
let amenities;
|
|
158
|
+
if (Array.isArray(obj.amenityFeature)) {
|
|
159
|
+
amenities = obj.amenityFeature
|
|
160
|
+
.map((a) => (typeof a?.name === "string" ? a.name.trim() : ""))
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.slice(0, 8);
|
|
163
|
+
}
|
|
164
|
+
else if (Array.isArray(obj.amenities)) {
|
|
165
|
+
amenities = obj.amenities
|
|
166
|
+
.map((a) => (typeof a === "string" ? a.trim() : ""))
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.slice(0, 8);
|
|
169
|
+
}
|
|
170
|
+
const itemUrl = typeof obj.url === "string"
|
|
171
|
+
? resolveAbsoluteUrl(obj.url, pageUrl)
|
|
172
|
+
: pageUrl;
|
|
173
|
+
// string | string[] | ImageObject | ImageObject[] (hotel sites use all four)
|
|
174
|
+
const rawImage = ldImage(obj.image ?? obj.photo);
|
|
175
|
+
const image = rawImage ? resolveAbsoluteUrl(rawImage, pageUrl) : undefined;
|
|
176
|
+
out.push({
|
|
177
|
+
name,
|
|
178
|
+
address: addrInfo.address,
|
|
179
|
+
city: addrInfo.city,
|
|
180
|
+
country: addrInfo.country || countryCode,
|
|
181
|
+
pricePerNight: parsedPrice.pricePerNight,
|
|
182
|
+
priceText: parsedPrice.priceText,
|
|
183
|
+
currency: parsedPrice.currency,
|
|
184
|
+
starRating: starRatingNum && starRatingNum >= 1 && starRatingNum <= 5
|
|
185
|
+
? starRatingNum
|
|
186
|
+
: undefined,
|
|
187
|
+
guestRating: guestRatingNum && guestRatingNum > 0 && guestRatingNum <= 10
|
|
188
|
+
? Number(guestRatingNum.toFixed(1))
|
|
189
|
+
: undefined,
|
|
190
|
+
reviewCount: reviewCountNum && Number.isFinite(reviewCountNum) && reviewCountNum > 0
|
|
191
|
+
? reviewCountNum
|
|
192
|
+
: undefined,
|
|
193
|
+
amenities: amenities?.length ? amenities : undefined,
|
|
194
|
+
image,
|
|
195
|
+
latitude: lat,
|
|
196
|
+
longitude: lng,
|
|
197
|
+
url: /undefined|null/i.test(safeHost(itemUrl)) ? pageUrl : itemUrl,
|
|
198
|
+
provider: /undefined|null/i.test(safeHost(itemUrl))
|
|
199
|
+
? portal
|
|
200
|
+
: extractDomainHost(itemUrl) || portal,
|
|
201
|
+
extractedVia,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
for (const val of Object.values(obj)) {
|
|
205
|
+
if (val && typeof val === "object") {
|
|
206
|
+
walkHotelJsonNode(val, pageUrl, portal, countryCode, extractedVia, out, depth + 1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function extractHotelListingsFromHtml(html, pageUrl, countryCode) {
|
|
211
|
+
const out = [];
|
|
212
|
+
const portal = extractDomainHost(pageUrl) || "unknown";
|
|
213
|
+
// Tier 1: Schema.org JSON-LD
|
|
214
|
+
const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
215
|
+
let match;
|
|
216
|
+
while ((match = jsonLdRegex.exec(html)) !== null) {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(match[1].trim());
|
|
219
|
+
walkHotelJsonNode(parsed, pageUrl, portal, countryCode, "json-ld", out);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Ignore malformed JSON-LD
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Tier 2: Embedded JSON state (__NEXT_DATA__, application/json)
|
|
226
|
+
if (out.length < 3) {
|
|
227
|
+
const stateRegex = /<script[^>]+(?:id=["']__NEXT_DATA__["']|type=["']application\/json["'])[^>]*>([\s\S]*?)<\/script>/gi;
|
|
228
|
+
while ((match = stateRegex.exec(html)) !== null) {
|
|
229
|
+
try {
|
|
230
|
+
const raw = match[1].trim();
|
|
231
|
+
if (raw.length > 20 && raw.length < 2_000_000) {
|
|
232
|
+
const parsed = JSON.parse(raw);
|
|
233
|
+
walkHotelJsonNode(parsed, pageUrl, portal, countryCode, "json-state", out);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Ignore
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Tier 2.2: OTA result cards (Booking.com district / search pages ship no JSON-LD)
|
|
242
|
+
if (out.length < 3 && html.includes('data-testid="title"')) {
|
|
243
|
+
out.push(...extractBookingCards(html, pageUrl, portal, countryCode));
|
|
244
|
+
}
|
|
245
|
+
// Tier 2.5: Extract structured hotel cards / headings from HTML when JSON-LD & JSON state have < 3 hotels
|
|
246
|
+
if (out.length < 3) {
|
|
247
|
+
const headingRegex = /<h[23][^>]*>([\s\S]{3,160}?)<\/h[23]>([\s\S]{0,600}?)(?=<h[23]|\bfooter\b|$)/gi;
|
|
248
|
+
let hMatch;
|
|
249
|
+
while ((hMatch = headingRegex.exec(html)) !== null && out.length < 25) {
|
|
250
|
+
const rawHeading = hMatch[1]
|
|
251
|
+
.replace(/<[^>]+>/g, " ")
|
|
252
|
+
.replace(/&/g, "&")
|
|
253
|
+
.replace(/'|'/g, "'")
|
|
254
|
+
.replace(/"/g, '"')
|
|
255
|
+
.replace(/^\s*(?:#?\d+\s*[.)\-:]?\s+)/, "")
|
|
256
|
+
.replace(/\s+/g, " ")
|
|
257
|
+
.trim();
|
|
258
|
+
if (!rawHeading ||
|
|
259
|
+
rawHeading.length < 4 ||
|
|
260
|
+
rawHeading.length > 90 ||
|
|
261
|
+
/\b(best\s+hotels?|where\s+to\s+stay|frequently\s+asked|faq|about\s+sydney|things\s+to\s+do|how\s+we\s+choose|more\s+hotels?|related|search|filter|map|subscribe|newsletter|luxury\s+hotels?\s+in|widget|advertisement|sponsored)\b/i.test(rawHeading)) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const isNamedHotel = /\b(hotel|resort|suites?|inn|lodge|palace|hyatt|marriott|hilton|sheraton|intercontinental|novotel|sofitel|ibis|pullman|westin|radisson|ritz|four\s+seasons|crowne\s+plaza|mercure|shangri-la|langham|capella|crown\s+towers|kimpton|oxfords?|pier\s+one|qt\s+\w+|w\s+\w+|ovolos?|ace\s+hotel|paramount\s+house|taronga)\b/i.test(rawHeading);
|
|
265
|
+
if (!isNamedHotel) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const snippet = hMatch[2]
|
|
269
|
+
.replace(/<[^>]+>/g, " ")
|
|
270
|
+
.replace(/\s+/g, " ")
|
|
271
|
+
.trim();
|
|
272
|
+
const explicitPrice = snippet.match(/(?:A\$|AU\$|US\$|CA\$|NZ\$|S\$|HK\$|\$|£|€|¥|₹|\b(?:USD|AUD|GBP|EUR|CAD|NZD|SGD)\b)\s*(\d[\d,]*(?:\.\d{1,2})?)/i);
|
|
273
|
+
const parsedPrice = parseHotelPrice(explicitPrice ? explicitPrice[0] : undefined, countryCode);
|
|
274
|
+
const starMatch = `${rawHeading} ${snippet}`.match(/\b([1-5])(?:\.\d)?[- ]star\b/i);
|
|
275
|
+
const ratingMatch = snippet.match(/\b([1-9]\.\d)\s*\/\s*(?:5|10)\b/);
|
|
276
|
+
out.push({
|
|
277
|
+
name: rawHeading,
|
|
278
|
+
country: countryCode,
|
|
279
|
+
pricePerNight: parsedPrice.pricePerNight,
|
|
280
|
+
priceText: parsedPrice.priceText,
|
|
281
|
+
currency: parsedPrice.currency,
|
|
282
|
+
starRating: starMatch ? Number.parseInt(starMatch[1], 10) : undefined,
|
|
283
|
+
guestRating: ratingMatch
|
|
284
|
+
? Number.parseFloat(ratingMatch[1])
|
|
285
|
+
: undefined,
|
|
286
|
+
url: pageUrl,
|
|
287
|
+
provider: portal,
|
|
288
|
+
extractedVia: "json-state",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return dedupeHotelListings(out);
|
|
293
|
+
}
|
|
294
|
+
function safeHost(url) {
|
|
295
|
+
try {
|
|
296
|
+
return new URL(url).hostname;
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return "";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const decodeEntities = (s) => s
|
|
303
|
+
.replace(/&/g, "&")
|
|
304
|
+
.replace(/'|'/g, "'")
|
|
305
|
+
.replace(/"/g, '"')
|
|
306
|
+
.replace(/</g, "<")
|
|
307
|
+
.replace(/>/g, ">")
|
|
308
|
+
.replace(/ | /g, " ");
|
|
309
|
+
const stripTags = (s) => decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
310
|
+
/** Drop affiliate / session params so the same hotel keeps one stable URL. */
|
|
311
|
+
function cleanOtaUrl(href, pageUrl) {
|
|
312
|
+
const abs = resolveAbsoluteUrl(decodeEntities(href), pageUrl);
|
|
313
|
+
try {
|
|
314
|
+
const u = new URL(abs);
|
|
315
|
+
return `${u.origin}${u.pathname}`;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return abs;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Booking.com property cards. District pages use `data-testid="card"`, search
|
|
323
|
+
* results use `data-testid="property-card"`; both carry `data-testid="title"`,
|
|
324
|
+
* a `titleLink`, an `image`, a star aria-label and a "guest rating" line.
|
|
325
|
+
*/
|
|
326
|
+
export function extractBookingCards(html, pageUrl, portal, countryCode) {
|
|
327
|
+
const out = [];
|
|
328
|
+
const parts = html.split(/<div[^>]+data-testid="(?:property-)?card"/i).slice(1);
|
|
329
|
+
for (const card of parts.slice(0, 30)) {
|
|
330
|
+
const titleBlock = card.match(/data-testid="title"[^>]*>([\s\S]{0,1200}?)<\/(?:h3|div)>/i);
|
|
331
|
+
if (!titleBlock) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
// Name is the first text node inside the title block (stars follow as an empty div).
|
|
335
|
+
const firstText = titleBlock[1].match(/>\s*([^<>]{2,120}?)\s*</);
|
|
336
|
+
const name = stripTags(firstText ? firstText[1] : titleBlock[1]);
|
|
337
|
+
if (!name || name.length < 2 || name.length > 120) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const href = card.match(/data-testid="titleLink"[^>]*href="([^"]+)"/i)?.[1] ??
|
|
341
|
+
card.match(/href="([^"]+)"[^>]*data-testid="titleLink"/i)?.[1] ??
|
|
342
|
+
card.match(/href="(https?:\/\/[^"]*\/hotel\/[^"]+)"/i)?.[1];
|
|
343
|
+
const imgSrc = card.match(/data-testid="image"[^>]*src="([^"]+)"/i)?.[1] ??
|
|
344
|
+
card.match(/<img[^>]+src="([^"]+)"[^>]*data-testid="image"/i)?.[1];
|
|
345
|
+
const stars = card.match(/aria-label="([1-5]) out of 5 (?:stars|quality rating)"/i);
|
|
346
|
+
const scored = card.match(/guest rating\s*([0-9]+(?:\.[0-9])?)/i)
|
|
347
|
+
?? card.match(/data-testid="review-score"[\s\S]{0,300}?>\s*([0-9]{1,2}(?:\.[0-9])?)\s*</i);
|
|
348
|
+
const reviews = card.match(/([\d,.]+)\s+reviews?\b/i);
|
|
349
|
+
const location = card.match(/data-testid="address"[^>]*>([^<]{3,100})</i)?.[1] ??
|
|
350
|
+
card.match(/<\/svg><\/span>([^<]{3,100})<\/div>/i)?.[1];
|
|
351
|
+
const priceRaw = card.match(/data-testid="price-and-discounted-price"[^>]*>([^<]{2,40})</i)?.[1] ??
|
|
352
|
+
card.match(/Price from[\s\S]{0,400}?<span[^>]*>([^<]{2,40})<\/span>/i)?.[1];
|
|
353
|
+
const parsedPrice = parseHotelPrice(priceRaw ? decodeEntities(priceRaw).trim() : undefined, countryCode);
|
|
354
|
+
const guestRating = scored ? Number.parseFloat(scored[1]) : undefined;
|
|
355
|
+
out.push({
|
|
356
|
+
name: decodeEntities(name),
|
|
357
|
+
address: location ? stripTags(location) : undefined,
|
|
358
|
+
country: countryCode,
|
|
359
|
+
pricePerNight: parsedPrice.pricePerNight,
|
|
360
|
+
priceText: parsedPrice.priceText,
|
|
361
|
+
currency: parsedPrice.currency,
|
|
362
|
+
starRating: stars ? Number.parseInt(stars[1], 10) : undefined,
|
|
363
|
+
guestRating: guestRating !== undefined && guestRating > 0 && guestRating <= 10
|
|
364
|
+
? guestRating
|
|
365
|
+
: undefined,
|
|
366
|
+
reviewCount: reviews
|
|
367
|
+
? Number.parseInt(reviews[1].replace(/[,.]/g, ""), 10) || undefined
|
|
368
|
+
: undefined,
|
|
369
|
+
image: imgSrc ? resolveAbsoluteUrl(decodeEntities(imgSrc), pageUrl) : undefined,
|
|
370
|
+
url: href ? cleanOtaUrl(href, pageUrl) : pageUrl,
|
|
371
|
+
provider: portal,
|
|
372
|
+
extractedVia: "html-cards",
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
export function extractHotelListingFromSerpResult(title, description, url, countryCode) {
|
|
378
|
+
const cleanTitle = title.replace(/\s*[|\-–\u2014•]\s*.*$/, "").trim();
|
|
379
|
+
if (!cleanTitle || cleanTitle.length < 3) {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
// Skip generic roundup / listicle / search hub titles so ScaleDown extracts the actual hotels inside the page
|
|
383
|
+
if (/\b(\d+\s+best|best\s+\d+|top\s+\d+|the\s+best\b|hotels?\s+in\b|luxury\s+hotels?\s+in\b|accommodation\s+in\b|where\s+to\s+stay|guide\s+to|compare\s+hotels?)/i.test(cleanTitle)) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
const combined = `${title} ${description}`;
|
|
387
|
+
const explicitPriceMatch = combined.match(/(?:A\$|AU\$|US\$|CA\$|NZ\$|S\$|SG\$|HK\$|Rs\.?|\$|£|€|¥|₹|₫|฿|₱|₩|\b(?:USD|AUD|GBP|EUR|CAD|NZD|SGD|INR|VND|THB|MYR|IDR|CHF|AED)\b)\s*(\d[\d.,]*)|(\d[\d.,]*)\s*(?:€|£|₫|₹|\b(?:EUR|GBP|INR|SGD|VND|CHF)\b)/i);
|
|
388
|
+
const priceParsed = explicitPriceMatch
|
|
389
|
+
? parsePriceString(explicitPriceMatch[0], countryCode, undefined, 15_000)
|
|
390
|
+
: null;
|
|
391
|
+
const starMatch = combined.match(/\b([1-5])(?:\.\d)?[- ]star\b/i);
|
|
392
|
+
const ratingMatch = combined.match(/\b([1-9]\.\d)\s*\/\s*(?:5|10)\b/) ||
|
|
393
|
+
combined.match(/\brating[:\s]+([1-9]\.\d)\b/i);
|
|
394
|
+
const reviewMatch = combined.match(/\b(\d[\d,]{1,6})\s+reviews?\b/i);
|
|
395
|
+
const looksLikeSpecificHotel = /\b(hotel|resort|suites?|inn|lodge|motel|hostel|palace|hyatt|marriott|hilton|sheraton|intercontinental|novotel|sofitel|ibis|pullman|westin|radisson|ritz|four\s+seasons|crowne\s+plaza|mercure|shangri-la|langham|capella|w\s+sydney|crown\s+towers)\b/i.test(cleanTitle);
|
|
396
|
+
if (!looksLikeSpecificHotel) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const parsedPrice = parseHotelPrice(priceParsed?.price, countryCode, priceParsed?.currency);
|
|
400
|
+
const amenities = [];
|
|
401
|
+
if (/\bfree\s+wi-?fi\b/i.test(combined)) {
|
|
402
|
+
amenities.push("Free WiFi");
|
|
403
|
+
}
|
|
404
|
+
if (/\bpool\b/i.test(combined)) {
|
|
405
|
+
amenities.push("Pool");
|
|
406
|
+
}
|
|
407
|
+
if (/\bbreakfast\b/i.test(combined)) {
|
|
408
|
+
amenities.push("Breakfast");
|
|
409
|
+
}
|
|
410
|
+
if (/\bspa\b/i.test(combined)) {
|
|
411
|
+
amenities.push("Spa");
|
|
412
|
+
}
|
|
413
|
+
if (/\bfree\s+cancellation\b/i.test(combined)) {
|
|
414
|
+
amenities.push("Free Cancellation");
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
name: cleanTitle,
|
|
418
|
+
country: countryCode,
|
|
419
|
+
pricePerNight: parsedPrice.pricePerNight,
|
|
420
|
+
priceText: parsedPrice.priceText,
|
|
421
|
+
currency: parsedPrice.currency,
|
|
422
|
+
starRating: starMatch ? Number.parseInt(starMatch[1], 10) : undefined,
|
|
423
|
+
guestRating: ratingMatch ? Number.parseFloat(ratingMatch[1]) : undefined,
|
|
424
|
+
reviewCount: reviewMatch
|
|
425
|
+
? Number.parseInt(reviewMatch[1].replace(/,/g, ""), 10)
|
|
426
|
+
: undefined,
|
|
427
|
+
amenities: amenities.length > 0 ? amenities : undefined,
|
|
428
|
+
freeCancellation: /\bfree\s+cancellation\b/i.test(combined) || undefined,
|
|
429
|
+
url,
|
|
430
|
+
provider: extractDomainHost(url) || "unknown",
|
|
431
|
+
extractedVia: "serp-snippet",
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
export function dedupeHotelListings(hotels, maxItems = 30) {
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
const unique = [];
|
|
437
|
+
for (const h of hotels) {
|
|
438
|
+
const normName = normalizeIdentity(h.name);
|
|
439
|
+
// Same provider + different URL = different listing; only exact repeats are dropped.
|
|
440
|
+
const key = resultDedupeKey(h.url, normName);
|
|
441
|
+
if (!normName || normName.length < 3 || seen.has(key)) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
seen.add(key);
|
|
445
|
+
unique.push(h);
|
|
446
|
+
}
|
|
447
|
+
if (unique.length <= maxItems) {
|
|
448
|
+
return unique;
|
|
449
|
+
}
|
|
450
|
+
// Over the cap: take providers in turn so the first page to answer does not
|
|
451
|
+
// crowd out every other source.
|
|
452
|
+
const byProvider = new Map();
|
|
453
|
+
for (const h of unique) {
|
|
454
|
+
const list = byProvider.get(h.provider) ?? [];
|
|
455
|
+
list.push(h);
|
|
456
|
+
byProvider.set(h.provider, list);
|
|
457
|
+
}
|
|
458
|
+
const queues = [...byProvider.values()];
|
|
459
|
+
const out = [];
|
|
460
|
+
for (let i = 0; out.length < maxItems; i++) {
|
|
461
|
+
let took = false;
|
|
462
|
+
for (const q of queues) {
|
|
463
|
+
if (i < q.length && out.length < maxItems) {
|
|
464
|
+
out.push(q[i]);
|
|
465
|
+
took = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!took) {
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { extractDomainHost, parsePriceString, resolveCurrencyCode, } from "./currency.js";
|
|
2
|
+
export { extractOfferFromSerpSnippet, extractOffersFromHtml, groupOffersIntoProducts, parseBingShoppingHtml, type AggregatedProduct, type ProductOffer, } from "./shopping.js";
|
|
3
|
+
export { dedupePropertyListings, extractPropertyListingFromSerpResult, extractPropertyListingsFromHtml, inferListingType, inferPricePeriod, parsePropertyPrice, resolveAbsoluteUrl, type PropertyListing, } from "./real-estate.js";
|
|
4
|
+
export { dedupeFlightOffers, extractFlightOfferFromSerpResult, extractFlightOffersFromHtml, inferCabinClass, inferDurationAndStops, inferRouteFromText, inferTripType, parseFlightPrice, type FlightOffer, } from "./flights.js";
|
|
5
|
+
export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelListingsFromHtml, parseHotelPrice, type HotelListing, } from "./hotels.js";
|
|
6
|
+
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, type BusinessListing, } from "./business.js";
|
|
7
|
+
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, type CveAffected, type CveRecord, type CveSeverity, } from "./cve.js";
|
|
8
|
+
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
+
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, type QuoteAuthor, } from "./structured.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { extractDomainHost, parsePriceString, resolveCurrencyCode, } from "./currency.js";
|
|
2
|
+
export { extractOfferFromSerpSnippet, extractOffersFromHtml, groupOffersIntoProducts, parseBingShoppingHtml, } from "./shopping.js";
|
|
3
|
+
export { dedupePropertyListings, extractPropertyListingFromSerpResult, extractPropertyListingsFromHtml, inferListingType, inferPricePeriod, parsePropertyPrice, resolveAbsoluteUrl, } from "./real-estate.js";
|
|
4
|
+
export { dedupeFlightOffers, extractFlightOfferFromSerpResult, extractFlightOffersFromHtml, inferCabinClass, inferDurationAndStops, inferRouteFromText, inferTripType, parseFlightPrice, } from "./flights.js";
|
|
5
|
+
export { dedupeHotelListings, extractHotelListingFromSerpResult, extractHotelListingsFromHtml, parseHotelPrice, } from "./hotels.js";
|
|
6
|
+
export { dedupeBusinessListings, extractBusinessListingFromSerpResult, extractBusinessListingsFromHtml, } from "./business.js";
|
|
7
|
+
export { cvss3BaseScore, cveSeverityLabel, extractCveIdsFromText, extractGhsaIdsFromText, looksLikeVulnId, mergeCveRecords, normalizeCveId, nvdUrl, parseNvdCve, parseNvdResponse, parseOsvVuln, severityFromScore, sortCveRecords, } from "./cve.js";
|
|
8
|
+
export { extractBusinessListingsWithScaleDown, extractFlightOffersWithScaleDown, extractHotelListingsWithScaleDown, extractPropertyListingsWithScaleDown, } from "./scaledown.js";
|
|
9
|
+
export { extractByPromptAndSchema, extractQuoteAuthorPairs, hasCustomExtractRequest, shapeQuoteAuthors, wantsQuoteAuthorExtract, } from "./structured.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface PropertyListing {
|
|
2
|
+
title: string;
|
|
3
|
+
address: string;
|
|
4
|
+
suburb?: string;
|
|
5
|
+
state?: string;
|
|
6
|
+
postcode?: string;
|
|
7
|
+
country?: string;
|
|
8
|
+
price?: number;
|
|
9
|
+
priceText: string;
|
|
10
|
+
currency?: string;
|
|
11
|
+
pricePeriod?: "total" | "week" | "month" | "year";
|
|
12
|
+
listingType: "sale" | "rent" | "sold" | "unknown";
|
|
13
|
+
propertyType?: string;
|
|
14
|
+
bedrooms?: number;
|
|
15
|
+
bathrooms?: number;
|
|
16
|
+
parking?: number;
|
|
17
|
+
landSize?: string;
|
|
18
|
+
buildingSize?: string;
|
|
19
|
+
url: string;
|
|
20
|
+
portal: string;
|
|
21
|
+
agency?: string;
|
|
22
|
+
agent?: string;
|
|
23
|
+
image?: string;
|
|
24
|
+
latitude?: number;
|
|
25
|
+
longitude?: number;
|
|
26
|
+
extractedVia: "json-state" | "rsc-payload" | "json-ld" | "scaledown-extract-1" | "serp-snippet";
|
|
27
|
+
}
|
|
28
|
+
export declare function inferListingType(rawUrl: string, rawStatus?: string, rawPriceText?: string): "sale" | "rent" | "sold" | "unknown";
|
|
29
|
+
export declare function inferPricePeriod(rawPriceText: string, listingType: "sale" | "rent" | "sold" | "unknown"): "total" | "week" | "month" | "year";
|
|
30
|
+
export declare function parsePropertyPrice(rawPriceText: string | number | undefined | null, countryCode?: string, explicitCurrency?: string): {
|
|
31
|
+
price?: number;
|
|
32
|
+
priceText: string;
|
|
33
|
+
currency: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function resolveAbsoluteUrl(href: string | undefined, baseUrl: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Extract structured real-estate property listings from any HTML page via:
|
|
38
|
+
* 1. Generic recursive JSON-tree discovery over `<script id="__NEXT_DATA__">` and `<script type="application/json">`
|
|
39
|
+
* 2. Next.js App Router Flight RSC payload (`self.__next_f.push`)
|
|
40
|
+
* 3. Schema.org JSON-LD (`RealEstateListing`, `Residence`, `SingleFamilyResidence`, `Apartment`, `House`)
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractPropertyListingsFromHtml(html: string, pageUrl: string, countryCode?: string): PropertyListing[];
|
|
43
|
+
/**
|
|
44
|
+
* Schema.org `image` may be a URL string, an ImageObject (`url` / `contentUrl`),
|
|
45
|
+
* or an array of either. Returns the first usable URL.
|
|
46
|
+
*/
|
|
47
|
+
export declare function ldImage(v: unknown): string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Fallback extractor for SERP result snippets when a real-estate result describes a property
|
|
50
|
+
* or suburb listing summary in its title/description.
|
|
51
|
+
*/
|
|
52
|
+
export declare function extractPropertyListingFromSerpResult(title: string, description: string, url: string, countryCode?: string): PropertyListing | null;
|
|
53
|
+
export declare function dedupePropertyListings(listings: PropertyListing[], maxListings?: number): PropertyListing[];
|