letsfg 2026.5.54 → 2026.5.55
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 +9 -98
- package/dist/chunk-7C3EKQ37.mjs +1768 -0
- package/dist/cli.js +267 -279
- package/dist/cli.mjs +10 -65
- package/dist/index.d.mts +300 -84
- package/dist/index.d.ts +300 -84
- package/dist/index.js +1525 -248
- package/dist/index.mjs +21 -7
- package/package.json +4 -2
- package/dist/chunk-LBHJ4GE4.mjs +0 -487
package/dist/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __create = Object.create;
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
7
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
6
|
var __export = (target, all) => {
|
|
9
7
|
for (var name in all)
|
|
@@ -17,14 +15,6 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
17
15
|
}
|
|
18
16
|
return to;
|
|
19
17
|
};
|
|
20
|
-
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
-
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
-
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
-
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
-
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
-
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
-
mod
|
|
27
|
-
));
|
|
28
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
19
|
|
|
30
20
|
// src/index.ts
|
|
@@ -39,16 +29,1434 @@ __export(index_exports, {
|
|
|
39
29
|
LetsFGError: () => LetsFGError,
|
|
40
30
|
OfferExpiredError: () => OfferExpiredError,
|
|
41
31
|
PaymentRequiredError: () => PaymentRequiredError,
|
|
32
|
+
TRIP_PURPOSES: () => TRIP_PURPOSES,
|
|
42
33
|
ValidationError: () => ValidationError,
|
|
43
34
|
cheapestOffer: () => cheapestOffer,
|
|
35
|
+
deduplicateOffers: () => deduplicateOffers,
|
|
44
36
|
default: () => index_default,
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
extractOfferDetailSignals: () => extractOfferDetailSignals,
|
|
38
|
+
getOfferDetailBadges: () => getOfferDetailBadges,
|
|
39
|
+
getOfferDetailPromptNotes: () => getOfferDetailPromptNotes,
|
|
40
|
+
getPrimaryTripPurpose: () => getPrimaryTripPurpose,
|
|
41
|
+
getProfileLabel: () => getProfileLabel,
|
|
42
|
+
normalizeTripPurposes: () => normalizeTripPurposes,
|
|
47
43
|
offerSummary: () => offerSummary,
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
rankOffers: () => rankOffers,
|
|
45
|
+
selectDiverseTop: () => selectDiverseTop
|
|
50
46
|
});
|
|
51
47
|
module.exports = __toCommonJS(index_exports);
|
|
48
|
+
|
|
49
|
+
// src/offer-details.ts
|
|
50
|
+
var MEAL_RE = /\b(meal|meals|meal service|hot meal|catering|breakfast|lunch|dinner|santan)\b/i;
|
|
51
|
+
var REFRESHMENT_RE = /\b(refreshment|refreshments|drink|drinks|beverage|beverages|snack|snacks|food and drink)\b/i;
|
|
52
|
+
var INSURANCE_RE = /\b(insurance|coverage|protection|travel insurance|disruption cover)\b/i;
|
|
53
|
+
var LOUNGE_RE = /\b(lounge|vip lounge|priority pass|airport lounge)\b/i;
|
|
54
|
+
var WIFI_RE = /\b(wi[ -]?fi|wifi|internet access|onboard internet|wireless internet)\b/i;
|
|
55
|
+
var POWER_RE = /\b(in[- ]?seat power|usb outlets?|usb ports?|usb power|power outlets?|power sockets?|ac power|seat power|charging ports?|charging outlets?)\b/i;
|
|
56
|
+
var ENTERTAINMENT_RE = /\b(in[- ]?flight entertainment|ife|seatback screen|entertainment screen|personal entertainment|stream(?:ing)? media(?: to your device)?|stream to your device|watch on your device)\b/i;
|
|
57
|
+
var WIFI_BARE_RE = /\b(wi[ -]?fi|wifi|internet access|onboard internet|wireless internet)\b/i;
|
|
58
|
+
var POWER_BARE_RE = /\b(in[- ]?seat power|usb outlets?|usb ports?|usb power|power outlets?|power sockets?|ac power|seat power|charging ports?|charging outlets?)\b/i;
|
|
59
|
+
var ENTERTAINMENT_BARE_RE = /\b(in[- ]?flight entertainment|ife|seatback screen|entertainment screen|personal entertainment|stream(?:ing)? media(?: to your device)?|stream to your device|watch on your device)\b/i;
|
|
60
|
+
var SERVICE_INCLUDED_RE = /\b(included?|incl\.?|includes?|including|with|complimentary|provided|free of charge|free)\b/i;
|
|
61
|
+
var SERVICE_AVAILABLE_RE = /\b(available|optional|option|add[- ]?on|extra|for a fee|with a fee|fee|charges may apply|buy[- ]?on[- ]?board|sold separately|upgrade)\b/i;
|
|
62
|
+
var SERVICE_UNAVAILABLE_RE = /\b(not available|unavailable|not offered|not included|not possible|sold[- ]?out)\b/i;
|
|
63
|
+
var REFUND_TEXT_RE = /\b(refund|refundable|cancell|cancel)\b/i;
|
|
64
|
+
var REFUND_NOT_ALLOWED_RE = /\b(non[- ]?refundable|not refundable|no refunds?|refunds? not allowed|cancellations? not allowed)\b/i;
|
|
65
|
+
var REFUND_WITH_FEE_RE = /\b(refund|refundable|cancell|cancel)\b.*\b(with fee|fee|penalty|charges may apply)\b/i;
|
|
66
|
+
var REFUND_ALLOWED_RE = /\b(refundable|refunds? allowed|cancellations? allowed|cancel(?:lation)? allowed|free of charge)\b/i;
|
|
67
|
+
var CHANGE_TEXT_RE = /\b(change|changes|changeable|rebook|rebooking)\b/i;
|
|
68
|
+
var CHANGE_NOT_ALLOWED_RE = /\b(no changes?|changes? not allowed|not changeable|not possible)\b/i;
|
|
69
|
+
var CHANGE_WITH_FEE_RE = /\b(change|changes|rebook|rebooking)\b.*\b(with fee|fee|penalty|charges may apply)\b/i;
|
|
70
|
+
var CHANGE_ALLOWED_RE = /\b(changes? allowed|changeable|rebook(?:ing)? allowed|free of charge)\b/i;
|
|
71
|
+
var SEAT_SELECTION_RE = /\b(seat selection|select your seat|choose your seat|choose seats?|seat choice|standard seat|extra legroom|preferred seat)\b/i;
|
|
72
|
+
var LEGROOM_RE = /\b(?:average\s+legroom|extra legroom|legroom|pitch)\b/i;
|
|
73
|
+
function normalizeDetailText(value) {
|
|
74
|
+
const withoutKey = value.replace(/^[a-z][a-z_ ]{0,40}:\s*/i, "");
|
|
75
|
+
return withoutKey.replace(/\s+/g, " ").trim();
|
|
76
|
+
}
|
|
77
|
+
function truncateDetailText(value, maxLength = 96) {
|
|
78
|
+
if (value.length <= maxLength) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
return `${value.slice(0, maxLength - 3).trimEnd()}...`;
|
|
82
|
+
}
|
|
83
|
+
function pushUniqueNote(notes, note) {
|
|
84
|
+
if (!note || notes.includes(note)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
notes.push(note);
|
|
88
|
+
}
|
|
89
|
+
function getConditionValue(offer, ...keys) {
|
|
90
|
+
for (const key of keys) {
|
|
91
|
+
const value = offer.conditions?.[key];
|
|
92
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
93
|
+
return value.trim();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function summarizeConditionList(value, maxItems = 3) {
|
|
99
|
+
if (!value) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const parts = splitDetailText(value).map((part) => normalizeDetailText(part)).filter((part) => part.length > 0);
|
|
103
|
+
if (parts.length === 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return truncateDetailText(parts.slice(0, maxItems).join("; "));
|
|
107
|
+
}
|
|
108
|
+
function collectOfferSegments(offer) {
|
|
109
|
+
return [
|
|
110
|
+
...offer.segments ?? [],
|
|
111
|
+
...offer.inbound?.segments ?? []
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
function collectUniqueDetailValues(values) {
|
|
115
|
+
const seen = /* @__PURE__ */ new Set();
|
|
116
|
+
const output = [];
|
|
117
|
+
for (const value of values) {
|
|
118
|
+
const normalized = typeof value === "string" ? value.trim() : "";
|
|
119
|
+
if (!normalized) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const key = normalized.toLowerCase();
|
|
123
|
+
if (seen.has(key)) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
seen.add(key);
|
|
127
|
+
output.push(normalized);
|
|
128
|
+
}
|
|
129
|
+
return output;
|
|
130
|
+
}
|
|
131
|
+
function collectFlightNumbers(offer) {
|
|
132
|
+
const segmentFlightNumbers = collectOfferSegments(offer).map((segment) => segment.flight_number);
|
|
133
|
+
return collectUniqueDetailValues([
|
|
134
|
+
...segmentFlightNumbers,
|
|
135
|
+
offer.flight_number
|
|
136
|
+
]);
|
|
137
|
+
}
|
|
138
|
+
function collectAircraftTypes(offer) {
|
|
139
|
+
return collectUniqueDetailValues(
|
|
140
|
+
collectOfferSegments(offer).map((segment) => segment.aircraft?.replace(/\s*\([^)]*\)/g, "").trim())
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
function collectOperatingCarriers(offer) {
|
|
144
|
+
return collectUniqueDetailValues(
|
|
145
|
+
collectOfferSegments(offer).map((segment) => segment.airline)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
function findMatchingSourceText(sources, pattern) {
|
|
149
|
+
for (const source of sources) {
|
|
150
|
+
if (!pattern.test(source.text)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const cleaned = normalizeDetailText(source.text);
|
|
154
|
+
if (cleaned.length > 0) {
|
|
155
|
+
return truncateDetailText(cleaned);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function formatFareFamilyBadgeLabel(value) {
|
|
161
|
+
return truncateDetailText(`Fare: ${value}`, 28);
|
|
162
|
+
}
|
|
163
|
+
function splitDetailText(value) {
|
|
164
|
+
return value.split(/(?:\r?\n|;|•|\|)+/).map((part) => part.trim()).filter((part) => part.length > 0);
|
|
165
|
+
}
|
|
166
|
+
function collectTextSources(offer) {
|
|
167
|
+
const sources = [];
|
|
168
|
+
const ancillaries = offer.ancillaries;
|
|
169
|
+
for (const ancillary of [ancillaries?.cabin_bag, ancillaries?.checked_bag, ancillaries?.seat_selection]) {
|
|
170
|
+
if (typeof ancillary?.description === "string" && ancillary.description.trim().length > 0) {
|
|
171
|
+
for (const fragment of splitDetailText(ancillary.description.trim())) {
|
|
172
|
+
sources.push({
|
|
173
|
+
text: fragment,
|
|
174
|
+
included: ancillary.included,
|
|
175
|
+
source: "ancillary"
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const [key, value] of Object.entries(offer.conditions ?? {})) {
|
|
181
|
+
if (key === "refund_before_departure" || key === "change_before_departure") {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
185
|
+
for (const fragment of splitDetailText(value.trim())) {
|
|
186
|
+
sources.push({
|
|
187
|
+
text: `${key.replace(/_/g, " ")}: ${fragment}`,
|
|
188
|
+
source: "condition"
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return sources;
|
|
194
|
+
}
|
|
195
|
+
function buildUnknownAmenityAssessment() {
|
|
196
|
+
return {
|
|
197
|
+
state: "unknown",
|
|
198
|
+
confidence: "unknown",
|
|
199
|
+
source: "unknown"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function scoreAmenityAssessment(assessment) {
|
|
203
|
+
const confidenceScore = assessment.confidence === "verified" ? 20 : assessment.confidence === "inferred" ? 10 : 0;
|
|
204
|
+
const stateScore = assessment.state === "included" ? 3 : assessment.state === "available" ? 2 : assessment.state === "unavailable" ? 1 : 0;
|
|
205
|
+
return confidenceScore + stateScore;
|
|
206
|
+
}
|
|
207
|
+
function chooseAmenityAssessment(current, next) {
|
|
208
|
+
return scoreAmenityAssessment(next) > scoreAmenityAssessment(current) ? next : current;
|
|
209
|
+
}
|
|
210
|
+
function normalizeAmenitySignal(assessment) {
|
|
211
|
+
if (assessment.confidence !== "verified") {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (assessment.state === "included" || assessment.state === "available") {
|
|
215
|
+
return assessment.state;
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function assessServiceSignal(pattern, sources, options) {
|
|
220
|
+
let best = buildUnknownAmenityAssessment();
|
|
221
|
+
for (const source of sources) {
|
|
222
|
+
if (!pattern.test(source.text)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const normalized = source.text.toLowerCase();
|
|
226
|
+
let candidate;
|
|
227
|
+
if (SERVICE_UNAVAILABLE_RE.test(normalized)) {
|
|
228
|
+
candidate = {
|
|
229
|
+
state: "unavailable",
|
|
230
|
+
confidence: "verified",
|
|
231
|
+
source: source.source,
|
|
232
|
+
evidence: source.text
|
|
233
|
+
};
|
|
234
|
+
} else if (SERVICE_AVAILABLE_RE.test(normalized) || source.included === false) {
|
|
235
|
+
candidate = {
|
|
236
|
+
state: "available",
|
|
237
|
+
confidence: "verified",
|
|
238
|
+
source: source.source,
|
|
239
|
+
evidence: source.text
|
|
240
|
+
};
|
|
241
|
+
} else if (SERVICE_INCLUDED_RE.test(normalized)) {
|
|
242
|
+
candidate = {
|
|
243
|
+
state: "included",
|
|
244
|
+
confidence: "verified",
|
|
245
|
+
source: source.source,
|
|
246
|
+
evidence: source.text
|
|
247
|
+
};
|
|
248
|
+
} else if (options?.barePattern?.test(normalized)) {
|
|
249
|
+
candidate = {
|
|
250
|
+
state: options.bareState ?? "included",
|
|
251
|
+
confidence: "verified",
|
|
252
|
+
source: source.source,
|
|
253
|
+
evidence: source.text
|
|
254
|
+
};
|
|
255
|
+
} else if (source.included === true) {
|
|
256
|
+
candidate = {
|
|
257
|
+
state: "included",
|
|
258
|
+
confidence: "inferred",
|
|
259
|
+
source: source.source,
|
|
260
|
+
evidence: source.text
|
|
261
|
+
};
|
|
262
|
+
} else {
|
|
263
|
+
candidate = {
|
|
264
|
+
state: "available",
|
|
265
|
+
confidence: "inferred",
|
|
266
|
+
source: source.source,
|
|
267
|
+
evidence: source.text
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
best = chooseAmenityAssessment(best, candidate);
|
|
271
|
+
}
|
|
272
|
+
return best;
|
|
273
|
+
}
|
|
274
|
+
function classifyConditionStateFromText(sources, textRe, notAllowedRe, withFeeRe, allowedRe) {
|
|
275
|
+
for (const source of sources) {
|
|
276
|
+
const normalized = source.text.toLowerCase();
|
|
277
|
+
if (!textRe.test(normalized)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (notAllowedRe.test(normalized)) {
|
|
281
|
+
return "not_allowed";
|
|
282
|
+
}
|
|
283
|
+
if (withFeeRe.test(normalized)) {
|
|
284
|
+
return "allowed_with_fee";
|
|
285
|
+
}
|
|
286
|
+
if (allowedRe.test(normalized)) {
|
|
287
|
+
return "allowed";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
function extractOfferDetailSignals(offer) {
|
|
293
|
+
const textSources = collectTextSources(offer);
|
|
294
|
+
const mealAssessment = assessServiceSignal(MEAL_RE, textSources);
|
|
295
|
+
const refreshmentAssessment = assessServiceSignal(REFRESHMENT_RE, textSources);
|
|
296
|
+
const insuranceAssessment = assessServiceSignal(INSURANCE_RE, textSources);
|
|
297
|
+
const loungeAssessment = assessServiceSignal(LOUNGE_RE, textSources);
|
|
298
|
+
const wifiAssessment = assessServiceSignal(WIFI_RE, textSources, {
|
|
299
|
+
barePattern: WIFI_BARE_RE,
|
|
300
|
+
bareState: "available"
|
|
301
|
+
});
|
|
302
|
+
const powerAssessment = assessServiceSignal(POWER_RE, textSources, {
|
|
303
|
+
barePattern: POWER_BARE_RE,
|
|
304
|
+
bareState: "included"
|
|
305
|
+
});
|
|
306
|
+
const entertainmentAssessment = assessServiceSignal(ENTERTAINMENT_RE, textSources, {
|
|
307
|
+
barePattern: ENTERTAINMENT_BARE_RE,
|
|
308
|
+
bareState: "included"
|
|
309
|
+
});
|
|
310
|
+
return {
|
|
311
|
+
refundability: offer.conditions?.refund_before_departure ?? classifyConditionStateFromText(
|
|
312
|
+
textSources,
|
|
313
|
+
REFUND_TEXT_RE,
|
|
314
|
+
REFUND_NOT_ALLOWED_RE,
|
|
315
|
+
REFUND_WITH_FEE_RE,
|
|
316
|
+
REFUND_ALLOWED_RE
|
|
317
|
+
),
|
|
318
|
+
changeability: offer.conditions?.change_before_departure ?? classifyConditionStateFromText(
|
|
319
|
+
textSources,
|
|
320
|
+
CHANGE_TEXT_RE,
|
|
321
|
+
CHANGE_NOT_ALLOWED_RE,
|
|
322
|
+
CHANGE_WITH_FEE_RE,
|
|
323
|
+
CHANGE_ALLOWED_RE
|
|
324
|
+
),
|
|
325
|
+
meals: normalizeAmenitySignal(mealAssessment),
|
|
326
|
+
refreshments: normalizeAmenitySignal(refreshmentAssessment),
|
|
327
|
+
insurance: normalizeAmenitySignal(insuranceAssessment),
|
|
328
|
+
lounge: normalizeAmenitySignal(loungeAssessment),
|
|
329
|
+
wifi: normalizeAmenitySignal(wifiAssessment),
|
|
330
|
+
power: normalizeAmenitySignal(powerAssessment),
|
|
331
|
+
entertainment: normalizeAmenitySignal(entertainmentAssessment),
|
|
332
|
+
amenities: {
|
|
333
|
+
meals: mealAssessment,
|
|
334
|
+
refreshments: refreshmentAssessment,
|
|
335
|
+
insurance: insuranceAssessment,
|
|
336
|
+
lounge: loungeAssessment,
|
|
337
|
+
wifi: wifiAssessment,
|
|
338
|
+
power: powerAssessment,
|
|
339
|
+
entertainment: entertainmentAssessment
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
var AMENITY_BADGE_META = [
|
|
344
|
+
{
|
|
345
|
+
key: "meals",
|
|
346
|
+
included: { key: "meal_included", label: "Meal included", tone: "positive" },
|
|
347
|
+
available: { key: "meal_option", label: "Meal option", tone: "neutral" },
|
|
348
|
+
includedNote: "Meal included in the fare data",
|
|
349
|
+
availableNote: "Meal option shown in the fare data"
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: "refreshments",
|
|
353
|
+
included: { key: "refreshments_included", label: "Refreshments included", tone: "positive" },
|
|
354
|
+
available: { key: "refreshments_option", label: "Refreshments available", tone: "neutral" },
|
|
355
|
+
includedNote: "Refreshments included in the fare data",
|
|
356
|
+
availableNote: "Refreshments shown in the fare data"
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
key: "wifi",
|
|
360
|
+
included: { key: "wifi_included", label: "Wi-Fi included", tone: "positive" },
|
|
361
|
+
available: { key: "wifi_available", label: "Wi-Fi available", tone: "neutral" },
|
|
362
|
+
includedNote: "Wi-Fi included in the fare data",
|
|
363
|
+
availableNote: "Wi-Fi availability shown in the fare data"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
key: "power",
|
|
367
|
+
included: { key: "power_included", label: "USB / power at seat", tone: "positive" },
|
|
368
|
+
available: { key: "power_available", label: "USB / power available", tone: "neutral" },
|
|
369
|
+
includedNote: "USB or power outlet shown in the fare data",
|
|
370
|
+
availableNote: "USB or power availability shown in the fare data"
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
key: "entertainment",
|
|
374
|
+
included: { key: "ife_included", label: "In-flight entertainment", tone: "positive" },
|
|
375
|
+
available: { key: "ife_available", label: "Entertainment available", tone: "neutral" },
|
|
376
|
+
includedNote: "In-flight entertainment shown in the fare data",
|
|
377
|
+
availableNote: "Entertainment availability shown in the fare data"
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
key: "insurance",
|
|
381
|
+
included: { key: "insurance_included", label: "Insurance included", tone: "positive" },
|
|
382
|
+
available: { key: "insurance_option", label: "Insurance option", tone: "neutral" },
|
|
383
|
+
includedNote: "Insurance included in the fare data",
|
|
384
|
+
availableNote: "Insurance option shown in the fare data"
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
key: "lounge",
|
|
388
|
+
included: { key: "lounge_included", label: "Lounge included", tone: "positive" },
|
|
389
|
+
available: { key: "lounge_option", label: "Lounge option", tone: "neutral" },
|
|
390
|
+
includedNote: "Lounge access included in the fare data",
|
|
391
|
+
availableNote: "Lounge access option shown in the fare data"
|
|
392
|
+
}
|
|
393
|
+
];
|
|
394
|
+
function getOfferDetailBadges(offer) {
|
|
395
|
+
const signals = extractOfferDetailSignals(offer);
|
|
396
|
+
const badges = [];
|
|
397
|
+
const fareFamily = getConditionValue(offer, "fare_family", "fare_bundle");
|
|
398
|
+
if (signals.refundability === "allowed" && signals.changeability === "allowed") {
|
|
399
|
+
badges.push({ key: "flexible", label: "Flexible fare", tone: "positive" });
|
|
400
|
+
} else {
|
|
401
|
+
if (signals.refundability === "allowed") {
|
|
402
|
+
badges.push({ key: "refund_allowed", label: "Refundable", tone: "positive" });
|
|
403
|
+
} else if (signals.refundability === "allowed_with_fee") {
|
|
404
|
+
badges.push({ key: "refund_fee", label: "Refund with fee", tone: "neutral" });
|
|
405
|
+
} else if (signals.refundability === "not_allowed") {
|
|
406
|
+
badges.push({ key: "refund_none", label: "No refund", tone: "negative" });
|
|
407
|
+
}
|
|
408
|
+
if (signals.changeability === "allowed") {
|
|
409
|
+
badges.push({ key: "change_allowed", label: "Changes allowed", tone: "positive" });
|
|
410
|
+
} else if (signals.changeability === "allowed_with_fee") {
|
|
411
|
+
badges.push({ key: "change_fee", label: "Changes with fee", tone: "neutral" });
|
|
412
|
+
} else if (signals.changeability === "not_allowed") {
|
|
413
|
+
badges.push({ key: "change_none", label: "No changes", tone: "negative" });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (fareFamily) {
|
|
417
|
+
badges.push({ key: "fare_family", label: formatFareFamilyBadgeLabel(fareFamily), tone: "neutral" });
|
|
418
|
+
}
|
|
419
|
+
for (const amenity of AMENITY_BADGE_META) {
|
|
420
|
+
const signal = signals[amenity.key];
|
|
421
|
+
if (signal === "included") {
|
|
422
|
+
badges.push(amenity.included);
|
|
423
|
+
} else if (signal === "available") {
|
|
424
|
+
badges.push(amenity.available);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return badges.slice(0, 4);
|
|
428
|
+
}
|
|
429
|
+
function getOfferDetailPromptNotes(offer) {
|
|
430
|
+
const signals = extractOfferDetailSignals(offer);
|
|
431
|
+
const textSources = collectTextSources(offer);
|
|
432
|
+
const notes = [];
|
|
433
|
+
const fareFamily = getConditionValue(offer, "fare_family", "fare_bundle");
|
|
434
|
+
const fareBenefits = summarizeConditionList(getConditionValue(offer, "fare_bundle_benefits", "fare_bundle_description"));
|
|
435
|
+
if (fareFamily) {
|
|
436
|
+
notes.push(`Fare family shown: ${fareFamily}`);
|
|
437
|
+
}
|
|
438
|
+
if (fareBenefits) {
|
|
439
|
+
notes.push(`Fare bundle benefits shown: ${fareBenefits}`);
|
|
440
|
+
}
|
|
441
|
+
if (signals.refundability === "allowed") {
|
|
442
|
+
notes.push("Refunds allowed before departure");
|
|
443
|
+
} else if (signals.refundability === "allowed_with_fee") {
|
|
444
|
+
notes.push("Refunds allowed before departure with a fee");
|
|
445
|
+
} else if (signals.refundability === "not_allowed") {
|
|
446
|
+
notes.push("No refunds shown before departure");
|
|
447
|
+
}
|
|
448
|
+
if (signals.changeability === "allowed") {
|
|
449
|
+
notes.push("Changes allowed before departure");
|
|
450
|
+
} else if (signals.changeability === "allowed_with_fee") {
|
|
451
|
+
notes.push("Changes allowed before departure with a fee");
|
|
452
|
+
} else if (signals.changeability === "not_allowed") {
|
|
453
|
+
notes.push("No changes shown before departure");
|
|
454
|
+
}
|
|
455
|
+
for (const amenity of AMENITY_BADGE_META) {
|
|
456
|
+
const signal = signals[amenity.key];
|
|
457
|
+
if (signal === "included") {
|
|
458
|
+
notes.push(amenity.includedNote);
|
|
459
|
+
} else if (signal === "available") {
|
|
460
|
+
notes.push(amenity.availableNote);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const seatSelectionEvidence = findMatchingSourceText(textSources, SEAT_SELECTION_RE);
|
|
464
|
+
if (seatSelectionEvidence && !(fareBenefits && fareBenefits.toLowerCase().includes(seatSelectionEvidence.toLowerCase()))) {
|
|
465
|
+
pushUniqueNote(notes, `Seat selection shown in fare data: ${seatSelectionEvidence}`);
|
|
466
|
+
}
|
|
467
|
+
const legroomEvidence = findMatchingSourceText(textSources, LEGROOM_RE);
|
|
468
|
+
if (legroomEvidence) {
|
|
469
|
+
pushUniqueNote(notes, `Legroom shown in fare data: ${legroomEvidence}`);
|
|
470
|
+
}
|
|
471
|
+
const flightNumbers = collectFlightNumbers(offer);
|
|
472
|
+
if (flightNumbers.length > 0) {
|
|
473
|
+
pushUniqueNote(notes, `Flight numbers shown: ${flightNumbers.join(", ")}`);
|
|
474
|
+
}
|
|
475
|
+
const aircraftTypes = collectAircraftTypes(offer);
|
|
476
|
+
if (aircraftTypes.length > 0) {
|
|
477
|
+
pushUniqueNote(notes, `Aircraft shown: ${aircraftTypes.join(", ")}`);
|
|
478
|
+
}
|
|
479
|
+
const operatingCarriers = collectOperatingCarriers(offer);
|
|
480
|
+
const normalizedOfferAirline = typeof offer.airline === "string" ? offer.airline.trim().toLowerCase() : "";
|
|
481
|
+
if (operatingCarriers.length === 1 && operatingCarriers[0].toLowerCase() !== normalizedOfferAirline) {
|
|
482
|
+
pushUniqueNote(notes, `Operating carrier shown: ${operatingCarriers[0]}`);
|
|
483
|
+
} else if (operatingCarriers.length > 1) {
|
|
484
|
+
pushUniqueNote(notes, `Operating carriers shown: ${operatingCarriers.join(", ")}`);
|
|
485
|
+
}
|
|
486
|
+
return notes;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/trip-purpose.ts
|
|
490
|
+
var TRIP_PURPOSES = [
|
|
491
|
+
"honeymoon",
|
|
492
|
+
"special_occasion",
|
|
493
|
+
"business",
|
|
494
|
+
"ski",
|
|
495
|
+
"beach",
|
|
496
|
+
"city_break",
|
|
497
|
+
"family_holiday",
|
|
498
|
+
"graduation",
|
|
499
|
+
"concert_festival",
|
|
500
|
+
"sports_event",
|
|
501
|
+
"spring_break"
|
|
502
|
+
];
|
|
503
|
+
function normalizeTripPurposes({ tripPurpose, tripPurposes }) {
|
|
504
|
+
const normalized = [];
|
|
505
|
+
const seen = /* @__PURE__ */ new Set();
|
|
506
|
+
for (const purpose of tripPurposes ?? []) {
|
|
507
|
+
if (!purpose || seen.has(purpose)) continue;
|
|
508
|
+
seen.add(purpose);
|
|
509
|
+
normalized.push(purpose);
|
|
510
|
+
}
|
|
511
|
+
if (tripPurpose && !seen.has(tripPurpose)) {
|
|
512
|
+
normalized.push(tripPurpose);
|
|
513
|
+
}
|
|
514
|
+
return normalized;
|
|
515
|
+
}
|
|
516
|
+
function getPrimaryTripPurpose(options) {
|
|
517
|
+
return normalizeTripPurposes(options)[0];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/ranking.ts
|
|
521
|
+
var GF_SAVINGS_COMPARISON_ENABLED = false;
|
|
522
|
+
function normalizeGoogleFlightsComparisonPrice(googleFlightsPrice, _travelerCount = 1) {
|
|
523
|
+
if (!Number.isFinite(googleFlightsPrice)) {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
const normalized = Math.round(googleFlightsPrice * 100) / 100;
|
|
527
|
+
return normalized > 0 ? normalized : null;
|
|
528
|
+
}
|
|
529
|
+
function getOfferInstanceKey(offer) {
|
|
530
|
+
return [
|
|
531
|
+
offer.id,
|
|
532
|
+
offer.departure_time,
|
|
533
|
+
offer.arrival_time,
|
|
534
|
+
offer.stops,
|
|
535
|
+
offer.inbound?.departure_time ?? "",
|
|
536
|
+
offer.inbound?.arrival_time ?? "",
|
|
537
|
+
offer.inbound?.stops ?? ""
|
|
538
|
+
].join("|");
|
|
539
|
+
}
|
|
540
|
+
var W = {
|
|
541
|
+
// Generic solo / default — price-driven
|
|
542
|
+
default: {
|
|
543
|
+
price: 0.34,
|
|
544
|
+
stops: 0.22,
|
|
545
|
+
duration: 0.12,
|
|
546
|
+
depTime: 0.08,
|
|
547
|
+
arrivalTime: 0.04,
|
|
548
|
+
baggage: 0.02,
|
|
549
|
+
savings: 0.06,
|
|
550
|
+
comfortHours: 0.04,
|
|
551
|
+
layover: 0.08
|
|
552
|
+
},
|
|
553
|
+
// Business — time and directness > price; long layovers are unacceptable
|
|
554
|
+
business_traveler: {
|
|
555
|
+
price: 0.1,
|
|
556
|
+
stops: 0.26,
|
|
557
|
+
duration: 0.2,
|
|
558
|
+
depTime: 0.18,
|
|
559
|
+
arrivalTime: 0.04,
|
|
560
|
+
baggage: 0.06,
|
|
561
|
+
savings: 0,
|
|
562
|
+
comfortHours: 0.06,
|
|
563
|
+
layover: 0.1
|
|
564
|
+
},
|
|
565
|
+
// Family — directness + baggage practicality; kids + 8h layover = nightmare
|
|
566
|
+
family: {
|
|
567
|
+
price: 0.12,
|
|
568
|
+
stops: 0.2,
|
|
569
|
+
duration: 0.16,
|
|
570
|
+
depTime: 0.08,
|
|
571
|
+
arrivalTime: 0.04,
|
|
572
|
+
baggage: 0.2,
|
|
573
|
+
savings: 0.04,
|
|
574
|
+
comfortHours: 0.08,
|
|
575
|
+
layover: 0.08
|
|
576
|
+
},
|
|
577
|
+
// Couple — balance of price, comfort, arrival time, savings
|
|
578
|
+
couple: {
|
|
579
|
+
price: 0.22,
|
|
580
|
+
stops: 0.2,
|
|
581
|
+
duration: 0.12,
|
|
582
|
+
depTime: 0.1,
|
|
583
|
+
arrivalTime: 0.14,
|
|
584
|
+
baggage: 0.02,
|
|
585
|
+
savings: 0.1,
|
|
586
|
+
comfortHours: 0.04,
|
|
587
|
+
layover: 0.06
|
|
588
|
+
},
|
|
589
|
+
// Honeymoon — direct > everything; no one wants a 10h layover on their honeymoon
|
|
590
|
+
honeymoon: {
|
|
591
|
+
price: 0.08,
|
|
592
|
+
stops: 0.28,
|
|
593
|
+
duration: 0.1,
|
|
594
|
+
depTime: 0.14,
|
|
595
|
+
arrivalTime: 0.18,
|
|
596
|
+
baggage: 0.02,
|
|
597
|
+
savings: 0.02,
|
|
598
|
+
comfortHours: 0.08,
|
|
599
|
+
layover: 0.1
|
|
600
|
+
},
|
|
601
|
+
// Special occasion — still prioritize smooth timing/directness, but keep price meaningful.
|
|
602
|
+
special_occasion: {
|
|
603
|
+
price: 0.14,
|
|
604
|
+
stops: 0.24,
|
|
605
|
+
duration: 0.1,
|
|
606
|
+
depTime: 0.12,
|
|
607
|
+
arrivalTime: 0.16,
|
|
608
|
+
baggage: 0.02,
|
|
609
|
+
savings: 0.06,
|
|
610
|
+
comfortHours: 0.08,
|
|
611
|
+
layover: 0.08
|
|
612
|
+
},
|
|
613
|
+
// Ski — bag essential (equipment); early arrival to maximize slopes
|
|
614
|
+
ski: {
|
|
615
|
+
price: 0.12,
|
|
616
|
+
stops: 0.16,
|
|
617
|
+
duration: 0.1,
|
|
618
|
+
depTime: 0.16,
|
|
619
|
+
arrivalTime: 0.08,
|
|
620
|
+
baggage: 0.24,
|
|
621
|
+
savings: 0.04,
|
|
622
|
+
comfortHours: 0.02,
|
|
623
|
+
layover: 0.08
|
|
624
|
+
},
|
|
625
|
+
// Beach — arrive early to enjoy the day; price matters
|
|
626
|
+
beach: {
|
|
627
|
+
price: 0.24,
|
|
628
|
+
stops: 0.16,
|
|
629
|
+
duration: 0.1,
|
|
630
|
+
depTime: 0.1,
|
|
631
|
+
arrivalTime: 0.14,
|
|
632
|
+
baggage: 0.1,
|
|
633
|
+
savings: 0.06,
|
|
634
|
+
comfortHours: 0.04,
|
|
635
|
+
layover: 0.06
|
|
636
|
+
},
|
|
637
|
+
// City break — maximize time on the ground; 2-day trip loses half a day to a 6h layover
|
|
638
|
+
city_break: {
|
|
639
|
+
price: 0.26,
|
|
640
|
+
stops: 0.2,
|
|
641
|
+
duration: 0.08,
|
|
642
|
+
depTime: 0.12,
|
|
643
|
+
arrivalTime: 0.16,
|
|
644
|
+
baggage: 0.02,
|
|
645
|
+
savings: 0.04,
|
|
646
|
+
comfortHours: 0.04,
|
|
647
|
+
layover: 0.08
|
|
648
|
+
},
|
|
649
|
+
// Quick flight — user explicitly wants shortest possible total duration
|
|
650
|
+
quick_flight: {
|
|
651
|
+
price: 0.14,
|
|
652
|
+
stops: 0.2,
|
|
653
|
+
duration: 0.4,
|
|
654
|
+
depTime: 0.06,
|
|
655
|
+
arrivalTime: 0.04,
|
|
656
|
+
baggage: 0.02,
|
|
657
|
+
savings: 0.04,
|
|
658
|
+
comfortHours: 0.04,
|
|
659
|
+
layover: 0.06
|
|
660
|
+
},
|
|
661
|
+
// Cheapest — user explicitly asked for lowest price; price overwhelms all other factors
|
|
662
|
+
cheapest: {
|
|
663
|
+
price: 0.88,
|
|
664
|
+
stops: 0.06,
|
|
665
|
+
duration: 0.03,
|
|
666
|
+
depTime: 0.01,
|
|
667
|
+
arrivalTime: 0.01,
|
|
668
|
+
baggage: 0.01,
|
|
669
|
+
savings: 0,
|
|
670
|
+
comfortHours: 0,
|
|
671
|
+
layover: 0
|
|
672
|
+
},
|
|
673
|
+
// Cheapest direct — user asked for BOTH cheapest AND direct/nonstop.
|
|
674
|
+
// Stops must dominate enough that a direct flight wins even if it costs more;
|
|
675
|
+
// price wins within the same stop tier (all directs sorted by price, all 1-stops sorted
|
|
676
|
+
// by price below them, etc.). The scoreStops delta between 0-stop (1.00) and 1-stop (0.40)
|
|
677
|
+
// is 0.60; with stops weight 0.38 that gap is worth 0.228, which means a 1-stop would
|
|
678
|
+
// need an enormous price advantage to beat a direct — effectively "direct first".
|
|
679
|
+
cheapest_direct: {
|
|
680
|
+
price: 0.52,
|
|
681
|
+
stops: 0.38,
|
|
682
|
+
duration: 0.04,
|
|
683
|
+
depTime: 0.02,
|
|
684
|
+
arrivalTime: 0.01,
|
|
685
|
+
baggage: 0.01,
|
|
686
|
+
savings: 0,
|
|
687
|
+
comfortHours: 0,
|
|
688
|
+
layover: 0.02
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
var PURPOSE_WEIGHT_PROFILES = {
|
|
692
|
+
honeymoon: W.honeymoon,
|
|
693
|
+
special_occasion: W.special_occasion,
|
|
694
|
+
business: W.business_traveler,
|
|
695
|
+
ski: W.ski,
|
|
696
|
+
beach: W.beach,
|
|
697
|
+
city_break: W.city_break,
|
|
698
|
+
family_holiday: W.family,
|
|
699
|
+
graduation: W.city_break,
|
|
700
|
+
concert_festival: W.city_break,
|
|
701
|
+
sports_event: W.city_break,
|
|
702
|
+
spring_break: W.beach
|
|
703
|
+
};
|
|
704
|
+
var EARLY_ARRIVAL_PURPOSES = /* @__PURE__ */ new Set([
|
|
705
|
+
"city_break",
|
|
706
|
+
"beach",
|
|
707
|
+
"special_occasion",
|
|
708
|
+
"graduation",
|
|
709
|
+
"concert_festival",
|
|
710
|
+
"sports_event",
|
|
711
|
+
"spring_break"
|
|
712
|
+
]);
|
|
713
|
+
function blendWeights(profiles) {
|
|
714
|
+
if (profiles.length === 0) return { ...W.default };
|
|
715
|
+
const blended = {
|
|
716
|
+
price: 0,
|
|
717
|
+
stops: 0,
|
|
718
|
+
duration: 0,
|
|
719
|
+
depTime: 0,
|
|
720
|
+
arrivalTime: 0,
|
|
721
|
+
baggage: 0,
|
|
722
|
+
savings: 0,
|
|
723
|
+
comfortHours: 0,
|
|
724
|
+
layover: 0
|
|
725
|
+
};
|
|
726
|
+
for (const profile of profiles) {
|
|
727
|
+
blended.price += profile.price;
|
|
728
|
+
blended.stops += profile.stops;
|
|
729
|
+
blended.duration += profile.duration;
|
|
730
|
+
blended.depTime += profile.depTime;
|
|
731
|
+
blended.arrivalTime += profile.arrivalTime;
|
|
732
|
+
blended.baggage += profile.baggage;
|
|
733
|
+
blended.savings += profile.savings;
|
|
734
|
+
blended.comfortHours += profile.comfortHours;
|
|
735
|
+
blended.layover += profile.layover;
|
|
736
|
+
}
|
|
737
|
+
return {
|
|
738
|
+
price: blended.price / profiles.length,
|
|
739
|
+
stops: blended.stops / profiles.length,
|
|
740
|
+
duration: blended.duration / profiles.length,
|
|
741
|
+
depTime: blended.depTime / profiles.length,
|
|
742
|
+
arrivalTime: blended.arrivalTime / profiles.length,
|
|
743
|
+
baggage: blended.baggage / profiles.length,
|
|
744
|
+
savings: blended.savings / profiles.length,
|
|
745
|
+
comfortHours: blended.comfortHours / profiles.length,
|
|
746
|
+
layover: blended.layover / profiles.length
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function resolveWeights(ctx) {
|
|
750
|
+
if (ctx.preferCheapest && ctx.preferDirect) return { ...W.cheapest_direct };
|
|
751
|
+
if (ctx.preferCheapest) return { ...W.cheapest };
|
|
752
|
+
const tripPurposes = normalizeTripPurposes({
|
|
753
|
+
tripPurpose: ctx.tripPurpose,
|
|
754
|
+
tripPurposes: ctx.tripPurposes
|
|
755
|
+
});
|
|
756
|
+
const purposeProfiles = tripPurposes.map((purpose) => PURPOSE_WEIGHT_PROFILES[purpose]);
|
|
757
|
+
const w = ctx.preferQuickFlight ? { ...W.quick_flight } : purposeProfiles.length > 0 ? blendWeights(purposeProfiles) : ctx.tripContext === "business_traveler" ? { ...W.business_traveler } : ctx.tripContext === "family" ? { ...W.family } : ctx.tripContext === "couple" ? { ...W.couple } : { ...W.default };
|
|
758
|
+
if (ctx.preferDirect) {
|
|
759
|
+
const boost = 0.2;
|
|
760
|
+
w.stops = Math.min(0.5, w.stops + boost);
|
|
761
|
+
const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
|
|
762
|
+
const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
|
|
763
|
+
w.price -= fromPrice;
|
|
764
|
+
w.duration -= fromDuration;
|
|
765
|
+
}
|
|
766
|
+
if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
|
|
767
|
+
const boost = 0.12;
|
|
768
|
+
w.stops = Math.min(0.44, w.stops + boost);
|
|
769
|
+
const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
|
|
770
|
+
const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
|
|
771
|
+
w.price -= fromPrice;
|
|
772
|
+
w.duration -= fromDuration;
|
|
773
|
+
}
|
|
774
|
+
if (ctx.preferLongLayover) {
|
|
775
|
+
const boost = 0.12;
|
|
776
|
+
w.layover = Math.min(0.24, w.layover + boost);
|
|
777
|
+
const fromPrice = Math.min(boost * 0.6, Math.max(0.04, w.price) - 0.04);
|
|
778
|
+
const fromDuration = Math.min(boost - fromPrice, Math.max(0.02, w.duration) - 0.02);
|
|
779
|
+
w.price -= fromPrice;
|
|
780
|
+
w.duration -= fromDuration;
|
|
781
|
+
}
|
|
782
|
+
if (ctx.depTimePref) {
|
|
783
|
+
const boost = 0.13;
|
|
784
|
+
w.depTime = Math.min(0.36, w.depTime + boost);
|
|
785
|
+
const fromArrival = Math.min(boost * 0.65, Math.max(0, w.arrivalTime - 0.03));
|
|
786
|
+
const fromDuration = Math.min(boost - fromArrival, Math.max(0, w.duration - 0.03));
|
|
787
|
+
w.arrivalTime -= fromArrival;
|
|
788
|
+
w.duration -= fromDuration;
|
|
789
|
+
}
|
|
790
|
+
if (ctx.requireBag && w.baggage < 0.15) {
|
|
791
|
+
const boost = 0.12;
|
|
792
|
+
w.baggage += boost;
|
|
793
|
+
w.price = Math.max(0.04, w.price - boost * 0.6);
|
|
794
|
+
w.duration = Math.max(0.02, w.duration - boost * 0.4);
|
|
795
|
+
}
|
|
796
|
+
return w;
|
|
797
|
+
}
|
|
798
|
+
function timeConstraintPenalty(depMins, afterMins, beforeMins) {
|
|
799
|
+
if (afterMins !== void 0 && depMins < afterMins) return 0.05;
|
|
800
|
+
if (beforeMins !== void 0 && depMins > beforeMins) return 0.05;
|
|
801
|
+
return 1;
|
|
802
|
+
}
|
|
803
|
+
function depTimeMismatchMultiplier(depMins, depTimePref) {
|
|
804
|
+
if (!depTimePref) return 1;
|
|
805
|
+
const s = scoreDepTime(depMins, depTimePref);
|
|
806
|
+
if (s >= 0.55) return 1;
|
|
807
|
+
if (s >= 0.3) return 0.8;
|
|
808
|
+
return 0.55;
|
|
809
|
+
}
|
|
810
|
+
function retDepTimeMismatchMultiplier(offer, retTimePref) {
|
|
811
|
+
if (!retTimePref) return 1;
|
|
812
|
+
const retDep = offer.inbound?.departure_time;
|
|
813
|
+
if (!retDep) return 1;
|
|
814
|
+
const retDepMins = isoToMins(retDep);
|
|
815
|
+
const s = scoreDepTime(retDepMins, retTimePref);
|
|
816
|
+
if (s >= 0.55) return 1;
|
|
817
|
+
if (s >= 0.15) return 0.88;
|
|
818
|
+
return 0.7;
|
|
819
|
+
}
|
|
820
|
+
function deduplicateOffers(offers) {
|
|
821
|
+
const mergeAncillary = (current, next) => {
|
|
822
|
+
if (!current) return next ? { ...next } : void 0;
|
|
823
|
+
if (!next) return current;
|
|
824
|
+
return {
|
|
825
|
+
included: typeof current.included === "boolean" ? current.included : next.included,
|
|
826
|
+
price: typeof current.price === "number" ? current.price : next.price,
|
|
827
|
+
currency: current.currency || next.currency,
|
|
828
|
+
description: current.description || next.description
|
|
829
|
+
};
|
|
830
|
+
};
|
|
831
|
+
const mergeOfferDetails = (representative, bucketOffers, representativePrice) => {
|
|
832
|
+
const samePriceMatches = bucketOffers.filter((candidate) => Math.abs((candidate.displayPrice ?? candidate.price) - representativePrice) < 0.01);
|
|
833
|
+
if (samePriceMatches.length <= 1) {
|
|
834
|
+
return representative;
|
|
835
|
+
}
|
|
836
|
+
let mergedAncillaries = representative.ancillaries ? {
|
|
837
|
+
cabin_bag: representative.ancillaries.cabin_bag,
|
|
838
|
+
checked_bag: representative.ancillaries.checked_bag,
|
|
839
|
+
seat_selection: representative.ancillaries.seat_selection
|
|
840
|
+
} : void 0;
|
|
841
|
+
let mergedConditions = representative.conditions ? { ...representative.conditions } : void 0;
|
|
842
|
+
for (const candidate of samePriceMatches) {
|
|
843
|
+
if (candidate.ancillaries) {
|
|
844
|
+
mergedAncillaries = {
|
|
845
|
+
cabin_bag: mergeAncillary(mergedAncillaries?.cabin_bag, candidate.ancillaries.cabin_bag),
|
|
846
|
+
checked_bag: mergeAncillary(mergedAncillaries?.checked_bag, candidate.ancillaries.checked_bag),
|
|
847
|
+
seat_selection: mergeAncillary(mergedAncillaries?.seat_selection, candidate.ancillaries.seat_selection)
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (candidate.conditions) {
|
|
851
|
+
const nextConditions = { ...mergedConditions ?? {} };
|
|
852
|
+
for (const [conditionKey, conditionValue] of Object.entries(candidate.conditions)) {
|
|
853
|
+
if (!conditionValue) continue;
|
|
854
|
+
const currentValue = nextConditions[conditionKey];
|
|
855
|
+
if (!currentValue || currentValue === "unknown") {
|
|
856
|
+
nextConditions[conditionKey] = conditionValue;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
mergedConditions = nextConditions;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
...representative,
|
|
864
|
+
ancillaries: mergedAncillaries,
|
|
865
|
+
conditions: mergedConditions
|
|
866
|
+
};
|
|
867
|
+
};
|
|
868
|
+
const bucket = (iso) => Math.round(isoToMins(iso) / 30);
|
|
869
|
+
const dayKey = (iso) => {
|
|
870
|
+
if (!iso) return "";
|
|
871
|
+
const d = new Date(iso);
|
|
872
|
+
return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
|
|
873
|
+
};
|
|
874
|
+
const legKey = (departureTime, arrivalTime, origin, destination, stops, segments) => {
|
|
875
|
+
const intermediate = segments && segments.length > 1 ? segments.slice(0, -1).map((s) => (s.destination ?? "").toUpperCase()).filter(Boolean).sort().join(",") : "";
|
|
876
|
+
return [
|
|
877
|
+
dayKey(departureTime),
|
|
878
|
+
(origin ?? "").toUpperCase(),
|
|
879
|
+
(destination ?? "").toUpperCase(),
|
|
880
|
+
departureTime ? bucket(departureTime) : "",
|
|
881
|
+
arrivalTime ? bucket(arrivalTime) : "",
|
|
882
|
+
stops ?? "",
|
|
883
|
+
intermediate
|
|
884
|
+
].join("_");
|
|
885
|
+
};
|
|
886
|
+
const normRefund = (c) => c === "allowed" || c === "allowed_with_fee" ? c : "nonrefundable";
|
|
887
|
+
const key = (o) => [
|
|
888
|
+
(o.airline ?? "").toUpperCase(),
|
|
889
|
+
legKey(o.departure_time, o.arrival_time, o.origin, o.destination, o.stops, o.segments),
|
|
890
|
+
o.inbound ? legKey(
|
|
891
|
+
o.inbound.departure_time,
|
|
892
|
+
o.inbound.arrival_time,
|
|
893
|
+
o.inbound.origin,
|
|
894
|
+
o.inbound.destination,
|
|
895
|
+
o.inbound.stops,
|
|
896
|
+
o.inbound.segments
|
|
897
|
+
) : "oneway",
|
|
898
|
+
normRefund(o.conditions?.refund_before_departure)
|
|
899
|
+
].join("_");
|
|
900
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
901
|
+
offers.forEach((offer, index) => {
|
|
902
|
+
const offerKey = key(offer);
|
|
903
|
+
const effectivePrice = offer.displayPrice ?? offer.price;
|
|
904
|
+
const bucketState = buckets.get(offerKey);
|
|
905
|
+
if (!bucketState) {
|
|
906
|
+
buckets.set(offerKey, {
|
|
907
|
+
minPrice: effectivePrice,
|
|
908
|
+
representative: offer,
|
|
909
|
+
representativeIndex: index,
|
|
910
|
+
offers: [offer]
|
|
911
|
+
});
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
bucketState.offers.push(offer);
|
|
915
|
+
if (effectivePrice < bucketState.minPrice - 0.01) {
|
|
916
|
+
bucketState.minPrice = effectivePrice;
|
|
917
|
+
bucketState.representative = offer;
|
|
918
|
+
bucketState.representativeIndex = index;
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
return [...buckets.values()].sort((left, right) => left.representativeIndex - right.representativeIndex).map((bucketState) => mergeOfferDetails(bucketState.representative, bucketState.offers, bucketState.minPrice));
|
|
922
|
+
}
|
|
923
|
+
function isoToMins(iso) {
|
|
924
|
+
if (!iso) return 0;
|
|
925
|
+
const match = /T(\d{2}):(\d{2})/.exec(iso);
|
|
926
|
+
if (match) return parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
|
|
927
|
+
const d = new Date(iso);
|
|
928
|
+
if (isNaN(d.getTime())) return 0;
|
|
929
|
+
return d.getUTCHours() * 60 + d.getUTCMinutes();
|
|
930
|
+
}
|
|
931
|
+
function daysBetween(depIso, arrIso) {
|
|
932
|
+
if (!depIso || !arrIso) return 0;
|
|
933
|
+
const dep = new Date(depIso);
|
|
934
|
+
const arr = new Date(arrIso);
|
|
935
|
+
if (isNaN(dep.getTime()) || isNaN(arr.getTime())) return 0;
|
|
936
|
+
const depDay = Math.floor(dep.getTime() / 864e5);
|
|
937
|
+
const arrDay = Math.floor(arr.getTime() / 864e5);
|
|
938
|
+
return Math.max(0, arrDay - depDay);
|
|
939
|
+
}
|
|
940
|
+
function formatMins(mins) {
|
|
941
|
+
const h = Math.floor(mins / 60) % 24;
|
|
942
|
+
const m = mins % 60;
|
|
943
|
+
const ampm = h >= 12 ? "pm" : "am";
|
|
944
|
+
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
945
|
+
return `${h12}:${m.toString().padStart(2, "0")}${ampm}`;
|
|
946
|
+
}
|
|
947
|
+
function p5p95(arr) {
|
|
948
|
+
if (arr.length === 0) return [0, 0];
|
|
949
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
950
|
+
const lo = s[Math.floor(s.length * 0.05)] ?? s[0];
|
|
951
|
+
const hi = s[Math.min(s.length - 1, Math.ceil(s.length * 0.95) - 1)];
|
|
952
|
+
return [lo, hi];
|
|
953
|
+
}
|
|
954
|
+
function scorePrice(price, lo, hi) {
|
|
955
|
+
if (hi <= lo) return 1;
|
|
956
|
+
return 1 - Math.max(0, Math.min(1, (price - lo) / (hi - lo)));
|
|
957
|
+
}
|
|
958
|
+
function scoreDuration(mins, lo, hi) {
|
|
959
|
+
if (hi <= lo) return 0.8;
|
|
960
|
+
return 1 - Math.max(0, Math.min(1, (mins - lo) / (hi - lo)));
|
|
961
|
+
}
|
|
962
|
+
function scoreStops(stops) {
|
|
963
|
+
if (stops === 0) return 1;
|
|
964
|
+
if (stops === 1) return 0.4;
|
|
965
|
+
if (stops === 2) return 0.14;
|
|
966
|
+
return 0.04;
|
|
967
|
+
}
|
|
968
|
+
var TIME_RANGES = {
|
|
969
|
+
// [perfectLo, perfectHi, okLo, okHi] — all in minutes from midnight
|
|
970
|
+
early_morning: [0, 330, 330, 420],
|
|
971
|
+
morning: [360, 660, 300, 750],
|
|
972
|
+
afternoon: [720, 1020, 660, 1140],
|
|
973
|
+
evening: [1080, 1320, 1020, 1380],
|
|
974
|
+
// "night" is later/stricter than "evening" — when a user says "Sunday night
|
|
975
|
+
// back" they mean ~21:00+, not 18:00. Perfect 21:00–23:30, ok 20:00–23:59.
|
|
976
|
+
// A 18:20 return fails this gate while still passing the evening gate.
|
|
977
|
+
night: [1260, 1410, 1200, 1439],
|
|
978
|
+
red_eye: [1320, 1439, 0, 420]
|
|
979
|
+
};
|
|
980
|
+
function scoreDepTime(depMins, pref) {
|
|
981
|
+
if (!pref) {
|
|
982
|
+
if (depMins >= 360 && depMins <= 1260) return 0.8;
|
|
983
|
+
if (depMins >= 270 && depMins <= 1380) return 0.5;
|
|
984
|
+
return 0.2;
|
|
985
|
+
}
|
|
986
|
+
const r = TIME_RANGES[pref];
|
|
987
|
+
if (!r) return 0.5;
|
|
988
|
+
const [pLo, pHi, oLo, oHi] = r;
|
|
989
|
+
if (depMins >= pLo && depMins <= pHi) return 1;
|
|
990
|
+
if (depMins >= oLo && depMins <= oHi) return 0.6;
|
|
991
|
+
const distBelowOk = Math.max(0, oLo - depMins);
|
|
992
|
+
const distAboveOk = Math.max(0, depMins - oHi);
|
|
993
|
+
const dist = distBelowOk > 0 ? distBelowOk : distAboveOk;
|
|
994
|
+
const span = oHi - oLo;
|
|
995
|
+
return Math.max(0.05, 0.2 * Math.exp(-dist / Math.max(span * 1.5, 60)));
|
|
996
|
+
}
|
|
997
|
+
function scoreArrivalTime(arrMins, pref, tripPurposes, dayOffset = 0) {
|
|
998
|
+
const isExploring = (tripPurposes ?? []).some((purpose) => EARLY_ARRIVAL_PURPOSES.has(purpose));
|
|
999
|
+
if (!pref && !isExploring) return 0.5;
|
|
1000
|
+
let base;
|
|
1001
|
+
if (pref === "morning") {
|
|
1002
|
+
if (arrMins < 720) base = 1;
|
|
1003
|
+
else if (arrMins < 900) base = 0.65;
|
|
1004
|
+
else base = 0.25;
|
|
1005
|
+
} else if (pref === "afternoon") {
|
|
1006
|
+
if (arrMins >= 720 && arrMins < 1020) base = 1;
|
|
1007
|
+
else if (arrMins < 1140) base = 0.65;
|
|
1008
|
+
else base = 0.25;
|
|
1009
|
+
} else if (pref === "evening") {
|
|
1010
|
+
if (arrMins >= 1020 && arrMins < 1320) base = 1;
|
|
1011
|
+
else if (arrMins >= 900) base = 0.65;
|
|
1012
|
+
else base = 0.25;
|
|
1013
|
+
} else {
|
|
1014
|
+
if (arrMins < 360) {
|
|
1015
|
+
base = dayOffset >= 1 ? 0.15 : 0.3;
|
|
1016
|
+
} else if (arrMins < 600) base = 1;
|
|
1017
|
+
else if (arrMins < 720) base = 0.85;
|
|
1018
|
+
else if (arrMins < 900) base = 0.7;
|
|
1019
|
+
else if (arrMins < 1080) base = 0.55;
|
|
1020
|
+
else if (arrMins < 1260) base = 0.35;
|
|
1021
|
+
else base = 0.15;
|
|
1022
|
+
}
|
|
1023
|
+
if (dayOffset >= 2) {
|
|
1024
|
+
const extraDays = dayOffset - 1;
|
|
1025
|
+
const penalty = isExploring ? Math.min(base, extraDays * 0.45) : Math.min(base, extraDays * 0.25);
|
|
1026
|
+
base = Math.max(0, base - penalty);
|
|
1027
|
+
}
|
|
1028
|
+
return base;
|
|
1029
|
+
}
|
|
1030
|
+
function scoreBaggage(offer, requireBag) {
|
|
1031
|
+
const bag = offer.ancillaries?.checked_bag;
|
|
1032
|
+
const isIncluded = bag?.included === true;
|
|
1033
|
+
const fee = bag?.included === false ? bag?.price ?? null : null;
|
|
1034
|
+
if (!requireBag) {
|
|
1035
|
+
return isIncluded ? 0.8 : 0.5;
|
|
1036
|
+
}
|
|
1037
|
+
if (isIncluded) return 1;
|
|
1038
|
+
if (fee === null) return 0.3;
|
|
1039
|
+
const ratio = fee / Math.max(offer.price, 1);
|
|
1040
|
+
if (ratio < 0.05) return 0.72;
|
|
1041
|
+
if (ratio < 0.12) return 0.52;
|
|
1042
|
+
if (ratio < 0.22) return 0.32;
|
|
1043
|
+
return 0.12;
|
|
1044
|
+
}
|
|
1045
|
+
function scoreSavings(offer, travelerCount) {
|
|
1046
|
+
const gfp = normalizeGoogleFlightsComparisonPrice(offer.google_flights_price, travelerCount);
|
|
1047
|
+
if (!gfp || gfp <= 0) return 0.5;
|
|
1048
|
+
const pct = (gfp - offer.price) / gfp;
|
|
1049
|
+
if (pct >= 0.2) return 1;
|
|
1050
|
+
if (pct >= 0.12) return 0.85;
|
|
1051
|
+
if (pct >= 0.06) return 0.72;
|
|
1052
|
+
if (pct >= 0.01) return 0.6;
|
|
1053
|
+
if (pct >= -0.05) return 0.48;
|
|
1054
|
+
return 0.22;
|
|
1055
|
+
}
|
|
1056
|
+
function scoreComfortHours(depMins) {
|
|
1057
|
+
if (depMins >= 360 && depMins <= 1320) return 1;
|
|
1058
|
+
if (depMins >= 270 && depMins <= 1380) return 0.6;
|
|
1059
|
+
if (depMins >= 180) return 0.3;
|
|
1060
|
+
return 0.1;
|
|
1061
|
+
}
|
|
1062
|
+
function scoreLayover(offer, preferLong = false) {
|
|
1063
|
+
const outStops = offer.stops ?? 0;
|
|
1064
|
+
const inStops = offer.inbound?.stops ?? 0;
|
|
1065
|
+
if (outStops === 0 && inStops === 0) return preferLong ? 0.7 : 1;
|
|
1066
|
+
const layoverMins = [
|
|
1067
|
+
...offer.segments ?? [],
|
|
1068
|
+
...offer.inbound?.segments ?? []
|
|
1069
|
+
].filter((s) => (s.layover_minutes ?? 0) > 0).map((s) => s.layover_minutes);
|
|
1070
|
+
if (layoverMins.length === 0) return 0.5;
|
|
1071
|
+
const worst = Math.max(...layoverMins);
|
|
1072
|
+
if (preferLong) {
|
|
1073
|
+
if (worst < 40) return 0.1;
|
|
1074
|
+
if (worst < 120) return 0.3;
|
|
1075
|
+
if (worst < 240) return 0.55;
|
|
1076
|
+
if (worst < 480) return 0.78;
|
|
1077
|
+
if (worst <= 900) return 1;
|
|
1078
|
+
if (worst <= 1440) return 0.92;
|
|
1079
|
+
return 0.75;
|
|
1080
|
+
}
|
|
1081
|
+
if (worst < 40) return 0.1;
|
|
1082
|
+
if (worst < 60) return 0.35;
|
|
1083
|
+
if (worst <= 180) return 1;
|
|
1084
|
+
if (worst <= 240) return 0.82;
|
|
1085
|
+
if (worst <= 360) return 0.55;
|
|
1086
|
+
if (worst <= 480) return 0.28;
|
|
1087
|
+
if (worst <= 660) return 0.12;
|
|
1088
|
+
return 0.04;
|
|
1089
|
+
}
|
|
1090
|
+
function combinedStops(offer) {
|
|
1091
|
+
const out = offer.stops ?? 0;
|
|
1092
|
+
const inb = offer.inbound?.stops ?? 0;
|
|
1093
|
+
return Math.max(out, inb);
|
|
1094
|
+
}
|
|
1095
|
+
function totalRoundTripDuration(offer) {
|
|
1096
|
+
const out = offer.duration_minutes ?? 0;
|
|
1097
|
+
if (!offer.inbound) return out;
|
|
1098
|
+
const inb = offer.inbound.duration_minutes ?? 0;
|
|
1099
|
+
return out + inb;
|
|
1100
|
+
}
|
|
1101
|
+
function refundabilityMultiplier(offer, requireCancellation) {
|
|
1102
|
+
if (!requireCancellation) return 1;
|
|
1103
|
+
const refundability = offer.conditions?.refund_before_departure;
|
|
1104
|
+
if (refundability === "allowed") return 1;
|
|
1105
|
+
if (refundability === "allowed_with_fee") return 0.82;
|
|
1106
|
+
if (refundability === "unknown" || refundability === void 0) return 0.58;
|
|
1107
|
+
return 0.08;
|
|
1108
|
+
}
|
|
1109
|
+
function mealPreferenceMultiplier(offer, requireMeals) {
|
|
1110
|
+
if (!requireMeals) return 1;
|
|
1111
|
+
const mealSignal = extractOfferDetailSignals(offer).meals;
|
|
1112
|
+
if (mealSignal === "included") return 1;
|
|
1113
|
+
if (mealSignal === "available") return 0.84;
|
|
1114
|
+
return 0.56;
|
|
1115
|
+
}
|
|
1116
|
+
function generateFacts(offer, bd, refPrice, fastestMins, ctx, travelerCount, isHero) {
|
|
1117
|
+
const heroFacts = [];
|
|
1118
|
+
const tradeoffs = [];
|
|
1119
|
+
const cur = offer.currency;
|
|
1120
|
+
const priceDiff = Math.round(offer.price - refPrice);
|
|
1121
|
+
if (isHero) {
|
|
1122
|
+
if (priceDiff <= 5) {
|
|
1123
|
+
heroFacts.push(`cheapest available (${Math.round(offer.price)} ${cur})`);
|
|
1124
|
+
} else {
|
|
1125
|
+
tradeoffs.push(`${priceDiff} ${cur} above the cheapest option`);
|
|
1126
|
+
}
|
|
1127
|
+
} else {
|
|
1128
|
+
if (priceDiff < -5) {
|
|
1129
|
+
heroFacts.push(`${Math.abs(priceDiff)} ${cur} cheaper than the top pick`);
|
|
1130
|
+
} else if (priceDiff > 5) {
|
|
1131
|
+
tradeoffs.push(`${priceDiff} ${cur} more expensive than the top pick`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (offer.stops === 0) {
|
|
1135
|
+
heroFacts.push("direct flight \u2014 no layovers or connections");
|
|
1136
|
+
} else if (offer.stops === 1) {
|
|
1137
|
+
tradeoffs.push("1 stop");
|
|
1138
|
+
} else {
|
|
1139
|
+
tradeoffs.push(`${offer.stops} stops`);
|
|
1140
|
+
}
|
|
1141
|
+
const normalizedGooglePrice = normalizeGoogleFlightsComparisonPrice(offer.google_flights_price, travelerCount);
|
|
1142
|
+
if (GF_SAVINGS_COMPARISON_ENABLED && normalizedGooglePrice && normalizedGooglePrice > offer.price + 8) {
|
|
1143
|
+
const saving = Math.round(normalizedGooglePrice - offer.price);
|
|
1144
|
+
heroFacts.push(
|
|
1145
|
+
`${saving} ${cur} cheaper than Google Flights (Google shows ${Math.round(normalizedGooglePrice)} ${cur})`
|
|
1146
|
+
);
|
|
1147
|
+
} else if (GF_SAVINGS_COMPARISON_ENABLED && normalizedGooglePrice && offer.price > normalizedGooglePrice + 8) {
|
|
1148
|
+
const extra = Math.round(offer.price - normalizedGooglePrice);
|
|
1149
|
+
tradeoffs.push(`${extra} ${cur} more expensive than Google Flights shows`);
|
|
1150
|
+
}
|
|
1151
|
+
const durDiff = offer.duration_minutes - fastestMins;
|
|
1152
|
+
if (durDiff === 0) {
|
|
1153
|
+
heroFacts.push(
|
|
1154
|
+
`fastest flight on this route (${Math.floor(offer.duration_minutes / 60)}h ${offer.duration_minutes % 60}m)`
|
|
1155
|
+
);
|
|
1156
|
+
} else if (durDiff > 90) {
|
|
1157
|
+
tradeoffs.push(
|
|
1158
|
+
`${Math.floor(durDiff / 60)}h ${durDiff % 60}m longer than the fastest option`
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
const depMins = isoToMins(offer.departure_time);
|
|
1162
|
+
if (bd.depTime >= 0.88 && ctx.depTimePref) {
|
|
1163
|
+
heroFacts.push(
|
|
1164
|
+
`departs ${formatMins(depMins)} \u2014 matches your ${ctx.depTimePref.replace("_", " ")} preference`
|
|
1165
|
+
);
|
|
1166
|
+
} else if (bd.depTime <= 0.25 && ctx.depTimePref) {
|
|
1167
|
+
tradeoffs.push(
|
|
1168
|
+
`departure at ${formatMins(depMins)} doesn't match your ${ctx.depTimePref.replace("_", " ")} preference`
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
const arrMins = isoToMins(offer.arrival_time);
|
|
1172
|
+
const tripPurposes = normalizeTripPurposes({
|
|
1173
|
+
tripPurpose: ctx.tripPurpose,
|
|
1174
|
+
tripPurposes: ctx.tripPurposes
|
|
1175
|
+
});
|
|
1176
|
+
const isExploring = tripPurposes.some((purpose) => EARLY_ARRIVAL_PURPOSES.has(purpose));
|
|
1177
|
+
if (bd.arrivalTime >= 0.88) {
|
|
1178
|
+
heroFacts.push(
|
|
1179
|
+
`arrives ${formatMins(arrMins)}${isExploring ? " \u2014 full day to explore" : ""}`
|
|
1180
|
+
);
|
|
1181
|
+
} else if (bd.arrivalTime <= 0.25) {
|
|
1182
|
+
tradeoffs.push(`late arrival (${formatMins(arrMins)})`);
|
|
1183
|
+
}
|
|
1184
|
+
const bagIncluded = offer.ancillaries?.checked_bag?.included === true;
|
|
1185
|
+
const bagFee = offer.ancillaries?.checked_bag?.included === false ? offer.ancillaries.checked_bag.price : null;
|
|
1186
|
+
if (ctx.requireBag && bagIncluded) {
|
|
1187
|
+
heroFacts.push("checked bag already included in the ticket price");
|
|
1188
|
+
} else if (ctx.requireBag && bagFee != null) {
|
|
1189
|
+
tradeoffs.push(
|
|
1190
|
+
`bag costs extra (${Math.round(bagFee)} ${offer.ancillaries?.checked_bag?.currency ?? cur})`
|
|
1191
|
+
);
|
|
1192
|
+
} else if (ctx.requireBag && bagFee === null && !bagIncluded) {
|
|
1193
|
+
tradeoffs.push("bag fee unknown \u2014 check at booking");
|
|
1194
|
+
}
|
|
1195
|
+
const refundability = offer.conditions?.refund_before_departure;
|
|
1196
|
+
if (ctx.requireCancellation && refundability === "allowed") {
|
|
1197
|
+
heroFacts.push("refundable before departure");
|
|
1198
|
+
} else if (ctx.requireCancellation && refundability === "allowed_with_fee") {
|
|
1199
|
+
tradeoffs.push("refunds allowed with a fee");
|
|
1200
|
+
} else if (ctx.requireCancellation && refundability === "not_allowed") {
|
|
1201
|
+
tradeoffs.push("not refundable before departure");
|
|
1202
|
+
} else if (ctx.requireCancellation) {
|
|
1203
|
+
tradeoffs.push("refund policy not shown in the fare data");
|
|
1204
|
+
}
|
|
1205
|
+
const mealSignal = extractOfferDetailSignals(offer).meals;
|
|
1206
|
+
if (ctx.requireMeals && mealSignal === "included") {
|
|
1207
|
+
heroFacts.push("meal included in fare");
|
|
1208
|
+
} else if (ctx.requireMeals && mealSignal === "available") {
|
|
1209
|
+
heroFacts.push("meal option shown in fare data");
|
|
1210
|
+
} else if (ctx.requireMeals) {
|
|
1211
|
+
tradeoffs.push("meal availability not shown in the fare data");
|
|
1212
|
+
}
|
|
1213
|
+
if (ctx.preferredAirline) {
|
|
1214
|
+
const airLower = offer.airline.toLowerCase();
|
|
1215
|
+
const prefLower = ctx.preferredAirline.toLowerCase();
|
|
1216
|
+
if (airLower.includes(prefLower) || prefLower.includes(airLower.split(" ")[0])) {
|
|
1217
|
+
heroFacts.push(`with ${offer.airline} as you mentioned`);
|
|
1218
|
+
} else {
|
|
1219
|
+
tradeoffs.push(`not ${ctx.preferredAirline} (which you mentioned)`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
return { heroFacts, tradeoffs };
|
|
1223
|
+
}
|
|
1224
|
+
function premiumPenalty(offerPrice, cheapestPrice) {
|
|
1225
|
+
if (cheapestPrice <= 0) return 1;
|
|
1226
|
+
const ratio = (offerPrice - cheapestPrice) / cheapestPrice;
|
|
1227
|
+
if (ratio <= 0) return 1;
|
|
1228
|
+
if (ratio <= 0.08) return 1;
|
|
1229
|
+
if (ratio <= 0.18) return 0.96;
|
|
1230
|
+
if (ratio <= 0.3) return 0.88;
|
|
1231
|
+
if (ratio <= 0.5) return 0.76;
|
|
1232
|
+
if (ratio <= 0.8) return 0.58;
|
|
1233
|
+
if (ratio <= 1.2) return 0.4;
|
|
1234
|
+
if (ratio <= 2) return 0.26;
|
|
1235
|
+
return 0.14;
|
|
1236
|
+
}
|
|
1237
|
+
function selectHeroByGates(sorted, ctx) {
|
|
1238
|
+
if (sorted.length === 0) throw new Error("selectHeroByGates: empty input");
|
|
1239
|
+
const gates = [];
|
|
1240
|
+
if (ctx.requireCancellation) {
|
|
1241
|
+
gates.push({
|
|
1242
|
+
name: "refund",
|
|
1243
|
+
pred: (o) => o.conditions?.refund_before_departure === "allowed"
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
if (ctx.requireBag) {
|
|
1247
|
+
gates.push({
|
|
1248
|
+
name: "bag",
|
|
1249
|
+
pred: (o) => o.ancillaries?.checked_bag?.included === true
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
const timePreds = [];
|
|
1253
|
+
if (ctx.depTimePref) {
|
|
1254
|
+
timePreds.push((o) => scoreDepTime(isoToMins(o.departure_time), ctx.depTimePref) >= 0.55);
|
|
1255
|
+
}
|
|
1256
|
+
if (ctx.retTimePref) {
|
|
1257
|
+
timePreds.push((o) => {
|
|
1258
|
+
const retDep = o.inbound?.departure_time;
|
|
1259
|
+
if (!retDep) return true;
|
|
1260
|
+
return scoreDepTime(isoToMins(retDep), ctx.retTimePref) >= 0.55;
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
if (ctx.arrivalTimePref) {
|
|
1264
|
+
const tripPurposes = normalizeTripPurposes({
|
|
1265
|
+
tripPurpose: ctx.tripPurpose,
|
|
1266
|
+
tripPurposes: ctx.tripPurposes
|
|
1267
|
+
});
|
|
1268
|
+
timePreds.push((o) => {
|
|
1269
|
+
const arrMins = isoToMins(o.arrival_time);
|
|
1270
|
+
const dayOffset = daysBetween(o.departure_time, o.arrival_time);
|
|
1271
|
+
return scoreArrivalTime(arrMins, ctx.arrivalTimePref, tripPurposes, dayOffset) >= 0.55;
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (timePreds.length > 0) {
|
|
1275
|
+
gates.push({ name: "time", pred: (o) => timePreds.every((p) => p(o)) });
|
|
1276
|
+
}
|
|
1277
|
+
if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
|
|
1278
|
+
const cap = ctx.maxStops;
|
|
1279
|
+
gates.push({ name: "max_stops", pred: (o) => combinedStops(o) <= cap });
|
|
1280
|
+
}
|
|
1281
|
+
if (ctx.preferDirect) {
|
|
1282
|
+
gates.push({ name: "direct", pred: (o) => combinedStops(o) === 0 });
|
|
1283
|
+
}
|
|
1284
|
+
gates.push({ name: "not_suspect", pred: (o) => o.quality !== "suspect" });
|
|
1285
|
+
const topByScore = sorted.reduce((best, s) => s.score > best.score ? s : best, sorted[0]);
|
|
1286
|
+
const gateByName = new Map(gates.map((g) => [g.name, g.pred]));
|
|
1287
|
+
const trulyRelaxed = (names, hero) => names.filter((name) => {
|
|
1288
|
+
const pred = gateByName.get(name);
|
|
1289
|
+
return pred ? !pred(hero.offer) : true;
|
|
1290
|
+
});
|
|
1291
|
+
const relaxed = [];
|
|
1292
|
+
for (let dropCount = 0; dropCount <= gates.length; dropCount++) {
|
|
1293
|
+
const active = gates.slice(dropCount);
|
|
1294
|
+
const candidate = sorted.find((s) => active.every((g) => g.pred(s.offer)));
|
|
1295
|
+
if (candidate) {
|
|
1296
|
+
if (!relaxed.includes("bag") && ctx.requireBag) {
|
|
1297
|
+
const otherActive = active.filter((g) => g.name !== "bag");
|
|
1298
|
+
const altCandidate = sorted.find((s) => otherActive.every((g) => g.pred(s.offer)));
|
|
1299
|
+
if (altCandidate && altCandidate !== candidate && altCandidate === topByScore) {
|
|
1300
|
+
const ratio = candidate.score / altCandidate.score;
|
|
1301
|
+
if (ratio < 0.75) {
|
|
1302
|
+
relaxed.push("bag");
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return { hero: candidate, relaxed: trulyRelaxed(relaxed, candidate) };
|
|
1308
|
+
}
|
|
1309
|
+
if (dropCount < gates.length) relaxed.push(gates[dropCount].name);
|
|
1310
|
+
}
|
|
1311
|
+
return { hero: sorted[0], relaxed: trulyRelaxed(relaxed, sorted[0]) };
|
|
1312
|
+
}
|
|
1313
|
+
function rankOffers(offers, ctx, options) {
|
|
1314
|
+
if (offers.length === 0) return [];
|
|
1315
|
+
const pool = options?.skipDedup ? offers : deduplicateOffers(offers);
|
|
1316
|
+
const plausible = pool.filter((o) => {
|
|
1317
|
+
const out = o.duration_minutes ?? 0;
|
|
1318
|
+
if (out < 10 || out > 2880) return false;
|
|
1319
|
+
const inb = o.inbound?.duration_minutes;
|
|
1320
|
+
if (typeof inb === "number" && (inb < 10 || inb > 2880)) return false;
|
|
1321
|
+
return totalRoundTripDuration(o) <= 5760;
|
|
1322
|
+
});
|
|
1323
|
+
const weights = resolveWeights(ctx);
|
|
1324
|
+
const effectivePrice = (o) => o.displayPrice ?? o.price;
|
|
1325
|
+
const prices = plausible.map(effectivePrice);
|
|
1326
|
+
const durations = plausible.map(totalRoundTripDuration);
|
|
1327
|
+
const [pLo, pHi] = p5p95(prices);
|
|
1328
|
+
const [dLo, dHi] = p5p95(durations);
|
|
1329
|
+
const cheapestPrice = Math.min(...prices);
|
|
1330
|
+
const cheapestDirectPrice = ctx.preferDirect ? (() => {
|
|
1331
|
+
const dp = plausible.filter((o) => (o.stops ?? 0) === 0).map(effectivePrice).filter((p) => isFinite(p));
|
|
1332
|
+
return dp.length > 0 ? Math.min(...dp) : cheapestPrice;
|
|
1333
|
+
})() : cheapestPrice;
|
|
1334
|
+
const fastestMins = Math.min(...durations);
|
|
1335
|
+
const outboundDurations = plausible.map((o) => o.duration_minutes);
|
|
1336
|
+
const fastestOutboundMins = outboundDurations.length > 0 ? Math.min(...outboundDurations) : 0;
|
|
1337
|
+
const cheapestQuickPrice = ctx.preferQuickFlight ? (() => {
|
|
1338
|
+
const qp = plausible.filter((o) => totalRoundTripDuration(o) <= fastestMins * 2.5).map(effectivePrice).filter((p) => isFinite(p));
|
|
1339
|
+
return qp.length > 0 ? Math.min(...qp) : cheapestPrice;
|
|
1340
|
+
})() : cheapestPrice;
|
|
1341
|
+
const tripPurposes = normalizeTripPurposes({
|
|
1342
|
+
tripPurpose: ctx.tripPurpose,
|
|
1343
|
+
tripPurposes: ctx.tripPurposes
|
|
1344
|
+
});
|
|
1345
|
+
const scored = plausible.map((offer) => {
|
|
1346
|
+
const depMins = isoToMins(offer.departure_time);
|
|
1347
|
+
const arrMins = isoToMins(offer.arrival_time);
|
|
1348
|
+
const dayOffset = daysBetween(offer.departure_time, offer.arrival_time);
|
|
1349
|
+
const ep = effectivePrice(offer);
|
|
1350
|
+
const bd = {
|
|
1351
|
+
price: scorePrice(ep, pLo, pHi),
|
|
1352
|
+
stops: scoreStops(combinedStops(offer)),
|
|
1353
|
+
duration: scoreDuration(totalRoundTripDuration(offer), dLo, dHi),
|
|
1354
|
+
depTime: scoreDepTime(depMins, ctx.depTimePref),
|
|
1355
|
+
arrivalTime: scoreArrivalTime(arrMins, ctx.arrivalTimePref, tripPurposes, dayOffset),
|
|
1356
|
+
baggage: scoreBaggage(offer, ctx.requireBag),
|
|
1357
|
+
savings: scoreSavings(offer, ctx.travelerCount),
|
|
1358
|
+
comfortHours: scoreComfortHours(depMins),
|
|
1359
|
+
layover: scoreLayover(offer, ctx.preferLongLayover)
|
|
1360
|
+
};
|
|
1361
|
+
const rawScore = (bd.price * weights.price + bd.stops * weights.stops + bd.duration * weights.duration + bd.depTime * weights.depTime + bd.arrivalTime * weights.arrivalTime + bd.baggage * weights.baggage + bd.savings * weights.savings + bd.comfortHours * weights.comfortHours + bd.layover * weights.layover) * 100;
|
|
1362
|
+
const penaltyRef = ctx.preferDirect && (offer.stops ?? 0) === 0 ? cheapestDirectPrice : ctx.preferQuickFlight && totalRoundTripDuration(offer) <= fastestMins * 2.5 ? cheapestQuickPrice : cheapestPrice;
|
|
1363
|
+
const score = rawScore * premiumPenalty(ep, penaltyRef) * timeConstraintPenalty(depMins, ctx.departAfterMins, ctx.departBeforeMins) * depTimeMismatchMultiplier(depMins, ctx.depTimePref) * retDepTimeMismatchMultiplier(offer, ctx.retTimePref) * mealPreferenceMultiplier(offer, ctx.requireMeals) * refundabilityMultiplier(offer, ctx.requireCancellation);
|
|
1364
|
+
return { offer, score, rank: 0, breakdown: bd, heroFacts: [], tradeoffs: [] };
|
|
1365
|
+
});
|
|
1366
|
+
scored.sort((a, b) => {
|
|
1367
|
+
if (ctx.preferDirect) {
|
|
1368
|
+
const aDirect = combinedStops(a.offer) === 0 ? 0 : 1;
|
|
1369
|
+
const bDirect = combinedStops(b.offer) === 0 ? 0 : 1;
|
|
1370
|
+
if (aDirect !== bDirect) return aDirect - bDirect;
|
|
1371
|
+
}
|
|
1372
|
+
if (ctx.maxStops !== void 0 && !ctx.preferDirect) {
|
|
1373
|
+
const aIn = combinedStops(a.offer) <= ctx.maxStops ? 0 : 1;
|
|
1374
|
+
const bIn = combinedStops(b.offer) <= ctx.maxStops ? 0 : 1;
|
|
1375
|
+
if (aIn !== bIn) return aIn - bIn;
|
|
1376
|
+
}
|
|
1377
|
+
const scoreDelta = b.score - a.score;
|
|
1378
|
+
if (Math.abs(scoreDelta) > 1e-3) return scoreDelta;
|
|
1379
|
+
const priceDelta = effectivePrice(a.offer) - effectivePrice(b.offer);
|
|
1380
|
+
if (Math.abs(priceDelta) > 1e-3) return priceDelta;
|
|
1381
|
+
const stopsDelta = a.offer.stops - b.offer.stops;
|
|
1382
|
+
if (stopsDelta !== 0) return stopsDelta;
|
|
1383
|
+
const durationDelta = a.offer.duration_minutes - b.offer.duration_minutes;
|
|
1384
|
+
if (durationDelta !== 0) return durationDelta;
|
|
1385
|
+
const depDelta = isoToMins(a.offer.departure_time) - isoToMins(b.offer.departure_time);
|
|
1386
|
+
if (depDelta !== 0) return depDelta;
|
|
1387
|
+
return a.offer.id.localeCompare(b.offer.id);
|
|
1388
|
+
});
|
|
1389
|
+
if (scored.length > 0) {
|
|
1390
|
+
const { hero, relaxed } = selectHeroByGates(scored, ctx);
|
|
1391
|
+
const idx = scored.indexOf(hero);
|
|
1392
|
+
if (idx > 0) {
|
|
1393
|
+
scored.splice(idx, 1);
|
|
1394
|
+
scored.unshift(hero);
|
|
1395
|
+
}
|
|
1396
|
+
if (relaxed.length > 0) scored[0].relaxedGates = relaxed;
|
|
1397
|
+
}
|
|
1398
|
+
const heroPrice = scored[0]?.offer.price ?? cheapestPrice;
|
|
1399
|
+
for (let i = 0; i < scored.length; i++) {
|
|
1400
|
+
scored[i].rank = i + 1;
|
|
1401
|
+
const isHero = i === 0;
|
|
1402
|
+
const refPrice = isHero ? cheapestPrice : heroPrice;
|
|
1403
|
+
const { heroFacts, tradeoffs } = generateFacts(
|
|
1404
|
+
scored[i].offer,
|
|
1405
|
+
scored[i].breakdown,
|
|
1406
|
+
refPrice,
|
|
1407
|
+
fastestOutboundMins,
|
|
1408
|
+
ctx,
|
|
1409
|
+
ctx.travelerCount,
|
|
1410
|
+
isHero
|
|
1411
|
+
);
|
|
1412
|
+
scored[i].heroFacts = heroFacts;
|
|
1413
|
+
scored[i].tradeoffs = tradeoffs;
|
|
1414
|
+
}
|
|
1415
|
+
return scored;
|
|
1416
|
+
}
|
|
1417
|
+
function selectDiverseTop(ranked, n) {
|
|
1418
|
+
if (ranked.length === 0) return [];
|
|
1419
|
+
const result = [ranked[0]];
|
|
1420
|
+
const depSlot = (iso) => Math.floor(isoToMins(iso) / 180);
|
|
1421
|
+
for (const candidate of ranked.slice(1)) {
|
|
1422
|
+
if (result.length >= n) break;
|
|
1423
|
+
const isDiverse = result.every(
|
|
1424
|
+
(sel) => Math.abs(depSlot(candidate.offer.departure_time) - depSlot(sel.offer.departure_time)) >= 1 || Math.abs(candidate.offer.stops - sel.offer.stops) >= 1
|
|
1425
|
+
);
|
|
1426
|
+
if (isDiverse) result.push(candidate);
|
|
1427
|
+
}
|
|
1428
|
+
for (const candidate of ranked.slice(1)) {
|
|
1429
|
+
if (result.length >= n) break;
|
|
1430
|
+
if (!result.some((r) => getOfferInstanceKey(r.offer) === getOfferInstanceKey(candidate.offer))) {
|
|
1431
|
+
result.push(candidate);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return result;
|
|
1435
|
+
}
|
|
1436
|
+
function getProfileLabel(ctx) {
|
|
1437
|
+
const tripPurpose = getPrimaryTripPurpose({
|
|
1438
|
+
tripPurpose: ctx.tripPurpose,
|
|
1439
|
+
tripPurposes: ctx.tripPurposes
|
|
1440
|
+
});
|
|
1441
|
+
if (tripPurpose === "honeymoon") return "Honeymoon";
|
|
1442
|
+
if (tripPurpose === "special_occasion") return "Special occasion";
|
|
1443
|
+
if (tripPurpose === "business") return "Business travel";
|
|
1444
|
+
if (tripPurpose === "ski") return "Ski trip";
|
|
1445
|
+
if (tripPurpose === "beach") return "Beach holiday";
|
|
1446
|
+
if (tripPurpose === "city_break") return "City break";
|
|
1447
|
+
if (tripPurpose === "family_holiday") return "Family holiday";
|
|
1448
|
+
if (tripPurpose === "graduation") return "Graduation trip";
|
|
1449
|
+
if (tripPurpose === "concert_festival") return "Concert / festival";
|
|
1450
|
+
if (tripPurpose === "sports_event") return "Sports event";
|
|
1451
|
+
if (tripPurpose === "spring_break") return "Spring break";
|
|
1452
|
+
if (ctx.tripContext === "family") return "Family trip";
|
|
1453
|
+
if (ctx.tripContext === "couple") return "Couple";
|
|
1454
|
+
if (ctx.tripContext === "business_traveler") return "Business traveler";
|
|
1455
|
+
if (ctx.requireBag) return "Checked bag needed";
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/index.ts
|
|
52
1460
|
var ErrorCode = {
|
|
53
1461
|
// Transient (safe to retry after short delay)
|
|
54
1462
|
SUPPLIER_TIMEOUT: "SUPPLIER_TIMEOUT",
|
|
@@ -159,7 +1567,7 @@ var ValidationError = class extends LetsFGError {
|
|
|
159
1567
|
function routeStr(route) {
|
|
160
1568
|
if (!route.segments.length) return "";
|
|
161
1569
|
const codes = [route.segments[0].origin, ...route.segments.map((s) => s.destination)];
|
|
162
|
-
return codes.join("
|
|
1570
|
+
return codes.join(" -> ");
|
|
163
1571
|
}
|
|
164
1572
|
function durationHuman(seconds) {
|
|
165
1573
|
const h = Math.floor(seconds / 3600);
|
|
@@ -176,79 +1584,45 @@ function cheapestOffer(result) {
|
|
|
176
1584
|
if (!result.offers.length) return null;
|
|
177
1585
|
return result.offers.reduce((min, o) => o.price < min.price ? o : min, result.offers[0]);
|
|
178
1586
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
origin: origin.toUpperCase(),
|
|
183
|
-
destination: destination.toUpperCase(),
|
|
184
|
-
date_from: dateFrom,
|
|
185
|
-
adults: options.adults ?? 1,
|
|
186
|
-
children: options.children ?? 0,
|
|
187
|
-
currency: options.currency ?? "EUR",
|
|
188
|
-
limit: options.limit ?? 50,
|
|
189
|
-
return_date: options.returnDate,
|
|
190
|
-
cabin_class: options.cabinClass,
|
|
191
|
-
...options.maxBrowsers != null && { max_browsers: options.maxBrowsers },
|
|
192
|
-
...options.mode != null && { mode: options.mode }
|
|
193
|
-
});
|
|
194
|
-
return new Promise((resolve, reject) => {
|
|
195
|
-
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
196
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
197
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
198
|
-
});
|
|
199
|
-
let stdout = "";
|
|
200
|
-
let stderr = "";
|
|
201
|
-
child.stdout.on("data", (d) => {
|
|
202
|
-
stdout += d.toString();
|
|
203
|
-
});
|
|
204
|
-
child.stderr.on("data", (d) => {
|
|
205
|
-
stderr += d.toString();
|
|
206
|
-
});
|
|
207
|
-
child.on("close", (code) => {
|
|
208
|
-
try {
|
|
209
|
-
const data = JSON.parse(stdout);
|
|
210
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
211
|
-
else resolve(data);
|
|
212
|
-
} catch {
|
|
213
|
-
reject(new LetsFGError(
|
|
214
|
-
`Python search failed (code ${code}): ${stdout || stderr}
|
|
215
|
-
Make sure LetsFG is installed: pip install letsfg && playwright install chromium`
|
|
216
|
-
));
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
child.on("error", (err) => {
|
|
220
|
-
reject(new LetsFGError(
|
|
221
|
-
`Cannot start Python: ${err.message}
|
|
222
|
-
Install: pip install letsfg && playwright install chromium`
|
|
223
|
-
));
|
|
224
|
-
});
|
|
225
|
-
child.stdin.write(params);
|
|
226
|
-
child.stdin.end();
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
var DEFAULT_BASE_URL = "https://letsfg.co/developers";
|
|
1587
|
+
var DEFAULT_BASE_URL = "https://letsfg.co";
|
|
1588
|
+
var PFS_POLL_INTERVAL_MS = 1e4;
|
|
1589
|
+
var PFS_POLL_TIMEOUT_MS = 12e4;
|
|
230
1590
|
var LetsFG = class {
|
|
1591
|
+
bearerToken;
|
|
231
1592
|
apiKey;
|
|
232
1593
|
baseUrl;
|
|
233
1594
|
timeout;
|
|
234
1595
|
constructor(config = {}) {
|
|
1596
|
+
this.bearerToken = config.bearerToken || process.env.LETSFG_BEARER_TOKEN || "";
|
|
235
1597
|
this.apiKey = config.apiKey || process.env.LETSFG_API_KEY || "";
|
|
236
1598
|
this.baseUrl = (config.baseUrl || process.env.LETSFG_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
237
1599
|
this.timeout = config.timeout || 3e4;
|
|
238
1600
|
}
|
|
1601
|
+
requireAuth() {
|
|
1602
|
+
if (!this.bearerToken && !this.apiKey) {
|
|
1603
|
+
throw new AuthenticationError(
|
|
1604
|
+
"Authentication required. Set bearerToken (from `letsfg auth`) or apiKey in config, or set LETSFG_BEARER_TOKEN / LETSFG_API_KEY env vars."
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
239
1608
|
requireApiKey() {
|
|
240
1609
|
if (!this.apiKey) {
|
|
241
1610
|
throw new AuthenticationError(
|
|
242
|
-
"API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var
|
|
1611
|
+
"Developer API key required for this operation. Set apiKey in config or LETSFG_API_KEY env var. Register at letsfg.co/developers."
|
|
243
1612
|
);
|
|
244
1613
|
}
|
|
245
1614
|
}
|
|
1615
|
+
/** True when using PFS Bearer token (free search path) */
|
|
1616
|
+
get usingPFS() {
|
|
1617
|
+
return !!this.bearerToken;
|
|
1618
|
+
}
|
|
246
1619
|
// ── Core methods ─────────────────────────────────────────────────────
|
|
247
1620
|
/**
|
|
248
|
-
* Search for flights — FREE
|
|
1621
|
+
* Search for flights — FREE.
|
|
249
1622
|
*
|
|
250
|
-
* Uses
|
|
251
|
-
*
|
|
1623
|
+
* Uses PFS (Bearer token) or Developer API (X-API-Key) depending on config.
|
|
1624
|
+
* PFS: async polling (POST /api/search -> poll /api/results/<id> every 10s).
|
|
1625
|
+
* Developer API: synchronous 60-90s call.
|
|
252
1626
|
*
|
|
253
1627
|
* @param origin - IATA code (e.g., "GDN", "LON")
|
|
254
1628
|
* @param destination - IATA code (e.g., "BER", "BCN")
|
|
@@ -256,57 +1630,60 @@ var LetsFG = class {
|
|
|
256
1630
|
* @param options - Optional search parameters
|
|
257
1631
|
*/
|
|
258
1632
|
async search(origin, destination, dateFrom, options = {}) {
|
|
259
|
-
|
|
1633
|
+
this.requireAuth();
|
|
1634
|
+
const body = {
|
|
1635
|
+
origin: origin.toUpperCase(),
|
|
1636
|
+
destination: destination.toUpperCase(),
|
|
1637
|
+
date_from: dateFrom,
|
|
1638
|
+
adults: options.adults ?? 1,
|
|
1639
|
+
children: options.children ?? 0,
|
|
1640
|
+
currency: options.currency ?? "EUR",
|
|
1641
|
+
limit: options.limit ?? 50
|
|
1642
|
+
};
|
|
1643
|
+
if (options.returnDate) body.return_date = options.returnDate;
|
|
1644
|
+
if (options.cabinClass) body.cabin_class = options.cabinClass;
|
|
1645
|
+
if (options.maxStopovers != null) body.max_stopovers = options.maxStopovers;
|
|
1646
|
+
if (options.sort) body.sort = options.sort;
|
|
1647
|
+
if (this.usingPFS) {
|
|
1648
|
+
return this.searchPFS(body);
|
|
1649
|
+
}
|
|
1650
|
+
return this.post("/developers/api/v1/flights/search", body);
|
|
1651
|
+
}
|
|
1652
|
+
/** PFS path: POST /api/search -> poll /api/results/<id> */
|
|
1653
|
+
async searchPFS(body) {
|
|
1654
|
+
const { search_id } = await this.postWithBearer("/api/search", body);
|
|
1655
|
+
const deadline = Date.now() + PFS_POLL_TIMEOUT_MS;
|
|
1656
|
+
while (Date.now() < deadline) {
|
|
1657
|
+
await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
|
|
1658
|
+
const result = await this.getNoAuth(
|
|
1659
|
+
`/api/results/${search_id}`
|
|
1660
|
+
);
|
|
1661
|
+
if (result.status !== "pending") {
|
|
1662
|
+
return result;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
|
|
260
1666
|
}
|
|
261
1667
|
/**
|
|
262
|
-
* Resolve a city/airport name to IATA codes
|
|
1668
|
+
* Resolve a city/airport name to IATA codes.
|
|
263
1669
|
*/
|
|
264
1670
|
async resolveLocation(query) {
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
270
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
271
|
-
});
|
|
272
|
-
let stdout = "";
|
|
273
|
-
let stderr = "";
|
|
274
|
-
child.stdout.on("data", (d) => {
|
|
275
|
-
stdout += d.toString();
|
|
276
|
-
});
|
|
277
|
-
child.stderr.on("data", (d) => {
|
|
278
|
-
stderr += d.toString();
|
|
279
|
-
});
|
|
280
|
-
child.on("close", (code) => {
|
|
281
|
-
try {
|
|
282
|
-
const data = JSON.parse(stdout);
|
|
283
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
284
|
-
else resolve(Array.isArray(data) ? data : data.locations || [data]);
|
|
285
|
-
} catch {
|
|
286
|
-
reject(new LetsFGError(
|
|
287
|
-
`Location resolution failed (code ${code}): ${stdout || stderr}`
|
|
288
|
-
));
|
|
289
|
-
}
|
|
290
|
-
});
|
|
291
|
-
child.on("error", (err) => {
|
|
292
|
-
reject(new LetsFGError(`Cannot start Python: ${err.message}`));
|
|
293
|
-
});
|
|
294
|
-
child.stdin.write(params);
|
|
295
|
-
child.stdin.end();
|
|
296
|
-
});
|
|
1671
|
+
this.requireAuth();
|
|
1672
|
+
const path = this.usingPFS ? `/api/locations?q=${encodeURIComponent(query)}` : `/developers/api/v1/flights/locations/${encodeURIComponent(query)}`;
|
|
1673
|
+
const data = await this.getWithAuth(path);
|
|
1674
|
+
return Array.isArray(data) ? data : data.locations || [];
|
|
297
1675
|
}
|
|
298
1676
|
/**
|
|
299
|
-
* Unlock a flight offer —
|
|
300
|
-
*
|
|
1677
|
+
* Unlock a flight offer — confirms live price, reveals direct airline booking URL.
|
|
1678
|
+
* Cost: 1% of ticket price, min $3. Free with Developer API.
|
|
301
1679
|
*/
|
|
302
1680
|
async unlock(offerId) {
|
|
303
|
-
this.
|
|
304
|
-
|
|
1681
|
+
this.requireAuth();
|
|
1682
|
+
const path = this.usingPFS ? "/api/unlock" : "/developers/api/v1/bookings/unlock";
|
|
1683
|
+
return this.postWithAuth(path, { offer_id: offerId });
|
|
305
1684
|
}
|
|
306
1685
|
/**
|
|
307
|
-
* Book a flight — charges ticket price via Stripe.
|
|
308
|
-
* Creates a real airline reservation with PNR.
|
|
309
|
-
*
|
|
1686
|
+
* Book a flight via Developer API — charges ticket price via Stripe, creates real PNR.
|
|
310
1687
|
* Always provide idempotencyKey to prevent double-bookings on retry.
|
|
311
1688
|
*/
|
|
312
1689
|
async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
|
|
@@ -319,113 +1696,32 @@ var LetsFG = class {
|
|
|
319
1696
|
contact_phone: contactPhone
|
|
320
1697
|
};
|
|
321
1698
|
if (idempotencyKey) body.idempotency_key = idempotencyKey;
|
|
322
|
-
return this.post("/api/v1/bookings/book", body);
|
|
1699
|
+
return this.post("/developers/api/v1/bookings/book", body);
|
|
323
1700
|
}
|
|
324
1701
|
/**
|
|
325
|
-
* Set up payment method (required before booking).
|
|
1702
|
+
* Set up payment method (required before unlock/booking).
|
|
326
1703
|
*/
|
|
327
1704
|
async setupPayment(token = "tok_visa") {
|
|
328
1705
|
this.requireApiKey();
|
|
329
|
-
return this.post("/api/v1/agents/setup-payment", { token });
|
|
330
|
-
}
|
|
331
|
-
/**
|
|
332
|
-
* Start automated checkout — drives to payment page, NEVER submits payment.
|
|
333
|
-
*
|
|
334
|
-
* Requires unlock first. Returns progress with screenshot and
|
|
335
|
-
* booking URL for manual completion.
|
|
336
|
-
*
|
|
337
|
-
* @param offerId - Offer ID from search results
|
|
338
|
-
* @param passengers - Passenger details (use test data for safety)
|
|
339
|
-
* @param checkoutToken - Token from unlock() response
|
|
340
|
-
*/
|
|
341
|
-
async startCheckout(offerId, passengers, checkoutToken) {
|
|
342
|
-
this.requireApiKey();
|
|
343
|
-
return this.post("/api/v1/bookings/start-checkout", {
|
|
344
|
-
offer_id: offerId,
|
|
345
|
-
passengers,
|
|
346
|
-
checkout_token: checkoutToken
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Start checkout locally via Python (runs on your machine).
|
|
351
|
-
* Requires: pip install letsfg && playwright install chromium
|
|
352
|
-
*
|
|
353
|
-
* @param offer - Full FlightOffer object from search results
|
|
354
|
-
* @param passengers - Passenger details
|
|
355
|
-
* @param checkoutToken - Token from unlock()
|
|
356
|
-
*/
|
|
357
|
-
async startCheckoutLocal(offer, passengers, checkoutToken) {
|
|
358
|
-
const { spawn } = await import("child_process");
|
|
359
|
-
const input = JSON.stringify({
|
|
360
|
-
__checkout: true,
|
|
361
|
-
offer,
|
|
362
|
-
passengers,
|
|
363
|
-
checkout_token: checkoutToken,
|
|
364
|
-
api_key: this.apiKey,
|
|
365
|
-
base_url: this.baseUrl
|
|
366
|
-
});
|
|
367
|
-
return new Promise((resolve, reject) => {
|
|
368
|
-
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
369
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
370
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
371
|
-
timeout: 18e4
|
|
372
|
-
});
|
|
373
|
-
let stdout = "";
|
|
374
|
-
let stderr = "";
|
|
375
|
-
child.stdout.on("data", (d) => {
|
|
376
|
-
stdout += d.toString();
|
|
377
|
-
});
|
|
378
|
-
child.stderr.on("data", (d) => {
|
|
379
|
-
stderr += d.toString();
|
|
380
|
-
});
|
|
381
|
-
child.on("close", (code) => {
|
|
382
|
-
try {
|
|
383
|
-
const data = JSON.parse(stdout);
|
|
384
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
385
|
-
else resolve(data);
|
|
386
|
-
} catch {
|
|
387
|
-
reject(new LetsFGError(`Checkout failed (code ${code}): ${stdout || stderr}`));
|
|
388
|
-
}
|
|
389
|
-
});
|
|
390
|
-
child.on("error", (err) => {
|
|
391
|
-
reject(new LetsFGError(`Cannot start Python: ${err.message}`));
|
|
392
|
-
});
|
|
393
|
-
child.stdin.write(input);
|
|
394
|
-
child.stdin.end();
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* Link GitHub account for FREE unlimited access.
|
|
399
|
-
*
|
|
400
|
-
* Star https://github.com/LetsFG/LetsFG, then call this with your username.
|
|
401
|
-
* Once verified, access is permanent.
|
|
402
|
-
*/
|
|
403
|
-
async linkGithub(githubUsername) {
|
|
404
|
-
this.requireApiKey();
|
|
405
|
-
return this.post("/api/v1/agents/link-github", { github_username: githubUsername });
|
|
1706
|
+
return this.post("/developers/api/v1/agents/setup-payment", { token });
|
|
406
1707
|
}
|
|
407
1708
|
/**
|
|
408
1709
|
* Get current agent profile and usage stats.
|
|
409
1710
|
*/
|
|
410
1711
|
async me() {
|
|
411
1712
|
this.requireApiKey();
|
|
412
|
-
return this.get("/api/v1/agents/me");
|
|
1713
|
+
return this.get("/developers/api/v1/agents/me");
|
|
413
1714
|
}
|
|
414
1715
|
// ── Static methods ───────────────────────────────────────────────────
|
|
415
1716
|
/**
|
|
416
|
-
* Register a new agent — no
|
|
1717
|
+
* Register a new Developer API agent — no auth needed.
|
|
417
1718
|
*/
|
|
418
1719
|
static async register(agentName, email, baseUrl, ownerName = "", description = "") {
|
|
419
1720
|
const url = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
420
|
-
const resp = await fetch(`${url}/api/v1/agents/register`, {
|
|
1721
|
+
const resp = await fetch(`${url}/developers/api/v1/agents/register`, {
|
|
421
1722
|
method: "POST",
|
|
422
1723
|
headers: { "Content-Type": "application/json" },
|
|
423
|
-
body: JSON.stringify({
|
|
424
|
-
agent_name: agentName,
|
|
425
|
-
email,
|
|
426
|
-
owner_name: ownerName,
|
|
427
|
-
description
|
|
428
|
-
})
|
|
1724
|
+
body: JSON.stringify({ agent_name: agentName, email, owner_name: ownerName, description })
|
|
429
1725
|
});
|
|
430
1726
|
const data = await resp.json();
|
|
431
1727
|
if (!resp.ok) {
|
|
@@ -438,28 +1734,39 @@ var LetsFG = class {
|
|
|
438
1734
|
return data;
|
|
439
1735
|
}
|
|
440
1736
|
// ── Internal ────────────────────────────────────────────────────────
|
|
1737
|
+
async postWithBearer(path, body) {
|
|
1738
|
+
return this.requestWithHeaders(path, "POST", { "Authorization": `Bearer ${this.bearerToken}` }, body);
|
|
1739
|
+
}
|
|
1740
|
+
async getNoAuth(path) {
|
|
1741
|
+
return this.requestWithHeaders(path, "GET", {});
|
|
1742
|
+
}
|
|
1743
|
+
async getWithAuth(path) {
|
|
1744
|
+
const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
|
|
1745
|
+
return this.requestWithHeaders(path, "GET", headers);
|
|
1746
|
+
}
|
|
1747
|
+
async postWithAuth(path, body) {
|
|
1748
|
+
const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
|
|
1749
|
+
return this.requestWithHeaders(path, "POST", headers, body);
|
|
1750
|
+
}
|
|
441
1751
|
async post(path, body) {
|
|
442
|
-
return this.
|
|
443
|
-
method: "POST",
|
|
444
|
-
body: JSON.stringify(body)
|
|
445
|
-
});
|
|
1752
|
+
return this.requestWithHeaders(path, "POST", { "X-API-Key": this.apiKey }, body);
|
|
446
1753
|
}
|
|
447
1754
|
async get(path) {
|
|
448
|
-
return this.
|
|
1755
|
+
return this.requestWithHeaders(path, "GET", { "X-API-Key": this.apiKey });
|
|
449
1756
|
}
|
|
450
|
-
async
|
|
1757
|
+
async requestWithHeaders(path, method, extraHeaders, body) {
|
|
451
1758
|
const controller = new AbortController();
|
|
452
1759
|
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
453
1760
|
try {
|
|
454
1761
|
const resp = await fetch(`${this.baseUrl}${path}`, {
|
|
455
|
-
|
|
1762
|
+
method,
|
|
456
1763
|
headers: {
|
|
457
1764
|
"Content-Type": "application/json",
|
|
458
|
-
"X-API-Key": this.apiKey,
|
|
459
1765
|
"User-Agent": "LetsFG-js/0.1.0",
|
|
460
1766
|
"X-Client-Type": "js-sdk",
|
|
461
|
-
...
|
|
1767
|
+
...extraHeaders
|
|
462
1768
|
},
|
|
1769
|
+
...body != null ? { body: JSON.stringify(body) } : {},
|
|
463
1770
|
signal: controller.signal
|
|
464
1771
|
});
|
|
465
1772
|
const data = await resp.json();
|
|
@@ -478,42 +1785,6 @@ var LetsFG = class {
|
|
|
478
1785
|
}
|
|
479
1786
|
}
|
|
480
1787
|
};
|
|
481
|
-
async function systemInfo() {
|
|
482
|
-
const { spawn } = await import("child_process");
|
|
483
|
-
return new Promise((resolve, reject) => {
|
|
484
|
-
const pythonCmd = process.platform === "win32" ? "python" : "python3";
|
|
485
|
-
const child = spawn(pythonCmd, ["-m", "letsfg.local"], {
|
|
486
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
487
|
-
});
|
|
488
|
-
let stdout = "";
|
|
489
|
-
let stderr = "";
|
|
490
|
-
child.stdout.on("data", (d) => {
|
|
491
|
-
stdout += d.toString();
|
|
492
|
-
});
|
|
493
|
-
child.stderr.on("data", (d) => {
|
|
494
|
-
stderr += d.toString();
|
|
495
|
-
});
|
|
496
|
-
child.on("close", (code) => {
|
|
497
|
-
try {
|
|
498
|
-
const data = JSON.parse(stdout);
|
|
499
|
-
if (data.error) reject(new LetsFGError(data.error));
|
|
500
|
-
else resolve(data);
|
|
501
|
-
} catch {
|
|
502
|
-
reject(new LetsFGError(
|
|
503
|
-
`Python system-info failed (code ${code}): ${stdout || stderr}`
|
|
504
|
-
));
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
child.on("error", (err) => {
|
|
508
|
-
reject(new LetsFGError(
|
|
509
|
-
`Cannot start Python: ${err.message}
|
|
510
|
-
Install: pip install letsfg`
|
|
511
|
-
));
|
|
512
|
-
});
|
|
513
|
-
child.stdin.write(JSON.stringify({ __system_info: true }));
|
|
514
|
-
child.stdin.end();
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
1788
|
var index_default = LetsFG;
|
|
518
1789
|
var BoostedTravel = LetsFG;
|
|
519
1790
|
var BoostedTravelError = LetsFGError;
|
|
@@ -528,11 +1799,17 @@ var BoostedTravelError = LetsFGError;
|
|
|
528
1799
|
LetsFGError,
|
|
529
1800
|
OfferExpiredError,
|
|
530
1801
|
PaymentRequiredError,
|
|
1802
|
+
TRIP_PURPOSES,
|
|
531
1803
|
ValidationError,
|
|
532
1804
|
cheapestOffer,
|
|
533
|
-
|
|
534
|
-
|
|
1805
|
+
deduplicateOffers,
|
|
1806
|
+
extractOfferDetailSignals,
|
|
1807
|
+
getOfferDetailBadges,
|
|
1808
|
+
getOfferDetailPromptNotes,
|
|
1809
|
+
getPrimaryTripPurpose,
|
|
1810
|
+
getProfileLabel,
|
|
1811
|
+
normalizeTripPurposes,
|
|
535
1812
|
offerSummary,
|
|
536
|
-
|
|
537
|
-
|
|
1813
|
+
rankOffers,
|
|
1814
|
+
selectDiverseTop
|
|
538
1815
|
});
|