@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.
- package/dist/about.d.ts +24 -0
- package/dist/about.d.ts.map +1 -0
- package/dist/about.js +28 -0
- package/dist/about.js.map +1 -0
- package/dist/aggregate/hotels.d.ts.map +1 -1
- package/dist/aggregate/hotels.js +156 -37
- package/dist/aggregate/hotels.js.map +1 -1
- package/dist/aggregate/make.js +2 -2
- package/dist/aggregate/make.js.map +1 -1
- package/dist/backend.d.ts +34 -2
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +21 -0
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/coupon-words.d.ts +12 -0
- package/dist/coupon-words.d.ts.map +1 -0
- package/dist/coupon-words.js +19 -0
- package/dist/coupon-words.js.map +1 -0
- package/dist/dates.js +1 -1
- package/dist/dates.js.map +1 -1
- package/dist/demo-hotels.d.ts +6 -0
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +80 -27
- package/dist/demo-hotels.js.map +1 -1
- package/dist/huazhu/backend.d.ts +6 -0
- package/dist/huazhu/backend.d.ts.map +1 -1
- package/dist/huazhu/backend.js +38 -30
- package/dist/huazhu/backend.js.map +1 -1
- package/dist/huazhu/catalog.d.ts.map +1 -1
- package/dist/huazhu/catalog.js +43 -6
- package/dist/huazhu/catalog.js.map +1 -1
- package/dist/huazhu/client.js +1 -1
- package/dist/huazhu/client.js.map +1 -1
- package/dist/huazhu/drift.d.ts.map +1 -1
- package/dist/huazhu/drift.js +3 -0
- package/dist/huazhu/drift.js.map +1 -1
- package/dist/huazhu/make.d.ts.map +1 -1
- package/dist/huazhu/make.js +1 -0
- package/dist/huazhu/make.js.map +1 -1
- package/dist/huazhu/sandbox.d.ts.map +1 -1
- package/dist/huazhu/sandbox.js +8 -1
- package/dist/huazhu/sandbox.js.map +1 -1
- package/dist/huazhu/wire.d.ts +7 -0
- package/dist/huazhu/wire.d.ts.map +1 -1
- package/dist/huazhu/wire.js.map +1 -1
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +3 -0
- package/dist/money.js.map +1 -1
- package/dist/paid.d.ts.map +1 -1
- package/dist/paid.js +3 -1
- package/dist/paid.js.map +1 -1
- package/dist/present.d.ts +14 -2
- package/dist/present.d.ts.map +1 -1
- package/dist/present.js +98 -7
- package/dist/present.js.map +1 -1
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js +13 -6
- package/dist/reference.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -4
- package/dist/server.js.map +1 -1
- package/dist/supplier.d.ts +6 -0
- package/dist/supplier.d.ts.map +1 -1
- package/dist/supplier.js +1 -0
- package/dist/supplier.js.map +1 -1
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +27 -18
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +11 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +480 -110
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +53 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/vocab.d.ts +15 -1
- package/dist/vocab.d.ts.map +1 -1
- package/dist/vocab.js +26 -2
- package/dist/vocab.js.map +1 -1
- package/package.json +1 -1
package/dist/about.d.ts
ADDED
|
@@ -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,
|
|
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"}
|
package/dist/aggregate/hotels.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
405
|
-
|
|
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,
|
|
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
|
-
|
|
410
|
-
|
|
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
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
681
|
-
if (failed.length === settled.length)
|
|
682
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|