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.
- package/README.md +1 -1
- package/dist/browser/scraper.js +1 -0
- package/dist/carriers/aliexpress/adapter.js +13 -6
- package/dist/carriers/brt/parser.d.ts +2 -1
- package/dist/carriers/brt/parser.js +31 -4
- package/dist/carriers/dhl/adapter.js +35 -9
- package/dist/carriers/dhl/status.d.ts +8 -0
- package/dist/carriers/dhl/status.js +13 -0
- package/dist/carriers/dhl/statuses.json +1 -0
- package/dist/carriers/dhl-express/adapter.js +10 -1
- package/dist/carriers/dhl-express/carrier.json +5 -2
- package/dist/carriers/dhlink/carrier.json +5 -3
- package/dist/carriers/dhlink/parser.js +11 -2
- package/dist/carriers/dpd-pt/carrier.json +2 -1
- package/dist/carriers/dpd-pt/parser.js +26 -4
- package/dist/carriers/dpd-pt/status.d.ts +2 -0
- package/dist/carriers/dpd-pt/status.js +2 -0
- package/dist/carriers/fedex/adapter.js +50 -6
- package/dist/carriers/first-line/carrier.json +6 -4
- package/dist/carriers/first-line/parser.js +6 -1
- package/dist/carriers/gofo/carrier.json +5 -4
- package/dist/carriers/gofo/parser.d.ts +8 -0
- package/dist/carriers/gofo/parser.js +15 -2
- package/dist/carriers/gofo-ca/carrier.json +5 -4
- package/dist/carriers/gofo-es/carrier.json +4 -3
- package/dist/carriers/gofo-fr/carrier.json +6 -4
- package/dist/carriers/gofo-fr/parser.js +3 -1
- package/dist/carriers/gofo-it/carrier.json +6 -4
- package/dist/carriers/gofo-nl/carrier.json +4 -3
- package/dist/carriers/hermes-de/adapter.js +3 -2
- package/dist/carriers/imile/carrier.json +3 -2
- package/dist/carriers/imile/parser.js +25 -3
- package/dist/carriers/js-express/carrier.json +3 -2
- package/dist/carriers/js-express/parser.d.ts +1 -1
- package/dist/carriers/js-express/parser.js +21 -3
- package/dist/carriers/poste-italiane/adapter.js +3 -1
- package/dist/carriers/royal-mail/adapter.js +3 -2
- package/dist/carriers/royal-mail/browser.js +2 -3
- package/dist/carriers/royal-mail/parser.d.ts +4 -0
- package/dist/carriers/royal-mail/parser.js +28 -6
- package/dist/carriers/speedx/parser.js +9 -2
- package/dist/carriers/spx-br/status.js +1 -0
- package/dist/carriers/spx-br/statuses.json +8 -0
- package/dist/carriers/ups/adapter.d.ts +1 -1
- package/dist/carriers/ups/adapter.js +19 -2
- package/dist/carriers/usps/adapter.js +56 -6
- package/dist/core/result/partial.js +6 -0
- package/dist/core/result/round.d.ts +6 -0
- package/dist/core/result/round.js +25 -0
- package/dist/providers/parcelsapp/adapter.js +11 -1
- package/dist/providers/seventeentrack/adapter.js +25 -1
- package/dist/server/openapi.json +1 -1
- 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,
|
|
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.
|
package/dist/browser/scraper.js
CHANGED
|
@@ -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
|
|
256
|
-
const
|
|
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, ...
|
|
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 { ...
|
|
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
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
122
|
-
|
|
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:
|
|
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 :
|
|
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:
|
|
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);
|