@curless/hotel-storefront 0.52.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/about.d.ts +24 -0
  2. package/dist/about.d.ts.map +1 -0
  3. package/dist/about.js +28 -0
  4. package/dist/about.js.map +1 -0
  5. package/dist/aggregate/hotels.d.ts.map +1 -1
  6. package/dist/aggregate/hotels.js +156 -37
  7. package/dist/aggregate/hotels.js.map +1 -1
  8. package/dist/aggregate/make.js +2 -2
  9. package/dist/aggregate/make.js.map +1 -1
  10. package/dist/backend.d.ts +34 -2
  11. package/dist/backend.d.ts.map +1 -1
  12. package/dist/backend.js +21 -0
  13. package/dist/backend.js.map +1 -1
  14. package/dist/card.generated.d.ts +1 -1
  15. package/dist/card.generated.js +2 -2
  16. package/dist/card.generated.js.map +1 -1
  17. package/dist/coupon-words.d.ts +12 -0
  18. package/dist/coupon-words.d.ts.map +1 -0
  19. package/dist/coupon-words.js +19 -0
  20. package/dist/coupon-words.js.map +1 -0
  21. package/dist/dates.js +1 -1
  22. package/dist/dates.js.map +1 -1
  23. package/dist/demo-hotels.d.ts +6 -0
  24. package/dist/demo-hotels.d.ts.map +1 -1
  25. package/dist/demo-hotels.js +80 -27
  26. package/dist/demo-hotels.js.map +1 -1
  27. package/dist/huazhu/backend.d.ts +6 -0
  28. package/dist/huazhu/backend.d.ts.map +1 -1
  29. package/dist/huazhu/backend.js +38 -30
  30. package/dist/huazhu/backend.js.map +1 -1
  31. package/dist/huazhu/catalog.d.ts.map +1 -1
  32. package/dist/huazhu/catalog.js +43 -6
  33. package/dist/huazhu/catalog.js.map +1 -1
  34. package/dist/huazhu/client.js +1 -1
  35. package/dist/huazhu/client.js.map +1 -1
  36. package/dist/huazhu/drift.d.ts.map +1 -1
  37. package/dist/huazhu/drift.js +3 -0
  38. package/dist/huazhu/drift.js.map +1 -1
  39. package/dist/huazhu/make.d.ts.map +1 -1
  40. package/dist/huazhu/make.js +1 -0
  41. package/dist/huazhu/make.js.map +1 -1
  42. package/dist/huazhu/sandbox.d.ts.map +1 -1
  43. package/dist/huazhu/sandbox.js +8 -1
  44. package/dist/huazhu/sandbox.js.map +1 -1
  45. package/dist/huazhu/wire.d.ts +7 -0
  46. package/dist/huazhu/wire.d.ts.map +1 -1
  47. package/dist/huazhu/wire.js.map +1 -1
  48. package/dist/money.d.ts.map +1 -1
  49. package/dist/money.js +3 -0
  50. package/dist/money.js.map +1 -1
  51. package/dist/paid.d.ts.map +1 -1
  52. package/dist/paid.js +3 -1
  53. package/dist/paid.js.map +1 -1
  54. package/dist/present.d.ts +14 -2
  55. package/dist/present.d.ts.map +1 -1
  56. package/dist/present.js +98 -7
  57. package/dist/present.js.map +1 -1
  58. package/dist/reference.d.ts.map +1 -1
  59. package/dist/reference.js +13 -6
  60. package/dist/reference.js.map +1 -1
  61. package/dist/server.d.ts +2 -2
  62. package/dist/server.js +4 -4
  63. package/dist/server.js.map +1 -1
  64. package/dist/supplier.d.ts +6 -0
  65. package/dist/supplier.d.ts.map +1 -1
  66. package/dist/supplier.js +1 -0
  67. package/dist/supplier.js.map +1 -1
  68. package/dist/surface.d.ts.map +1 -1
  69. package/dist/surface.js +27 -18
  70. package/dist/surface.js.map +1 -1
  71. package/dist/tools.d.ts +11 -0
  72. package/dist/tools.d.ts.map +1 -1
  73. package/dist/tools.js +480 -110
  74. package/dist/tools.js.map +1 -1
  75. package/dist/types.d.ts +53 -6
  76. package/dist/types.d.ts.map +1 -1
  77. package/dist/vocab.d.ts +15 -1
  78. package/dist/vocab.d.ts.map +1 -1
  79. package/dist/vocab.js +26 -2
  80. package/dist/vocab.js.map +1 -1
  81. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A hotel's introduction in the parts its own 【…】 heads mark (robin, 2026-11-03).
3
+ * Huazhu's 汉庭大连中山广场地铁站酒店 runs on one line, its parts two spaces apart:
4
+ * 「…大连世界金融中心。 【轨道交通】距中山广场地铁站B1出口步行仅需8分钟;… 【免费停车场】
5
+ * 【周边配套】距离友谊商城…」 - and the card drew it as one block.
6
+ *
7
+ * ⚠️ LAYOUT ONLY: every word is the supplier's, kept, none merged or dropped -
8
+ * the heads too, brackets and all. Only the white space where a part starts or
9
+ * ends goes; a single space inside a part (「东 北方向」, 「美食一条 街」) stays as it
10
+ * came. Only where there are two heads or more: one is a word in a sentence.
11
+ * One rule for every group and every deployment - the card and the text the
12
+ * model reads cut at the same places.
13
+ */
14
+ export type AboutPart = {
15
+ head: string;
16
+ body: string;
17
+ };
18
+ export declare const aboutParts: (text: string) => {
19
+ lead: string;
20
+ parts: AboutPart[];
21
+ } | null;
22
+ /** The same, as text: each head starts a line of its own (a head with no words after it is a line by itself). */
23
+ export declare const aboutLines: (text: string) => string;
24
+ //# sourceMappingURL=about.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"about.d.ts","sourceRoot":"","sources":["../src/about.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAIvD,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,SAAS,EAAE,CAAA;CAAE,GAAG,IAgBhF,CAAC;AAEF,iHAAiH;AACjH,eAAO,MAAM,UAAU,GAAI,MAAM,MAAM,KAAG,MAKzC,CAAC"}
package/dist/about.js ADDED
@@ -0,0 +1,28 @@
1
+ const HEAD = /【[^【】\n]{1,24}】/g;
2
+ export const aboutParts = (text) => {
3
+ const heads = [...text.matchAll(HEAD)];
4
+ if (heads.length < 2)
5
+ return null;
6
+ const at = (m) => m.index ?? 0;
7
+ const first = heads[0];
8
+ if (!first)
9
+ return null;
10
+ return {
11
+ lead: text.slice(0, at(first)).trim(),
12
+ parts: heads.map((m, i) => {
13
+ const next = heads[i + 1];
14
+ return {
15
+ head: m[0],
16
+ body: text.slice(at(m) + m[0].length, next ? at(next) : text.length).trim(),
17
+ };
18
+ }),
19
+ };
20
+ };
21
+ /** The same, as text: each head starts a line of its own (a head with no words after it is a line by itself). */
22
+ export const aboutLines = (text) => {
23
+ const p = aboutParts(text);
24
+ return p
25
+ ? [p.lead, ...p.parts.map((x) => `${x.head}${x.body}`)].filter(Boolean).join('\n')
26
+ : text;
27
+ };
28
+ //# sourceMappingURL=about.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"about.js","sourceRoot":"","sources":["../src/about.ts"],"names":[],"mappings":"AAeA,MAAM,IAAI,GAAG,kBAAkB,CAAC;AAEhC,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,IAAY,EAA+C,EAAE;IACtF,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IACvC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,MAAM,EAAE,GAAG,CAAC,CAAmB,EAAU,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC;IACzD,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;IACvB,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAC;IACxB,OAAO;QACL,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE;QACrC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;YACxB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC1B,OAAO;gBACL,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;gBACV,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE;aAC5E,CAAC;QACJ,CAAC,CAAC;KACH,CAAC;AACJ,CAAC,CAAC;AAEF,iHAAiH;AACjH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,IAAY,EAAU,EAAE;IACjD,MAAM,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC;IAC3B,OAAO,CAAC;QACN,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;QAClF,CAAC,CAAC,IAAI,CAAC;AACX,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"hotels.d.ts","sourceRoot":"","sources":["../../src/aggregate/hotels.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,mBAAmB,EAGxB,KAAK,YAAY,EAEjB,KAAK,cAAc,EACnB,KAAK,YAAY,EAClB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA6B7C,+FAA+F;AAC/F,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,iFAAiF;IACjF,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kJAAkJ;IAClJ,SAAS,CAAC,EAAE,cAAc,CAAC;CAC5B,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAqEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,mBAAmB,EAAE,KAAG,mBAW9D,CAAC;AAoCH;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,IAW/D,CAAC;AAkCF,eAAO,MAAM,qBAAqB,GAChC,MAAM,gBAAgB,KACrB,YAAY,GAAG;IAChB,YAAY,EAAE,mBAAmB,CAAC;IAClC,SAAS,IAAI,YAAY,EAAE,CAAC;IAC5B,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAwqBjD,CAAC"}
1
+ {"version":3,"file":"hotels.d.ts","sourceRoot":"","sources":["../../src/aggregate/hotels.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,mBAAmB,EAIxB,KAAK,YAAY,EAEjB,KAAK,cAAc,EACnB,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA8B7C,+FAA+F;AAC/F,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,YAAY,CAAC;IACtB,iFAAiF;IACjF,IAAI,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,CAAC,CAAC;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,sEAAsE;IACtE,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kJAAkJ;IAClJ,SAAS,CAAC,EAAE,cAAc,CAAC;CAC5B,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAqEhD;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,KAAK,mBAAmB,EAAE,KAAG,mBAW9D,CAAC;AAoCH;;;GAGG;AACH,eAAO,MAAM,gBAAgB,GAAI,OAAO,MAAM,EAAE,KAAK,MAAM,EAAE,KAAG,IAW/D,CAAC;AA+CF,eAAO,MAAM,qBAAqB,GAChC,MAAM,gBAAgB,KACrB,YAAY,GAAG;IAChB,YAAY,EAAE,mBAAmB,CAAC;IAClC,SAAS,IAAI,YAAY,EAAE,CAAC;IAC5B,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAyyBjD,CAAC"}
@@ -1,4 +1,4 @@
1
- import { DEFAULT_PAGE, FULL_CAPABILITIES, MAX_PAGE, } from '../backend.js';
1
+ import { DEFAULT_PAGE, FULL_CAPABILITIES, MAX_PAGE, notPricedForNoAnswer, unansweredBy, } from '../backend.js';
2
2
  import { NAMESPACE, shownFor, unwrap, wrap } from './ids.js';
3
3
  import { mergeKnown, pagePlan } from './paging.js';
4
4
  import { inEnglish, isDistrict, queryInEnglish } from './places.js';
@@ -136,15 +136,28 @@ export const checkSupplierIds = (brand, ids) => {
136
136
  if (dup)
137
137
  throw new Error(`${brand}: two suppliers are called "${dup}"`);
138
138
  };
139
+ /** A group as an answer names one that did not answer - see `Unanswered`. */
140
+ const unansweredOf = (s) => ({
141
+ supplier: s.id,
142
+ name: s.name,
143
+ ...(s.names ? { names: s.names } : {}),
144
+ });
145
+ /**
146
+ * The refusal for groups that did not answer in time: its `unanswered` says
147
+ * which, ALL of them (2026-11-03 review, C8) - the tools write "no group
148
+ * answered this time" from it rather than from the first group's message.
149
+ */
150
+ const timedOut = (groups, message) => Object.assign(new Error(message), {
151
+ code: 'supplier-timeout',
152
+ unanswered: groups.map(unansweredOf),
153
+ });
139
154
  /**
140
155
  * `work` for one supplier, given up on after `ms` - as a rejection, like any
141
156
  * other way a supplier's search fails. The work itself is not stopped (a
142
157
  * promise cannot be); its late answer is simply not read.
143
158
  */
144
159
  const within = (s, ms, work) => new Promise((resolve, reject) => {
145
- const timer = setTimeout(() => reject(Object.assign(new Error(`${s.name} did not answer within ${ms} ms`), {
146
- code: 'supplier-timeout',
147
- })), ms);
160
+ const timer = setTimeout(() => reject(timedOut([s], `${s.name} did not answer within ${ms} ms`)), ms);
148
161
  work().then((v) => {
149
162
  clearTimeout(timer);
150
163
  resolve(v);
@@ -204,6 +217,12 @@ export const createAggregateHotels = (opts) => {
204
217
  * across groups, "Shanghai" can be two hotels in two groups, and picking
205
218
  * either is booking the guest somewhere they did not choose. Exactly one, or
206
219
  * an exact name, or nothing.
220
+ *
221
+ * ⚠️ A GROUP THAT DID NOT ANSWER CANNOT BE RULED OUT (2026-11-03 review, C5).
222
+ * Each group is asked within the time limit; one that timed out may be the
223
+ * one that has the hotel, or a second one that makes the name ambiguous. So
224
+ * unless an exact name settles it, the refusal names the groups that did not
225
+ * answer - never "no such hotel", never a pick among the rest.
207
226
  */
208
227
  const hotelRef = async (ref) => {
209
228
  const own = unwrap(ref, known);
@@ -213,16 +232,20 @@ export const createAggregateHotels = (opts) => {
213
232
  }
214
233
  const found = await Promise.all(suppliers.map(async (s) => {
215
234
  try {
216
- const d = await s.backend.getHotel(ref);
235
+ const d = await within(s, timeoutMs, () => s.backend.getHotel(ref));
217
236
  return d ? { s, hotel: d.hotel } : undefined;
218
237
  }
219
- catch {
220
- return undefined;
238
+ catch (err) {
239
+ return unansweredBy(err) ? { s, down: true } : undefined;
221
240
  }
222
241
  }));
223
- const hits = found.filter((x) => !!x);
242
+ const hits = found.flatMap((x) => (x?.hotel ? [{ s: x.s, hotel: x.hotel }] : []));
243
+ const down = found.flatMap((x) => (x?.down ? [x.s] : []));
224
244
  const exact = hits.filter((x) => x.hotel.name.toLowerCase() === ref.trim().toLowerCase());
225
- const one = hits.length === 1 ? hits[0] : exact.length === 1 ? exact[0] : undefined;
245
+ const one = exact.length === 1 ? exact[0] : hits.length === 1 && down.length === 0 ? hits[0] : undefined;
246
+ if (!one && down.length) {
247
+ throw timedOut(down, `${listWords(down.map((x) => x.name))} did not answer within ${timeoutMs} ms`);
248
+ }
226
249
  return one ? { s: one.s, id: one.hotel.id } : undefined;
227
250
  };
228
251
  /**
@@ -250,6 +273,14 @@ export const createAggregateHotels = (opts) => {
250
273
  const order = first.length ? first : suppliers;
251
274
  return [...new Set(order)].map((s) => ({ s, ref }));
252
275
  };
276
+ /**
277
+ * A READ of one supplier, given up on after the time limit (2026-11-03
278
+ * review, C5): the hotel page, the dates, the add-ons, the policies, a
279
+ * price, a booking looked up. ⚠️ NEVER A WRITE: a booking or a cancellation
280
+ * given up on may still go through on the group's side, and "did not
281
+ * answer" about one would be a lie in one of two ways.
282
+ */
283
+ const read = (s, work) => within(s, timeoutMs, work);
253
284
  /** The first supplier, most likely first, that answers for this booking reference. */
254
285
  const firstAnswer = async (ref, ask) => {
255
286
  for (const c of bookingCandidates(ref)) {
@@ -319,13 +350,24 @@ export const createAggregateHotels = (opts) => {
319
350
  * unnarrowed search's facets at most every ten minutes.
320
351
  */
321
352
  const PLACES_TTL_MS = 10 * 60_000;
353
+ /**
354
+ * How long a supplier whose city list could not be had is left alone, in
355
+ * milliseconds (2026-11-03 review, C4): each search waited for it again, and a
356
+ * group that hangs once hangs for the next search too. A guest's next ask
357
+ * within this goes on without its list - it is then asked as typed, like a
358
+ * place no group knows.
359
+ */
360
+ const PLACES_RETRY_MS = 30_000;
322
361
  const placeCache = new Map();
323
- const placesOf = async (s) => {
362
+ const placesDown = new Map();
363
+ const placesOf = async (s, ms) => {
324
364
  const kept = placeCache.get(s.id);
325
365
  if (kept && Date.now() - kept.at < PLACES_TTL_MS)
326
366
  return kept.names;
367
+ if ((placesDown.get(s.id) ?? 0) > Date.now())
368
+ return kept?.names;
327
369
  try {
328
- const r = await within(s, timeoutMs, () => s.backend.searchHotels({ limit: 1 }));
370
+ const r = await within(s, ms, () => s.backend.searchHotels({ limit: 1 }));
329
371
  // A city's names in every language too (robin, 2026-10-15): a guest
330
372
  // who read "Shanghai" on Huazhu's card asks for Shanghai.
331
373
  const names = new Set([
@@ -333,9 +375,11 @@ export const createAggregateHotels = (opts) => {
333
375
  ...(r.facets?.cities ?? []).flatMap((x) => [x.name, ...Object.values(x.names ?? {})]),
334
376
  ].map((x) => x.toLowerCase()));
335
377
  placeCache.set(s.id, { at: Date.now(), names });
378
+ placesDown.delete(s.id);
336
379
  return names;
337
380
  }
338
381
  catch {
382
+ placesDown.set(s.id, Date.now() + PLACES_RETRY_MS);
339
383
  return kept?.names;
340
384
  }
341
385
  };
@@ -350,15 +394,33 @@ export const createAggregateHotels = (opts) => {
350
394
  * simply has none there. A place no group knows is asked of everyone, as
351
395
  * typed - it may be a spelling one of them understands.
352
396
  */
353
- const lacking = async (list, place) => {
354
- const sets = await Promise.all(list.map(placesOf));
397
+ const lacking = async (list, place, ms) => {
398
+ const sets = await Promise.all(list.map((s) => placesOf(s, ms)));
355
399
  const wanted = place.toLowerCase();
356
400
  if (!sets.some((x) => x?.has(wanted)))
357
401
  return new Set();
358
402
  return new Set(list.filter((_, i) => sets[i] && !sets[i]?.has(wanted)).map((x) => x.id));
359
403
  };
360
404
  const NONE = { hotels: [], total: 0, currencies: [] };
405
+ /**
406
+ * A page of hotels asked for a group that already answered gets at least this
407
+ * long even when the search's deadline has passed, in milliseconds: it is
408
+ * the same group, which just answered, and a slow neighbour must not leave
409
+ * its page short.
410
+ */
411
+ const FOLLOW_UP_FLOOR_MS = Math.min(timeoutMs, 2000);
412
+ /** The share of the deadline the cities each group has may take (`placesOf`). */
413
+ const PLACES_SHARE = 4;
361
414
  const searchHotels = async (q, who) => {
415
+ // ⚠️ ONE DEADLINE FOR THE WHOLE SEARCH (2026-11-03 review, C4). The time
416
+ // limit was per call, and a search made several in turn - the cities each
417
+ // group has, the search itself, the same again without a price ceiling, a
418
+ // wider search after an empty one - so one group that hung cost two to four
419
+ // limits (16-32 seconds at the default) and the host's own tool time limit
420
+ // came first: the guest saw "no answer" instead of the groups that had
421
+ // one. Now every call below waits for what is left of this one deadline.
422
+ const deadline = Date.now() + timeoutMs;
423
+ const left = () => Math.max(1, deadline - Date.now());
362
424
  const asked = q.filters;
363
425
  const { supplier: wantedName, ...rest } = asked ?? {};
364
426
  const wanted = wantedName ? supplierNamed(wantedName) : undefined;
@@ -401,13 +463,21 @@ export const createAggregateHotels = (opts) => {
401
463
  const firstWindow = ordered
402
464
  ? { offset: 0, limit: Math.min(from + page, MAX_PAGE) }
403
465
  : { offset: Math.floor(from / n), limit: Math.min(MAX_PAGE, Math.ceil(page / n) + 1) };
404
- const skip = place && !district ? await lacking(suppliers, place) : new Set();
405
- const ask = async (s, window) => skip.has(s.id)
466
+ // The cities each group has take a share of the deadline, not all of it: a
467
+ // group that hangs there is left to the search itself, which has the rest.
468
+ const skip = place && !district
469
+ ? await lacking(suppliers, place, Math.min(left(), Math.ceil(timeoutMs / PLACES_SHARE)))
470
+ : new Set();
471
+ const ask = async (s, window, ms = left()) => skip.has(s.id)
406
472
  ? NONE
407
- : within(s, timeoutMs, () => s.backend.searchHotels({ ...base, ...window }, caller));
473
+ : within(s, ms, () => s.backend.searchHotels({ ...base, ...window }, caller));
408
474
  const round = async (list, window) => Promise.allSettled(list.map((s) => ask(s, window)));
409
- let first = await round(active, firstWindow);
410
- let counts = await round(others, { offset: 0, limit: 1 });
475
+ // Together: the groups left out of `filters.supplier` are only counted, and
476
+ // are not to wait their turn behind the ones that are searched.
477
+ let [first, counts] = await Promise.all([
478
+ round(active, firstWindow),
479
+ round(others, { offset: 0, limit: 1 }),
480
+ ]);
411
481
  const okOf = (settled, list) => settled.flatMap((r, i) => {
412
482
  const s = list[i];
413
483
  return r.status === 'fulfilled' && s ? [{ s, k: i, result: r.value }] : [];
@@ -421,13 +491,23 @@ export const createAggregateHotels = (opts) => {
421
491
  if (Number(q.maxNightly) > 0 && union(answers).length > 1) {
422
492
  const { maxNightly: _dropped, ...without } = base;
423
493
  base = without;
424
- first = await round(active, firstWindow);
425
- counts = await round(others, { offset: 0, limit: 1 });
494
+ [first, counts] = await Promise.all([
495
+ round(active, firstWindow),
496
+ round(others, { offset: 0, limit: 1 }),
497
+ ]);
426
498
  answers = okOf(first, active);
427
499
  }
428
500
  const down = first.flatMap((r, i) => (r.status === 'rejected' && active[i] ? [active[i]] : []));
429
501
  if (active.length > 0 && answers.length === 0) {
430
- throw first.find((r) => r.status === 'rejected').reason;
502
+ // ⚠️ ALL OF THEM, NOT THE FIRST (2026-11-03 review, C8): three groups
503
+ // that did not answer were reported as the first one alone. Where any
504
+ // timed out, the refusal names every group that is down; a refusal of
505
+ // the groups' own keeps its words.
506
+ const rejected = first.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []));
507
+ if (rejected.some((reason) => unansweredBy(reason))) {
508
+ throw timedOut(down, `No hotel group answered this time: ${listWords(down.map((x) => x.name))} did not answer within ${timeoutMs} ms`);
509
+ }
510
+ throw rejected[0];
431
511
  }
432
512
  // ⚠️ SAID BY `degraded`, ONCE: the tools write the sentence from it and the
433
513
  // card draws its own line - an `unapplied` sentence besides was said twice.
@@ -472,7 +552,7 @@ export const createAggregateHotels = (opts) => {
472
552
  for (let lo = Math.min(...need), hi = Math.max(...need); lo <= hi; lo += MAX_PAGE) {
473
553
  const limit = Math.min(MAX_PAGE, hi - lo + 1);
474
554
  try {
475
- const more = await ask(s, { offset: lo, limit });
555
+ const more = await ask(s, { offset: lo, limit }, Math.max(left(), FOLLOW_UP_FLOOR_MS));
476
556
  more.hotels.forEach((h, j) => got[k]?.set(lo + j, h));
477
557
  }
478
558
  catch (err) {
@@ -559,12 +639,12 @@ export const createAggregateHotels = (opts) => {
559
639
  const at = await hotelRef(id);
560
640
  if (!at)
561
641
  return null;
562
- const d = await at.s.backend.getHotel(at.id, stay, plain(who));
642
+ const d = await read(at.s, () => at.s.backend.getHotel(at.id, stay, plain(who)));
563
643
  return d ? { ...d, hotel: hotelOut(at.s.id, d.hotel) } : null;
564
644
  },
565
645
  async datesAround(id, stay, o, who) {
566
646
  const at = await hotelRef(id);
567
- return at ? at.s.backend.datesAround(at.id, stay, o, plain(who)) : [];
647
+ return at ? read(at.s, () => at.s.backend.datesAround(at.id, stay, o, plain(who))) : [];
568
648
  },
569
649
  // ⚠️ NO MEMBERS IN THE MARKET (v1) - `capabilities.members` is false, so
570
650
  // the tools never list a sign-in. Answered anyway, as nobody.
@@ -579,7 +659,7 @@ export const createAggregateHotels = (opts) => {
579
659
  const at = await hotelRef(hotelId);
580
660
  if (!at || !capsOf(at.s).extras)
581
661
  return [];
582
- const extras = await at.s.backend.extrasFor(at.id, stay);
662
+ const extras = await read(at.s, () => at.s.backend.extrasFor(at.id, stay));
583
663
  return extras.map((x) => ({ ...x, hotelId: wrap(at.s.id, x.hotelId) }));
584
664
  },
585
665
  /**
@@ -603,9 +683,32 @@ export const createAggregateHotels = (opts) => {
603
683
  }
604
684
  : q;
605
685
  }
606
- const quotes = await Promise.all([...parts.entries()].map(async ([ns, lines]) => quoteOut(ns, await byId.get(ns).backend.quote(draftFor(ns, lines, trip), caller))));
686
+ // ⚠️ A GROUP THAT DOES NOT ANSWER IS SAID, NOT WAITED FOR (2026-11-03
687
+ // review, C5): each group prices its own lines within the time limit; the
688
+ // ones that did are the quote, and a group that did not is a line in
689
+ // `dropped` - "did not answer in time", not "cannot be had". With no
690
+ // group answering there is no quote to give, and the refusal names them.
691
+ const settled = await Promise.all([...parts.entries()].map(async ([ns, lines]) => {
692
+ const s = byId.get(ns);
693
+ try {
694
+ return {
695
+ q: quoteOut(ns, await read(s, () => s.backend.quote(draftFor(ns, lines, trip), caller))),
696
+ };
697
+ }
698
+ catch (err) {
699
+ if (!unansweredBy(err))
700
+ throw err;
701
+ return { down: s };
702
+ }
703
+ }));
704
+ const quotes = settled.flatMap((x) => (x.q ? [x.q] : []));
705
+ const unanswered = settled.flatMap((x) => (x.down ? [x.down] : []));
706
+ if (quotes.length === 0) {
707
+ throw timedOut(unanswered, `${listWords(unanswered.map((x) => x.name))} did not answer within ${timeoutMs} ms`);
708
+ }
607
709
  const dropped = [
608
710
  ...quotes.flatMap((q) => q.dropped ?? []),
711
+ ...unanswered.map(notPricedForNoAnswer),
609
712
  ...strays.map((l) => ({
610
713
  title: l.hotelId,
611
714
  why: 'That hotel is not in this market any more.',
@@ -676,23 +779,37 @@ export const createAggregateHotels = (opts) => {
676
779
  async listBookings(who, limit = 10) {
677
780
  const most = Math.max(1, Math.min(MAX_PAGE, Math.floor(Number(limit) > 0 ? limit : 10)));
678
781
  const caller = plain(who);
679
- const settled = await Promise.allSettled(suppliers.map((s) => s.backend.listBookings(caller, most)));
680
- const failed = settled.filter((r) => r.status === 'rejected');
681
- if (failed.length === settled.length)
682
- throw failed[0].reason;
782
+ const settled = await Promise.allSettled(suppliers.map((s) => read(s, () => s.backend.listBookings(caller, most))));
783
+ const failed = settled.flatMap((r, i) => r.status === 'rejected' && suppliers[i] ? [i] : []);
784
+ if (failed.length === settled.length) {
785
+ // ⚠️ ALL OF THEM, NOT THE FIRST (C8), where they only did not answer.
786
+ const reasons = settled.flatMap((r) => (r.status === 'rejected' ? [r.reason] : []));
787
+ if (reasons.some((reason) => unansweredBy(reason))) {
788
+ throw timedOut(suppliers, `No hotel group answered this time: ${listWords(suppliers.map((x) => x.name))} did not answer within ${timeoutMs} ms`);
789
+ }
790
+ throw reasons[0];
791
+ }
683
792
  if (failed.length)
684
793
  console.error('[market] listing bookings failed for', failed.length, 'supplier(s)');
685
- return settled
794
+ const list = settled
686
795
  .flatMap((r, i) => {
687
796
  const s = suppliers[i];
688
797
  return r.status === 'fulfilled' && s ? r.value.map((b) => bookingOut(s.id, b)) : [];
689
798
  })
690
799
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
691
800
  .slice(0, most);
801
+ // ⚠️ THE GROUPS WHOSE BOOKINGS ARE NOT IN THE LIST (2026-11-03 review,
802
+ // C5): a list without Atour's bookings, from an Atour that did not answer,
803
+ // reads as a guest with none there. Named, so the tools say "did not
804
+ // answer", never "has none".
805
+ const degraded = failed.flatMap((i) => (suppliers[i] ? [unansweredOf(suppliers[i])] : []));
806
+ if (degraded.length)
807
+ list.degraded = degraded;
808
+ return list;
692
809
  },
693
810
  async findBooking(ref, who) {
694
811
  const caller = plain(who);
695
- const hit = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
812
+ const hit = await firstAnswer(ref, (s, r) => read(s, () => s.backend.findBooking(r, caller)));
696
813
  return hit ? bookingOut(hit.s.id, hit.value) : null;
697
814
  },
698
815
  /**
@@ -703,19 +820,19 @@ export const createAggregateHotels = (opts) => {
703
820
  */
704
821
  async previewChange(ref, change, who) {
705
822
  const caller = plain(who);
706
- const found = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
823
+ const found = await firstAnswer(ref, (s, r) => read(s, () => s.backend.findBooking(r, caller)));
707
824
  if (!found)
708
825
  return null;
709
826
  const { s } = found;
710
827
  const refused = unchangeable(s, found.value, change);
711
828
  if (refused)
712
829
  return refused;
713
- const p = await s.backend.previewChange(found.value.id, change, caller);
830
+ const p = await read(s, () => s.backend.previewChange(found.value.id, change, caller));
714
831
  return p ? { ...p, bookingId: wrap(s.id, p.bookingId), supplier: s.id } : null;
715
832
  },
716
833
  async changeBooking(ref, change, who) {
717
834
  const caller = plain(who);
718
- const found = await firstAnswer(ref, (s, r) => s.backend.findBooking(r, caller));
835
+ const found = await firstAnswer(ref, (s, r) => read(s, () => s.backend.findBooking(r, caller)));
719
836
  if (!found || unchangeable(found.s, found.value, change))
720
837
  return null;
721
838
  const b = await found.s.backend.changeBooking(found.value.id, change, caller);
@@ -723,7 +840,7 @@ export const createAggregateHotels = (opts) => {
723
840
  },
724
841
  async previewCancellation(ref, who) {
725
842
  const caller = plain(who);
726
- const hit = await firstAnswer(ref, (s, r) => s.backend.previewCancellation(r, caller));
843
+ const hit = await firstAnswer(ref, (s, r) => read(s, () => s.backend.previewCancellation(r, caller)));
727
844
  return hit
728
845
  ? { ...hit.value, bookingId: wrap(hit.s.id, hit.value.bookingId), supplier: hit.s.id }
729
846
  : null;
@@ -736,7 +853,9 @@ export const createAggregateHotels = (opts) => {
736
853
  // A group that publishes no policy text is not asked for it.
737
854
  async policies(hotelId) {
738
855
  const at = await hotelRef(hotelId);
739
- return at && capsOf(at.s).policies !== false ? at.s.backend.policies(at.id) : [];
856
+ return at && capsOf(at.s).policies !== false
857
+ ? read(at.s, () => at.s.backend.policies(at.id))
858
+ : [];
740
859
  },
741
860
  };
742
861
  };