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.
@@ -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. A parcel
636
- * the guest tier does not know, or places in another country, the app does
637
- * not answer either: the app stops waiting, reads nothing once its session
638
- * opens, and the guest tier's step reports it. Otherwise the app keeps
639
- * waiting, and the reply serves the guest tier's step if the session does
640
- * not open in time. A failed reply is asked again there.
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 = (tracked, stop) => {
648
+ const besideGuest = (stop) => {
643
649
  const reply = guestReply();
644
650
  const pending = new Promise(() => { });
645
- return Promise.race([tracked, reply.then(() => pending, (error) => {
646
- if (!app.recovers(error)) {
647
- const stopped = new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', { reason: DPD_DE_SESSION_OPENING });
648
- stop.abort(stopped);
649
- throw stopped;
650
- }
651
- if (guest === reply)
652
- guest = undefined;
653
- return pending;
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 tracked = app.run(trackingNumber, { signal, stop: stop.signal, timeoutMs: remainingMs, leads, postcode: resolvedPostcode });
663
- return opening ? besideGuest(tracked, stop) : tracked;
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` bounds the wait for a session still opening; the whole `timeoutMs` by default.
29
- * Once `stop` aborts, the lookup ends with its reason when the session opens, and reads nothing.
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` bounds the wait for a session still opening; the whole `timeoutMs` by default.
199
- * Once `stop` aborts, the lookup ends with its reason when the session opens, and reads nothing.
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.min(left(), options.sessionWaitMs ?? Infinity));
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}`;
@@ -84,7 +84,7 @@
84
84
  "domains": [
85
85
  "tracking.dpd.de"
86
86
  ],
87
- "path": "^/status/[a-z]{2}_[A-Z]{2}/parcel/(\\d{14})/?$"
87
+ "path": "^/status/[a-z]{2}_[A-Z]{2}/parcel/(\\d{14}[0-9A-Z]?)/?$"
88
88
  },
89
89
  {
90
90
  "domains": [
@@ -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 a lookup would wait for a session to open: none is open. */
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): Promise<string>;
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 a lookup would wait for a session to open: none is open. */
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
- let leave;
344
+ options.waiting?.();
345
+ const ended = options.stop ? AbortSignal.any([signal, options.stop]) : signal;
346
+ ended.throwIfAborted();
343
347
  let timer;
344
- const left = new Promise((_resolve, reject) => {
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, left]);
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) {
@@ -84,6 +84,13 @@
84
84
  "pattern": "^13\\d{12}$",
85
85
  "confidence": "low",
86
86
  "preferred": true
87
+ },
88
+ {
89
+ "id": "dpd-pl-3",
90
+ "pattern": "^13\\d{12}[0-9A-Z]$",
91
+ "confidence": "low",
92
+ "checksum": "dpd",
93
+ "preferred": true
87
94
  }
88
95
  ]
89
96
  }
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "carrier": "emile",
3
+ "entries": []
4
+ }
@@ -216,9 +216,12 @@
216
216
  },
217
217
  "expect": {
218
218
  "carrier": "unknown",
219
- "confidence": "none"
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; no rule covers it."
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
- // EXISTING regression wording: a negative handoff does not prove GLS possession.
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: 'unknown', stage: 'in_transit' };
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": "Deliberately unmapped: a negative handoff does not prove GLS possession. Reported as status 'unknown' with stage in_transit." },
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": "Deliberately unmapped: a negative handoff does not prove GLS possession. Reported as status 'unknown' with stage in_transit." },
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
- function eventClock(value) {
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. Do not compare offsetless wall
81
- // clocks from different countries or move malformed clocks behind old scans.
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 UTC instants.
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