universal-parcel-scraper 0.956.0-main.5145 → 0.956.2-main.5151

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 (53) hide show
  1. package/README.md +1 -1
  2. package/dist/browser/scraper.js +1 -0
  3. package/dist/carriers/aliexpress/adapter.js +13 -6
  4. package/dist/carriers/brt/parser.d.ts +2 -1
  5. package/dist/carriers/brt/parser.js +31 -4
  6. package/dist/carriers/dhl/adapter.js +35 -9
  7. package/dist/carriers/dhl/status.d.ts +8 -0
  8. package/dist/carriers/dhl/status.js +13 -0
  9. package/dist/carriers/dhl/statuses.json +1 -0
  10. package/dist/carriers/dhl-express/adapter.js +10 -1
  11. package/dist/carriers/dhl-express/carrier.json +5 -2
  12. package/dist/carriers/dhlink/carrier.json +5 -3
  13. package/dist/carriers/dhlink/parser.js +11 -2
  14. package/dist/carriers/dpd-pt/carrier.json +2 -1
  15. package/dist/carriers/dpd-pt/parser.js +26 -4
  16. package/dist/carriers/dpd-pt/status.d.ts +2 -0
  17. package/dist/carriers/dpd-pt/status.js +2 -0
  18. package/dist/carriers/fedex/adapter.js +50 -6
  19. package/dist/carriers/first-line/carrier.json +6 -4
  20. package/dist/carriers/first-line/parser.js +6 -1
  21. package/dist/carriers/gofo/carrier.json +5 -4
  22. package/dist/carriers/gofo/parser.d.ts +8 -0
  23. package/dist/carriers/gofo/parser.js +15 -2
  24. package/dist/carriers/gofo-ca/carrier.json +5 -4
  25. package/dist/carriers/gofo-es/carrier.json +4 -3
  26. package/dist/carriers/gofo-fr/carrier.json +6 -4
  27. package/dist/carriers/gofo-fr/parser.js +3 -1
  28. package/dist/carriers/gofo-it/carrier.json +6 -4
  29. package/dist/carriers/gofo-nl/carrier.json +4 -3
  30. package/dist/carriers/hermes-de/adapter.js +3 -2
  31. package/dist/carriers/imile/carrier.json +3 -2
  32. package/dist/carriers/imile/parser.js +25 -3
  33. package/dist/carriers/js-express/carrier.json +3 -2
  34. package/dist/carriers/js-express/parser.d.ts +1 -1
  35. package/dist/carriers/js-express/parser.js +21 -3
  36. package/dist/carriers/poste-italiane/adapter.js +3 -1
  37. package/dist/carriers/royal-mail/adapter.js +3 -2
  38. package/dist/carriers/royal-mail/browser.js +2 -3
  39. package/dist/carriers/royal-mail/parser.d.ts +4 -0
  40. package/dist/carriers/royal-mail/parser.js +28 -6
  41. package/dist/carriers/speedx/parser.js +9 -2
  42. package/dist/carriers/spx-br/status.js +1 -0
  43. package/dist/carriers/spx-br/statuses.json +8 -0
  44. package/dist/carriers/ups/adapter.d.ts +1 -1
  45. package/dist/carriers/ups/adapter.js +19 -2
  46. package/dist/carriers/usps/adapter.js +56 -6
  47. package/dist/core/result/partial.js +6 -0
  48. package/dist/core/result/round.d.ts +6 -0
  49. package/dist/core/result/round.js +25 -0
  50. package/dist/providers/parcelsapp/adapter.js +11 -1
  51. package/dist/providers/seventeentrack/adapter.js +25 -1
  52. package/dist/server/openapi.json +1 -1
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -163,7 +163,7 @@ the command line's `track --source ParcelsApp` does the same.
163
163
  <!-- GENERATED:stages -->
164
164
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
165
165
 
166
- The carrier folders record 18,654 such statuses, each filed under one stage.
166
+ The carrier folders record 18,656 such statuses, each filed under one stage.
167
167
  <!-- /GENERATED:stages -->
168
168
 
169
169
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
@@ -46102,6 +46102,7 @@ function cd(e, t, n) {
46102
46102
  for (let e of sd) n[e] === void 0 ? delete r[e] : Object.assign(r, { [e]: n[e] });
46103
46103
  i = n.current_stage;
46104
46104
  }
46105
+ Xu.has(i ?? "") && (r.expected_delivery != null && (r.expected_delivery = null), delete r.expected_delivery_from);
46105
46106
  for (let e of od) if (!(e[0] === "expected_delivery" && Xu.has(i ?? "")) && !(e[0] === "pickup_point" && Wc({
46106
46107
  ...t,
46107
46108
  current_stage: i
@@ -158,6 +158,15 @@ function recordedInBeijing(scan, wall) {
158
158
  const beijing = new Date(epoch + BEIJING_OFFSET_MS);
159
159
  return Number.isFinite(beijing.getTime()) && beijing.toISOString().slice(0, 19).replace('T', ' ') === wall;
160
160
  }
161
+ // Epoch milliseconds from 2001 to 2286, as an estimate's bounds are written.
162
+ const EPOCH_MS = /^\d{13}$/;
163
+ /** The day of one of the estimate's bounds, or null for anything but epoch milliseconds. */
164
+ function etaDay(value) {
165
+ const millis = typeof value === 'number' ? value : typeof value === 'string' && EPOCH_MS.test(value.trim()) ? Number(value) : Number.NaN;
166
+ if (!Number.isSafeInteger(millis) || millis < 1e12 || millis >= 1e13)
167
+ return null;
168
+ return new Date(millis).toISOString().slice(0, 10);
169
+ }
161
170
  /** The module of a `detail.json` payload for the number asked; any other shipment is refused. */
162
171
  function trackingModuleOf(value, trackingNumber) {
163
172
  const payload = record(value);
@@ -251,13 +260,11 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
251
260
  else if (event.stage)
252
261
  previous = event.stage;
253
262
  }
263
+ // The estimate's bounds, epoch milliseconds as a number or as digits; a window
264
+ // with one bound is that day.
254
265
  const eta = record(trackingModule.globalEtaInfo);
255
- const toMillis = (value_) => (typeof value_ === 'number' && Number.isFinite(value_) ? value_ : null);
256
- const deliveryMinTime = toMillis(eta.deliveryMinTime);
257
- const deliveryMaxTime = toMillis(eta.deliveryMaxTime);
258
- const toDate = (millis) => (millis == null ? null : new Date(millis).toISOString().slice(0, 10));
259
- const expected = toDate(deliveryMaxTime);
260
- const expectedFrom = toDate(deliveryMinTime);
266
+ const expectedFrom = etaDay(eta.deliveryMinTime);
267
+ const expected = etaDay(eta.deliveryMaxTime) ?? expectedFrom;
261
268
  const handoff = cainiaoHandoffNumber(trackingModule);
262
269
  // Cainiao can follow the country with the recipient's town, "France,Town": only the country is read.
263
270
  const destination = text(trackingModule.destCountry).split(',')[0].trim().slice(0, 80);
@@ -32,7 +32,8 @@ export declare function readBrt(html: string, raw: string, parcelList?: string):
32
32
  * to 14 hours ahead of UTC, so a scan before one is dated more than 14 hours
33
33
  * before its reading as UTC. These scans and BRT's shipping data follow BRT's other
34
34
  * scans, in time order when all are dated. While BRT has only the shipping
35
- * data, the partner's newer scan gives the current status.
35
+ * data, the partner's newer scan gives the current status. The partner's
36
+ * delivery estimate stands while BRT's scans have not overtaken it.
36
37
  */
37
38
  export declare function withOriginLeg(result: CarrierResult, partner: CarrierResult): CarrierResult;
38
39
  /**
@@ -320,6 +320,30 @@ export function readBrt(html, raw, parcelList) {
320
320
  }
321
321
  // The fields of a result's current status.
322
322
  const STATUS_FIELDS = ['status', 'current_stage', 'current_stage_source', 'last_status_text', 'last_update', 'last_update_local', 'delivered_at'];
323
+ // The fields of a result's delivery estimate.
324
+ const ESTIMATE_FIELDS = ['expected_delivery', 'expected_delivery_from'];
325
+ /** The fields of `answer` that `fields` names and it gives. */
326
+ function picked(answer, fields) {
327
+ return Object.fromEntries(fields.flatMap((field) => answer[field] === undefined ? [] : [[field, answer[field]]]));
328
+ }
329
+ /** The calendar day a clock or an estimate opens with, or ''. */
330
+ function dayOf(value) {
331
+ return typeof value === 'string' ? /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0] ?? '' : '';
332
+ }
333
+ /**
334
+ * BRT's page gives no delivery estimate. The partner's stands for a shipment BRT has not
335
+ * delivered, returned or left at a pickup point, until BRT scans the parcel on a later day:
336
+ * DPD's guest service answers for the whole group and dates the delivery in Italy.
337
+ */
338
+ function withPartnerEstimate(result, partner) {
339
+ const expected = dayOf(partner.expected_delivery);
340
+ const newest = result.events?.[0];
341
+ if (!expected || result.expected_delivery || result.status === 'delivered'
342
+ || ['delivered', 'returned', 'ready_for_pickup'].includes(String(result.current_stage))
343
+ || expected < dayOf(newest?.time ?? newest?.local_time))
344
+ return result;
345
+ return { ...result, ...picked(partner, ESTIMATE_FIELDS) };
346
+ }
323
347
  /**
324
348
  * The shipment with the leg its partner carried it on before BRT: the
325
349
  * partner's dated scans before BRT's first scan beyond the shipping data it was
@@ -327,9 +351,13 @@ const STATUS_FIELDS = ['status', 'current_stage', 'current_stage_source', 'last_
327
351
  * to 14 hours ahead of UTC, so a scan before one is dated more than 14 hours
328
352
  * before its reading as UTC. These scans and BRT's shipping data follow BRT's other
329
353
  * scans, in time order when all are dated. While BRT has only the shipping
330
- * data, the partner's newer scan gives the current status.
354
+ * data, the partner's newer scan gives the current status. The partner's
355
+ * delivery estimate stands while BRT's scans have not overtaken it.
331
356
  */
332
357
  export function withOriginLeg(result, partner) {
358
+ return withPartnerEstimate(originLeg(result, partner), partner);
359
+ }
360
+ function originLeg(result, partner) {
333
361
  const events = result.events ?? [];
334
362
  let handover = events.length - 1;
335
363
  while (handover >= 0 && events[handover].stage === 'registered')
@@ -349,7 +377,7 @@ export function withOriginLeg(result, partner) {
349
377
  return merged;
350
378
  for (const field of STATUS_FIELDS)
351
379
  delete merged[field];
352
- return { ...merged, ...Object.fromEntries(STATUS_FIELDS.flatMap((field) => partner[field] === undefined ? [] : [[field, partner[field]]])) };
380
+ return { ...merged, ...picked(partner, STATUS_FIELDS) };
353
381
  }
354
382
  // A place in Italy as the partners write it: Chronopost's "Town - IT (depot 82)", DPD's "Town, IT".
355
383
  const ITALIAN_PLACE = /(?:^|, | - )(?:IT|Italy|Italia)(?: \(depot \d{1,6}\))?$/;
@@ -364,8 +392,7 @@ export function partnerShipment(partner) {
364
392
  if (!events.length || (partner.destination_country !== 'IT'
365
393
  && !events.some((event) => ITALIAN_PLACE.test(event.location ?? ''))))
366
394
  return null;
367
- return { ...Object.fromEntries(STATUS_FIELDS.flatMap((field) => partner[field] === undefined ? [] : [[field, partner[field]]])),
368
- destination_country: 'IT', events: events.slice(0, 100) };
395
+ return { ...picked(partner, [...STATUS_FIELDS, ...ESTIMATE_FIELDS]), destination_country: 'IT', events: events.slice(0, 100) };
369
396
  }
370
397
  /**
371
398
  * The DPD group's guest reply for a BRTcode it places in Italy, where the group's network is BRT:
@@ -26,11 +26,11 @@ import { carrierIdFromPartnerLinks } from '../../core/catalog/hints.js';
26
26
  import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
27
27
  import { runSteps, singleFlight, takeTurn } from '../../core/runner/index.js';
28
28
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
29
- import { countryCode, isoTime } from '../../core/time/index.js';
29
+ import { countryCode, explicitOffsetTime, isoTime } from '../../core/time/index.js';
30
30
  import { clean as cleanText, fetchBounded, parseJsonBytes, UpstreamHttpError, UpstreamNetworkError, } from '../../core/transport/index.js';
31
31
  import { isRecord } from '../../core/types.js';
32
32
  import { withoutPartnerLink } from './identity.js';
33
- import { BOOKED_OPTION, stageForText, statusForStage } from './status.js';
33
+ import { BOOKED_OPTION, movedParcel, preAdvice, stageForText, statusForStage } from './status.js';
34
34
  const PROVIDER = 'DHL';
35
35
  const TIMEZONE = 'Europe/Berlin';
36
36
  const ORIGIN = 'https://www.dhl.de';
@@ -62,9 +62,8 @@ export function dhlTrackingUrl(number) {
62
62
  return url.toString();
63
63
  }
64
64
  /**
65
- * DHL timestamps carry their own offset and its delivery window is a calendar
66
- * day; both are kept verbatim rather than normalized, so `core/time` is used
67
- * to validate them in the German zone only.
65
+ * DHL timestamps carry their own offset, and are kept verbatim rather than
66
+ * normalized, so `core/time` is used to validate them in the German zone only.
68
67
  */
69
68
  function date(value) {
70
69
  const raw = clean(value, 64);
@@ -72,6 +71,27 @@ function date(value) {
72
71
  return '';
73
72
  return isoTime(raw, TIMEZONE) ? raw : '';
74
73
  }
74
+ /**
75
+ * DHL's delivery window, `zustellzeitfensterVon` to `zustellzeitfensterBis`, kept as
76
+ * DHL states it when both ends carry an offset, in order, on one German day.
77
+ * Otherwise it is the calendar day of either end, or the days from one end to
78
+ * the other when they fall on two.
79
+ */
80
+ function deliveryWindow(delivery) {
81
+ const from = date(delivery.zustellzeitfensterVon);
82
+ const to = date(delivery.zustellzeitfensterBis);
83
+ const start = explicitOffsetTime(from);
84
+ const end = explicitOffsetTime(to);
85
+ const day = (timestamp) => DateTime.fromMillis(timestamp, { zone: TIMEZONE }).toISODate();
86
+ if (start && end && start.timestamp < end.timestamp && day(start.timestamp) === day(end.timestamp)) {
87
+ return { expected_delivery_from: from, expected_delivery: to };
88
+ }
89
+ const first = from.slice(0, 10) || to.slice(0, 10);
90
+ const last = to.slice(0, 10);
91
+ if (first && last && first < last)
92
+ return { expected_delivery_from: first, expected_delivery: last };
93
+ return first ? { expected_delivery: first } : {};
94
+ }
75
95
  function millis(value) {
76
96
  return isoTime(value, TIMEZONE)?.timestamp ?? 0;
77
97
  }
@@ -184,7 +204,11 @@ export function parseDHLTrackingResponse(payload, trackingNumber) {
184
204
  });
185
205
  if (listsNewestFirst(scans) === false)
186
206
  scans.reverse();
187
- const events = scans.sort((left, right) => millis(right.time) - millis(left.time)).slice(0, 100)
207
+ // A pre-advice dated after a scan that moved the parcel is a notice, not movement: it is left
208
+ // out, so the stage cannot move back. One dated first, or with the first move, stays registered.
209
+ const firstMove = Math.min(...scans.filter((scan) => scan.time && movedParcel(scan.description)).map((scan) => millis(scan.time)));
210
+ const events = scans.filter((scan) => !(scan.time && preAdvice(scan.description) && millis(scan.time) > firstMove))
211
+ .sort((left, right) => millis(right.time) - millis(left.time)).slice(0, 100)
188
212
  .map(({ time, ...event }) => ({ ...event, ...clock(time, event.location) }));
189
213
  const summary = withoutPartnerLink(clean(timeline.status));
190
214
  if (!events.length && !summary && details.istZugestellt !== true) {
@@ -192,13 +216,14 @@ export function parseDHLTrackingResponse(payload, trackingNumber) {
192
216
  return noData();
193
217
  throw new SchemaError(PROVIDER, 'DHL returned no usable tracking status');
194
218
  }
195
- const currentText = summary || events[0]?.description || 'Delivered';
219
+ // Nor is a pre-advice DHL gives as its summary once a scan moved the parcel: the newest scan is.
220
+ const currentText = (preAdvice(summary) && Number.isFinite(firstMove) ? '' : summary) || events[0]?.description || 'Delivered';
196
221
  const fallback = Number(timeline.fortschritt) <= 1 ? 'registered' : String(events[0]?.stage || 'in_transit');
197
222
  const stage = details.istZugestellt === true
198
223
  ? details.ruecksendung === true ? 'returned' : 'delivered'
199
224
  : stageForText(currentText, fallback);
200
225
  const delivery = isRecord(details.zustellung) ? details.zustellung : {};
201
- const expected = date(delivery.zustellzeitfensterVon) || date(delivery.zustellzeitfensterBis);
226
+ const window = ['delivered', 'returned'].includes(stage) ? {} : deliveryWindow(delivery);
202
227
  const stated = date(timeline.datumAktuellerStatus);
203
228
  const newest = events[0];
204
229
  // The status DHL dates as its newest scan abroad keeps that scan's wall clock.
@@ -209,7 +234,8 @@ export function parseDHLTrackingResponse(payload, trackingNumber) {
209
234
  last_status_text: currentText,
210
235
  last_update: local ? null : stated || newest?.time || null,
211
236
  ...(local ? { last_update_local: local } : {}),
212
- expected_delivery: ['delivered', 'returned'].includes(stage) ? null : expected.slice(0, 10) || null,
237
+ expected_delivery: window.expected_delivery ?? null,
238
+ ...(window.expected_delivery_from ? { expected_delivery_from: window.expected_delivery_from } : {}),
213
239
  timezone: TIMEZONE, events,
214
240
  ...(deliveryCarrier ? { delivery_carrier: deliveryCarrier } : {}),
215
241
  ...destination(details),
@@ -19,6 +19,14 @@ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
19
19
  * Germany's booked delivery day; read as unmapped wording, it came as transit.
20
20
  */
21
21
  export declare const BOOKED_OPTION: RegExp;
22
+ /**
23
+ * DHL's pre-advice: the sender's electronic data ("Die Sendung wurde elektronisch angekündigt.",
24
+ * "The instruction data … provided … electronically"). Listed after a scan that moved the parcel,
25
+ * it is a notice, not movement, and the parser leaves it out.
26
+ */
27
+ export declare function preAdvice(text: string): boolean;
28
+ /** Whether the wording reads as a step that moved the parcel; unknown wording does not. */
29
+ export declare function movedParcel(text: string): boolean;
22
30
  export declare function stageForText(text: string, fallback?: string): string;
23
31
  export declare function statusForStage(stage: string): CarrierStatus;
24
32
  /**
@@ -6,6 +6,19 @@ import { nonterminalEnglishReturn } from '../../core/status/language.js';
6
6
  * Germany's booked delivery day; read as unmapped wording, it came as transit.
7
7
  */
8
8
  export const BOOKED_OPTION = /\bals empfangsoption vorgemerkt\b/i;
9
+ /**
10
+ * DHL's pre-advice: the sender's electronic data ("Die Sendung wurde elektronisch angekündigt.",
11
+ * "The instruction data … provided … electronically"). Listed after a scan that moved the parcel,
12
+ * it is a notice, not movement, and the parser leaves it out.
13
+ */
14
+ export function preAdvice(text) {
15
+ return /\belectronically\b|\belektronisch\b/i.test(text) && stageForText(text, '') === 'registered';
16
+ }
17
+ const MOVED = new Set(['accepted', 'in_transit', 'out_for_delivery', 'failed_attempt', 'ready_for_pickup', 'delivered', 'returned']);
18
+ /** Whether the wording reads as a step that moved the parcel; unknown wording does not. */
19
+ export function movedParcel(text) {
20
+ return MOVED.has(stageForText(text, ''));
21
+ }
9
22
  export function stageForText(text, fallback = 'in_transit') {
10
23
  const value = text.toLowerCase();
11
24
  // A return still to come or under way, such as a parcel left uncollected past its storage period:
@@ -3,6 +3,7 @@
3
3
  "entries": [
4
4
  { "wording": "The instruction data for this shipment have been provided by the sender to DHL electronically", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "Pre-advice; matched on \"electronically\"." },
5
5
  { "wording": "Die Sendung wurde vom Absender elektronisch angekündigt.", "language": "de", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "German pre-advice; matched on \"elektronisch\"." },
6
+ { "wording": "Die Sendung wurde elektronisch angekündigt. Sobald die Sendung von uns bearbeitet wurde, erhalten Sie weitere Informationen.", "language": "de", "stage": "registered", "confirmedBy": "live", "firstSeen": "2026-11-03", "note": "Pre-advice. Seen after a processing scan; dated after a scan that moved the parcel, it is left out of the history." },
6
7
  { "wording": "Für diese Sendung wurde ein Liefertag als Empfangsoption vorgemerkt.", "language": "de", "confirmedBy": "live", "firstSeen": "2026-11-03", "note": "The recipient booked a delivery day; deliberately not mapped and left out of the history, like Hermes Germany's booked day, so it is not read as transit." },
7
8
  { "wording": "Pick-up has been scheduled for the next working day and the information has been sent to the delivery person", "language": "en", "stage": "registered", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "A booked collection, before \"Pick-up was successful.\"." },
8
9
  { "wording": "Pick-up was successful.", "language": "en", "stage": "accepted", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "" },
@@ -94,6 +94,14 @@ export function parseUnifiedPiece(payload, raw) {
94
94
  throw new SchemaError(PROVIDER, 'DHL returned no waybill for the piece');
95
95
  return { ...unifiedResult(shipment), canonical_tracking_number: shipment.id };
96
96
  }
97
+ /**
98
+ * The day of DHL's delivery estimate (`estimatedTimeOfDelivery`), as its own clock writes it.
99
+ * The date alone is kept, as for DHL eCommerce's reply of the same shape.
100
+ */
101
+ function estimatedDay(value) {
102
+ const match = /^(\d{4}-\d{2}-\d{2})(?:T|$)/.exec(clean(value, 64));
103
+ return match && DateTime.fromISO(match[1]).isValid ? match[1] : null;
104
+ }
97
105
  function unifiedResult(shipment) {
98
106
  if (!Array.isArray(shipment.events) || shipment.events.length > 1000)
99
107
  throw new SchemaError(PROVIDER, 'DHL returned invalid events');
@@ -117,8 +125,9 @@ function unifiedResult(shipment) {
117
125
  : ['exception', 'failed_attempt', 'returned'].includes(current.stage) ? 'exception'
118
126
  : ['pending', 'registered'].includes(current.stage) ? 'pending' : 'in_transit';
119
127
  const destination = isRecord(shipment.destination) && isRecord(shipment.destination.address) ? shipment.destination.address : {};
128
+ const expected = ['delivered', 'returned'].includes(current.stage) ? null : estimatedDay(shipment.estimatedTimeOfDelivery);
120
129
  return { status, current_stage: current.stage, current_stage_source: current.stage_source,
121
- last_status_text: current.description, last_update: current.time ?? null, events,
130
+ last_status_text: current.description, last_update: current.time ?? null, expected_delivery: expected, events,
122
131
  ...(typeof destination.countryCode === 'string' && /^[A-Z]{2}$/.test(destination.countryCode) ? { destination_country: destination.countryCode } : {}) };
123
132
  }
124
133
  /**
@@ -32,7 +32,8 @@
32
32
  },
33
33
  "capabilities": [
34
34
  "history",
35
- "location"
35
+ "location",
36
+ "eta"
36
37
  ],
37
38
  "portal": {
38
39
  "url": "https://mydhl.express.dhl/gb/en/tracking.html#/results?id={trackingNumber}",
@@ -43,12 +44,14 @@
43
44
  "status",
44
45
  "history",
45
46
  "location",
47
+ "eta",
46
48
  "signature"
47
49
  ],
48
50
  "scraped": [
49
51
  "status",
50
52
  "history",
51
- "location"
53
+ "location",
54
+ "eta"
52
55
  ],
53
56
  "discarded": [
54
57
  "signature"
@@ -33,6 +33,7 @@
33
33
  "capabilities": [
34
34
  "history",
35
35
  "location",
36
+ "eta",
36
37
  "provider_code",
37
38
  "destination_country",
38
39
  "delivery_partner",
@@ -52,7 +53,8 @@
52
53
  "destination_country",
53
54
  "tracking_alias",
54
55
  "delivery_tracking_number",
55
- "order_reference"
56
+ "order_reference",
57
+ "eta"
56
58
  ],
57
59
  "scraped": [
58
60
  "status",
@@ -61,7 +63,8 @@
61
63
  "provider_code",
62
64
  "destination_country",
63
65
  "delivery_partner",
64
- "delivery_tracking_number"
66
+ "delivery_tracking_number",
67
+ "eta"
65
68
  ],
66
69
  "discarded": [
67
70
  "progress_rail",
@@ -71,7 +74,6 @@
71
74
  "order_reference"
72
75
  ],
73
76
  "unavailable": [
74
- "eta",
75
77
  "offset_timestamps",
76
78
  "delivered_at",
77
79
  "service_name",
@@ -1,6 +1,7 @@
1
1
  import { DateTime, FixedOffsetZone } from 'luxon';
2
2
  import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
3
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { uspsRoundDelivery } from '../../core/result/round.js';
4
5
  import { languageStageStatus } from '../../core/status/index.js';
5
6
  import { countryCode, firstOccurrence, isoTime, usStateTimeZone } from '../../core/time/index.js';
6
7
  import { cleanScalar } from '../../core/transport/text.js';
@@ -182,6 +183,10 @@ function outranks(left, right) {
182
183
  const index = left.findIndex((value, position) => value !== right[position]);
183
184
  return index >= 0 && left[index] > right[index];
184
185
  }
186
+ /** The delivery a relayed USPS round promises on its scan's day. */
187
+ function roundDelivery(event) {
188
+ return typeof event.time === 'string' ? uspsRoundDelivery(event.description ?? '', event.time.slice(0, 10)) : null;
189
+ }
185
190
  /** The last leg, when its number's whole layout is one the table maps. */
186
191
  function lastLeg(entry) {
187
192
  const number = normalizeTrackingNumber(text(entry, 'lastTurnNo', 64));
@@ -261,7 +266,8 @@ export function parseDhlink(payload, raw, readAt = Date.now()) {
261
266
  && other.scan.event.description === scan.event.description && usClockCopy(scan.stamp, other.time.timestamp))) : [];
262
267
  // A place naming only the destination country is the parcel's, not the scan's: a Canada line's
263
268
  // airline hand-over in China reads "Canada".
264
- const events = dated.filter((entry) => !copies.includes(entry))
269
+ const kept = dated.filter((entry) => !copies.includes(entry));
270
+ const events = kept
265
271
  .map(({ scan, time }) => {
266
272
  const { location, ...event } = scan.event;
267
273
  const placed = location && !(destination && countryCode(location) === destination) ? { location } : {};
@@ -288,13 +294,16 @@ export function parseDhlink(payload, raw, readAt = Date.now()) {
288
294
  stage = 'delivered';
289
295
  source = 'carrier_map';
290
296
  }
297
+ // The round the parcel is on, when it is the current scan and is read on the destination's clock.
298
+ const round = stage === 'out_for_delivery' && current?.stage === 'out_for_delivery'
299
+ && ![null, RECORD_ZONE].includes(kept[events.indexOf(current)].zone) ? roundDelivery(current) : null;
291
300
  return {
292
301
  status: stage ? languageStageStatus(stage) : 'unknown',
293
302
  ...(stage ? { current_stage: stage, current_stage_source: source } : {}),
294
303
  last_status_text: newest.description,
295
304
  last_update: newest.time ?? null,
296
305
  ...(newest.local_time ? { last_update_local: newest.local_time } : {}),
297
- expected_delivery: null,
306
+ expected_delivery: round,
298
307
  ...(stage === 'delivered' && newest.stage === 'delivered' && newest.time ? { delivered_at: newest.time } : {}),
299
308
  ...(last && ![number, tracking, own].includes(last.number) ? { delivery_carrier: last.leg.carrier, delivery_tracking_number: last.number } : {}),
300
309
  ...(COUNTRY.test(destination) ? { destination_country: destination } : {}),
@@ -32,6 +32,7 @@
32
32
  "capabilities": [
33
33
  "history",
34
34
  "provider_code",
35
+ "eta_window",
35
36
  "pickup_point"
36
37
  ],
37
38
  "portal": {
@@ -54,12 +55,12 @@
54
55
  "status",
55
56
  "history",
56
57
  "provider_code",
58
+ "eta_window",
57
59
  "pickup_point"
58
60
  ],
59
61
  "discarded": [
60
62
  "progress_rail",
61
63
  "coordinates",
62
- "eta_window",
63
64
  "depot_code",
64
65
  "signatory",
65
66
  "proof_of_delivery"
@@ -5,7 +5,7 @@ import { normalizeTrackingNumber } from '../../core/detection/index.js';
5
5
  import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
6
6
  import { languageStageStatus } from '../../core/status/index.js';
7
7
  import { clean } from '../../core/transport/index.js';
8
- import { DPD_PT_COLLECTION, DPD_PT_DELIVERY, DPD_PT_FORECAST, DPD_PT_POINT_RECEIPT, DPD_PT_RETURN, dpdPtStage, isDpdPtNotice } from './status.js';
8
+ import { DPD_PT_COLLECTION, DPD_PT_DELIVERY, DPD_PT_FORECAST, DPD_PT_POINT_RECEIPT, DPD_PT_RETURN, DPD_PT_WINDOW, dpdPtStage, isDpdPtNotice, } from './status.js';
9
9
  export const PROVIDER = 'DPD Portugal';
10
10
  // The page is under 50 kB with a long history.
11
11
  export const DPD_PT_MAX_BYTES = 1024 * 1024;
@@ -16,6 +16,8 @@ const HEADERS = ['Código', 'Data', 'Hora', 'Descrição', 'Comentário', 'Local
16
16
  const CODE = /^[A-Z0-9]{1,8}$/;
17
17
  const DATE = /^(\d{2})-(\d{2})-(\d{4})$/;
18
18
  const CLOCK = /^(\d{2}):(\d{2})$/;
19
+ // The delivery window a message to the recipient gives, as "10:45 - 11:45".
20
+ const WINDOW = /^((?:[01]\d|2[0-3]):[0-5]\d)\s*-\s*((?:[01]\d|2[0-3]):[0-5]\d)$/;
19
21
  const MAX_ROWS = 500;
20
22
  const MAX_EVENTS = 100;
21
23
  /** A DPD parcel number: fourteen digits, or fourteen and their matching check character, looked up by the digits. */
@@ -62,6 +64,25 @@ function pickupPoint(newestFirst) {
62
64
  }
63
65
  return null;
64
66
  }
67
+ /**
68
+ * The hour DPD's message gives the recipient on the delivery day (code 706), while that delivery
69
+ * is still ahead: the message is the day's, and nothing after it but the van's scans and other
70
+ * messages. A failed attempt, a return to the depot or a later day ends it.
71
+ */
72
+ function deliveryWindow(newestFirst, stage) {
73
+ if (stage !== 'in_transit' && stage !== 'out_for_delivery')
74
+ return null;
75
+ const at = newestFirst.findIndex(scan => scan.code === DPD_PT_WINDOW);
76
+ if (at < 0)
77
+ return null;
78
+ const message = newestFirst[at];
79
+ const hours = WINDOW.exec(message.comment);
80
+ const day = message.event.local_time.slice(0, 10);
81
+ if (!hours || hours[2] <= hours[1] || newestFirst[0].event.local_time.slice(0, 10) !== day)
82
+ return null;
83
+ const ahead = newestFirst.slice(0, at).every(({ event }) => event.stage_source === 'none' || event.stage === 'out_for_delivery');
84
+ return ahead ? `${day} ${hours[1]}–${hours[2]}` : null;
85
+ }
65
86
  /**
66
87
  * The tracker's page for one reference: a heading naming the reference asked,
67
88
  * then either its not-found notice or the scans, newest first, in a table of
@@ -118,8 +139,9 @@ export function parseDpdPt(html, raw) {
118
139
  if (code === DPD_PT_FORECAST)
119
140
  continue;
120
141
  // The comment column mixes the receiver's name, delivery windows, depot
121
- // and pickup point codes and references; only a pickup point's name is read.
122
- const comment = code === DPD_PT_POINT_RECEIPT ? clean(cells.eq(4).text(), 120) : '';
142
+ // and pickup point codes and references; only a pickup point's name and a
143
+ // window message's hours are read.
144
+ const comment = code === DPD_PT_POINT_RECEIPT || code === DPD_PT_WINDOW ? clean(cells.eq(4).text(), 120) : '';
123
145
  const key = JSON.stringify([local_time, code, description]);
124
146
  // The page lists some scans twice.
125
147
  if (seen.has(key))
@@ -161,7 +183,7 @@ export function parseDpdPt(html, raw) {
161
183
  last_status_text: (decider ?? events[0]).description,
162
184
  last_update: null,
163
185
  last_update_local: events[0].local_time,
164
- expected_delivery: null,
186
+ expected_delivery: deliveryWindow(scans, stage),
165
187
  ...(point ? { pickup_point: point } : {}),
166
188
  events: events.slice(0, MAX_EVENTS),
167
189
  ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
@@ -8,6 +8,8 @@ export declare const DPD_PT_DELIVERY = "POD";
8
8
  export declare const DPD_PT_RETURN = "424";
9
9
  /** The pickup point's own scan; its comment names the point. */
10
10
  export declare const DPD_PT_POINT_RECEIPT = "002";
11
+ /** The message giving the recipient the delivery's hour on its day; the comment column holds the window. */
12
+ export declare const DPD_PT_WINDOW = "706";
11
13
  /** The expected delivery day, dated when DPD set it rather than with the day: the history leaves it out. */
12
14
  export declare const DPD_PT_FORECAST = "714";
13
15
  /** The stage DPD Portugal's code gives a scan, if any, read without the scans before it. */
@@ -40,6 +40,8 @@ export const DPD_PT_DELIVERY = 'POD';
40
40
  export const DPD_PT_RETURN = '424';
41
41
  /** The pickup point's own scan; its comment names the point. */
42
42
  export const DPD_PT_POINT_RECEIPT = '002';
43
+ /** The message giving the recipient the delivery's hour on its day; the comment column holds the window. */
44
+ export const DPD_PT_WINDOW = '706';
43
45
  /** The expected delivery day, dated when DPD set it rather than with the day: the history leaves it out. */
44
46
  export const DPD_PT_FORECAST = '714';
45
47
  // Messages to the recipient and requests for a new delivery or address: no
@@ -191,6 +191,36 @@ function expectedDelivery(value) {
191
191
  const match = /^(\d{4}-\d{2}-\d{2})/.exec(cleanScalar(value, 64));
192
192
  return match ? match[1] : null;
193
193
  }
194
+ function windowEnd(value) {
195
+ const raw = cleanScalar(value, 64);
196
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T([01]\d|2[0-3]):([0-5]\d)(?::[0-5]\d(?:\.\d+)?)?)?/.exec(raw);
197
+ const day = match ? calendarDay(Number(match[1]), Number(match[2]), Number(match[3])) : null;
198
+ if (!match || !day)
199
+ return null;
200
+ const clock = match[4] && `${match[4]}:${match[5]}` !== '00:00' ? `${match[4]}:${match[5]}` : '';
201
+ return { day, clock, instant: clock ? explicitOffsetTime(raw) : null };
202
+ }
203
+ /**
204
+ * A window's two ends: two instants when both carry an offset, a slot on one day when
205
+ * they carry wall clocks alone, and a range of days when both are days.
206
+ */
207
+ function windowEstimate(beginsValue, endsValue) {
208
+ const begins = windowEnd(beginsValue);
209
+ const ends = windowEnd(endsValue);
210
+ if (!begins || !ends)
211
+ return null;
212
+ if (begins.instant && ends.instant) {
213
+ return begins.instant.timestamp < ends.instant.timestamp
214
+ ? { expected_delivery_from: begins.instant.iso, expected_delivery: ends.instant.iso } : null;
215
+ }
216
+ if (begins.clock && ends.clock) {
217
+ return begins.day === ends.day && begins.clock < ends.clock
218
+ ? { expected_delivery: `${begins.day} ${begins.clock}–${ends.clock}` } : null;
219
+ }
220
+ if (begins.clock || ends.clock)
221
+ return null;
222
+ return begins.day < ends.day ? { expected_delivery_from: begins.day, expected_delivery: ends.day } : null;
223
+ }
194
224
  /** A delivered-at instant; offset-less or unparsable values are dropped. */
195
225
  function deliveredAt(value) {
196
226
  return explicitOffsetTime(value)?.iso ?? null;
@@ -302,7 +332,7 @@ function shipmentResult(shipment, number) {
302
332
  const stage = afterCutoff ? 'accepted' : FEDEX_CODE_STAGE[keyStatusCD] ?? wording ?? undefined;
303
333
  const status = afterCutoff ? 'in_transit' : fedexStatus(keyStatusCD, `${keyStatus} ${statusDetails}`, trimmed.length > 0);
304
334
  const delivered = stage === 'delivered';
305
- const estimate = delivered ? null : expectedDelivery(shipment.estimate);
335
+ const estimate = delivered ? null : shipment.estimate;
306
336
  const pickupPoint = stage === 'ready_for_pickup' ? shipment.holdLocation : '';
307
337
  const postal = uspsHandoff(shipment.scans, number);
308
338
  // A host starts a scan dated by its day alone at midnight on the result's zone, else the catalog's
@@ -317,7 +347,8 @@ function shipmentResult(shipment, number) {
317
347
  // A newest scan dated by its day alone has no instant, only that day.
318
348
  ...(!newest?.time && typeof newest?.local_time === 'string' ? { last_update_local: newest.local_time } : {}),
319
349
  ...(dayZone ? { timezone: dayZone } : {}),
320
- expected_delivery: estimate,
350
+ expected_delivery: estimate?.expected_delivery ?? null,
351
+ ...(estimate?.expected_delivery_from ? { expected_delivery_from: estimate.expected_delivery_from } : {}),
321
352
  ...(delivered && deliveredAt(shipment.deliveredAt)
322
353
  ? { delivered_at: deliveredAt(shipment.deliveredAt) } : {}),
323
354
  ...(pickupPoint ? { pickup_point: pickupPoint } : {}),
@@ -378,7 +409,7 @@ export function parseFedExTrackingResponse(payload, trackingNumber, readAt = Dat
378
409
  statusText: keyStatus,
379
410
  statusCode: keyStatusCD,
380
411
  statusDetails,
381
- estimate: shipment.estDeliveryDt,
412
+ estimate: { expected_delivery: expectedDelivery(shipment.estDeliveryDt) },
382
413
  deliveredAt: shipment.actDeliveryDt,
383
414
  holdLocation: holdLocation(shipment.halCmpnyName, shipment.halAddress, shipment.halresidential === true),
384
415
  }, number);
@@ -393,6 +424,21 @@ function apiDate(result, ...types) {
393
424
  }
394
425
  return undefined;
395
426
  }
427
+ /**
428
+ * The API's estimate: the day of `ESTIMATED_DELIVERY`, which FedEx dates at midnight, and
429
+ * `estimatedDeliveryTimeWindow` when it falls on that day or FedEx gives no such date.
430
+ * A window without both ends still gives its end's day.
431
+ */
432
+ function apiEstimate(result) {
433
+ const day = expectedDelivery(apiDate(result, 'ESTIMATED_DELIVERY'));
434
+ const window = isRecord(result.estimatedDeliveryTimeWindow) && isRecord(result.estimatedDeliveryTimeWindow.window)
435
+ ? result.estimatedDeliveryTimeWindow.window : {};
436
+ const range = windowEstimate(window.begins, window.ends);
437
+ const onDay = (value) => !value || value.slice(0, 10) === day;
438
+ if (range && (!day || (onDay(range.expected_delivery_from) && onDay(range.expected_delivery))))
439
+ return range;
440
+ return { expected_delivery: day ?? expectedDelivery(window.ends) };
441
+ }
396
442
  /**
397
443
  * The official Track API's answer (`POST /track/v1/trackingnumbers`). It reads
398
444
  * the scans, the latest status, the delivery dates and the hold location; the
@@ -431,8 +477,6 @@ export function parseFedExApiResponse(payload, trackingNumber, readAt = Date.now
431
477
  ? latest.ancillaryDetails.filter(isRecord).map((detail) => clean(detail.reasonDescription)).filter(Boolean) : [];
432
478
  const hold = isRecord(result.holdAtLocation) ? result.holdAtLocation : {};
433
479
  const held = isRecord(hold.locationContactAndAddress) ? hold.locationContactAndAddress : {};
434
- const estimateWindow = isRecord(result.estimatedDeliveryTimeWindow) && isRecord(result.estimatedDeliveryTimeWindow.window)
435
- ? result.estimatedDeliveryTimeWindow.window : {};
436
480
  return shipmentResult({
437
481
  scans: scans.slice(0, MAX_EVENTS_TO_INSPECT).map((raw) => ({
438
482
  description: scanDescription(clean(raw.eventDescription), clean(raw.exceptionDescription)),
@@ -445,7 +489,7 @@ export function parseFedExApiResponse(payload, trackingNumber, readAt = Date.now
445
489
  statusText,
446
490
  statusCode,
447
491
  statusDetails: reasons.join(' '),
448
- estimate: apiDate(result, 'ESTIMATED_DELIVERY') ?? estimateWindow.ends,
492
+ estimate: apiEstimate(result),
449
493
  deliveredAt: apiDate(result, 'ACTUAL_DELIVERY'),
450
494
  holdLocation: holdLocation(isRecord(held.contact) ? held.contact.companyName : undefined, held.address, false),
451
495
  }, number);