universal-parcel-scraper 0.55.0-main.708 → 0.56.0-main.723

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +42 -1
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +49 -2
  5. package/dist/browser/scraper.js +1048 -1005
  6. package/dist/carriers/chronopost/adapter.js +23 -16
  7. package/dist/carriers/dpd/adapter.d.ts +2 -2
  8. package/dist/carriers/dpd/adapter.js +29 -20
  9. package/dist/carriers/dpd-de/adapter.js +2 -3
  10. package/dist/carriers/dpd-de/app.d.ts +5 -2
  11. package/dist/carriers/dpd-de/app.js +10 -3
  12. package/dist/carriers/dpd-de/carrier.json +1 -1
  13. package/dist/carriers/dpd-de/numbers.json +26 -0
  14. package/dist/carriers/dpd-de/service.d.ts +42 -7
  15. package/dist/carriers/dpd-de/service.js +54 -26
  16. package/dist/carriers/dpd-fr/adapter.js +18 -16
  17. package/dist/carriers/dpd-pl/carrier.json +7 -0
  18. package/dist/carriers/emile/carrier.json +56 -0
  19. package/dist/carriers/emile/numbers.json +94 -0
  20. package/dist/carriers/emile/statuses.json +4 -0
  21. package/dist/carriers/four-px/numbers.json +5 -2
  22. package/dist/carriers/gls-ch/adapter.d.ts +4 -0
  23. package/dist/carriers/gls-ch/adapter.js +8 -3
  24. package/dist/carriers/gls-ch/status.js +2 -2
  25. package/dist/carriers/gls-ch/statuses.json +1 -1
  26. package/dist/carriers/gls-de/adapter.d.ts +4 -0
  27. package/dist/carriers/gls-de/adapter.js +8 -3
  28. package/dist/carriers/gls-de/statuses.json +1 -1
  29. package/dist/carriers/gls-fr/adapter.js +8 -4
  30. package/dist/carriers/la-poste/adapter.js +23 -11
  31. package/dist/carriers/poczta-polska/parser.js +22 -6
  32. package/dist/carriers/swiss-post/adapter.d.ts +1 -1
  33. package/dist/carriers/swiss-post/adapter.js +17 -8
  34. package/dist/carriers/ukrposhta/parser.js +19 -3
  35. package/dist/core/catalog/hints.js +8 -5
  36. package/dist/core/errors/index.d.ts +8 -1
  37. package/dist/core/errors/index.js +7 -0
  38. package/dist/core/runner/networkRetry.d.ts +15 -0
  39. package/dist/core/runner/networkRetry.js +51 -0
  40. package/dist/core/status/language.js +32 -6
  41. package/dist/data/catalog.json +42 -1
  42. package/dist/generated/catalog.d.ts +30 -2
  43. package/dist/generated/catalog.js +45 -2
  44. package/dist/generated/registry.js +1 -0
  45. package/dist/node.d.ts +1 -1
  46. package/dist/providers/parcelsapp/adapter.js +9 -3
  47. package/dist/server/openapi.json +1 -1
  48. package/package.json +1 -1
@@ -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",
@@ -39,6 +39,10 @@ export declare class GLSSwitzerlandTracker {
39
39
  * a 400 or 403 can be a challenge, so it stays a failure.
40
40
  */
41
41
  recognizes(rawTrackingNumber: string, context?: TrackingContext): Promise<boolean>;
42
+ /**
43
+ * A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
44
+ * what is left, so the second fits in the rest.
45
+ */
42
46
  private request;
43
47
  }
44
48
  /** Keep the verified overview's delivery owner when the postcode detail omits it. */
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
3
3
  import { lookupBudget } from '../../core/adapter/index.js';
4
4
  import { DELIVERY_POSTCODE, deliveryPostcodeText } from '../../core/catalog/postcode.js';
5
5
  import { InputRequiredError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
6
+ import { withNetworkRetry } from '../../core/runner/networkRetry.js';
6
7
  import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
7
8
  import { isRecord } from '../../core/types.js';
8
9
  import { classifyDescription, GLS_STATUSES, statusCode } from './status.js';
@@ -403,17 +404,21 @@ export class GLSSwitzerlandTracker {
403
404
  throw error;
404
405
  }
405
406
  }
407
+ /**
408
+ * A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
409
+ * what is left, so the second fits in the rest.
410
+ */
406
411
  async request(url, budget) {
407
- const { response, bytes } = await fetchBounded(url, {
412
+ const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(url, {
408
413
  signal: budget.signal,
409
414
  headers: pageHeaders(this.#userAgent),
410
415
  }, {
411
416
  provider: 'GLS Switzerland tracking',
412
- timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
417
+ timeoutMs: Math.min(this.timeoutMs, timeoutMs),
413
418
  maxBytes: MAX_RESPONSE_BYTES,
414
419
  allowHttpError: true,
415
420
  ...(this.#fetcher ? { fetcher: this.#fetcher } : {}),
416
- });
421
+ }));
417
422
  if ([400, 403, 404].includes(response.status))
418
423
  throw new GLSSwitzerlandTrackingError();
419
424
  if (!response.ok)
@@ -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." },
@@ -24,6 +24,10 @@ export declare class GLSGermanyTracker {
24
24
  * only after this returns true; a provider failure must stay a failure.
25
25
  */
26
26
  recognizes(rawTrackingNumber: string, context?: TrackingContext): Promise<boolean>;
27
+ /**
28
+ * A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
29
+ * what is left, so the second fits in the rest.
30
+ */
27
31
  private request;
28
32
  }
29
33
  export declare const adapter: AdapterFactory;
@@ -1,5 +1,6 @@
1
1
  import { lookupBudget } from '../../core/adapter/index.js';
2
2
  import { NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
3
+ import { withNetworkRetry } from '../../core/runner/networkRetry.js';
3
4
  import { cleanScalar, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
4
5
  import { isRecord } from '../../core/types.js';
5
6
  import { glsSwitzerlandDetailApiUrl, glsDeliveryReference, glsSwitzerlandOverviewApiUrl, GLSSwitzerlandTrackingError, normalizeGLSSwitzerlandPostcode, normalizeGLSSwitzerlandTrackingNumber, parseGLSSwitzerlandTrackingResponse, selectGLSParcel, } from '../gls-ch/adapter.js';
@@ -88,17 +89,21 @@ export class GLSGermanyTracker {
88
89
  throw error;
89
90
  }
90
91
  }
92
+ /**
93
+ * A request that fails to reach GLS, or hangs, is sent once more: the first gets at most half of
94
+ * what is left, so the second fits in the rest.
95
+ */
91
96
  async request(url, budget) {
92
- const { response, bytes } = await fetchBounded(url, {
97
+ const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(url, {
93
98
  signal: budget.signal,
94
99
  headers: { Accept: 'application/json', Referer: 'https://gls-group.eu/EU/en/parcel-tracking', 'User-Agent': this.#userAgent },
95
100
  }, {
96
101
  provider: PROVIDER,
97
- timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
102
+ timeoutMs: Math.min(this.timeoutMs, timeoutMs),
98
103
  maxBytes: 1_000_000,
99
104
  allowHttpError: true,
100
105
  ...(this.#fetcher ? { fetcher: this.#fetcher } : {}),
101
- });
106
+ }));
102
107
  // A challenge, invalid postcode, rate limit or outage must not become "not found".
103
108
  if (response.status === 404) {
104
109
  const payload = parseJsonBytes(bytes, PROVIDER);
@@ -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." },
@@ -13,6 +13,7 @@ import { DateTime } from 'luxon';
13
13
  import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
14
14
  import { isValidGlsParcelNumber } from '../../core/detection/index.js';
15
15
  import { InvalidInputError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
16
+ import { withNetworkRetry } from '../../core/runner/networkRetry.js';
16
17
  import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
17
18
  import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
18
19
  import { isRecord } from '../../core/types.js';
@@ -280,7 +281,8 @@ export class GLSFranceTracker {
280
281
  async fetch(trackingNumber, context = {}, { pickupPoint = true } = {}) {
281
282
  const normalized = normalizeGLSFranceTrackingNumber(trackingNumber);
282
283
  const printed = printedNumber(trackingNumber);
283
- const budget = lookupBudget(context, this.timeoutMs);
284
+ // Room for a request and its retry.
285
+ const budget = lookupBudget(context, 2 * this.timeoutMs);
284
286
  let parsed;
285
287
  try {
286
288
  parsed = await this.lookup(normalized, normalized, budget);
@@ -335,16 +337,18 @@ export class GLSFranceTracker {
335
337
  };
336
338
  }
337
339
  async lookup(code, normalized, budget) {
338
- const { response, bytes } = await fetchBounded(`${TRACKING_API}/${encodeURIComponent(code)}`, {
340
+ // A request that fails to reach GLS, or hangs, is sent once more: the first
341
+ // gets at most half of what is left, so the second fits in the rest.
342
+ const { response, bytes } = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(`${TRACKING_API}/${encodeURIComponent(code)}`, {
339
343
  signal: budget.signal,
340
344
  headers: this.#headers(),
341
345
  }, {
342
346
  provider: 'GLS France tracking',
343
- timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
347
+ timeoutMs: Math.min(this.timeoutMs, timeoutMs),
344
348
  maxBytes: MAX_RESPONSE_BYTES,
345
349
  fetcher: this.#fetcher,
346
350
  allowHttpStatuses: [404, 410],
347
- });
351
+ }));
348
352
  budget.signal.throwIfAborted();
349
353
  if ([404, 410].includes(response.status)) {
350
354
  // The native negative names the complete submitted code. An absent API
@@ -3,6 +3,7 @@ import { carrierIdFromPartner } from '../../core/catalog/hints.js';
3
3
  import { dpdParcelNumber } from '../../core/detection/dpd.js';
4
4
  import { CarrierError, InvalidInputError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
5
5
  import { runSteps } from '../../core/runner/index.js';
6
+ import { firstAttemptMs, isNetworkFailure } from '../../core/runner/networkRetry.js';
6
7
  import { isoTime } from '../../core/time/index.js';
7
8
  import { clean, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
8
9
  import { isRecord } from '../../core/types.js';
@@ -20,6 +21,9 @@ import { eventStage, eventStatus } from './status.js';
20
21
  // - Production HTTP 403s carried La Poste's "Site indisponible - Incident en
21
22
  // cours" page and immediately following checks succeeded, so a 403 is
22
23
  // retried up to three times inside the original deadline (see README.md).
24
+ // - A request that hangs would otherwise take the whole budget, so a network
25
+ // failure or timeout is retried once too, and an attempt gets at most half
26
+ // of what is left while that retry remains.
23
27
  // - The tracking page links the point holding a parcel to La Poste's locator,
24
28
  // `localiser.laposte.fr/{idPoint}`, which redirects to the point's page. That
25
29
  // page carries the point's record, address included, as JSON in
@@ -273,7 +277,7 @@ export class LaPosteTracker {
273
277
  const normalized = normalizeLaPosteTrackingNumber(trackingNumber);
274
278
  const budgetMs = context.budgetMs ?? this.timeoutMs;
275
279
  const deadline = performance.now() + budgetMs;
276
- const request = async (remainingMs, signal) => {
280
+ const request = async (timeoutMs, signal) => {
277
281
  const { bytes } = await fetchBounded(laPosteTrackingApiUrl(normalized), {
278
282
  signal,
279
283
  headers: {
@@ -284,7 +288,7 @@ export class LaPosteTracker {
284
288
  },
285
289
  }, {
286
290
  provider: 'La Poste tracking',
287
- timeoutMs: Math.max(1, Math.floor(Math.min(this.timeoutMs, remainingMs))),
291
+ timeoutMs: Math.max(1, Math.floor(Math.min(this.timeoutMs, timeoutMs))),
288
292
  maxBytes: MAX_RESPONSE_BYTES,
289
293
  fetcher: this.fetcher,
290
294
  });
@@ -302,21 +306,28 @@ export class LaPosteTracker {
302
306
  // later, is answered normally. It is a hiccup of that one request, not
303
307
  // maintenance, so the page is retried like any other 403: three immediate
304
308
  // retries sharing the original deadline. A real incident still fails every
305
- // attempt within seconds and reaches the router. Do not retry other HTTP or
306
- // parsing failures. Once the deadline is spent the retry is refused, so the
307
- // caller still sees the provider's own rejection rather than a budget error.
308
- const retriable = (error) => error instanceof UpstreamHttpError
309
- && error.status === 403
310
- && deadline - performance.now() >= 1;
309
+ // attempt within seconds and reaches the router. A request that fails to
310
+ // reach La Poste, or hangs until its timeout, gets one retry as well. Until
311
+ // that retry is spent, an attempt gets at most half of what is left, so a
312
+ // hang leaves it time. Do not retry other HTTP or parsing failures, nor a
313
+ // not-found. Once the deadline is spent the retry is refused, so the caller
314
+ // still sees the provider's own rejection rather than a budget error.
315
+ let networkRetried = false;
316
+ const retriable = (error) => deadline - performance.now() >= 1
317
+ && ((error instanceof UpstreamHttpError && error.status === 403) || (!networkRetried && isNetworkFailure(error)));
311
318
  const retry = {
312
319
  id: 'retry',
313
320
  recovers: retriable,
314
- run: ({ remainingMs, signal }) => request(remainingMs, signal),
321
+ run: ({ remainingMs, signal, previousError }) => {
322
+ if (isNetworkFailure(previousError))
323
+ networkRetried = true;
324
+ return request(networkRetried ? remainingMs : firstAttemptMs(remainingMs), signal);
325
+ },
315
326
  };
316
327
  return await runSteps({
317
328
  carrier: 'la-poste', budgetMs, signal: context.signal, recorder: this.recorder,
318
329
  }, [
319
- { id: 'direct', run: ({ remainingMs, signal }) => request(remainingMs, signal) },
330
+ { id: 'direct', run: ({ remainingMs, signal }) => request(firstAttemptMs(remainingMs), signal) },
320
331
  retry,
321
332
  { ...retry },
322
333
  { ...retry },
@@ -369,7 +380,8 @@ export const adapter = (environment) => {
369
380
  return {
370
381
  id: 'la-poste',
371
382
  // One keyless request, then up to three immediate retries of the same
372
- // request after a transient HTTP 403, inside the original deadline.
383
+ // request after a transient HTTP 403, and one after a network failure or
384
+ // timeout, inside the original deadline.
373
385
  steps: ['direct', 'retry'],
374
386
  track: (input, context) => tracker.fetch(input.number, context),
375
387
  recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeLaPosteTrackingNumber(number))),
@@ -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);
@@ -21,7 +21,7 @@ export declare function parseSwissPostShipment(item: JsonObject, rawEvents: unkn
21
21
  export declare class SwissPostTracker {
22
22
  #private;
23
23
  constructor(options?: SwissPostOptions);
24
- readJson(fetcher: typeof fetch, url: string, init?: RequestInit, budget?: LookupBudget): Promise<unknown>;
24
+ readJson(fetcher: typeof fetch, url: string, init?: RequestInit, budget?: LookupBudget, timeoutMs?: number): Promise<unknown>;
25
25
  /** Cached for the process: the table is large, static, and shared by every lookup. */
26
26
  loadTranslations(fetcher: typeof fetch, budget?: LookupBudget): Promise<Record<string, string>>;
27
27
  fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
@@ -4,6 +4,7 @@ import { CookieJar } from 'tough-cookie';
4
4
  import { lookupBudget } from '../../core/adapter/index.js';
5
5
  import { NotFoundError, SchemaError } from '../../core/errors/index.js';
6
6
  import { countryCode } from '../../core/time/index.js';
7
+ import { withNetworkRetry } from '../../core/runner/networkRetry.js';
7
8
  import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
8
9
  import { isRecord } from '../../core/types.js';
9
10
  import { FALLBACK_EVENT_LABELS, GLOBAL_STATUS_STAGE, STAGE_STATUS, STATUS_MAP, swissPostEventStage, } from './status.js';
@@ -266,14 +267,22 @@ export class SwissPostTracker {
266
267
  constructor(options = {}) {
267
268
  this.#fetcher = options.fetcher;
268
269
  }
269
- async readJson(fetcher, url, init = {}, budget) {
270
+ async readJson(fetcher, url, init = {}, budget, timeoutMs = REQUEST_TIMEOUT_MS) {
270
271
  const { bytes } = await fetchBounded(url, budget ? { ...init, signal: budget.signal } : init, {
271
272
  provider: 'Swiss Post tracking',
272
- timeoutMs: Math.min(REQUEST_TIMEOUT_MS, budget?.remainingMs() ?? REQUEST_TIMEOUT_MS),
273
+ timeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs, budget?.remainingMs() ?? REQUEST_TIMEOUT_MS),
273
274
  fetcher,
274
275
  });
275
276
  return parseJsonBytes(bytes, PROVIDER);
276
277
  }
278
+ /**
279
+ * A call of the lookup's chain, sent once more if it fails to reach Swiss Post or hangs: the
280
+ * first gets at most half of what is left, so the second fits in the rest. The search's POST
281
+ * only files the number under the throwaway user, so it is sent again like a read.
282
+ */
283
+ #readRetrying(fetcher, url, init, budget) {
284
+ return withNetworkRetry(budget, (timeoutMs) => this.readJson(fetcher, url, init, budget, timeoutMs));
285
+ }
277
286
  /** Cached for the process: the table is large, static, and shared by every lookup. */
278
287
  async loadTranslations(fetcher, budget) {
279
288
  if (this.#translations)
@@ -301,11 +310,11 @@ export class SwissPostTracker {
301
310
  async fetch(trackingNumber, context = {}) {
302
311
  const budget = lookupBudget(context, DEFAULT_BUDGET_MS);
303
312
  const fetcher = makeFetchCookie(this.#fetcher ?? fetch, new CookieJar());
304
- const userResult = await fetchBounded(`${API_BASE}/user`, { headers: HEADERS, signal: budget.signal }, {
313
+ const userResult = await withNetworkRetry(budget, (timeoutMs) => fetchBounded(`${API_BASE}/user`, { headers: HEADERS, signal: budget.signal }, {
305
314
  provider: 'Swiss Post tracking',
306
- timeoutMs: Math.min(REQUEST_TIMEOUT_MS, budget.remainingMs()),
315
+ timeoutMs: Math.min(REQUEST_TIMEOUT_MS, timeoutMs),
307
316
  fetcher,
308
- });
317
+ }));
309
318
  const userPayload = parseJsonBytes(userResult.bytes, PROVIDER);
310
319
  if (!isRecord(userPayload))
311
320
  throw new SchemaError(PROVIDER, 'Swiss Post returned an invalid anonymous user response');
@@ -315,7 +324,7 @@ export class SwissPostTracker {
315
324
  const csrf = userResult.response.headers.get('x-csrf-token') ?? '';
316
325
  const headers = { ...HEADERS, 'x-csrf-token': csrf };
317
326
  const query = new URLSearchParams({ userId });
318
- const historyPayload = await this.readJson(fetcher, `${API_BASE}/history?${query}`, {
327
+ const historyPayload = await this.#readRetrying(fetcher, `${API_BASE}/history?${query}`, {
319
328
  method: 'POST',
320
329
  headers: { ...headers, 'Content-Type': 'application/json' },
321
330
  body: JSON.stringify({ searchQuery: trackingNumber }),
@@ -323,7 +332,7 @@ export class SwissPostTracker {
323
332
  const hash = isRecord(historyPayload) ? text(historyPayload.hash) : '';
324
333
  if (!hash)
325
334
  throw new SchemaError(PROVIDER, 'Swiss Post did not return a shipment search identifier');
326
- const items = await this.readJson(fetcher, `${API_BASE}/history/not-included/${encodeURIComponent(hash)}?${query}`, { headers }, budget);
335
+ const items = await this.#readRetrying(fetcher, `${API_BASE}/history/not-included/${encodeURIComponent(hash)}?${query}`, { headers }, budget);
327
336
  if (!Array.isArray(items)) {
328
337
  throw new SchemaError(PROVIDER, 'Swiss Post returned an invalid shipment response');
329
338
  }
@@ -348,7 +357,7 @@ export class SwissPostTracker {
348
357
  let events = [];
349
358
  if (identity) {
350
359
  try {
351
- const payload = await this.readJson(fetcher, `${API_BASE}/shipment/id/${encodeURIComponent(identity)}/events`, { headers }, budget);
360
+ const payload = await this.#readRetrying(fetcher, `${API_BASE}/shipment/id/${encodeURIComponent(identity)}/events`, { headers }, budget);
352
361
  if (Array.isArray(payload))
353
362
  events = payload;
354
363
  }
@@ -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