webcrawl 1.2.4 → 1.2.7
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/awswaf.d.ts +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- 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,373 @@
|
|
|
1
|
+
import { extractDomainHost, parsePriceString, resolveCurrencyCode, } from "./currency.js";
|
|
2
|
+
import { canonicalResultUrl } from "./dedupe-key.js";
|
|
3
|
+
import { resolveAbsoluteUrl } from "./real-estate.js";
|
|
4
|
+
const KNOWN_AIRLINES = [
|
|
5
|
+
"Qantas",
|
|
6
|
+
"Virgin Australia",
|
|
7
|
+
"Jetstar",
|
|
8
|
+
"Rex",
|
|
9
|
+
"Air New Zealand",
|
|
10
|
+
"United Airlines",
|
|
11
|
+
"United",
|
|
12
|
+
"Delta Air Lines",
|
|
13
|
+
"Delta",
|
|
14
|
+
"American Airlines",
|
|
15
|
+
"Southwest Airlines",
|
|
16
|
+
"Southwest",
|
|
17
|
+
"Alaska Airlines",
|
|
18
|
+
"JetBlue",
|
|
19
|
+
"Spirit Airlines",
|
|
20
|
+
"Frontier Airlines",
|
|
21
|
+
"British Airways",
|
|
22
|
+
"EasyJet",
|
|
23
|
+
"Ryanair",
|
|
24
|
+
"Lufthansa",
|
|
25
|
+
"Air France",
|
|
26
|
+
"KLM",
|
|
27
|
+
"Emirates",
|
|
28
|
+
"Qatar Airways",
|
|
29
|
+
"Singapore Airlines",
|
|
30
|
+
"Cathay Pacific",
|
|
31
|
+
"ANA",
|
|
32
|
+
"Japan Airlines",
|
|
33
|
+
"Air Canada",
|
|
34
|
+
"Turkish Airlines",
|
|
35
|
+
"Etihad Airways",
|
|
36
|
+
"Swiss",
|
|
37
|
+
"Iberia",
|
|
38
|
+
"Finnair",
|
|
39
|
+
"Scoot",
|
|
40
|
+
"Fiji Airways",
|
|
41
|
+
"Hawaiian Airlines",
|
|
42
|
+
"Malaysia Airlines",
|
|
43
|
+
"Thai Airways",
|
|
44
|
+
"Korean Air",
|
|
45
|
+
];
|
|
46
|
+
const AIRLINE_REGEX = new RegExp(`\\b(${KNOWN_AIRLINES.map((a) => a.replace(/\s+/g, "\\s+")).join("|")})\\b`, "i");
|
|
47
|
+
export function inferRouteFromText(text) {
|
|
48
|
+
// Check parenthesized IATA codes like "Sydney (SYD) to Los Angeles (LAX)"
|
|
49
|
+
const parenIata = text.match(/\(([A-Z]{3})\)[^()]{1,40}?\b(?:to|->|→|–|-|\u2014)\b[^()]{1,40}?\(([A-Z]{3})\)/i);
|
|
50
|
+
if (parenIata && parenIata[1].toUpperCase() !== parenIata[2].toUpperCase()) {
|
|
51
|
+
return {
|
|
52
|
+
origin: parenIata[1].toUpperCase(),
|
|
53
|
+
destination: parenIata[2].toUpperCase(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
// Check IATA pair like "SYD to LAX", "SYD - MEL", "SYD → LHR", "(JFK) to (SFO)"
|
|
57
|
+
const iataMatch = text.match(/\(?([A-Z]{3})\)?\s*(?:to|->|→|–|-|\u2014)\s*\(?([A-Z]{3})\)?/);
|
|
58
|
+
if (iataMatch &&
|
|
59
|
+
iataMatch[1] !== iataMatch[2] &&
|
|
60
|
+
!/^(USD|AUD|GBP|EUR|CAD|NZD|SGD|JPY|INR|THE|AND|FOR|NON|ALL|TOP|LOW|FLY)$/.test(iataMatch[1]) &&
|
|
61
|
+
!/^(USD|AUD|GBP|EUR|CAD|NZD|SGD|JPY|INR|THE|AND|FOR|NON|ALL|TOP|LOW|FLY)$/.test(iataMatch[2])) {
|
|
62
|
+
return { origin: iataMatch[1], destination: iataMatch[2] };
|
|
63
|
+
}
|
|
64
|
+
// Check "from <Origin> to <Destination>"
|
|
65
|
+
const fromToMatch = text.match(/\b(?:flights?\s+from|from)\s+([A-Z][a-zA-Z\s]{2,22}?)\s+to\s+([A-Z][a-zA-Z\s]{2,22}?)(?=\s+(?:from|under|for|starting|on|in|with|\$|£|€|A\$|at|\||-|$))/i);
|
|
66
|
+
if (fromToMatch) {
|
|
67
|
+
return {
|
|
68
|
+
origin: fromToMatch[1].trim(),
|
|
69
|
+
destination: fromToMatch[2].trim(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// Check "<Origin> to <Destination> Flights"
|
|
73
|
+
const cityToCity = text.match(/\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)?)\s+to\s+([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)?)\s+flights?\b/i);
|
|
74
|
+
if (cityToCity) {
|
|
75
|
+
const o = cityToCity[1].trim();
|
|
76
|
+
const d = cityToCity[2].trim();
|
|
77
|
+
if (!/^(cheap|direct|nonstop|best|book|compare|find)$/i.test(o)) {
|
|
78
|
+
return { origin: o, destination: d };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
export function inferCabinClass(text) {
|
|
84
|
+
const lower = text.toLowerCase();
|
|
85
|
+
if (/\bfirst\s+class\b/.test(lower)) {
|
|
86
|
+
return "first";
|
|
87
|
+
}
|
|
88
|
+
if (/\bbusiness\s+class\b/.test(lower)) {
|
|
89
|
+
return "business";
|
|
90
|
+
}
|
|
91
|
+
if (/\bpremium\s+economy\b/.test(lower)) {
|
|
92
|
+
return "premium_economy";
|
|
93
|
+
}
|
|
94
|
+
if (/\beconomy\b/.test(lower)) {
|
|
95
|
+
return "economy";
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
export function inferTripType(text) {
|
|
100
|
+
const lower = text.toLowerCase();
|
|
101
|
+
if (/\b(one[- ]way)\b/.test(lower)) {
|
|
102
|
+
return "one_way";
|
|
103
|
+
}
|
|
104
|
+
if (/\b(round[- ]trip|return)\b/.test(lower)) {
|
|
105
|
+
return "round_trip";
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
export function inferDurationAndStops(text) {
|
|
110
|
+
const durMatch = text.match(/\b(\d{1,2}\s*h(?:rs?|ours?)?(?:\s*\d{1,2}\s*m(?:ins?)?)?)\b/i);
|
|
111
|
+
let stops;
|
|
112
|
+
if (/\b(non[- ]?stop|direct\s+flights?)\b/i.test(text)) {
|
|
113
|
+
stops = 0;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const stopsMatch = text.match(/\b(\d)\s+stops?\b/i);
|
|
117
|
+
if (stopsMatch) {
|
|
118
|
+
stops = Number.parseInt(stopsMatch[1], 10);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
duration: durMatch ? durMatch[1].replace(/\s+/g, " ").trim() : undefined,
|
|
123
|
+
stops,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function parseFlightPrice(rawPrice, countryCode, explicitCurrency) {
|
|
127
|
+
const defaultCurrency = resolveCurrencyCode(explicitCurrency, countryCode);
|
|
128
|
+
if (typeof rawPrice === "number" &&
|
|
129
|
+
Number.isFinite(rawPrice) &&
|
|
130
|
+
rawPrice > 10 &&
|
|
131
|
+
rawPrice < 50_000) {
|
|
132
|
+
const rounded = Math.round(rawPrice);
|
|
133
|
+
return {
|
|
134
|
+
price: rounded,
|
|
135
|
+
priceText: `${defaultCurrency} $${rounded.toLocaleString("en-US")}`,
|
|
136
|
+
currency: defaultCurrency,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const text = String(rawPrice || "").trim();
|
|
140
|
+
if (!text) {
|
|
141
|
+
return { priceText: "Check Fare", currency: defaultCurrency };
|
|
142
|
+
}
|
|
143
|
+
const parsed = parsePriceString(text, countryCode, explicitCurrency, 50_000);
|
|
144
|
+
if (parsed && parsed.price >= 15) {
|
|
145
|
+
return {
|
|
146
|
+
price: Math.round(parsed.price),
|
|
147
|
+
priceText: text,
|
|
148
|
+
currency: parsed.currency,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { priceText: text, currency: defaultCurrency };
|
|
152
|
+
}
|
|
153
|
+
function walkFlightJsonNode(node, pageUrl, portal, countryCode, queryRoute, extractedVia, out, depth = 0) {
|
|
154
|
+
if (depth > 12 || out.length >= 40 || !node || typeof node !== "object") {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(node)) {
|
|
158
|
+
for (const item of node) {
|
|
159
|
+
walkFlightJsonNode(item, pageUrl, portal, countryCode, queryRoute, extractedVia, out, depth + 1);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const obj = node;
|
|
164
|
+
const rawType = String(obj["@type"] || obj.type || "").toLowerCase();
|
|
165
|
+
// Check Schema.org Flight / Offer / Product or state flight object
|
|
166
|
+
const airlineObj = obj.airline ||
|
|
167
|
+
obj.carrier ||
|
|
168
|
+
obj.provider;
|
|
169
|
+
const airlineName = typeof obj.airline === "string"
|
|
170
|
+
? obj.airline
|
|
171
|
+
: typeof obj.carrier === "string"
|
|
172
|
+
? obj.carrier
|
|
173
|
+
: typeof obj.airlineName === "string"
|
|
174
|
+
? obj.airlineName
|
|
175
|
+
: typeof airlineObj?.name === "string"
|
|
176
|
+
? airlineObj.name
|
|
177
|
+
: undefined;
|
|
178
|
+
const depAirport = obj.departureAirport;
|
|
179
|
+
const arrAirport = obj.arrivalAirport;
|
|
180
|
+
const origin = typeof obj.origin === "string"
|
|
181
|
+
? obj.origin
|
|
182
|
+
: typeof obj.departureAirport === "string"
|
|
183
|
+
? obj.departureAirport
|
|
184
|
+
: typeof depAirport?.iataCode === "string"
|
|
185
|
+
? depAirport.iataCode
|
|
186
|
+
: typeof depAirport?.name === "string"
|
|
187
|
+
? depAirport.name
|
|
188
|
+
: queryRoute.origin;
|
|
189
|
+
const destination = typeof obj.destination === "string"
|
|
190
|
+
? obj.destination
|
|
191
|
+
: typeof obj.arrivalAirport === "string"
|
|
192
|
+
? obj.arrivalAirport
|
|
193
|
+
: typeof arrAirport?.iataCode === "string"
|
|
194
|
+
? arrAirport.iataCode
|
|
195
|
+
: typeof arrAirport?.name === "string"
|
|
196
|
+
? arrAirport.name
|
|
197
|
+
: queryRoute.destination;
|
|
198
|
+
const offersObj = obj.offers;
|
|
199
|
+
const rawPriceVal = obj.price ??
|
|
200
|
+
obj.minPrice ??
|
|
201
|
+
obj.lowPrice ??
|
|
202
|
+
obj.totalPrice ??
|
|
203
|
+
offersObj?.price ??
|
|
204
|
+
offersObj?.lowPrice;
|
|
205
|
+
const rawCurrency = (typeof obj.priceCurrency === "string" ? obj.priceCurrency : undefined) ||
|
|
206
|
+
(typeof obj.currency === "string" ? obj.currency : undefined) ||
|
|
207
|
+
(typeof offersObj?.priceCurrency === "string"
|
|
208
|
+
? offersObj.priceCurrency
|
|
209
|
+
: undefined);
|
|
210
|
+
const isFlightNode = rawType.includes("flight") ||
|
|
211
|
+
Boolean(airlineName && (origin || destination || rawPriceVal !== undefined)) ||
|
|
212
|
+
Boolean(origin && destination && rawPriceVal !== undefined);
|
|
213
|
+
if (isFlightNode) {
|
|
214
|
+
const parsedPrice = parseFlightPrice(rawPriceVal, countryCode, rawCurrency);
|
|
215
|
+
const flightNumber = typeof obj.flightNumber === "string" ||
|
|
216
|
+
typeof obj.flightNumber === "number"
|
|
217
|
+
? String(obj.flightNumber)
|
|
218
|
+
: undefined;
|
|
219
|
+
const departureTime = typeof obj.departureTime === "string" ? obj.departureTime : undefined;
|
|
220
|
+
const arrivalTime = typeof obj.arrivalTime === "string" ? obj.arrivalTime : undefined;
|
|
221
|
+
const duration = typeof obj.duration === "string"
|
|
222
|
+
? obj.duration
|
|
223
|
+
: typeof obj.flightDuration === "string"
|
|
224
|
+
? obj.flightDuration
|
|
225
|
+
: undefined;
|
|
226
|
+
const stops = typeof obj.stops === "number" || typeof obj.stops === "string"
|
|
227
|
+
? obj.stops
|
|
228
|
+
: typeof obj.numberOfStops === "number"
|
|
229
|
+
? obj.numberOfStops
|
|
230
|
+
: undefined;
|
|
231
|
+
const itemUrl = typeof obj.url === "string"
|
|
232
|
+
? resolveAbsoluteUrl(obj.url, pageUrl)
|
|
233
|
+
: pageUrl;
|
|
234
|
+
const routeLabel = origin && destination
|
|
235
|
+
? `${origin} → ${destination}`
|
|
236
|
+
: origin || destination || "Flight";
|
|
237
|
+
const title = typeof obj.name === "string" && obj.name.trim().length > 4
|
|
238
|
+
? obj.name.trim()
|
|
239
|
+
: `${airlineName ? `${airlineName} · ` : ""}${routeLabel}`;
|
|
240
|
+
if (parsedPrice.price || airlineName || (origin && destination)) {
|
|
241
|
+
out.push({
|
|
242
|
+
title,
|
|
243
|
+
airline: airlineName,
|
|
244
|
+
flightNumber,
|
|
245
|
+
origin,
|
|
246
|
+
destination,
|
|
247
|
+
departureTime,
|
|
248
|
+
arrivalTime,
|
|
249
|
+
duration,
|
|
250
|
+
stops,
|
|
251
|
+
price: parsedPrice.price,
|
|
252
|
+
priceText: parsedPrice.priceText,
|
|
253
|
+
currency: parsedPrice.currency,
|
|
254
|
+
url: itemUrl,
|
|
255
|
+
provider: extractDomainHost(itemUrl) || portal,
|
|
256
|
+
extractedVia,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const val of Object.values(obj)) {
|
|
261
|
+
if (val && typeof val === "object") {
|
|
262
|
+
walkFlightJsonNode(val, pageUrl, portal, countryCode, queryRoute, extractedVia, out, depth + 1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
export function extractFlightOffersFromHtml(html, pageUrl, countryCode, queryHint) {
|
|
267
|
+
const out = [];
|
|
268
|
+
const portal = extractDomainHost(pageUrl) || "unknown";
|
|
269
|
+
const queryRoute = inferRouteFromText(`${queryHint || ""} ${pageUrl}`);
|
|
270
|
+
// Tier 1: Schema.org JSON-LD blocks
|
|
271
|
+
const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
272
|
+
let match;
|
|
273
|
+
while ((match = jsonLdRegex.exec(html)) !== null) {
|
|
274
|
+
try {
|
|
275
|
+
const parsed = JSON.parse(match[1].trim());
|
|
276
|
+
walkFlightJsonNode(parsed, pageUrl, portal, countryCode, queryRoute, "json-ld", out);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Ignore malformed JSON-LD
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Tier 2: Embedded JSON state (__NEXT_DATA__, application/json)
|
|
283
|
+
if (out.length < 3) {
|
|
284
|
+
const stateRegex = /<script[^>]+(?:id=["']__NEXT_DATA__["']|type=["']application\/json["'])[^>]*>([\s\S]*?)<\/script>/gi;
|
|
285
|
+
while ((match = stateRegex.exec(html)) !== null) {
|
|
286
|
+
try {
|
|
287
|
+
const raw = match[1].trim();
|
|
288
|
+
if (raw.length > 20 && raw.length < 2_000_000) {
|
|
289
|
+
const parsed = JSON.parse(raw);
|
|
290
|
+
walkFlightJsonNode(parsed, pageUrl, portal, countryCode, queryRoute, "json-state", out);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Ignore
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return dedupeFlightOffers(out);
|
|
299
|
+
}
|
|
300
|
+
export function extractFlightOfferFromSerpResult(title, description, url, countryCode, queryHint) {
|
|
301
|
+
const combined = `${title} ${description} ${queryHint || ""}`;
|
|
302
|
+
const route = inferRouteFromText(combined);
|
|
303
|
+
const airlineMatch = `${title} ${description}`.match(AIRLINE_REGEX);
|
|
304
|
+
const explicitPriceMatch = `${title} ${description}`.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);
|
|
305
|
+
const priceParsed = explicitPriceMatch
|
|
306
|
+
? parsePriceString(explicitPriceMatch[0], countryCode, undefined, 25_000)
|
|
307
|
+
: null;
|
|
308
|
+
const { duration, stops } = inferDurationAndStops(`${title} ${description}`);
|
|
309
|
+
const cabinClass = inferCabinClass(combined);
|
|
310
|
+
const tripType = inferTripType(combined);
|
|
311
|
+
// Require an actual fare price or specific airline in the snippet
|
|
312
|
+
if ((!priceParsed || priceParsed.price < 25) && !airlineMatch) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
const defaultCurrency = resolveCurrencyCode(undefined, countryCode);
|
|
316
|
+
const price = priceParsed && priceParsed.price >= 25
|
|
317
|
+
? Math.round(priceParsed.price)
|
|
318
|
+
: undefined;
|
|
319
|
+
const currency = priceParsed?.currency || defaultCurrency;
|
|
320
|
+
const priceText = price
|
|
321
|
+
? `${currency} $${price.toLocaleString("en-US")}`
|
|
322
|
+
: "Check Live Fare";
|
|
323
|
+
const flightNoMatch = `${title} ${description}`.match(/\b([A-Z]{2}\s?\d{2,4})\b/);
|
|
324
|
+
const provider = extractDomainHost(url) || "unknown";
|
|
325
|
+
return {
|
|
326
|
+
title: title.replace(/\s*[|\-–\u2014•]\s*.*$/, "").trim() ||
|
|
327
|
+
`${route.origin || "Flight"} → ${route.destination || ""}`.trim(),
|
|
328
|
+
airline: airlineMatch ? airlineMatch[1] : undefined,
|
|
329
|
+
flightNumber: flightNoMatch &&
|
|
330
|
+
!/^(US|AU|UK|EU|IN|CA|up|to|in|on)\s?\d+$/i.test(flightNoMatch[1])
|
|
331
|
+
? flightNoMatch[1].replace(/\s+/g, "")
|
|
332
|
+
: undefined,
|
|
333
|
+
origin: route.origin,
|
|
334
|
+
destination: route.destination,
|
|
335
|
+
duration,
|
|
336
|
+
stops,
|
|
337
|
+
cabinClass,
|
|
338
|
+
tripType,
|
|
339
|
+
price,
|
|
340
|
+
priceText,
|
|
341
|
+
currency,
|
|
342
|
+
url,
|
|
343
|
+
provider,
|
|
344
|
+
extractedVia: "serp-snippet",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
export function dedupeFlightOffers(offers, maxItems = 30) {
|
|
348
|
+
const seen = new Set();
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const offer of offers) {
|
|
351
|
+
const key = `${(offer.airline || "").toLowerCase()}|${(offer.origin || "").toLowerCase()}|${(offer.destination || "").toLowerCase()}|${offer.price || offer.priceText}|${offer.provider}|${canonicalResultUrl(offer.url)}`;
|
|
352
|
+
if (seen.has(key)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
seen.add(key);
|
|
356
|
+
out.push(offer);
|
|
357
|
+
if (out.length >= maxItems) {
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return out.sort((a, b) => {
|
|
362
|
+
if (a.price !== undefined && b.price !== undefined) {
|
|
363
|
+
return a.price - b.price;
|
|
364
|
+
}
|
|
365
|
+
if (a.price !== undefined) {
|
|
366
|
+
return -1;
|
|
367
|
+
}
|
|
368
|
+
if (b.price !== undefined) {
|
|
369
|
+
return 1;
|
|
370
|
+
}
|
|
371
|
+
return 0;
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface HotelListing {
|
|
2
|
+
name: string;
|
|
3
|
+
address?: string;
|
|
4
|
+
city?: string;
|
|
5
|
+
country?: string;
|
|
6
|
+
pricePerNight?: number;
|
|
7
|
+
priceText: string;
|
|
8
|
+
currency?: string;
|
|
9
|
+
starRating?: number;
|
|
10
|
+
guestRating?: number;
|
|
11
|
+
reviewCount?: number;
|
|
12
|
+
amenities?: string[];
|
|
13
|
+
roomType?: string;
|
|
14
|
+
freeCancellation?: boolean;
|
|
15
|
+
image?: string;
|
|
16
|
+
latitude?: number;
|
|
17
|
+
longitude?: number;
|
|
18
|
+
url: string;
|
|
19
|
+
provider: string;
|
|
20
|
+
extractedVia: "json-state" | "rsc-payload" | "json-ld" | "html-cards" | "scaledown-extract-1" | "serp-snippet";
|
|
21
|
+
}
|
|
22
|
+
export declare function parseHotelPrice(rawPrice: string | number | undefined | null, countryCode?: string, explicitCurrency?: string): {
|
|
23
|
+
pricePerNight?: number;
|
|
24
|
+
priceText: string;
|
|
25
|
+
currency: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function extractHotelListingsFromHtml(html: string, pageUrl: string, countryCode?: string): HotelListing[];
|
|
28
|
+
/**
|
|
29
|
+
* Booking.com property cards. District pages use `data-testid="card"`, search
|
|
30
|
+
* results use `data-testid="property-card"`; both carry `data-testid="title"`,
|
|
31
|
+
* a `titleLink`, an `image`, a star aria-label and a "guest rating" line.
|
|
32
|
+
*/
|
|
33
|
+
export declare function extractBookingCards(html: string, pageUrl: string, portal: string, countryCode?: string): HotelListing[];
|
|
34
|
+
export declare function extractHotelListingFromSerpResult(title: string, description: string, url: string, countryCode?: string): HotelListing | null;
|
|
35
|
+
export declare function dedupeHotelListings(hotels: HotelListing[], maxItems?: number): HotelListing[];
|