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.
Files changed (114) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/core/engine/cache.d.ts +70 -0
  3. package/dist/core/engine/cache.js +297 -0
  4. package/dist/core/engine/crawl-policy.d.ts +36 -0
  5. package/dist/core/engine/crawl-policy.js +134 -0
  6. package/dist/core/engine/crawler.d.ts +151 -0
  7. package/dist/core/engine/crawler.js +885 -0
  8. package/dist/core/engine/fetcher.d.ts +66 -0
  9. package/dist/core/engine/fetcher.js +185 -0
  10. package/dist/core/engine/index.d.ts +4 -0
  11. package/dist/core/engine/index.js +4 -0
  12. package/dist/core/engine/markdown.d.ts +33 -0
  13. package/dist/core/engine/markdown.js +334 -0
  14. package/dist/core/engine/path-scope.d.ts +21 -0
  15. package/dist/core/engine/path-scope.js +88 -0
  16. package/dist/core/engine/safe-url.d.ts +23 -0
  17. package/dist/core/engine/safe-url.js +92 -0
  18. package/dist/core/engine/stealth.d.ts +79 -0
  19. package/dist/core/engine/stealth.js +155 -0
  20. package/dist/core/engine.d.ts +1 -0
  21. package/dist/core/engine.js +1 -0
  22. package/dist/core/extract/business.d.ts +28 -0
  23. package/dist/core/extract/business.js +344 -0
  24. package/dist/core/extract/currency.d.ts +15 -0
  25. package/dist/core/extract/currency.js +259 -0
  26. package/dist/core/extract/cve-cache.d.ts +62 -0
  27. package/dist/core/extract/cve-cache.js +277 -0
  28. package/dist/core/extract/cve.d.ts +57 -0
  29. package/dist/core/extract/cve.js +397 -0
  30. package/dist/core/extract/dedupe-key.d.ts +12 -0
  31. package/dist/core/extract/dedupe-key.js +36 -0
  32. package/dist/core/extract/flights.d.ts +37 -0
  33. package/dist/core/extract/flights.js +373 -0
  34. package/dist/core/extract/hotels.d.ts +35 -0
  35. package/dist/core/extract/hotels.js +473 -0
  36. package/dist/core/extract/index.d.ts +8 -0
  37. package/dist/core/extract/index.js +8 -0
  38. package/dist/core/extract/real-estate.d.ts +53 -0
  39. package/dist/core/extract/real-estate.js +835 -0
  40. package/dist/core/extract/scaledown.d.ts +14 -0
  41. package/dist/core/extract/scaledown.js +484 -0
  42. package/dist/core/extract/shopping.d.ts +44 -0
  43. package/dist/core/extract/shopping.js +393 -0
  44. package/dist/core/image-url.d.ts +5 -0
  45. package/dist/core/image-url.js +13 -0
  46. package/dist/core/interact/driver.d.ts +49 -0
  47. package/dist/core/interact/driver.js +264 -0
  48. package/dist/core/interact/remote.d.ts +41 -0
  49. package/dist/core/interact/remote.js +133 -0
  50. package/dist/core/interact/spans.d.ts +46 -0
  51. package/dist/core/interact/spans.js +32 -0
  52. package/dist/core/interact/validate.d.ts +34 -0
  53. package/dist/core/interact/validate.js +135 -0
  54. package/dist/core/interact/vm.d.ts +42 -0
  55. package/dist/core/interact/vm.js +296 -0
  56. package/dist/core/local-engine.d.ts +35 -0
  57. package/dist/core/local-engine.js +367 -0
  58. package/dist/core/parse/detect.d.ts +29 -0
  59. package/dist/core/parse/detect.js +152 -0
  60. package/dist/core/parse/fetch.d.ts +20 -0
  61. package/dist/core/parse/fetch.js +39 -0
  62. package/dist/core/parse/image.d.ts +13 -0
  63. package/dist/core/parse/image.js +100 -0
  64. package/dist/core/parse/index.d.ts +53 -0
  65. package/dist/core/parse/index.js +153 -0
  66. package/dist/core/parse/limits.d.ts +12 -0
  67. package/dist/core/parse/limits.js +29 -0
  68. package/dist/core/parse/media.d.ts +42 -0
  69. package/dist/core/parse/media.js +238 -0
  70. package/dist/core/parse/merge.d.ts +19 -0
  71. package/dist/core/parse/merge.js +48 -0
  72. package/dist/core/parse/ocr.d.ts +25 -0
  73. package/dist/core/parse/ocr.js +121 -0
  74. package/dist/core/parse/office.d.ts +28 -0
  75. package/dist/core/parse/office.js +83 -0
  76. package/dist/core/parse/pdf.d.ts +27 -0
  77. package/dist/core/parse/pdf.js +156 -0
  78. package/dist/core/parse/types.d.ts +38 -0
  79. package/dist/core/parse/types.js +1 -0
  80. package/dist/core/parse/youtube.d.ts +30 -0
  81. package/dist/core/parse/youtube.js +264 -0
  82. package/dist/core/ranker/decision.d.ts +28 -0
  83. package/dist/core/ranker/decision.js +151 -0
  84. package/dist/core/ranker/index.d.ts +5 -0
  85. package/dist/core/ranker/index.js +361 -0
  86. package/dist/core/ranker/lexical.d.ts +82 -0
  87. package/dist/core/ranker/lexical.js +642 -0
  88. package/dist/core/ranker/types.d.ts +90 -0
  89. package/dist/core/ranker/types.js +1 -0
  90. package/dist/core/ranker.d.ts +1 -0
  91. package/dist/core/ranker.js +1 -0
  92. package/dist/core/sandbox.d.ts +29 -0
  93. package/dist/core/sandbox.js +182 -0
  94. package/dist/core/search/catalog.d.ts +174 -0
  95. package/dist/core/search/catalog.js +488 -0
  96. package/dist/core/search/index.d.ts +3 -0
  97. package/dist/core/search/index.js +3 -0
  98. package/dist/core/search/pipeline.d.ts +7 -0
  99. package/dist/core/search/pipeline.js +704 -0
  100. package/dist/core/search/providers.d.ts +62 -0
  101. package/dist/core/search/providers.js +1175 -0
  102. package/dist/core/search.d.ts +1 -0
  103. package/dist/core/search.js +1 -0
  104. package/dist/core/targets/developer.d.ts +44 -0
  105. package/dist/core/targets/developer.js +338 -0
  106. package/dist/core/targets/index.d.ts +3 -0
  107. package/dist/core/targets/index.js +3 -0
  108. package/dist/core/targets/registry.d.ts +32 -0
  109. package/dist/core/targets/registry.js +411 -0
  110. package/dist/core/targets/types.d.ts +72 -0
  111. package/dist/core/targets/types.js +8 -0
  112. package/dist/core/targets.d.ts +1 -0
  113. package/dist/core/targets.js +1 -0
  114. package/package.json +82 -4
@@ -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(/&amp;/g, "&")
253
+ .replace(/&#x27;|&#39;/g, "'")
254
+ .replace(/&quot;/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(/&amp;/g, "&")
304
+ .replace(/&#x27;|&#39;/g, "'")
305
+ .replace(/&quot;/g, '"')
306
+ .replace(/&lt;/g, "<")
307
+ .replace(/&gt;/g, ">")
308
+ .replace(/&nbsp;|&#160;/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,8 @@
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";
@@ -0,0 +1,8 @@
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";
@@ -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[];