letsfg 2026.5.54 → 2026.5.56

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/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
- getSystemInfo: () => systemInfo,
46
- localSearch: () => searchLocal,
37
+ extractOfferDetailSignals: () => extractOfferDetailSignals,
38
+ getOfferDetailBadges: () => getOfferDetailBadges,
39
+ getOfferDetailPromptNotes: () => getOfferDetailPromptNotes,
40
+ getPrimaryTripPurpose: () => getPrimaryTripPurpose,
41
+ getProfileLabel: () => getProfileLabel,
42
+ normalizeTripPurposes: () => normalizeTripPurposes,
47
43
  offerSummary: () => offerSummary,
48
- searchLocal: () => searchLocal,
49
- systemInfo: () => systemInfo
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(" \u2192 ");
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
- async function searchLocal(origin, destination, dateFrom, options = {}) {
180
- const { spawn } = await import("child_process");
181
- const params = JSON.stringify({
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.\nNote: searchLocal() works without an API key."
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, unlimited, runs locally on your machine.
1621
+ * Search for flights — FREE.
249
1622
  *
250
- * Uses 200 airline connectors via Python subprocess. No backend call.
251
- * Requires: pip install letsfg && playwright install chromium
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,62 @@ var LetsFG = class {
256
1630
  * @param options - Optional search parameters
257
1631
  */
258
1632
  async search(origin, destination, dateFrom, options = {}) {
259
- return searchLocal(origin, destination, dateFrom, options);
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 (options.departureTimeFrom) body.departure_time_from = options.departureTimeFrom;
1648
+ if (options.departureTimeTo) body.departure_time_to = options.departureTimeTo;
1649
+ if (this.usingPFS) {
1650
+ return this.searchPFS(body);
1651
+ }
1652
+ return this.post("/developers/api/v1/flights/search", body);
1653
+ }
1654
+ /** PFS path: POST /api/search -> poll /api/results/<id> */
1655
+ async searchPFS(body) {
1656
+ const { search_id } = await this.postWithBearer("/api/search", body);
1657
+ const deadline = Date.now() + PFS_POLL_TIMEOUT_MS;
1658
+ while (Date.now() < deadline) {
1659
+ await new Promise((r) => setTimeout(r, PFS_POLL_INTERVAL_MS));
1660
+ const result = await this.getNoAuth(
1661
+ `/api/results/${search_id}`
1662
+ );
1663
+ if (result.status !== "pending") {
1664
+ return result;
1665
+ }
1666
+ }
1667
+ throw new LetsFGError("Search timed out after 120s. Try polling /api/results/<id> directly.", 504);
260
1668
  }
261
1669
  /**
262
- * Resolve a city/airport name to IATA codes — runs locally, no backend call.
1670
+ * Resolve a city/airport name to IATA codes.
263
1671
  */
264
1672
  async resolveLocation(query) {
265
- const { spawn } = await import("child_process");
266
- const params = JSON.stringify({ __resolve_location: true, query });
267
- return new Promise((resolve, reject) => {
268
- const pythonCmd = process.platform === "win32" ? "python" : "python3";
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
- });
1673
+ this.requireAuth();
1674
+ const path = this.usingPFS ? `/api/locations?q=${encodeURIComponent(query)}` : `/developers/api/v1/flights/locations/${encodeURIComponent(query)}`;
1675
+ const data = await this.getWithAuth(path);
1676
+ return Array.isArray(data) ? data : data.locations || [];
297
1677
  }
298
1678
  /**
299
- * Unlock a flight offer — FREE with GitHub star.
300
- * Confirms price, reserves for 30 minutes.
1679
+ * Unlock a flight offer — confirms live price, reveals direct airline booking URL.
1680
+ * Cost: 1% of ticket price, min $3. Free with Developer API.
301
1681
  */
302
1682
  async unlock(offerId) {
303
- this.requireApiKey();
304
- return this.post("/api/v1/bookings/unlock", { offer_id: offerId });
1683
+ this.requireAuth();
1684
+ const path = this.usingPFS ? "/api/unlock" : "/developers/api/v1/bookings/unlock";
1685
+ return this.postWithAuth(path, { offer_id: offerId });
305
1686
  }
306
1687
  /**
307
- * Book a flight — charges ticket price via Stripe.
308
- * Creates a real airline reservation with PNR.
309
- *
1688
+ * Book a flight via Developer API — charges ticket price via Stripe, creates real PNR.
310
1689
  * Always provide idempotencyKey to prevent double-bookings on retry.
311
1690
  */
312
1691
  async book(offerId, passengers, contactEmail, contactPhone = "", idempotencyKey = "") {
@@ -319,113 +1698,32 @@ var LetsFG = class {
319
1698
  contact_phone: contactPhone
320
1699
  };
321
1700
  if (idempotencyKey) body.idempotency_key = idempotencyKey;
322
- return this.post("/api/v1/bookings/book", body);
1701
+ return this.post("/developers/api/v1/bookings/book", body);
323
1702
  }
324
1703
  /**
325
- * Set up payment method (required before booking).
1704
+ * Set up payment method (required before unlock/booking).
326
1705
  */
327
1706
  async setupPayment(token = "tok_visa") {
328
1707
  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 });
1708
+ return this.post("/developers/api/v1/agents/setup-payment", { token });
406
1709
  }
407
1710
  /**
408
1711
  * Get current agent profile and usage stats.
409
1712
  */
410
1713
  async me() {
411
1714
  this.requireApiKey();
412
- return this.get("/api/v1/agents/me");
1715
+ return this.get("/developers/api/v1/agents/me");
413
1716
  }
414
1717
  // ── Static methods ───────────────────────────────────────────────────
415
1718
  /**
416
- * Register a new agent — no API key needed.
1719
+ * Register a new Developer API agent — no auth needed.
417
1720
  */
418
1721
  static async register(agentName, email, baseUrl, ownerName = "", description = "") {
419
1722
  const url = (baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
420
- const resp = await fetch(`${url}/api/v1/agents/register`, {
1723
+ const resp = await fetch(`${url}/developers/api/v1/agents/register`, {
421
1724
  method: "POST",
422
1725
  headers: { "Content-Type": "application/json" },
423
- body: JSON.stringify({
424
- agent_name: agentName,
425
- email,
426
- owner_name: ownerName,
427
- description
428
- })
1726
+ body: JSON.stringify({ agent_name: agentName, email, owner_name: ownerName, description })
429
1727
  });
430
1728
  const data = await resp.json();
431
1729
  if (!resp.ok) {
@@ -438,28 +1736,39 @@ var LetsFG = class {
438
1736
  return data;
439
1737
  }
440
1738
  // ── Internal ────────────────────────────────────────────────────────
1739
+ async postWithBearer(path, body) {
1740
+ return this.requestWithHeaders(path, "POST", { "Authorization": `Bearer ${this.bearerToken}` }, body);
1741
+ }
1742
+ async getNoAuth(path) {
1743
+ return this.requestWithHeaders(path, "GET", {});
1744
+ }
1745
+ async getWithAuth(path) {
1746
+ const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
1747
+ return this.requestWithHeaders(path, "GET", headers);
1748
+ }
1749
+ async postWithAuth(path, body) {
1750
+ const headers = this.usingPFS ? { "Authorization": `Bearer ${this.bearerToken}` } : { "X-API-Key": this.apiKey };
1751
+ return this.requestWithHeaders(path, "POST", headers, body);
1752
+ }
441
1753
  async post(path, body) {
442
- return this.request(path, {
443
- method: "POST",
444
- body: JSON.stringify(body)
445
- });
1754
+ return this.requestWithHeaders(path, "POST", { "X-API-Key": this.apiKey }, body);
446
1755
  }
447
1756
  async get(path) {
448
- return this.request(path, { method: "GET" });
1757
+ return this.requestWithHeaders(path, "GET", { "X-API-Key": this.apiKey });
449
1758
  }
450
- async request(path, init) {
1759
+ async requestWithHeaders(path, method, extraHeaders, body) {
451
1760
  const controller = new AbortController();
452
1761
  const timer = setTimeout(() => controller.abort(), this.timeout);
453
1762
  try {
454
1763
  const resp = await fetch(`${this.baseUrl}${path}`, {
455
- ...init,
1764
+ method,
456
1765
  headers: {
457
1766
  "Content-Type": "application/json",
458
- "X-API-Key": this.apiKey,
459
1767
  "User-Agent": "LetsFG-js/0.1.0",
460
1768
  "X-Client-Type": "js-sdk",
461
- ...init.headers || {}
1769
+ ...extraHeaders
462
1770
  },
1771
+ ...body != null ? { body: JSON.stringify(body) } : {},
463
1772
  signal: controller.signal
464
1773
  });
465
1774
  const data = await resp.json();
@@ -478,42 +1787,6 @@ var LetsFG = class {
478
1787
  }
479
1788
  }
480
1789
  };
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
1790
  var index_default = LetsFG;
518
1791
  var BoostedTravel = LetsFG;
519
1792
  var BoostedTravelError = LetsFGError;
@@ -528,11 +1801,17 @@ var BoostedTravelError = LetsFGError;
528
1801
  LetsFGError,
529
1802
  OfferExpiredError,
530
1803
  PaymentRequiredError,
1804
+ TRIP_PURPOSES,
531
1805
  ValidationError,
532
1806
  cheapestOffer,
533
- getSystemInfo,
534
- localSearch,
1807
+ deduplicateOffers,
1808
+ extractOfferDetailSignals,
1809
+ getOfferDetailBadges,
1810
+ getOfferDetailPromptNotes,
1811
+ getPrimaryTripPurpose,
1812
+ getProfileLabel,
1813
+ normalizeTripPurposes,
535
1814
  offerSummary,
536
- searchLocal,
537
- systemInfo
1815
+ rankOffers,
1816
+ selectDiverseTop
538
1817
  });