universal-parcel-scraper 0.49.0-main.533 → 0.49.0-main.541

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 (42) hide show
  1. package/NOTICE +2 -1
  2. package/README.md +1 -1
  3. package/dist/app.d.ts +6 -3
  4. package/dist/app.js +6 -3
  5. package/dist/carriers/chronopost/status.d.ts +3 -0
  6. package/dist/carriers/chronopost/status.js +14 -1
  7. package/dist/carriers/dhl-express/adapter.js +3 -19
  8. package/dist/carriers/dhl-express/status.d.ts +16 -0
  9. package/dist/carriers/dhl-express/status.js +38 -0
  10. package/dist/carriers/dhl-express/statuses.json +39 -0
  11. package/dist/carriers/dpd/status.d.ts +12 -2
  12. package/dist/carriers/dpd/status.js +30 -0
  13. package/dist/carriers/dpd/statuses.json +68 -0
  14. package/dist/carriers/la-poste/status.d.ts +11 -1
  15. package/dist/carriers/la-poste/status.js +22 -6
  16. package/dist/carriers/la-poste/statuses.json +13 -4
  17. package/dist/carriers/postlogistics/status.d.ts +3 -0
  18. package/dist/carriers/postlogistics/status.js +5 -0
  19. package/dist/carriers/swiss-post/status.d.ts +7 -0
  20. package/dist/carriers/swiss-post/status.js +10 -0
  21. package/dist/carriers/tnt/status.d.ts +3 -0
  22. package/dist/carriers/tnt/status.js +6 -0
  23. package/dist/carriers/tnt/statuses.json +9 -0
  24. package/dist/carriers/ups/status.d.ts +3 -0
  25. package/dist/carriers/ups/status.js +5 -0
  26. package/dist/carriers/yunexpress/status.d.ts +6 -0
  27. package/dist/carriers/yunexpress/status.js +19 -3
  28. package/dist/carriers/yunexpress/statuses.json +29 -0
  29. package/dist/core/catalog/statusMaps.d.ts +32 -0
  30. package/dist/core/catalog/statusMaps.js +52 -0
  31. package/dist/core/status/statusMap.d.ts +29 -0
  32. package/dist/core/status/statusMap.js +4 -0
  33. package/dist/places/countries.json +238 -16
  34. package/dist/places/hubs.json +5 -0
  35. package/dist/places/index.js +216 -39
  36. package/dist/places/names.d.mts +1 -0
  37. package/dist/places/names.mjs +5 -2
  38. package/dist/places/places.tsv.br +0 -0
  39. package/dist/places/trackingLocation.d.ts +2 -1
  40. package/dist/places/trackingLocation.js +23 -2
  41. package/dist/providers/seventeentrack/adapter.js +14 -1
  42. package/package.json +1 -1
package/NOTICE CHANGED
@@ -12,4 +12,5 @@ The places gazetteer includes GeoNames data (https://www.geonames.org),
12
12
  licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/).
13
13
  Facility coordinates include OpenStreetMap data (https://www.openstreetmap.org/copyright),
14
14
  licensed under the Open Database License. Natural Earth country label points
15
- (https://www.naturalearthdata.com) are public domain.
15
+ (https://www.naturalearthdata.com) and OurAirports airports
16
+ (https://ourairports.com/data/) are public domain.
package/README.md CHANGED
@@ -149,7 +149,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
149
149
  <!-- GENERATED:stages -->
150
150
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
151
151
 
152
- The carrier folders record 2,725 such statuses, each filed under one stage.
152
+ The carrier folders record 2,744 such statuses, each filed under one stage.
153
153
  <!-- /GENERATED:stages -->
154
154
 
155
155
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/dist/app.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Helpers shaped for the parcel app this package was extracted from: its
3
3
  * parcel view, carrier-name hints for provider results, the clock helpers
4
- * its sync uses, carrier scan-identity policies, and the country a scan's
5
- * location names. They change with that app and carry no stability promise;
6
- * the tracking contract is the other entry points.
4
+ * its sync uses, carrier scan-identity policies, what a carrier's status map
5
+ * says about a scan the app reviews, and the country a scan's location
6
+ * names. They change with that app and carry no stability promise; the
7
+ * tracking contract is the other entry points.
7
8
  */
8
9
  export * from './core/catalog/parcel.js';
9
10
  export * from './core/catalog/hints.js';
@@ -11,4 +12,6 @@ export * from './core/time/result.js';
11
12
  export { sameInstantIdentityPolicy, type SameInstantIdentityPolicy, type SameInstantScan } from './core/catalog/eventIdentity.js';
12
13
  export { universalCarrierHints } from './providers/shared/hints.js';
13
14
  export { recognitionAskedCarriers } from './core/catalog/recognition.js';
15
+ export { statusMapAnswer, type ObservedStatus, type StatusMapAnswer } from './core/catalog/statusMaps.js';
16
+ export { normalizeStatusWording } from './core/status/statusMap.js';
14
17
  export { countryFlag, countryName, trackingLocationCountry, trackingPlace, type TrackingPlace } from './places/trackingLocation.js';
package/dist/app.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Helpers shaped for the parcel app this package was extracted from: its
3
3
  * parcel view, carrier-name hints for provider results, the clock helpers
4
- * its sync uses, carrier scan-identity policies, and the country a scan's
5
- * location names. They change with that app and carry no stability promise;
6
- * the tracking contract is the other entry points.
4
+ * its sync uses, carrier scan-identity policies, what a carrier's status map
5
+ * says about a scan the app reviews, and the country a scan's location
6
+ * names. They change with that app and carry no stability promise; the
7
+ * tracking contract is the other entry points.
7
8
  */
8
9
  export * from './core/catalog/parcel.js';
9
10
  export * from './core/catalog/hints.js';
@@ -11,4 +12,6 @@ export * from './core/time/result.js';
11
12
  export { sameInstantIdentityPolicy } from './core/catalog/eventIdentity.js';
12
13
  export { universalCarrierHints } from './providers/shared/hints.js';
13
14
  export { recognitionAskedCarriers } from './core/catalog/recognition.js';
15
+ export { statusMapAnswer } from './core/catalog/statusMaps.js';
16
+ export { normalizeStatusWording } from './core/status/statusMap.js';
14
17
  export { countryFlag, countryName, trackingLocationCountry, trackingPlace } from './places/trackingLocation.js';
@@ -1,6 +1,9 @@
1
1
  import { type ClassifiedWording } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
2
3
  export declare function isChronopostNotification(label: string): boolean;
3
4
  /** The courier's drop-off scan, which can follow the pickup point's own arrival scan. */
4
5
  export declare function isPickupDropOff(code: string, label: string): boolean;
5
6
  /** Partner events can reuse codes; the observed wording must agree with the code. */
6
7
  export declare function chronopostStage(code: string, label: string): ClassifiedWording;
8
+ /** What the map says about one scan: a code whose observed wording it carries. */
9
+ export declare const statusMap: CarrierStatusMap;
@@ -1,4 +1,5 @@
1
1
  import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
2
3
  const observed = new Map([
3
4
  ['DC', { label: "Colis en cours de préparation chez l'expéditeur", stage: 'registered' }],
4
5
  ['DB', { label: "Colis déposé par l'expéditeur", stage: 'accepted' }],
@@ -14,8 +15,9 @@ const observed = new Map([
14
15
  ['P', { label: "Echec de livraison suite à l'absence du destinataire.", stage: 'failed_attempt' }],
15
16
  ['SK', { label: "Colis en attente d'informations complémentaires de votre part", stage: 'exception' }],
16
17
  ]);
18
+ const NOTIFICATION = 'Destinataire informé par SMS ou mail';
17
19
  export function isChronopostNotification(label) {
18
- return label === 'Destinataire informé par SMS ou mail';
20
+ return label === NOTIFICATION;
19
21
  }
20
22
  /** The courier's drop-off scan, which can follow the pickup point's own arrival scan. */
21
23
  export function isPickupDropOff(code, label) {
@@ -28,3 +30,14 @@ export function chronopostStage(code, label) {
28
30
  return { stage: known.stage, source: 'carrier_map' };
29
31
  return classifyWording(label, 'pending');
30
32
  }
33
+ /** What the map says about one scan: a code whose observed wording it carries. */
34
+ export const statusMap = {
35
+ stage: (code, wording) => {
36
+ const known = code ? observed.get(code) : undefined;
37
+ return known && normalizeStatusWording(known.label) === wording ? known.stage : undefined;
38
+ },
39
+ gaps: [{
40
+ code: 'SM', wording: normalizeStatusWording(NOTIFICATION),
41
+ note: 'A notice to the recipient, not a scan: it keeps the stage the parcel already had.',
42
+ }],
43
+ };
@@ -4,12 +4,12 @@ import { accepted, lookupBudget, recognizeFromBrowserLookup, recognizeFromLookup
4
4
  import { isValidDhlExpressWaybill } from '../../core/detection/numericChecksums.js';
5
5
  import { BudgetExceededError, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
6
6
  import { runSteps } from '../../core/runner/index.js';
7
- import { classifyWording } from '../../core/status/index.js';
8
7
  import { explicitOffsetTime, zonedTime } from '../../core/time/index.js';
9
8
  import { clean, parseJsonBytes } from '../../core/transport/index.js';
10
9
  import { isRecord } from '../../core/types.js';
11
10
  import { facilityZone } from './clock.js';
12
11
  import { mobileTracking } from './mobile.js';
12
+ import { dhlExpressStage } from './status.js';
13
13
  const PROVIDER = 'DHL Express';
14
14
  const BROWSER_API = 'https://www.dhl.com/utapi';
15
15
  const MAX_BYTES = 1_000_000;
@@ -42,22 +42,6 @@ function clock(date, time, location) {
42
42
  const instant = zone ? zonedTime(wall, CLOCK_FORMAT, zone, { locale: 'en' }) : null;
43
43
  return instant ? { time: instant.iso } : { local_time: local.toFormat("yyyy-MM-dd'T'HH:mm:ss") };
44
44
  }
45
- function stage(description) {
46
- const text = description.toLowerCase();
47
- if (text === 'delivered')
48
- return { stage: 'delivered', source: 'carrier_map' };
49
- if (text === 'shipment is out with courier for delivery')
50
- return { stage: 'out_for_delivery', source: 'carrier_map' };
51
- if (text.startsWith('delivery attempted'))
52
- return { stage: 'failed_attempt', source: 'carrier_map' };
53
- if (text === 'shipment is on hold')
54
- return { stage: 'exception', source: 'carrier_map' };
55
- if (text === 'shipment picked up')
56
- return { stage: 'accepted', source: 'carrier_map' };
57
- if (text.startsWith('clearance processing') || text.startsWith('customs clearance'))
58
- return { stage: 'customs', source: 'carrier_map' };
59
- return classifyWording(description, 'pending');
60
- }
61
45
  /** Public DHL web tracking: bind both the waybill and the Express division. */
62
46
  export function parseUnified(payload, raw) {
63
47
  const number = normalizeNumber(raw);
@@ -80,7 +64,7 @@ export function parseUnified(payload, raw) {
80
64
  if (!isRecord(row) || typeof row.description !== 'string' || !row.description.trim())
81
65
  throw new SchemaError(PROVIDER, 'DHL returned an invalid event');
82
66
  const description = row.statusCode === 'delivered' ? 'Delivered' : clean(row.description).slice(0, 1000);
83
- const mapped = stage(description);
67
+ const mapped = dhlExpressStage(description);
84
68
  const time = typeof row.timestamp === 'string' && /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/i.test(row.timestamp)
85
69
  ? explicitOffsetTime(row.timestamp)?.iso : undefined;
86
70
  const address = isRecord(row.location) && isRecord(row.location.address) ? row.location.address : {};
@@ -124,7 +108,7 @@ export function parse(payload, raw) {
124
108
  if (!isRecord(row) || typeof row.description !== 'string' || !row.description.trim())
125
109
  throw new SchemaError(PROVIDER, 'DHL Express returned an invalid checkpoint');
126
110
  const description = clean(row.description).slice(0, 1000);
127
- const mapped = stage(description);
111
+ const mapped = dhlExpressStage(description);
128
112
  const location = typeof row.location === 'string' ? clean(row.location).slice(0, 300) : '';
129
113
  return { description, ...clock(row.date, row.time, location), ...(typeof row.location === 'string' ? { location } : {}),
130
114
  stage: mapped.stage, stage_source: mapped.source };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * DHL Express checkpoint wording. Both the guest mobile API and the public
3
+ * web tracking send DHL's English checkpoint sentences, several of which end
4
+ * with the facility ("Processed at PARIS - FRANCE"), so those are matched on
5
+ * their fixed start. Other wording goes to the shared classifier.
6
+ */
7
+ import { type Stage } from '../../core/status/index.js';
8
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
9
+ /** The checkpoint's stage from DHL's own wording, if the wording is one of DHL's checkpoints. */
10
+ export declare function dhlExpressCheckpointStage(description: string): Stage | undefined;
11
+ export declare function dhlExpressStage(description: string): {
12
+ stage: Stage;
13
+ source: string;
14
+ };
15
+ /** What the map says about one scan: the web feed's codes are not read, only the checkpoint wording. */
16
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * DHL Express checkpoint wording. Both the guest mobile API and the public
3
+ * web tracking send DHL's English checkpoint sentences, several of which end
4
+ * with the facility ("Processed at PARIS - FRANCE"), so those are matched on
5
+ * their fixed start. Other wording goes to the shared classifier.
6
+ */
7
+ import { classifyWording } from '../../core/status/index.js';
8
+ const EXACT = new Map([
9
+ ['shipment information received', 'registered'],
10
+ ['shipment picked up', 'accepted'],
11
+ ['shipment is in transit to destination', 'in_transit'],
12
+ ['shipment is out with courier for delivery', 'out_for_delivery'],
13
+ ['shipment is on hold', 'exception'],
14
+ ['delivered', 'delivered'],
15
+ ]);
16
+ const PREFIXES = [
17
+ ['delivery attempted', 'failed_attempt'],
18
+ ['clearance processing', 'customs'],
19
+ ['customs clearance', 'customs'],
20
+ ['processed at ', 'in_transit'],
21
+ ['arrived at dhl sort facility', 'in_transit'],
22
+ ['arrived at dhl delivery facility', 'in_transit'],
23
+ ['shipment has departed from a dhl facility', 'in_transit'],
24
+ ];
25
+ /** The checkpoint's stage from DHL's own wording, if the wording is one of DHL's checkpoints. */
26
+ export function dhlExpressCheckpointStage(description) {
27
+ const text = description.toLowerCase();
28
+ return EXACT.get(text) ?? PREFIXES.find(([start]) => text.startsWith(start))?.[1];
29
+ }
30
+ export function dhlExpressStage(description) {
31
+ const stage = dhlExpressCheckpointStage(description);
32
+ return stage ? { stage, source: 'carrier_map' } : classifyWording(description, 'pending');
33
+ }
34
+ /** What the map says about one scan: the web feed's codes are not read, only the checkpoint wording. */
35
+ export const statusMap = {
36
+ stage: (_code, wording) => dhlExpressCheckpointStage(wording),
37
+ gaps: [],
38
+ };
@@ -42,6 +42,45 @@
42
42
  "stage": "in_transit",
43
43
  "confirmedBy": "live",
44
44
  "firstSeen": "2026-10-05"
45
+ },
46
+ {
47
+ "wording": "Shipment information received",
48
+ "language": "en",
49
+ "stage": "registered",
50
+ "confirmedBy": "live",
51
+ "firstSeen": "2026-10-05"
52
+ },
53
+ {
54
+ "wording": "Processed at",
55
+ "language": "en",
56
+ "stage": "in_transit",
57
+ "confirmedBy": "live",
58
+ "firstSeen": "2026-10-05",
59
+ "note": "Followed by the facility, as in \"Processed at <city> - <country>\"; matched on its start."
60
+ },
61
+ {
62
+ "wording": "Arrived at DHL Sort Facility",
63
+ "language": "en",
64
+ "stage": "in_transit",
65
+ "confirmedBy": "live",
66
+ "firstSeen": "2026-10-05",
67
+ "note": "Followed by the facility; matched on its start."
68
+ },
69
+ {
70
+ "wording": "Arrived at DHL Delivery Facility",
71
+ "language": "en",
72
+ "stage": "in_transit",
73
+ "confirmedBy": "live",
74
+ "firstSeen": "2026-10-05",
75
+ "note": "Followed by the facility; matched on its start."
76
+ },
77
+ {
78
+ "wording": "Shipment has departed from a DHL facility",
79
+ "language": "en",
80
+ "stage": "in_transit",
81
+ "confirmedBy": "live",
82
+ "firstSeen": "2026-10-05",
83
+ "note": "Followed by the facility; matched on its start."
45
84
  }
46
85
  ]
47
86
  }
@@ -13,8 +13,10 @@
13
13
  * `PARCEL_HANDED`, `IN_TRANSIT` and `AT_DELIVERY_CENTER` deliberately yield no
14
14
  * stage: they move the result status to `in_transit` but leave the event
15
15
  * unmapped so the sync's classifier records the wording for review instead of
16
- * this map inventing a milestone. Scan codes are mapped only where a live
17
- * lookup paired them with an enumeration value (see `scanStage`).
16
+ * this map inventing a milestone. Scan codes are mapped where a live lookup
17
+ * paired them with an enumeration value, or where DPD's own label names one
18
+ * movement inside its network (see `scanStage`). `statusMap` lists the codes
19
+ * and labels left unmapped on purpose.
18
20
  *
19
21
  * Provenance: the enumeration, the scan codes and their English labels are
20
22
  * what the myDPD guest API returns for Swiss consignee lookups; the wording
@@ -22,6 +24,7 @@
22
24
  */
23
25
  import type { CarrierStatus } from '../../core/result/index.js';
24
26
  import type { Stage } from '../../core/status/index.js';
27
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
25
28
  /** English labels for the guest API enumeration, used when the API sends no translation. */
26
29
  export declare const API_LABELS: Record<string, string>;
27
30
  /** Wording classifier for the rendered page, which carries no status codes. */
@@ -37,3 +40,10 @@ export declare function scanStage(code: string): Stage | null;
37
40
  * with no place. Paperwork, not a movement of the parcel.
38
41
  */
39
42
  export declare const PROOF_OF_DELIVERY_SCAN = "DEYY";
43
+ /**
44
+ * What the map says about one scan. A scan code or an enumeration value is all
45
+ * it reads: the rendered page's prose has no code and gets no stage here, and
46
+ * a code staged by its enumeration twin depends on the reply, so it is not
47
+ * answered by the code alone.
48
+ */
49
+ export declare const statusMap: CarrierStatusMap;
@@ -73,6 +73,8 @@ export function apiStage(description) {
73
73
  * `parcelEvents[].eventType` scan codes. On 2026-09-26 a verified lookup
74
74
  * listed ORI, DLI, DLO and DEY at the same wall clock as the `parcelHistory`
75
75
  * enumeration named beside them. CCO has no twin; it ends customs clearance.
76
+ * HUI, HUS, DLS and DLQ are the hub and delivery-depot scans a verified
77
+ * domestic history lists between ORI and DLO, each labelled with one movement.
76
78
  * ORI ("Origin depot - In") stays unmapped like its twin PARCEL_HANDED: the
77
79
  * result stage then comes from the same wording as the scan's, and an import
78
80
  * that cleared customs does not step back to accepted. Other codes stay
@@ -80,7 +82,11 @@ export function apiStage(description) {
80
82
  */
81
83
  const SCAN_STAGES = {
82
84
  CCO: 'in_transit', // "Customs - Out"
85
+ HUI: 'in_transit', // "Your parcel arrived at our hub"
86
+ HUS: 'in_transit', // "Your parcel is ready to be transported to our next premises"
83
87
  DLI: 'in_transit', // "Destination depot - Inbound", twin AT_DELIVERY_CENTER
88
+ DLS: 'in_transit', // "Your parcel has been sorted and is ready for delivery"
89
+ DLQ: 'in_transit', // "Your parcel is at our delivery depot, it will soon be on its way"
84
90
  DLO: 'out_for_delivery', // "Destination depot - Out for delivery", twin PARCEL_OUT_FOR_DELIVERY
85
91
  DEY: 'delivered', // "Delivery - Delivered", twin DELIVERED
86
92
  };
@@ -93,3 +99,27 @@ export function scanStage(code) {
93
99
  * with no place. Paperwork, not a movement of the parcel.
94
100
  */
95
101
  export const PROOF_OF_DELIVERY_SCAN = 'DEYY';
102
+ const MOVES_WITHOUT_MILESTONE = "DPD's map leaves it unmapped on purpose: it moves the parcel in transit without a milestone of its own.";
103
+ /**
104
+ * What the map says about one scan. A scan code or an enumeration value is all
105
+ * it reads: the rendered page's prose has no code and gets no stage here, and
106
+ * a code staged by its enumeration twin depends on the reply, so it is not
107
+ * answered by the code alone.
108
+ */
109
+ export const statusMap = {
110
+ stage: (code) => (code ? scanStage(code) ?? apiStage(code) : null) ?? undefined,
111
+ gaps: [
112
+ { code: 'PARCEL_HANDED', note: MOVES_WITHOUT_MILESTONE },
113
+ { code: 'IN_TRANSIT', note: MOVES_WITHOUT_MILESTONE },
114
+ { code: 'AT_DELIVERY_CENTER', note: MOVES_WITHOUT_MILESTONE },
115
+ { code: 'ORI', note: "DPD's map leaves it unmapped on purpose, like its twin PARCEL_HANDED, so an import that cleared customs does not step back to accepted." },
116
+ { wording: 'parcel handed to dpd', note: "The label of PARCEL_HANDED, which DPD's map leaves unmapped on purpose." },
117
+ { wording: 'your parcel is on its way', note: "The label of IN_TRANSIT, which DPD's map leaves unmapped on purpose." },
118
+ { wording: 'at delivery center', note: "The label of AT_DELIVERY_CENTER, which DPD's map leaves unmapped on purpose." },
119
+ { code: 'SPL', note: 'Worded like IN_TRANSIT and seen on both sides of the origin depot, so it names no movement of its own.' },
120
+ { code: 'OTHER', note: "The guest API's catch-all, which names no movement." },
121
+ { code: 'SPE', note: 'A delivery estimate or a changed delivery day, not a scan.' },
122
+ { code: 'MSDLO', note: 'The notice DPD emailed for the delivery round, not a scan.' },
123
+ { code: 'MIDLI', note: "The recipient's delivery instruction, not a scan." },
124
+ ],
125
+ };
@@ -104,6 +104,32 @@
104
104
  "firstSeen": "2026-09-26",
105
105
  "note": "Scan code (Origin depot - In), at the same instant as PARCEL_HANDED. Unmapped like its twin, so the event and the result stage both come from this wording; a stage here would disagree with the result stage and put accepted after customs on imports."
106
106
  },
107
+ {
108
+ "code": "SPL",
109
+ "wording": "Your parcel is on its way",
110
+ "language": "en",
111
+ "confirmedBy": "live",
112
+ "firstSeen": "2026-10-05",
113
+ "note": "Scan code worded like IN_TRANSIT, seen both before and after ORI. Unmapped like IN_TRANSIT: it names no milestone of its own."
114
+ },
115
+ {
116
+ "code": "HUI",
117
+ "wording": "Your parcel arrived at our hub",
118
+ "language": "en",
119
+ "stage": "in_transit",
120
+ "confirmedBy": "live",
121
+ "firstSeen": "2026-10-07",
122
+ "note": "Scan code: arrival at a hub."
123
+ },
124
+ {
125
+ "code": "HUS",
126
+ "wording": "Your parcel is ready to be transported to our next premises",
127
+ "language": "en",
128
+ "stage": "in_transit",
129
+ "confirmedBy": "live",
130
+ "firstSeen": "2026-10-07",
131
+ "note": "Scan code: the hub sends the parcel on."
132
+ },
107
133
  {
108
134
  "code": "DLI",
109
135
  "wording": "Your parcel arrived at our delivery depot",
@@ -113,6 +139,24 @@
113
139
  "firstSeen": "2026-09-26",
114
140
  "note": "Scan code (Destination depot - Inbound), at the same instant as AT_DELIVERY_CENTER."
115
141
  },
142
+ {
143
+ "code": "DLS",
144
+ "wording": "Your parcel has been sorted and is ready for delivery",
145
+ "language": "en",
146
+ "stage": "in_transit",
147
+ "confirmedBy": "live",
148
+ "firstSeen": "2026-10-07",
149
+ "note": "Scan code: sorted at the delivery depot, before DLO."
150
+ },
151
+ {
152
+ "code": "DLQ",
153
+ "wording": "Your parcel is at our delivery depot, it will soon be on its way",
154
+ "language": "en",
155
+ "stage": "in_transit",
156
+ "confirmedBy": "live",
157
+ "firstSeen": "2026-10-07",
158
+ "note": "Scan code: waiting at the delivery depot, before DLO."
159
+ },
116
160
  {
117
161
  "code": "DLO",
118
162
  "wording": "Your parcel is out for delivery",
@@ -139,6 +183,30 @@
139
183
  "confirmedBy": "live",
140
184
  "firstSeen": "2026-09-26",
141
185
  "note": "Scan code (Delivery - Proof of delivery), minutes after DEY with no place. Dropped when a delivery scan is listed or the enumeration is not DELIVERED; staged delivered only when it is the sole delivery evidence of a delivered parcel. Its podUrl is never kept."
186
+ },
187
+ {
188
+ "code": "SPE",
189
+ "wording": "Your parcel is estimated to be delivered on: <date>",
190
+ "language": "en",
191
+ "confirmedBy": "live",
192
+ "firstSeen": "2026-10-07",
193
+ "note": "A delivery estimate or a changed delivery day, not a scan; the date follows the colon."
194
+ },
195
+ {
196
+ "code": "MSDLO",
197
+ "wording": "We informed you via email that your parcel will be delivered on <date>",
198
+ "language": "en",
199
+ "confirmedBy": "live",
200
+ "firstSeen": "2026-10-06",
201
+ "note": "The notice DPD emailed for the delivery round, not a scan."
202
+ },
203
+ {
204
+ "code": "MIDLI",
205
+ "wording": "Your parcel will be delivered to a safe place according to your instructions",
206
+ "language": "en",
207
+ "confirmedBy": "live",
208
+ "firstSeen": "2026-10-05",
209
+ "note": "The recipient's delivery instruction, not a scan."
142
210
  }
143
211
  ]
144
212
  }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import type { CarrierStatus } from '../../core/result/index.js';
19
19
  import { type Stage } from '../../core/status/index.js';
20
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
20
21
  /** Progress-bar groups, the coarse key every event carries. */
21
22
  export declare const GROUP_STATUSES: Map<string, CarrierStatus>;
22
23
  /**
@@ -25,7 +26,9 @@ export declare const GROUP_STATUSES: Map<string, CarrierStatus>;
25
26
  * être livré ce jour ... sera mis en livraison au plus tôt", which reads as a
26
27
  * future delivery round, so the code decides. `DO2` is the customs release,
27
28
  * whose "il sera livré contre paiement" otherwise reads as an announcement, and
28
- * `RE1` the decision to send the item back.
29
+ * `RE1` the decision to send the item back. `MD1` under `DISTOU` is the morning
30
+ * sort into the delivery round: "nous le préparons pour le mettre en
31
+ * livraison" is followed the same day by the delivery or a failed attempt.
29
32
  */
30
33
  export declare const CODE_STATUSES: Map<string, CarrierStatus>;
31
34
  /** Lowercase, accent-free form used for every wording comparison. */
@@ -35,3 +38,10 @@ export declare function labelStatus(label: string, hasEvents: boolean): CarrierS
35
38
  export declare function eventStatus(group: string, code: string, label: string, hasEvents: boolean): CarrierStatus;
36
39
  /** The stage for one event; returns and pickup readiness are recognized before the status. */
37
40
  export declare function eventStage(group: string, code: string, label: string): Stage;
41
+ /**
42
+ * What the map says about one scan. Its provider code is `GROUP/CODE`, or the
43
+ * group or the code alone. The answer covers the groups and codes the map
44
+ * reads; under keys it does not know, a scan's stage comes from its wording
45
+ * and a default, which stay open for review.
46
+ */
47
+ export declare const statusMap: CarrierStatusMap;
@@ -1,5 +1,5 @@
1
1
  import { languageStageStatus, trackingLanguageStage } from '../../core/status/index.js';
2
- import { clean } from '../../core/transport/index.js';
2
+ import { clean } from '../../core/transport/text.js';
3
3
  /** Progress-bar groups, the coarse key every event carries. */
4
4
  export const GROUP_STATUSES = new Map([
5
5
  ['EXPANN', 'pending'],
@@ -20,7 +20,9 @@ export const GROUP_STATUSES = new Map([
20
20
  * être livré ce jour ... sera mis en livraison au plus tôt", which reads as a
21
21
  * future delivery round, so the code decides. `DO2` is the customs release,
22
22
  * whose "il sera livré contre paiement" otherwise reads as an announcement, and
23
- * `RE1` the decision to send the item back.
23
+ * `RE1` the decision to send the item back. `MD1` under `DISTOU` is the morning
24
+ * sort into the delivery round: "nous le préparons pour le mettre en
25
+ * livraison" is followed the same day by the delivery or a failed attempt.
24
26
  */
25
27
  export const CODE_STATUSES = new Map([
26
28
  ['DR1', 'pending'],
@@ -85,10 +87,6 @@ export function eventStatus(group, code, label, hasEvents) {
85
87
  const described = labelStatus(label, false);
86
88
  if (described === 'exception')
87
89
  return described;
88
- if (code.toUpperCase() === 'MD1'
89
- && comparable(label) === 'votre envoi est sur son site de distribution. nous le preparons pour le mettre en livraison.') {
90
- return 'in_transit';
91
- }
92
90
  return CODE_STATUSES.get(code.toLocaleUpperCase('en-US'))
93
91
  ?? GROUP_STATUSES.get(group.toLocaleUpperCase('en-US'))
94
92
  ?? (described !== 'unknown' ? described : labelStatus(label, hasEvents));
@@ -130,3 +128,21 @@ export function eventStage(group, code, label) {
130
128
  return 'accepted';
131
129
  return 'in_transit';
132
130
  }
131
+ /** The codes the map reads on their own, beside those of `CODE_STATUSES`. */
132
+ const KNOWN_CODES = new Set([...CODE_STATUSES.keys(), PICKUP_CODE, CUSTOMS_ENTRY_CODE, ...ACCEPTANCE_CODES]);
133
+ /**
134
+ * What the map says about one scan. Its provider code is `GROUP/CODE`, or the
135
+ * group or the code alone. The answer covers the groups and codes the map
136
+ * reads; under keys it does not know, a scan's stage comes from its wording
137
+ * and a default, which stay open for review.
138
+ */
139
+ export const statusMap = {
140
+ stage: (providerCode, wording) => {
141
+ const keys = (providerCode ?? '').toLocaleUpperCase('en-US').split('/');
142
+ const single = keys.length === 1 ? keys[0] : null;
143
+ const group = single === null ? keys[0] : KNOWN_CODES.has(single) ? '' : single;
144
+ const code = single === null ? keys.slice(1).join('/') : KNOWN_CODES.has(single) ? single : '';
145
+ return GROUP_STATUSES.has(group) || KNOWN_CODES.has(code) ? eventStage(group, code, wording) : undefined;
146
+ },
147
+ gaps: [],
148
+ };
@@ -159,14 +159,23 @@
159
159
  "firstSeen": "2026-09-20",
160
160
  "note": "Resolved by the shared classifier; \"sont terminees\" is the release and stays in_transit."
161
161
  },
162
+ {
163
+ "wording": "Votre colis est sur son site de distribution. Nous le préparons pour le mettre en livraison.",
164
+ "language": "fr",
165
+ "stage": "out_for_delivery",
166
+ "confirmedBy": "live",
167
+ "firstSeen": "2026-09-10",
168
+ "code": "MD1",
169
+ "note": "DISTOU/MD1, the sort into the morning's round: every live history shows the delivery or a failed attempt the same day, with no scan between."
170
+ },
162
171
  {
163
172
  "wording": "Votre envoi est sur son site de distribution. Nous le préparons pour le mettre en livraison.",
164
173
  "language": "fr",
165
- "stage": "in_transit",
166
- "confirmedBy": "fixture",
167
- "firstSeen": "2026-10-04",
174
+ "stage": "out_for_delivery",
175
+ "confirmedBy": "live",
176
+ "firstSeen": "2026-09-10",
168
177
  "code": "MD1",
169
- "note": "DISTOU/MD1 also covers preparation before the delivery round."
178
+ "note": "The tracked-letter wording of the same DISTOU/MD1 scan."
170
179
  },
171
180
  {
172
181
  "code": "PB1",
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { CarrierStatus } from '../../core/result/index.js';
10
10
  import type { Stage } from '../../generated/catalog.js';
11
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
11
12
  /** Codes that mean the parcel reached its recipient. */
12
13
  export declare const POSTLOGISTICS_DELIVERED_CODES: readonly ["DEL", "DLV", "POD", "SIG"];
13
14
  /** The code that means the shipment is announced but not yet moving. */
@@ -18,3 +19,5 @@ export declare const POSTLOGISTICS_IMAGE_CODE = "IMG";
18
19
  export declare function postlogisticsStage(code: string): Stage | undefined;
19
20
  /** The shipment status the newest history code implies. */
20
21
  export declare function postlogisticsStatus(code: string): CarrierStatus;
22
+ /** What the map says about one scan, by its code. */
23
+ export declare const statusMap: CarrierStatusMap;
@@ -25,3 +25,8 @@ export function postlogisticsStatus(code) {
25
25
  return 'pending';
26
26
  return 'in_transit';
27
27
  }
28
+ /** What the map says about one scan, by its code. */
29
+ export const statusMap = {
30
+ stage: (code) => (code ? postlogisticsStage(code) : undefined),
31
+ gaps: [{ code: POSTLOGISTICS_IMAGE_CODE, note: 'A picture taken with a scan, not a movement of the parcel.' }],
32
+ };
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import type { CarrierStatus } from '../../core/result/index.js';
14
14
  import type { Stage } from '../../generated/catalog.js';
15
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
15
16
  /** Shipment-level `globalStatus` → status. The event codes below refine it. */
16
17
  export declare const STATUS_MAP: Map<string, CarrierStatus>;
17
18
  /** Wording for codes the translation service does not resolve. */
@@ -32,3 +33,9 @@ export declare const STAGE_STATUS: Record<string, CarrierStatus>;
32
33
  export declare function swissPostEventStage(eventCode: string, subEventId?: string): Stage | undefined;
33
34
  /** The stage the shipment-level `globalStatus` implies, used when the newest scan has none. */
34
35
  export declare const GLOBAL_STATUS_STAGE: Record<string, Stage>;
36
+ /**
37
+ * What the map says about one scan. The map reads the last segment of the
38
+ * dotted event code. A revoked scan's wording ends with its sub-event's label,
39
+ * "— Revocation", and takes no stage.
40
+ */
41
+ export declare const statusMap: CarrierStatusMap;
@@ -108,3 +108,13 @@ export const GLOBAL_STATUS_STAGE = {
108
108
  RETURNED: 'returned',
109
109
  CUSTOMS: 'customs',
110
110
  };
111
+ /**
112
+ * What the map says about one scan. The map reads the last segment of the
113
+ * dotted event code. A revoked scan's wording ends with its sub-event's label,
114
+ * "— Revocation", and takes no stage.
115
+ */
116
+ export const statusMap = {
117
+ codeKey: (code) => code.split('.').at(-1) ?? code,
118
+ stage: (code, wording) => (code && !wording.includes('— revocation') ? swissPostEventStage(code) : undefined),
119
+ gaps: [{ code: '9112', note: 'An enquiry note, not a movement: the shipment summary decides the stage.' }],
120
+ };
@@ -1,3 +1,6 @@
1
1
  import type { ClassifiedStatus } from '../../core/status/index.js';
2
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
2
3
  export declare function tntFranceStatus(text: string): ClassifiedStatus | undefined;
3
4
  export declare function tntExpressStatus(code: string): ClassifiedStatus | undefined;
5
+ /** What the map says about one scan: tnt.com scans carry a code, TNT France's rows only wording. */
6
+ export declare const statusMap: CarrierStatusMap;
@@ -26,6 +26,7 @@ const EXPRESS_CODES = {
26
26
  RC: { status: 'in_transit', stage: 'in_transit' },
27
27
  OD: { status: 'out_for_delivery', stage: 'out_for_delivery' },
28
28
  OK: { status: 'delivered', stage: 'delivered' },
29
+ RES: { status: 'delivered', stage: 'delivered' },
29
30
  LP: { status: 'exception', stage: 'exception' },
30
31
  MR: { status: 'exception', stage: 'exception' },
31
32
  WL: { status: 'exception', stage: 'exception' },
@@ -34,3 +35,8 @@ const EXPRESS_CODES = {
34
35
  export function tntExpressStatus(code) {
35
36
  return Object.hasOwn(EXPRESS_CODES, code) ? EXPRESS_CODES[code] : undefined;
36
37
  }
38
+ /** What the map says about one scan: tnt.com scans carry a code, TNT France's rows only wording. */
39
+ export const statusMap = {
40
+ stage: (code, wording) => (code ? tntExpressStatus(code) : tntFranceStatus(wording))?.stage,
41
+ gaps: [],
42
+ };