@curless/hotel-storefront 0.30.2 → 0.30.4

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.
@@ -1,6 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { DEFAULT_PAGE, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, partyNeeds, } from '../backend.js';
3
- import { addDays, arrivalInstant, atHotelTime, nights as nightsBetween, stayProblem, todayAt, } from '../dates.js';
3
+ import { addDays, arrivalInstant, atHotelTime, nights as nightsBetween, stayProblem, stayProblemOf, todayAt, } from '../dates.js';
4
4
  import { byWords, splitByStay } from '../demo-hotels.js';
5
5
  import { FACILITIES, hasFacility } from '../facilities.js';
6
6
  import { endOf, startProblem } from '../hourly.js';
@@ -64,6 +64,8 @@ const SAME_TRIP_MS = 10 * 60_000;
64
64
  * rather than sorting the twenty it happened to price.
65
65
  */
66
66
  export const PRICE_ALL = 20;
67
+ /** Hotels whose product lists are read to find which sell rooms by the hour today. */
68
+ export const HOURLY_SCAN = 60;
67
69
  const PRICE_CONCURRENCY = 4;
68
70
  /** What a room is called here: the room type, then the rate when there is more than one. */
69
71
  const ROOM_SEP = '~';
@@ -509,6 +511,8 @@ export const createHuazhuHotels = (opts) => {
509
511
  return got;
510
512
  };
511
513
  const productsOf = async (hotelId) => (await productsWithDays(hotelId)).value;
514
+ /** Whether a hotel sells any room by the hour on its today - from its products, no prices. */
515
+ const sellsByTheHourToday = async (hotelId) => (await productsOf(hotelId)).some((p) => p.checkinType === 'HOUR' && inStayWindow(p.stayWindow, today()));
512
516
  const ariCache = new Map();
513
517
  /** Every product's prices, rules and rooms left, one element per date from `start` to `end`. */
514
518
  const ariFor = async (hotelId, start, end, fresh = false) => {
@@ -579,6 +583,7 @@ export const createHuazhuHotels = (opts) => {
579
583
  ok: false,
580
584
  kind: 'too-far',
581
585
  why: `rooms here are on sale at most ${sellDays} days ahead`,
586
+ args: { days: sellDays },
582
587
  },
583
588
  }))
584
589
  .filter((x) => !(x.roomType && x.roomType.status === 0));
@@ -795,6 +800,18 @@ export const createHuazhuHotels = (opts) => {
795
800
  ...(maxChildren > 0 && maxChildren < 99 ? { maxChildren } : {}),
796
801
  };
797
802
  };
803
+ /** A party too big for any room here, said by partyNeeds with its numbers beside it. */
804
+ const partyWhy = (guests, largest) => ({
805
+ why: partyNeeds(guests, largest),
806
+ code: 'party',
807
+ ...(largest > 0 ? { args: { largest, rooms: Math.ceil(guests / largest) } } : {}),
808
+ });
809
+ /** A hotel's "nothing to book", as HotelDetail carries it. */
810
+ const noRoomsOf = (w) => ({
811
+ noRooms: w.why,
812
+ noRoomsCode: w.code,
813
+ ...(w.args ? { noRoomsArgs: w.args } : {}),
814
+ });
798
815
  /** The most a room here sleeps, from rates refused for the party ("sleeps 2"). */
799
816
  const largestOf = (fails) => Math.max(...fails.map((f) => Number(/\d+/.exec(f.why)?.[0] ?? 0)));
800
817
  /**
@@ -807,49 +824,84 @@ export const createHuazhuHotels = (opts) => {
807
824
  */
808
825
  const hourlyNoRoomsWhy = (priced, guests) => {
809
826
  if (priced.length === 0)
810
- return 'This hotel sells no rooms by the hour today.';
827
+ return { why: 'This hotel sells no rooms by the hour today.', code: 'hourly-none' };
811
828
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
812
829
  if (fails.length > 0 && fails.every((f) => f.kind === 'party'))
813
- return partyNeeds(guests, largestOf(fails));
830
+ return partyWhy(guests, largestOf(fails));
814
831
  if (fails.some((f) => f.kind === 'prepaid'))
815
- return "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.";
832
+ return {
833
+ why: "This hotel's rooms by the hour are paid in advance, and this connector books rates paid at the hotel.",
834
+ code: 'prepaid-only',
835
+ };
816
836
  const said = (re) => fails
817
837
  .map((f) => (f.kind === 'closed' ? re.exec(f.why)?.[1] : undefined))
818
838
  .filter((t) => !!t)
819
839
  .sort();
820
840
  const opens = said(/on sale today from (\d\d:\d\d)/)[0];
821
841
  if (opens)
822
- return `Rooms by the hour here go on sale today from ${opens}.`;
842
+ return {
843
+ why: `Rooms by the hour here go on sale today from ${opens}.`,
844
+ code: 'hourly-not-yet',
845
+ args: { opensAt: opens },
846
+ };
823
847
  const last = said(/the last start today was (\d\d:\d\d)/).pop();
824
848
  if (last)
825
- return `No room by the hour is left here today: the last start today was ${last}.`;
826
- return 'No room by the hour is left here today.';
849
+ return {
850
+ why: `No room by the hour is left here today: the last start today was ${last}.`,
851
+ code: 'hourly-over',
852
+ args: { lastStart: last },
853
+ };
854
+ return { why: 'No room by the hour is left here today.', code: 'sold-out' };
827
855
  };
828
856
  /** Why nothing here can be sold for these nights - the most useful reason first. */
829
857
  const noRoomsWhy = (priced, stay, rooms) => {
830
858
  if (priced.length === 0)
831
- return 'This hotel has no rooms on sale through this connector.';
859
+ return {
860
+ why: 'This hotel has no rooms on sale through this connector.',
861
+ code: 'none-on-sale',
862
+ };
832
863
  const fails = priced.map((p) => p.res).filter((r) => !r.ok);
833
864
  const kinds = new Set(fails.map((f) => f.kind));
834
865
  const only = (k) => kinds.size === 1 && kinds.has(k);
835
866
  if (only('party'))
836
- return partyNeeds(stay.guests, largestOf(fails));
867
+ return partyWhy(stay.guests, largestOf(fails));
837
868
  if (only('prepaid'))
838
- return 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.';
869
+ return {
870
+ why: 'Every rate here for those nights is paid in advance, and this connector books rates paid at the hotel.',
871
+ code: 'prepaid-only',
872
+ };
839
873
  if (only('sold-out'))
840
874
  return rooms > 1
841
- ? `No room type here has ${rooms} left for those nights.`
842
- : 'Every room type here is taken for those nights.';
875
+ ? {
876
+ why: `No room type here has ${rooms} left for those nights.`,
877
+ code: 'sold-out',
878
+ args: { rooms },
879
+ }
880
+ : { why: 'Every room type here is taken for those nights.', code: 'sold-out' };
843
881
  if (only('too-many-rooms'))
844
- return `No rate here books ${rooms} rooms at once; ask for fewer.`;
882
+ return {
883
+ why: `No rate here books ${rooms} rooms at once; ask for fewer.`,
884
+ code: 'too-many-rooms',
885
+ args: { rooms },
886
+ };
845
887
  if (only('too-far'))
846
- return `Not on sale yet: ${fails[0]?.why ?? 'rooms here are not sold that far ahead'}.`;
888
+ return {
889
+ why: `Not on sale yet: ${fails[0]?.why ?? 'rooms here are not sold that far ahead'}.`,
890
+ code: 'too-far',
891
+ ...(fails[0]?.args?.days ? { args: { days: fails[0].args.days } } : {}),
892
+ };
847
893
  const rule = fails.find((f) => f.kind === 'stay-rule');
848
894
  if (rule)
849
- return `Not for those nights: ${rule.why}.`;
895
+ return {
896
+ why: `Not for those nights: ${rule.why}.`,
897
+ // Booked too far ahead for this rate is "too far", said with its days.
898
+ ...(rule.args?.days !== undefined && !rule.args.rule
899
+ ? { code: 'too-far', args: { days: rule.args.days } }
900
+ : { code: 'stay-rule', ...(rule.args ? { args: rule.args } : {}) }),
901
+ };
850
902
  if (kinds.has('closed'))
851
- return 'Not on sale for those nights.';
852
- return 'No room here can be booked for those nights.';
903
+ return { why: 'Not on sale for those nights.', code: 'closed' };
904
+ return { why: 'No room here can be booked for those nights.', code: 'unavailable' };
853
905
  };
854
906
  // --------------------------------------------------------------- quote ----
855
907
  const quoteLines = async (trip, fresh = false) => {
@@ -1976,6 +2028,19 @@ export const createHuazhuHotels = (opts) => {
1976
2028
  [h.brand, raw?.brandName ?? ''].some((b) => b.trim().toLowerCase() === (over.brand ?? '').trim().toLowerCase())) &&
1977
2029
  (over.facilities ?? []).every((k) => hasFacility(h.amenities, k));
1978
2030
  let matches = placed.filter(({ hotel, raw }) => passesStatic(hotel, f, raw));
2031
+ // ⚠️ ONLY HOTELS THAT SELL ROOMS BY THE HOUR TODAY - read from their
2032
+ // product lists, which carry no price (one call a hotel, kept for
2033
+ // `productsTtl`). Past PRICE_ALL the list was paged unfiltered, and every
2034
+ // hotel came back, most selling no room by the hour at all (robin,
2035
+ // 2026-10-14: 35 listed, a dozen sold them). A hotel that sells them
2036
+ // today and has none left, or none yet, stays - with why (`priceHotel`).
2037
+ if (hourlyOnly) {
2038
+ const scanned = matches.slice(0, HOURLY_SCAN);
2039
+ const sells = await pool(scanned, PRICE_CONCURRENCY, ({ raw }) => sellsByTheHourToday(raw.hotelId).catch(() => true));
2040
+ if (matches.length > HOURLY_SCAN)
2041
+ unapplied.push(`${matches.length} hotels matched - only the first ${HOURLY_SCAN} were asked whether they sell rooms by the hour. A district, a brand or a landmark narrows it.`);
2042
+ matches = scanned.filter((_, i) => sells[i]);
2043
+ }
1979
2044
  // ⚠️ PRICED WHEN THE NIGHTS ARE NAMED - but a city can hold hundreds of
1980
2045
  // these hotels and each one is two calls to Huazhu. Everything is priced
1981
2046
  // when there are few enough; past that, only the page, and the price
@@ -2020,11 +2085,14 @@ export const createHuazhuHotels = (opts) => {
2020
2085
  }
2021
2086
  catch {
2022
2087
  pricedRooms.set(raw.hotelId, []);
2023
- why.set(raw.hotelId, 'Rooms by the hour could not be read from Huazhu just now; open the hotel for them.');
2088
+ why.set(raw.hotelId, {
2089
+ why: 'Rooms by the hour could not be read from Huazhu just now; open the hotel for them.',
2090
+ code: 'unreachable',
2091
+ });
2024
2092
  }
2025
2093
  return;
2026
2094
  }
2027
- const problem = stayProblem(stay, today());
2095
+ const problem = stayProblemOf(stay, today());
2028
2096
  if (problem) {
2029
2097
  pricedRooms.set(raw.hotelId, []);
2030
2098
  why.set(raw.hotelId, problem);
@@ -2038,9 +2106,12 @@ export const createHuazhuHotels = (opts) => {
2038
2106
  }
2039
2107
  catch (err) {
2040
2108
  pricedRooms.set(raw.hotelId, []);
2041
- why.set(raw.hotelId, err instanceof HuazhuRefusal && err.code === 2009
2042
- ? 'Huazhu is answering slowly just now; open the hotel for its rooms.'
2043
- : 'Prices could not be read from Huazhu just now; open the hotel for its rooms.');
2109
+ why.set(raw.hotelId, {
2110
+ why: err instanceof HuazhuRefusal && err.code === 2009
2111
+ ? 'Huazhu is answering slowly just now; open the hotel for its rooms.'
2112
+ : 'Prices could not be read from Huazhu just now; open the hotel for its rooms.',
2113
+ code: 'unreachable',
2114
+ });
2044
2115
  }
2045
2116
  };
2046
2117
  const bestFor = (raw, over) => {
@@ -2140,11 +2211,18 @@ export const createHuazhuHotels = (opts) => {
2140
2211
  if (!stay)
2141
2212
  return m.hotel;
2142
2213
  const b = bestFor(m.raw, f);
2143
- if (!b)
2214
+ if (!b) {
2215
+ const w = why.get(m.raw.hotelId) ?? {
2216
+ why: 'No room here can be booked for those nights.',
2217
+ code: 'unavailable',
2218
+ };
2144
2219
  return {
2145
2220
  ...m.hotel,
2146
- noRoomsForStay: why.get(m.raw.hotelId) ?? 'No room here can be booked for those nights.',
2221
+ noRoomsForStay: w.why,
2222
+ noRoomsForStayCode: w.code,
2223
+ ...(w.args ? { noRoomsForStayArgs: w.args } : {}),
2147
2224
  };
2225
+ }
2148
2226
  return {
2149
2227
  ...m.hotel,
2150
2228
  forStay: {
@@ -2233,9 +2311,9 @@ export const createHuazhuHotels = (opts) => {
2233
2311
  const detail = { hotel: toHotel(raw, view, now()) };
2234
2312
  if (!stay)
2235
2313
  return detail;
2236
- const problem = stayProblem(stay, today());
2314
+ const problem = stayProblemOf(stay, today());
2237
2315
  if (problem)
2238
- return { ...detail, noRooms: problem };
2316
+ return { ...detail, ...noRoomsOf(problem) };
2239
2317
  let priced;
2240
2318
  try {
2241
2319
  priced = await priceStay(raw, stay, 1);
@@ -2244,7 +2322,10 @@ export const createHuazhuHotels = (opts) => {
2244
2322
  return {
2245
2323
  ...detail,
2246
2324
  stay,
2247
- noRooms: `Huazhu could not be asked for this hotel's rooms just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
2325
+ ...noRoomsOf({
2326
+ why: `Huazhu could not be asked for this hotel's rooms just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
2327
+ code: 'unreachable',
2328
+ }),
2248
2329
  };
2249
2330
  }
2250
2331
  const rooms = priced
@@ -2255,7 +2336,7 @@ export const createHuazhuHotels = (opts) => {
2255
2336
  ...detail,
2256
2337
  stay,
2257
2338
  rooms,
2258
- ...(rooms.length === 0 ? { noRooms: noRoomsWhy(priced, stay, 1) } : {}),
2339
+ ...(rooms.length === 0 ? noRoomsOf(noRoomsWhy(priced, stay, 1)) : {}),
2259
2340
  };
2260
2341
  },
2261
2342
  async hourlyRooms(id, guests) {
@@ -2273,7 +2354,10 @@ export const createHuazhuHotels = (opts) => {
2273
2354
  return {
2274
2355
  hotel,
2275
2356
  stay,
2276
- noRooms: `Huazhu could not be asked for this hotel's rooms by the hour just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
2357
+ ...noRoomsOf({
2358
+ why: `Huazhu could not be asked for this hotel's rooms by the hour just now (${err instanceof HuazhuRefusal ? err.huazhuMessage : 'no answer'}). Try again in a moment.`,
2359
+ code: 'unreachable',
2360
+ }),
2277
2361
  };
2278
2362
  }
2279
2363
  const rooms = priced
@@ -2284,7 +2368,7 @@ export const createHuazhuHotels = (opts) => {
2284
2368
  hotel,
2285
2369
  stay,
2286
2370
  rooms,
2287
- ...(rooms.length === 0 ? { noRooms: hourlyNoRoomsWhy(priced, guests) } : {}),
2371
+ ...(rooms.length === 0 ? noRoomsOf(hourlyNoRoomsWhy(priced, guests)) : {}),
2288
2372
  };
2289
2373
  },
2290
2374
  async datesAround(id, stay, o) {