universal-parcel-scraper 0.55.0-main.708 → 0.56.0-main.720
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/data/catalog.json +42 -1
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +49 -2
- package/dist/browser/scraper.js +1046 -1004
- package/dist/carriers/dpd/adapter.d.ts +2 -2
- package/dist/carriers/dpd/adapter.js +29 -20
- package/dist/carriers/dpd-de/adapter.js +2 -3
- package/dist/carriers/dpd-de/app.d.ts +5 -2
- package/dist/carriers/dpd-de/app.js +10 -3
- package/dist/carriers/dpd-de/carrier.json +1 -1
- package/dist/carriers/dpd-de/numbers.json +26 -0
- package/dist/carriers/dpd-de/service.d.ts +8 -3
- package/dist/carriers/dpd-de/service.js +10 -9
- package/dist/carriers/dpd-pl/carrier.json +7 -0
- package/dist/carriers/emile/carrier.json +56 -0
- package/dist/carriers/emile/numbers.json +94 -0
- package/dist/carriers/emile/statuses.json +4 -0
- package/dist/carriers/four-px/numbers.json +5 -2
- package/dist/carriers/gls-ch/status.js +2 -2
- package/dist/carriers/gls-ch/statuses.json +1 -1
- package/dist/carriers/gls-de/statuses.json +1 -1
- package/dist/carriers/poczta-polska/parser.js +22 -6
- package/dist/carriers/ukrposhta/parser.js +19 -3
- package/dist/core/catalog/hints.js +8 -5
- package/dist/core/status/language.js +32 -6
- package/dist/data/catalog.json +42 -1
- package/dist/generated/catalog.d.ts +30 -2
- package/dist/generated/catalog.js +45 -2
- package/dist/generated/registry.js +1 -0
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -51,6 +51,7 @@ export interface DPDParcelShops {
|
|
|
51
51
|
export interface DPDAppTier {
|
|
52
52
|
/**
|
|
53
53
|
* `leads` when the guest tier is still to come and needs time left. `postcode` is empty when none was given.
|
|
54
|
+
* `waiting`, given to a leading tier, is called when the lookup starts waiting for the tier's session to open.
|
|
54
55
|
* `stop` aborts when the lookup has its answer without the tier, which then reads nothing more.
|
|
55
56
|
*/
|
|
56
57
|
run(number: string, context: {
|
|
@@ -59,11 +60,10 @@ export interface DPDAppTier {
|
|
|
59
60
|
timeoutMs: number;
|
|
60
61
|
leads: boolean;
|
|
61
62
|
postcode: string;
|
|
63
|
+
waiting?: () => void;
|
|
62
64
|
}): Promise<CarrierResult>;
|
|
63
65
|
/** The failures of either tier the other may answer after. */
|
|
64
66
|
recovers(error: unknown): boolean;
|
|
65
|
-
/** Whether a lookup would first wait for the tier's session to open. */
|
|
66
|
-
opening?(): boolean;
|
|
67
67
|
}
|
|
68
68
|
export declare class DPDTracker {
|
|
69
69
|
#private;
|
|
@@ -632,35 +632,44 @@ export class DPDTracker {
|
|
|
632
632
|
let guest;
|
|
633
633
|
const guestReply = () => guest ??= this.apiFetch(trackingNumber, resolvedPostcode, lookup);
|
|
634
634
|
/**
|
|
635
|
-
* While the app's session opens, the guest tier is asked beside it
|
|
636
|
-
*
|
|
637
|
-
* not
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
635
|
+
* While the app's session opens, the guest tier is asked beside it, not
|
|
636
|
+
* while a host's store gives back a saved session. A parcel the guest tier
|
|
637
|
+
* does not know, or places in another country, the app does not answer
|
|
638
|
+
* either: the app stops waiting, reads nothing once its session opens, and
|
|
639
|
+
* the guest tier's step reports it. Otherwise the app keeps waiting, because
|
|
640
|
+
* the guest tier's reply without a postcode is a summary: one entry per
|
|
641
|
+
* milestone, without places, registration, repeated delivery attempts and
|
|
642
|
+
* failures, or the parcel's size. Its clocks and wording also differ from
|
|
643
|
+
* the app's scans, so a consumer that stored it would keep both versions of
|
|
644
|
+
* a scan once a later lookup reads the app. The reply serves the guest
|
|
645
|
+
* tier's step if the session does not open in time. A failed reply is
|
|
646
|
+
* asked again there.
|
|
641
647
|
*/
|
|
642
|
-
const besideGuest = (
|
|
648
|
+
const besideGuest = (stop) => {
|
|
643
649
|
const reply = guestReply();
|
|
644
650
|
const pending = new Promise(() => { });
|
|
645
|
-
return
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
651
|
+
return reply.then(() => pending, (error) => {
|
|
652
|
+
if (!app.recovers(error)) {
|
|
653
|
+
const stopped = new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', { reason: DPD_DE_SESSION_OPENING });
|
|
654
|
+
stop.abort(stopped);
|
|
655
|
+
throw stopped;
|
|
656
|
+
}
|
|
657
|
+
if (guest === reply)
|
|
658
|
+
guest = undefined;
|
|
659
|
+
return pending;
|
|
660
|
+
});
|
|
655
661
|
};
|
|
656
662
|
const appStep = (leads) => ({
|
|
657
663
|
id: 'app',
|
|
658
664
|
...(leads ? {} : { recovers: appRecovers }),
|
|
659
665
|
run: ({ signal, remainingMs }) => {
|
|
660
|
-
const opening = leads && app.opening?.() === true;
|
|
661
666
|
const stop = new AbortController();
|
|
662
|
-
const
|
|
663
|
-
|
|
667
|
+
const tier = { signal, stop: stop.signal, timeoutMs: remainingMs, leads, postcode: resolvedPostcode };
|
|
668
|
+
if (!leads)
|
|
669
|
+
return app.run(trackingNumber, tier);
|
|
670
|
+
let waiting;
|
|
671
|
+
const opening = new Promise((resolve) => { waiting = resolve; });
|
|
672
|
+
return Promise.race([app.run(trackingNumber, { ...tier, waiting }), opening.then(() => besideGuest(stop))]);
|
|
664
673
|
},
|
|
665
674
|
});
|
|
666
675
|
const result = await runSteps({
|
|
@@ -16,15 +16,14 @@ export const adapter = (environment) => {
|
|
|
16
16
|
recorder: environment.recorder,
|
|
17
17
|
userAgent: environment.userAgent,
|
|
18
18
|
app: {
|
|
19
|
-
run: (number, { signal, stop, timeoutMs, leads, postcode }) => app.track(number, {
|
|
20
|
-
signal, stop, timeoutMs, postcode, sessionWaitMs: leads ? Math.max(0, timeoutMs - GUEST_RESERVE_MS) : timeoutMs,
|
|
19
|
+
run: (number, { signal, stop, timeoutMs, leads, postcode, waiting }) => app.track(number, {
|
|
20
|
+
signal, stop, timeoutMs, postcode, waiting, sessionWaitMs: leads ? Math.max(0, timeoutMs - GUEST_RESERVE_MS) : timeoutMs,
|
|
21
21
|
}),
|
|
22
22
|
// Each service has its own host, limits and outages, so either answers when
|
|
23
23
|
// the other fails. Both read the same parcel: neither can place another
|
|
24
24
|
// country's delivery in Germany, nor find a parcel the guest API does not know.
|
|
25
25
|
recovers: error => carrierErrorKind(error) !== 'not_found'
|
|
26
26
|
&& !(error instanceof CarrierError && error.reason === DPD_DE_OTHER_COUNTRY),
|
|
27
|
-
opening: () => service.opening,
|
|
28
27
|
},
|
|
29
28
|
});
|
|
30
29
|
return {
|
|
@@ -25,8 +25,10 @@ export declare class DpdDeAppClient {
|
|
|
25
25
|
now?: () => number;
|
|
26
26
|
});
|
|
27
27
|
/**
|
|
28
|
-
* `sessionWaitMs`
|
|
29
|
-
*
|
|
28
|
+
* `sessionWaitMs` is how long from its start the lookup may wait for a session still opening, also
|
|
29
|
+
* for one that replaces a session the service refused; the whole `timeoutMs` by default. `waiting`
|
|
30
|
+
* is called when the lookup starts waiting for one. Once `stop` aborts, the lookup stops waiting,
|
|
31
|
+
* ends with its reason and reads nothing.
|
|
30
32
|
* DPD checks a `postcode` against the recipient's: a rejected one gets one lookup without it, and
|
|
31
33
|
* the result says which, as on the guest API.
|
|
32
34
|
*/
|
|
@@ -36,5 +38,6 @@ export declare class DpdDeAppClient {
|
|
|
36
38
|
sessionWaitMs?: number;
|
|
37
39
|
postcode?: string;
|
|
38
40
|
stop?: AbortSignal;
|
|
41
|
+
waiting?: () => void;
|
|
39
42
|
}): Promise<CarrierResult>;
|
|
40
43
|
}
|
|
@@ -195,8 +195,10 @@ export class DpdDeAppClient {
|
|
|
195
195
|
this.#service = options.service ?? new DpdAppService(options);
|
|
196
196
|
}
|
|
197
197
|
/**
|
|
198
|
-
* `sessionWaitMs`
|
|
199
|
-
*
|
|
198
|
+
* `sessionWaitMs` is how long from its start the lookup may wait for a session still opening, also
|
|
199
|
+
* for one that replaces a session the service refused; the whole `timeoutMs` by default. `waiting`
|
|
200
|
+
* is called when the lookup starts waiting for one. Once `stop` aborts, the lookup stops waiting,
|
|
201
|
+
* ends with its reason and reads nothing.
|
|
200
202
|
* DPD checks a `postcode` against the recipient's: a rejected one gets one lookup without it, and
|
|
201
203
|
* the result says which, as on the guest API.
|
|
202
204
|
*/
|
|
@@ -208,9 +210,12 @@ export class DpdDeAppClient {
|
|
|
208
210
|
throw new TypeError('DPD Germany app tracking takes a 5-digit postcode');
|
|
209
211
|
const deadline = performance.now() + options.timeoutMs;
|
|
210
212
|
const left = () => Math.max(1, Math.floor(deadline - performance.now()));
|
|
213
|
+
const waitEnds = Math.min(deadline, performance.now() + (options.sessionWaitMs ?? Infinity));
|
|
211
214
|
try {
|
|
212
215
|
for (let attempt = 0;; attempt += 1) {
|
|
213
|
-
const session = await this.#service.session(options.signal, Math.
|
|
216
|
+
const session = await this.#service.session(options.signal, Math.max(0, waitEnds - performance.now()), {
|
|
217
|
+
waiting: options.waiting, stop: options.stop,
|
|
218
|
+
});
|
|
214
219
|
options.stop?.throwIfAborted();
|
|
215
220
|
// Read-only: the parcel is neither added to the session nor redirected.
|
|
216
221
|
const tracking = (zip) => this.#service.call('getTrackingData', { SessionToken: session, ParcelNo: number, DeliveryZipCode: zip,
|
|
@@ -230,11 +235,13 @@ export class DpdDeAppClient {
|
|
|
230
235
|
data = await tracking('');
|
|
231
236
|
verified = false;
|
|
232
237
|
}
|
|
238
|
+
options.stop?.throwIfAborted();
|
|
233
239
|
const scans = await this.#service.call('getTrackingScanList', { SessionToken: session, ParcelNo: number,
|
|
234
240
|
DeliveryZipCode: verified ? postcode : '' }, options.signal, left());
|
|
235
241
|
const { result, shop } = parseDpdDeApp(data, scans, number);
|
|
236
242
|
// The scans name the shop without its address, which the shop's own record adds.
|
|
237
243
|
// Without it, the parcel is found all the same and its pickup point keeps the name alone.
|
|
244
|
+
options.stop?.throwIfAborted();
|
|
238
245
|
const address = shop ? (await this.#service.parcelShop(shop, { signal: options.signal, timeoutMs: left() }))?.address : undefined;
|
|
239
246
|
if (address)
|
|
240
247
|
result.pickup_point = `${result.pickup_point}\n${address}`;
|
|
@@ -296,6 +296,32 @@
|
|
|
296
296
|
]
|
|
297
297
|
},
|
|
298
298
|
"note": "Parcel number with its printed check character, from the German consumer forum. DPD Germany's tracking returns the fourteen-digit parcel's history."
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"number": "13499302309359E",
|
|
302
|
+
"role": "shipment",
|
|
303
|
+
"evidence": "public_shipment_report",
|
|
304
|
+
"source": {
|
|
305
|
+
"url": "https://web.archive.org/web/20220128062637/https://tracking.dpd.de/status/de_DE/parcel/13499302309359E",
|
|
306
|
+
"date": "2022-01-28",
|
|
307
|
+
"access": "indexed_excerpt",
|
|
308
|
+
"dateKind": "capture"
|
|
309
|
+
},
|
|
310
|
+
"context": {
|
|
311
|
+
"assessment": "candidate"
|
|
312
|
+
},
|
|
313
|
+
"expect": {
|
|
314
|
+
"carrier": "unknown",
|
|
315
|
+
"confidence": "low",
|
|
316
|
+
"candidates": [
|
|
317
|
+
"dpd-pl",
|
|
318
|
+
"dpd",
|
|
319
|
+
"chronopost",
|
|
320
|
+
"dpd-de",
|
|
321
|
+
"dpd-uk"
|
|
322
|
+
]
|
|
323
|
+
},
|
|
324
|
+
"note": "Printed with its check character. Its 13 depot range is the one DPD Poland's own archived tracking pages show. Neither DPD Germany nor DPD Poland found it in October 2026."
|
|
299
325
|
}
|
|
300
326
|
]
|
|
301
327
|
}
|
|
@@ -69,7 +69,7 @@ export declare class DpdAppService {
|
|
|
69
69
|
signal: AbortSignal;
|
|
70
70
|
timeoutMs: number;
|
|
71
71
|
}): Promise<DpdParcelShop | undefined>;
|
|
72
|
-
/** Whether
|
|
72
|
+
/** Whether no session is open, as while one opens or a host's store gives back its sessions. */
|
|
73
73
|
get opening(): boolean;
|
|
74
74
|
/** Forgets a session the service refused, unless another lookup already replaced it. */
|
|
75
75
|
expire(session: string): void;
|
|
@@ -84,9 +84,14 @@ export declare class DpdAppService {
|
|
|
84
84
|
/**
|
|
85
85
|
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
86
86
|
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
87
|
-
* A session past its renewal age answers at once while the next one opens.
|
|
87
|
+
* A session past its renewal age answers at once while the next one opens. `waiting` is called
|
|
88
|
+
* when the lookup starts waiting for an opening, once a host's store has given back its sessions.
|
|
89
|
+
* A lookup whose `stop` aborts stops waiting with its reason, and still holds the opening.
|
|
88
90
|
*/
|
|
89
|
-
session(signal: AbortSignal, waitMs: number
|
|
91
|
+
session(signal: AbortSignal, waitMs: number, options?: {
|
|
92
|
+
waiting?: () => void;
|
|
93
|
+
stop?: AbortSignal;
|
|
94
|
+
}): Promise<string>;
|
|
90
95
|
call(operation: string, fields: Fields, signal: AbortSignal, timeoutMs: number): Promise<XmlNode>;
|
|
91
96
|
}
|
|
92
97
|
/**
|
|
@@ -150,7 +150,7 @@ export class DpdAppService {
|
|
|
150
150
|
return undefined;
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
-
/** Whether
|
|
153
|
+
/** Whether no session is open, as while one opens or a host's store gives back its sessions. */
|
|
154
154
|
get opening() {
|
|
155
155
|
return this.#session === null;
|
|
156
156
|
}
|
|
@@ -321,9 +321,11 @@ export class DpdAppService {
|
|
|
321
321
|
/**
|
|
322
322
|
* One session for every lookup. The opening runs while a lookup that asked for it is still
|
|
323
323
|
* running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
|
|
324
|
-
* A session past its renewal age answers at once while the next one opens.
|
|
324
|
+
* A session past its renewal age answers at once while the next one opens. `waiting` is called
|
|
325
|
+
* when the lookup starts waiting for an opening, once a host's store has given back its sessions.
|
|
326
|
+
* A lookup whose `stop` aborts stops waiting with its reason, and still holds the opening.
|
|
325
327
|
*/
|
|
326
|
-
async session(signal, waitMs) {
|
|
328
|
+
async session(signal, waitMs, options = {}) {
|
|
327
329
|
if (this.#loading)
|
|
328
330
|
await until(this.#loading, signal);
|
|
329
331
|
const current = this.#session;
|
|
@@ -339,21 +341,20 @@ export class DpdAppService {
|
|
|
339
341
|
}
|
|
340
342
|
if (current)
|
|
341
343
|
return current.token;
|
|
342
|
-
|
|
344
|
+
options.waiting?.();
|
|
345
|
+
const ended = options.stop ? AbortSignal.any([signal, options.stop]) : signal;
|
|
346
|
+
ended.throwIfAborted();
|
|
343
347
|
let timer;
|
|
344
|
-
const
|
|
345
|
-
leave = () => reject(signal.reason);
|
|
348
|
+
const late = new Promise((_resolve, reject) => {
|
|
346
349
|
timer = setTimeout(() => reject(new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', {
|
|
347
350
|
reason: DPD_DE_SESSION_OPENING,
|
|
348
351
|
})), Math.max(0, Math.min(waitMs, SESSION_OPEN_MS)));
|
|
349
352
|
});
|
|
350
|
-
signal.addEventListener('abort', leave, { once: true });
|
|
351
353
|
try {
|
|
352
|
-
return await Promise.race([token,
|
|
354
|
+
return await until(Promise.race([token, late]), ended);
|
|
353
355
|
}
|
|
354
356
|
finally {
|
|
355
357
|
clearTimeout(timer);
|
|
356
|
-
signal.removeEventListener('abort', leave);
|
|
357
358
|
}
|
|
358
359
|
}
|
|
359
360
|
async call(operation, fields, signal, timeoutMs) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "emile",
|
|
3
|
+
"displayName": "Emile",
|
|
4
|
+
"aliases": [
|
|
5
|
+
"Emile Express"
|
|
6
|
+
],
|
|
7
|
+
"region": {
|
|
8
|
+
"countries": [
|
|
9
|
+
"CA"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"brand": {
|
|
13
|
+
"color": "#e42920"
|
|
14
|
+
},
|
|
15
|
+
"selectable": true,
|
|
16
|
+
"timezone": "UTC",
|
|
17
|
+
"tracking": {
|
|
18
|
+
"mode": "link-only",
|
|
19
|
+
"adapter": null,
|
|
20
|
+
"steps": []
|
|
21
|
+
},
|
|
22
|
+
"capabilities": [],
|
|
23
|
+
"portal": {
|
|
24
|
+
"url": "https://www.emileps.com/tracking?tracking={trackingNumber}",
|
|
25
|
+
"searchUrl": "https://www.emileps.com/tracking",
|
|
26
|
+
"canaryUrl": "https://www.emileps.com/",
|
|
27
|
+
"shows": [
|
|
28
|
+
"status",
|
|
29
|
+
"history",
|
|
30
|
+
"delivery_photo"
|
|
31
|
+
],
|
|
32
|
+
"scraped": [],
|
|
33
|
+
"discarded": [],
|
|
34
|
+
"unavailable": [
|
|
35
|
+
"status",
|
|
36
|
+
"history"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"links": [
|
|
40
|
+
{
|
|
41
|
+
"domains": [
|
|
42
|
+
"emileps.com"
|
|
43
|
+
],
|
|
44
|
+
"params": [
|
|
45
|
+
"tracking"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"detection": [
|
|
50
|
+
{
|
|
51
|
+
"id": "emile-1",
|
|
52
|
+
"pattern": "^EM\\d{12}CA$",
|
|
53
|
+
"confidence": "low"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"carrier": "emile",
|
|
3
|
+
"records": [
|
|
4
|
+
{
|
|
5
|
+
"number": "EM200006549049CA",
|
|
6
|
+
"role": "shipment",
|
|
7
|
+
"evidence": "public_shipment_report",
|
|
8
|
+
"source": {
|
|
9
|
+
"url": "https://web.archive.org/web/20260102183550/https://track.4px.com/query/EM200006549049CA",
|
|
10
|
+
"date": "2026-01-02",
|
|
11
|
+
"dateKind": "capture",
|
|
12
|
+
"access": "indexed_excerpt"
|
|
13
|
+
},
|
|
14
|
+
"context": {
|
|
15
|
+
"assessment": "candidate"
|
|
16
|
+
},
|
|
17
|
+
"expect": {
|
|
18
|
+
"carrier": "unknown",
|
|
19
|
+
"confidence": "low",
|
|
20
|
+
"candidates": [
|
|
21
|
+
"emile"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. 4PX's feed returns the parcel's history and names Emile as its last-mile provider."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"number": "EM100048497916CA",
|
|
28
|
+
"role": "shipment",
|
|
29
|
+
"evidence": "public_shipment_report",
|
|
30
|
+
"source": {
|
|
31
|
+
"url": "https://web.archive.org/web/20250306075727/https://global.cainiao.com/global/detail.json?mailNos=EM100048497916CA&lang=en-US&language=en-US",
|
|
32
|
+
"date": "2025-03-06",
|
|
33
|
+
"dateKind": "capture",
|
|
34
|
+
"access": "full_page"
|
|
35
|
+
},
|
|
36
|
+
"quarantine": true,
|
|
37
|
+
"context": {
|
|
38
|
+
"assessment": "review"
|
|
39
|
+
},
|
|
40
|
+
"expect": {
|
|
41
|
+
"carrier": "unknown",
|
|
42
|
+
"confidence": "low",
|
|
43
|
+
"candidates": [
|
|
44
|
+
"emile"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"note": "A lookup on Cainiao's global tracker archived by the Wayback Machine. The archived history of this AliExpress parcel ends with a Canadian local delivery company receiving it, but Cainiao names no company; only the shape points to Emile."
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"number": "EM100047250023CA",
|
|
51
|
+
"role": "shipment",
|
|
52
|
+
"evidence": "public_shipment_report",
|
|
53
|
+
"source": {
|
|
54
|
+
"url": "https://web.archive.org/web/20250217144441/https://www.parcelpanel.com/couriers/emile/",
|
|
55
|
+
"date": "2025-02-17",
|
|
56
|
+
"dateKind": "capture",
|
|
57
|
+
"access": "full_page"
|
|
58
|
+
},
|
|
59
|
+
"context": {
|
|
60
|
+
"assessment": "candidate"
|
|
61
|
+
},
|
|
62
|
+
"expect": {
|
|
63
|
+
"carrier": "unknown",
|
|
64
|
+
"confidence": "low",
|
|
65
|
+
"candidates": [
|
|
66
|
+
"emile"
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"note": "The number ParcelPanel's Emile tracking page offered to try, archived by the Wayback Machine. The page rotates its example."
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"number": "EM200009946130CA",
|
|
73
|
+
"role": "shipment",
|
|
74
|
+
"evidence": "public_shipment_report",
|
|
75
|
+
"source": {
|
|
76
|
+
"url": "https://web.archive.org/web/20260410161436/https://www.parcelpanel.com/couriers/emile/",
|
|
77
|
+
"date": "2026-04-10",
|
|
78
|
+
"dateKind": "capture",
|
|
79
|
+
"access": "full_page"
|
|
80
|
+
},
|
|
81
|
+
"context": {
|
|
82
|
+
"assessment": "candidate"
|
|
83
|
+
},
|
|
84
|
+
"expect": {
|
|
85
|
+
"carrier": "unknown",
|
|
86
|
+
"confidence": "low",
|
|
87
|
+
"candidates": [
|
|
88
|
+
"emile"
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
"note": "The number ParcelPanel's Emile tracking page offered to try, archived by the Wayback Machine. The page rotates its example."
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
@@ -216,9 +216,12 @@
|
|
|
216
216
|
},
|
|
217
217
|
"expect": {
|
|
218
218
|
"carrier": "unknown",
|
|
219
|
-
"confidence": "
|
|
219
|
+
"confidence": "low",
|
|
220
|
+
"candidates": [
|
|
221
|
+
"emile"
|
|
222
|
+
]
|
|
220
223
|
},
|
|
221
|
-
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. The number is the Canadian last-mile courier Emile's, which 4PX's feed also answers
|
|
224
|
+
"note": "A lookup on 4PX's tracker archived by the Wayback Machine. The number is the Canadian last-mile courier Emile's, which 4PX's feed also answers and names as the provider."
|
|
222
225
|
},
|
|
223
226
|
{
|
|
224
227
|
"number": "OJ791502074GB",
|
|
@@ -41,9 +41,9 @@ export function glsSwitzerlandStatus(value) {
|
|
|
41
41
|
}
|
|
42
42
|
/** Classify a history row that carries no status code, from its wording alone. */
|
|
43
43
|
export function classifyDescription(description) {
|
|
44
|
-
//
|
|
44
|
+
// GLS knows of the parcel but does not have it yet: no acceptance.
|
|
45
45
|
if (/^(?:the )?parcel has not been handed over to gls[.!]?$/i.test(description.trim())) {
|
|
46
|
-
return { status: '
|
|
46
|
+
return { status: 'pending', stage: 'registered' };
|
|
47
47
|
}
|
|
48
48
|
const translated = trackingLanguageStage(description);
|
|
49
49
|
if (translated)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{ "code": "CANCELLED", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Both spellings appear." },
|
|
17
17
|
{ "code": "FINAL", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "" },
|
|
18
18
|
{ "code": "NORECORD", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Reported as status 'unknown'." },
|
|
19
|
-
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "
|
|
19
|
+
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule, checked before the handover to GLS: GLS knows of the parcel but does not have it." },
|
|
20
20
|
{ "wording": "The parcel was handed over to GLS.", "language": "en", "stage": "accepted", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule for rows with no status code." },
|
|
21
21
|
{ "wording": "The parcel data was entered into the GLS IT system…", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule; contains 'not yet handed over to GLS' and must not read as a handoff." },
|
|
22
22
|
{ "wording": "The parcel was released by customs.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Release is completion, not active clearance." },
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{ "code": "CANCELLED", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Both spellings appear." },
|
|
17
17
|
{ "code": "FINAL", "stage": "returned", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "" },
|
|
18
18
|
{ "code": "NORECORD", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-08-30", "note": "Reported as status 'unknown'." },
|
|
19
|
-
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "
|
|
19
|
+
{ "wording": "The parcel has not been handed over to GLS.", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule, checked before the handover to GLS: GLS knows of the parcel but does not have it." },
|
|
20
20
|
{ "wording": "The parcel was handed over to GLS.", "language": "en", "stage": "accepted", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule for rows with no status code." },
|
|
21
21
|
{ "wording": "The parcel data was entered into the GLS IT system…", "language": "en", "stage": "registered", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Wording rule; contains 'not yet handed over to GLS' and must not read as a handoff." },
|
|
22
22
|
{ "wording": "The parcel was released by customs.", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-08-30", "note": "Release is completion, not active clearance." },
|
|
@@ -17,7 +17,22 @@ export function normalizePocztaPolskaNumber(raw) {
|
|
|
17
17
|
const sum = [...number].reduce((total, digit, index) => total + Number(digit) * (index % 2 ? 1 : 3), 0);
|
|
18
18
|
return `${number}${(10 - sum % 10) % 10}`;
|
|
19
19
|
}
|
|
20
|
-
|
|
20
|
+
// Poczta Polska's own offices carry an office type. Scans relayed from partners
|
|
21
|
+
// abroad carry none and keep the sending country's wall clock; an office code
|
|
22
|
+
// alone could be a foreign postcode.
|
|
23
|
+
function polishOffice(office) {
|
|
24
|
+
return isRecord(office) && clean(office.officeType, 40) !== '';
|
|
25
|
+
}
|
|
26
|
+
// A Polish office dates its scans on Polish time. A wall clock the zone skips
|
|
27
|
+
// or repeats stays local.
|
|
28
|
+
function warsawTime(local) {
|
|
29
|
+
const parsed = DateTime.fromISO(local, { zone: 'Europe/Warsaw' });
|
|
30
|
+
if (!parsed.isValid || parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) !== local
|
|
31
|
+
|| parsed.getPossibleOffsets().length !== 1)
|
|
32
|
+
return null;
|
|
33
|
+
return parsed.toISO({ suppressMilliseconds: true });
|
|
34
|
+
}
|
|
35
|
+
function eventClock(value, polish) {
|
|
21
36
|
if (value != null && typeof value !== 'string')
|
|
22
37
|
throw new SchemaError('poczta-polska', 'Poczta Polska returned an invalid clock field');
|
|
23
38
|
const raw = clean(value, 64);
|
|
@@ -30,6 +45,9 @@ function eventClock(value) {
|
|
|
30
45
|
// UTC validates the calendar only. It is never attached to local digits.
|
|
31
46
|
const parsed = DateTime.fromISO(raw, { zone: 'UTC' });
|
|
32
47
|
const local = parsed.isValid ? parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) : null;
|
|
48
|
+
const time = local && polish ? warsawTime(local) : null;
|
|
49
|
+
if (time)
|
|
50
|
+
return { time };
|
|
33
51
|
if (local)
|
|
34
52
|
return { local_time: local };
|
|
35
53
|
}
|
|
@@ -72,16 +90,14 @@ export function parsePocztaPolska(payload, number) {
|
|
|
72
90
|
if (startsPocztaPolskaReturn(code, isRecord(raw.state) ? clean(raw.state.code, 16) : ''))
|
|
73
91
|
returning = true;
|
|
74
92
|
const stage = returning && mapped?.stage === 'delivered' ? 'returned' : mapped?.stage;
|
|
75
|
-
return { ...eventClock(raw.time), description, provider_code: code,
|
|
93
|
+
return { ...eventClock(raw.time, polishOffice(raw.postOffice)), description, provider_code: code,
|
|
76
94
|
...(location ? { location } : {}), ...(stage ? { stage } : {}), ...(returning ? { provider_leg: 'return' } : {}) };
|
|
77
95
|
});
|
|
78
96
|
if (!projected.length)
|
|
79
97
|
throw new IndeterminateError('poczta-polska', 'Poczta Polska returned no parcel history');
|
|
80
|
-
// The widget's last source row is current
|
|
81
|
-
//
|
|
98
|
+
// The widget's last source row is current, so the provider's order stands even
|
|
99
|
+
// when every scan is dated: the clocks never reorder it.
|
|
82
100
|
projected.reverse();
|
|
83
|
-
if (projected.every(event => event.time))
|
|
84
|
-
projected.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
|
|
85
101
|
const seen = new Set();
|
|
86
102
|
const events = projected.filter(event => {
|
|
87
103
|
const key = JSON.stringify(event);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DateTime } from 'luxon';
|
|
2
2
|
import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
|
|
3
3
|
import { ChallengeError, IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { countryCode } from '../../core/time/index.js';
|
|
4
5
|
import { clean, cleanScalar } from '../../core/transport/text.js';
|
|
5
6
|
import { isRecord } from '../../core/types.js';
|
|
6
7
|
import { ukrposhtaReturnCue, ukrposhtaStatus } from './status.js';
|
|
@@ -37,6 +38,19 @@ export function ukrposhtaWallClock(value) {
|
|
|
37
38
|
const date = DateTime.fromFormat(raw, format, { zone: 'UTC' });
|
|
38
39
|
return date.isValid && date.toFormat(format) === raw ? date.toFormat("yyyy-MM-dd'T'HH:mm:ss") : null;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* A scan in Ukraine is dated on Kyiv time. The minute is the precision both
|
|
43
|
+
* sources share, so the status API and the portal date one scan alike. A wall
|
|
44
|
+
* clock the zone skips or repeats stays local.
|
|
45
|
+
*/
|
|
46
|
+
function kyivTime(local) {
|
|
47
|
+
const minute = `${local.slice(0, 16)}:00`;
|
|
48
|
+
const parsed = DateTime.fromISO(minute, { zone: 'Europe/Kyiv' });
|
|
49
|
+
if (!parsed.isValid || parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) !== minute
|
|
50
|
+
|| parsed.getPossibleOffsets().length !== 1)
|
|
51
|
+
return null;
|
|
52
|
+
return parsed.toISO({ suppressMilliseconds: true });
|
|
53
|
+
}
|
|
40
54
|
export function parseUkrposhtaOverview(payload, requested) {
|
|
41
55
|
const number = normalizeUkrposhtaNumber(requested);
|
|
42
56
|
const data = envelope(payload);
|
|
@@ -64,7 +78,8 @@ export function parseUkrposhtaOverview(payload, requested) {
|
|
|
64
78
|
function project(rows) {
|
|
65
79
|
const latest = rows[0];
|
|
66
80
|
// Both native histories are current-first. Keep that order across foreign
|
|
67
|
-
// wall clocks or missing dates instead of sorting them as
|
|
81
|
+
// wall clocks or missing dates instead of sorting them as instants. A row
|
|
82
|
+
// naming Ukraine is a Ukrposhta scan; others keep the clock they were given.
|
|
68
83
|
let returnLeg = false;
|
|
69
84
|
const events = new Array(rows.length);
|
|
70
85
|
for (let index = rows.length - 1; index >= 0; index--) {
|
|
@@ -77,10 +92,11 @@ function project(rows) {
|
|
|
77
92
|
if (classified?.stage === 'delivered' && returnLeg)
|
|
78
93
|
classified = { status: 'exception', stage: 'returned' };
|
|
79
94
|
const local = ukrposhtaWallClock(row.date);
|
|
95
|
+
const time = local && countryCode(row.country) === 'UA' ? kyivTime(local) : null;
|
|
80
96
|
events[index] = {
|
|
81
97
|
description: row.label, provider_code: row.code,
|
|
82
98
|
...(row.location || row.country ? { location: [row.location, row.country].filter(Boolean).join(', ') } : {}),
|
|
83
|
-
...(local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
|
|
99
|
+
...(time ? { time } : local ? { local_time: local } : row.date ? { provider_time_text: row.date } : {}),
|
|
84
100
|
...(classified ? { stage: classified.stage } : {}), ...(returnLeg ? { provider_leg: 'return' } : {}),
|
|
85
101
|
};
|
|
86
102
|
}
|
|
@@ -90,7 +106,7 @@ function project(rows) {
|
|
|
90
106
|
const seen = new Set();
|
|
91
107
|
const deduplicated = events.filter(event => { const key = JSON.stringify(event); if (seen.has(key))
|
|
92
108
|
return false; seen.add(key); return true; });
|
|
93
|
-
return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: null, events: deduplicated.slice(0, 100) };
|
|
109
|
+
return { status, ...(stage ? { current_stage: stage } : {}), last_status_text: latest.label, last_update: current.time ?? null, events: deduplicated.slice(0, 100) };
|
|
94
110
|
}
|
|
95
111
|
/**
|
|
96
112
|
* The status-tracking API's reply to one barcode: a bare array of scans, each naming the
|