universal-parcel-scraper 0.69.0 → 0.69.2-main.888

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,9 +33,9 @@ its own as a command, a Node library or an HTTP server.
33
33
  ## Benchmark
34
34
 
35
35
  <!-- GENERATED:coverage -->
36
- In a benchmark of 100 carriers picked for shoppers in Europe, run on public tracking numbers anyone can recheck, dedicated adapters return history for **79**, rising to **89** with all fallbacks enabled. The best single aggregator returns 72. Histories whose scans cannot be placed in time, which the library does not use, would add 28 for Postal Ninja and 3 for Ship24.
36
+ In a benchmark of 100 carriers picked for shoppers in Europe, run on public tracking numbers anyone can recheck, dedicated adapters return history for **79**, rising to **89** with all fallbacks enabled. The best single aggregator returns 73. Histories whose scans cannot be placed in time, which the library does not use, would add 28 for Postal Ninja and 3 for Ship24.
37
37
 
38
- <img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled 89, This project, dedicated adapters alone 79, 17TRACK 72, ParcelsApp 71, Ship24 57, Postal Ninja 32, UPU 11." width="760">
38
+ <img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled 89, This project, dedicated adapters alone 79, 17TRACK 73, ParcelsApp 71, Ship24 57, Postal Ninja 32, UPU 11." width="760">
39
39
  <!-- /GENERATED:coverage -->
40
40
 
41
41
  [Method, carriers and every number's results](benchmark/README.md)
package/data/catalog.json CHANGED
@@ -4703,7 +4703,7 @@
4703
4703
  },
4704
4704
  "allegro-one": {
4705
4705
  "displayName": "Allegro One",
4706
- "color": "#ff5a00",
4706
+ "color": "#006d57",
4707
4707
  "aliases": [
4708
4708
  "Allegro Delivery",
4709
4709
  "Allegro One Box",
@@ -9,7 +9,7 @@
9
9
  "providers": {
10
10
  "ParcelsApp": 71,
11
11
  "Postal Ninja": 32,
12
- "17TRACK": 72,
12
+ "17TRACK": 73,
13
13
  "Ship24": 57,
14
14
  "UPU": 11
15
15
  }
@@ -3854,7 +3854,7 @@ var ye = {
3854
3854
  },
3855
3855
  "allegro-one": {
3856
3856
  displayName: "Allegro One",
3857
- color: "#ff5a00",
3857
+ color: "#006d57",
3858
3858
  aliases: [
3859
3859
  "Allegro Delivery",
3860
3860
  "Allegro One Box",
@@ -12394,25 +12394,38 @@ var $c = { carriers: [
12394
12394
  carrier: "dhl-ecommerce",
12395
12395
  direct: "Yes",
12396
12396
  sample: "✓ 16",
12397
- references: [{ results: {
12398
- Ship24: {
12399
- rows: 11,
12400
- partial: "no destination leg"
12401
- },
12402
- ParcelsApp: 36,
12403
- "17TRACK": "no_history",
12404
- "Postal Ninja": 34,
12405
- UPU: "n/a"
12406
- } }, {
12407
- note: "Benchmark number 9261290316844317469885",
12408
- results: {
12409
- Ship24: "no_history",
12410
- ParcelsApp: 1,
12397
+ references: [
12398
+ { results: {
12399
+ Ship24: {
12400
+ rows: 11,
12401
+ partial: "no destination leg"
12402
+ },
12403
+ ParcelsApp: 36,
12411
12404
  "17TRACK": "no_history",
12412
- "Postal Ninja": "blocked",
12405
+ "Postal Ninja": 34,
12413
12406
  UPU: "n/a"
12407
+ } },
12408
+ {
12409
+ note: "Benchmark number 9261290316844317469885",
12410
+ results: {
12411
+ Ship24: "no_history",
12412
+ ParcelsApp: 1,
12413
+ "17TRACK": "no_history",
12414
+ "Postal Ninja": "blocked",
12415
+ UPU: "n/a"
12416
+ }
12417
+ },
12418
+ {
12419
+ note: "Benchmark number 9261290357482657956985",
12420
+ results: {
12421
+ Ship24: "no_history",
12422
+ ParcelsApp: 9,
12423
+ "17TRACK": 7,
12424
+ "Postal Ninja": "blocked",
12425
+ UPU: "n/a"
12426
+ }
12414
12427
  }
12415
- }]
12428
+ ]
12416
12429
  },
12417
12430
  {
12418
12431
  carrier: "aliexpress",
@@ -13379,7 +13392,7 @@ var $c = { carriers: [
13379
13392
  note: "Benchmark number 4PX3002555003946CN",
13380
13393
  results: {
13381
13394
  Ship24: "no_history",
13382
- ParcelsApp: "error",
13395
+ ParcelsApp: 30,
13383
13396
  "17TRACK": 44,
13384
13397
  "Postal Ninja": "no_history",
13385
13398
  UPU: "n/a"
@@ -13,7 +13,7 @@
13
13
  ]
14
14
  },
15
15
  "brand": {
16
- "color": "#ff5a00"
16
+ "color": "#006d57"
17
17
  },
18
18
  "selectable": true,
19
19
  "timezone": "Europe/Warsaw",
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The clocks GOFO US dates its scans on, for a relay that keeps only a scan's
3
+ * station and wall clock: Ship24's feed names the station GOFO gives
4
+ * (`processLocation`), without its state, and writes the wall clock of the
5
+ * zone GOFO gives it (`processTimeZone`). GOFO's own API names that zone on
6
+ * every scan, which the adapter reads.
7
+ */
8
+ /** The zone of a GOFO US scan at `station`, worded `status`. Null for a name not listed, a delivery round's included. */
9
+ export declare function gofoScanZone(station: string, status: string): string | null;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The clocks GOFO US dates its scans on, for a relay that keeps only a scan's
3
+ * station and wall clock: Ship24's feed names the station GOFO gives
4
+ * (`processLocation`), without its state, and writes the wall clock of the
5
+ * zone GOFO gives it (`processTimeZone`). GOFO's own API names that zone on
6
+ * every scan, which the adapter reads.
7
+ */
8
+ /**
9
+ * The stations GOFO named on the corpus's and benchmark's parcels, by the zone
10
+ * it gave them (15 parcels, checked 2026-10-11). A delivery round is named
11
+ * after the recipient's town and is left out: on all 15 it kept its delivery
12
+ * station's zone.
13
+ */
14
+ const STATION_ZONES = {
15
+ 'America/New_York': ['Atlanta', 'Brooklyn', 'Carteret', 'Charleston', 'Cincinnati', 'Detroit', 'Doral', 'IND.H', 'Jamaica',
16
+ 'Miami', 'Milford', 'New York', 'Ocala', 'Orlando', 'Suwanee', 'VPC01'],
17
+ 'America/Chicago': ['Melrose Park'],
18
+ // Newark is the Bay Area hub. ORD02.V picks up in Chicago, on Pacific time.
19
+ 'America/Los_Angeles': ['Chino', 'LAX.H', 'Los Angeles', 'Newark', 'ORD02.V', 'Portland', 'San Fernando Valley', 'San Francisco',
20
+ 'Seattle', 'Vernon'],
21
+ };
22
+ const STATIONS = new Map(Object.entries(STATION_ZONES).flatMap(([zone, names]) => names.map((name) => [name.toLowerCase(), zone])));
23
+ /** GOFO dates a label's creation on Pacific time wherever the parcel is (`processTimeZone` "US/Pacific" on all 15). */
24
+ const LABEL = /^shipping label created\b/i;
25
+ /** The zone of a GOFO US scan at `station`, worded `status`. Null for a name not listed, a delivery round's included. */
26
+ export function gofoScanZone(station, status) {
27
+ const name = station.trim().toLowerCase();
28
+ if (name)
29
+ return STATIONS.get(name) ?? null;
30
+ return LABEL.test(status.trim()) ? 'America/Los_Angeles' : null;
31
+ }
@@ -8,6 +8,7 @@ import { withNetworkRetry } from '../../core/runner/networkRetry.js';
8
8
  import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
9
9
  import { isRecord } from '../../core/types.js';
10
10
  import { FALLBACK_EVENT_LABELS, GLOBAL_STATUS_STAGE, STAGE_STATUS, STATUS_MAP, swissPostEventStage, } from './status.js';
11
+ import { DESTINATION_EVENTS } from './clock.js';
11
12
  // The public tracker signs an anonymous visitor in before it will search: it
12
13
  // creates a throwaway user, echoes a CSRF token, and keys the search result on
13
14
  // a hash. Every call in that sequence shares one cookie jar.
@@ -187,30 +188,19 @@ function timestamp(value) {
187
188
  const parsed = Date.parse(explicitZone ? value : `${value}Z`);
188
189
  return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY;
189
190
  }
191
+ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(?:Z|[+-]\d{2}:?\d{2})?$/i;
190
192
  /**
191
- * Events Swiss Post relays from the destination post of a parcel it sends
192
- * abroad: arrival in the destination country, its customs, sorting and
193
- * delivery rounds. With no city, postcode or country they carry the
194
- * destination office's wall clock under Swiss Post's offset: an El Salvador
195
- * delivery round at 12:26 local came as 12:26 +02:00, and Correos de El
196
- * Salvador dates it 18:26 UTC (checked 2026-10-10). Swiss Post's own
197
- * unlocated events (813, 915) keep its clock.
193
+ * A destination post's event, on its country's clock when that keeps one. In a
194
+ * country with several, it keeps Swiss Post's offset, the last guess: hours
195
+ * off, but the reply keeps its instants.
198
196
  */
199
- const DESTINATION_EVENTS = new Set(['909', '918', '919', '920', '921', '923', '924', '926', '934', '938']);
200
- const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(?:Z|[+-]\d{2}:?\d{2})?$/i;
201
- /** A destination post's event, on its country's clock when that keeps one, else a wall time. */
202
197
  function destinationClock(event, time, destination) {
203
198
  const located = text(event.city, 100) || text(event.zip, 30) || text(event.country, 10);
204
199
  const wall = WALL_CLOCK.exec(time)?.[1];
205
200
  if (!destination || located || !wall || !DESTINATION_EVENTS.has(text(event.eventCode, 100).split('.').at(-1) ?? ''))
206
- return { time };
201
+ return time;
207
202
  const zone = countryTimeZone(destination);
208
- const placed = zone ? isoTime(wall, zone) : null;
209
- return placed ? { time: placed.iso } : { local_time: wall };
210
- }
211
- /** An event's instant, else its wall time. */
212
- function clockOf(event) {
213
- return event.time ?? (typeof event.local_time === 'string' ? event.local_time : '');
203
+ return (zone ? isoTime(wall, zone)?.iso : null) ?? time;
214
204
  }
215
205
  export function parseSwissPostShipment(item, rawEvents, translations = {}) {
216
206
  const globalStatus = text(item.globalStatus, 100);
@@ -232,7 +222,7 @@ export function parseSwissPostShipment(item, rawEvents, translations = {}) {
232
222
  if (!time || !eventCode)
233
223
  continue;
234
224
  const event = {
235
- ...destinationClock(rawEvent, time, abroad),
225
+ time: destinationClock(rawEvent, time, abroad),
236
226
  location: eventLocation(rawEvent),
237
227
  description: eventDescription(rawEvent, translations, internationalType),
238
228
  provider_code: eventCode,
@@ -242,7 +232,7 @@ export function parseSwissPostShipment(item, rawEvents, translations = {}) {
242
232
  event.stage = stage;
243
233
  events.push(event);
244
234
  }
245
- events.sort((left, right) => timestamp(clockOf(right)) - timestamp(clockOf(left)));
235
+ events.sort((left, right) => timestamp(right.time ?? '') - timestamp(left.time ?? ''));
246
236
  // The newest scan's own stage leads; the shipment summary fills in when that
247
237
  // scan has none (an enquiry note, an unmapped code, or no scans at all).
248
238
  let stage = events[0]?.stage ?? GLOBAL_STATUS_STAGE[globalStatus];
@@ -255,12 +245,9 @@ export function parseSwissPostShipment(item, rawEvents, translations = {}) {
255
245
  status = STAGE_STATUS[stage];
256
246
  let lastStatusText;
257
247
  let lastUpdate;
258
- let lastUpdateLocal;
259
248
  if (events[0]) {
260
249
  lastStatusText = events[0].description ?? 'Tracking update';
261
250
  lastUpdate = events[0].time ?? null;
262
- if (!events[0].time)
263
- lastUpdateLocal = clockOf(events[0]) || undefined;
264
251
  }
265
252
  else {
266
253
  lastStatusText = globalStatus;
@@ -277,7 +264,6 @@ export function parseSwissPostShipment(item, rawEvents, translations = {}) {
277
264
  ...(stage ? { current_stage: stage } : {}),
278
265
  last_status_text: lastStatusText,
279
266
  last_update: lastUpdate,
280
- ...(lastUpdateLocal ? { last_update_local: lastUpdateLocal } : {}),
281
267
  expected_delivery: finished ? null : swissPostExpectedDelivery(item),
282
268
  ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
283
269
  ...(weight !== null ? { weight_kg: weight } : {}),
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Events Swiss Post relays from the destination post of a parcel it sends
3
+ * abroad: arrival in the destination country, its customs, sorting and
4
+ * delivery rounds. With no city, postcode or country they carry the
5
+ * destination office's wall clock under Swiss Post's offset: an El Salvador
6
+ * delivery round at 12:26 local came as 12:26 +02:00. UPU's record has the
7
+ * same 12:26 at El Salvador's office, and Correos de El Salvador's own tracker
8
+ * dates it 18:26:18 UTC (checked 2026-10-11). On an item to China, customs
9
+ * (901, 902) came at the 11:05 and 14:30 UPU gives Beijing's and Hangzhou's
10
+ * offices. Swiss Post's own unlocated events (813, 915) keep its clock.
11
+ */
12
+ export declare const DESTINATION_EVENTS: ReadonlySet<string>;
13
+ /**
14
+ * The clock of a Swiss Post scan as a relay gives it, when it is the
15
+ * destination post's: worded as one of those events, with no location, on an
16
+ * item bound for `destination`, another country, which keeps one clock. Null
17
+ * otherwise, when the relay's reading stays.
18
+ */
19
+ export declare function swissPostDestinationZone(wording: string, location: string, destination: string | null): string | null;
@@ -0,0 +1,29 @@
1
+ import { countryTimeZone } from '../../core/time/index.js';
2
+ /**
3
+ * Events Swiss Post relays from the destination post of a parcel it sends
4
+ * abroad: arrival in the destination country, its customs, sorting and
5
+ * delivery rounds. With no city, postcode or country they carry the
6
+ * destination office's wall clock under Swiss Post's offset: an El Salvador
7
+ * delivery round at 12:26 local came as 12:26 +02:00. UPU's record has the
8
+ * same 12:26 at El Salvador's office, and Correos de El Salvador's own tracker
9
+ * dates it 18:26:18 UTC (checked 2026-10-11). On an item to China, customs
10
+ * (901, 902) came at the 11:05 and 14:30 UPU gives Beijing's and Hangzhou's
11
+ * offices. Swiss Post's own unlocated events (813, 915) keep its clock.
12
+ */
13
+ export const DESTINATION_EVENTS = new Set(['901', '902', '909', '918', '919', '920', '921', '923', '924', '926',
14
+ '934', '938']);
15
+ /** The English wording of those events, for relays that give no code (Ship24, 17TRACK, Postal Ninja). */
16
+ const DESTINATION_WORDING = new RegExp('^(?:arrival in destination country|customs clearance process underway|sorting - forwarding'
17
+ + '|arrival at the processing/collection point|delivery failed: (?:recipient unknown|recipient absent|shipment undeliverable)'
18
+ + '|retention period was extended by recipient|delivered)$', 'i');
19
+ /**
20
+ * The clock of a Swiss Post scan as a relay gives it, when it is the
21
+ * destination post's: worded as one of those events, with no location, on an
22
+ * item bound for `destination`, another country, which keeps one clock. Null
23
+ * otherwise, when the relay's reading stays.
24
+ */
25
+ export function swissPostDestinationZone(wording, location, destination) {
26
+ if (location.trim() || !destination || destination === 'CH' || !DESTINATION_WORDING.test(wording.trim()))
27
+ return null;
28
+ return countryTimeZone(destination);
29
+ }
@@ -4703,7 +4703,7 @@
4703
4703
  },
4704
4704
  "allegro-one": {
4705
4705
  "displayName": "Allegro One",
4706
- "color": "#ff5a00",
4706
+ "color": "#006d57",
4707
4707
  "aliases": [
4708
4708
  "Allegro Delivery",
4709
4709
  "Allegro One Box",
@@ -3227,7 +3227,7 @@ export declare const CARRIER_CATALOG: {
3227
3227
  };
3228
3228
  readonly "allegro-one": {
3229
3229
  readonly displayName: "Allegro One";
3230
- readonly color: "#ff5a00";
3230
+ readonly color: "#006d57";
3231
3231
  readonly aliases: readonly ["Allegro Delivery", "Allegro One Box", "Allegro One Kurier", "Allegro One Punkt"];
3232
3232
  readonly countries: readonly ["PL"];
3233
3233
  readonly selectable: true;
@@ -4704,7 +4704,7 @@ export const CARRIER_CATALOG = {
4704
4704
  },
4705
4705
  "allegro-one": {
4706
4706
  "displayName": "Allegro One",
4707
- "color": "#ff5a00",
4707
+ "color": "#006d57",
4708
4708
  "aliases": [
4709
4709
  "Allegro Delivery",
4710
4710
  "Allegro One Box",
@@ -450,6 +450,16 @@
450
450
  "Postal Ninja": "blocked",
451
451
  "UPU": "n/a"
452
452
  }
453
+ },
454
+ {
455
+ "note": "Benchmark number 9261290357482657956985",
456
+ "results": {
457
+ "Ship24": "no_history",
458
+ "ParcelsApp": 9,
459
+ "17TRACK": 7,
460
+ "Postal Ninja": "blocked",
461
+ "UPU": "n/a"
462
+ }
453
463
  }
454
464
  ]
455
465
  },
@@ -1458,7 +1468,7 @@
1458
1468
  "note": "Benchmark number 4PX3002555003946CN",
1459
1469
  "results": {
1460
1470
  "Ship24": "no_history",
1461
- "ParcelsApp": "error",
1471
+ "ParcelsApp": 30,
1462
1472
  "17TRACK": 44,
1463
1473
  "Postal Ninja": "no_history",
1464
1474
  "UPU": "n/a"
@@ -69,10 +69,12 @@ const nameKey = (name) => typeof name === 'string' ? name.toLowerCase().replace(
69
69
  // Carriers whose ParcelsApp dates are a North American scan's local clock:
70
70
  // UPS's own instants, FedEx's own page, UniUni's own feed and Ship24's offsets
71
71
  // agree once they are read that way (checked 2026-09-30 on US scans; UPS's
72
- // also in Germany, the Netherlands and Spain). OnTrac's are UTC and
73
- // Landmark's keep one clock everywhere, so no other carrier gets this step
74
- // beyond the ones below.
75
- const LOCAL_CLOCK_CARRIERS = new Set(['ups', 'fedex', 'uniuni', 'uniexpress', 'easyship']);
72
+ // also in Germany, the Netherlands and Spain). So does DHL eCommerce's
73
+ // Webtrack, which names each scan's clock, on all 25 scans of three parcels
74
+ // in seven states and the UK (2026-10-11). OnTrac's are UTC and Landmark's
75
+ // keep one clock everywhere, so no other carrier gets this step beyond the
76
+ // ones below.
77
+ const LOCAL_CLOCK_CARRIERS = new Set(['ups', 'fedex', 'uniuni', 'uniexpress', 'easyship', 'dhlecommerce']);
76
78
  /**
77
79
  * Carriers whose dates are each scan's local clock under a label that is
78
80
  * never its instant, checked scan for scan on 2026-10-10: USPS's own page,
@@ -84,29 +86,81 @@ const LOCAL_CLOCK_CARRIERS = new Set(['ups', 'fedex', 'uniuni', 'uniexpress', 'e
84
86
  * names; `wallClockZones` reads the others.
85
87
  */
86
88
  const WALL_CLOCKS = new Set(['usps', 'canadapost', 'gofoexpress', 'speedpak', 'ems', 'cainiao', 'ecoscooting', '4pxexpress']);
89
+ // A known clock that is the destination country's (`knownClock`).
90
+ const DESTINATION = 'destination';
91
+ const wordings = (list) => new RegExp(`^(?:${list.join('|')})$`, 'i');
92
+ // Cainiao's last-mile scans, in its words or the local carrier's.
93
+ const CAINIAO_LAST_MILE = [
94
+ 'received by local\\s+delivery company', '(?:at|left) destination country/region(?: sorting center)?', 'arrived at local delivery center',
95
+ 'out for delivery', 'delivery failed(?:\\. not at home\\.)?', 'delivery attempt failure,.*', '(?:item )?delivered',
96
+ 'parcel is handled', 'your shipment has been delivered to the postal operator of the country.*',
97
+ ];
87
98
  /**
88
- * The clocks of some wall-clock scans whatever their neighbours', checked on
89
- * 2026-10-10. GOFO's feed dates its labels on Pacific time wherever the
90
- * parcel is injected (a Florida parcel's at -07:00). Cainiao's own feed dates
91
- * its origin leg on China time, from the warehouse to the linehaul office, in
92
- * two parcels bound for the UK whose later legs it dates on GMT+2, +1 and +0;
93
- * in a US parcel, "Package finished" and "Last-mile delivery forecast" fall
94
- * minutes from scans of that leg. Such a scan borrows no neighbour's clock:
95
- * borrowed from a GOFO parcel's Florida scans, the label came 3 hours early
96
- * and Cainiao's "Order received" 12 hours after it, 19 seconds on China time.
99
+ * The clocks of some wall-clock scans whatever their neighbours', from each
100
+ * carrier's own offsets. GOFO's feed dates its labels on Pacific time
101
+ * wherever the parcel is injected (a Florida parcel's at -07:00; 2026-10-10).
102
+ *
103
+ * Cainiao's own feed (checked 2026-10-11 on parcels to the UK, Belgium,
104
+ * Spain, Italy, Japan, Brazil and the US) dates its origin leg on China time,
105
+ * from the warehouse to the departing linehaul, and the return warehouse
106
+ * too. It dates import customs on the destination's clock, and the last mile
107
+ * as well, except the UK's, on GMT in July. Its transit hub, on GMT+2 for a
108
+ * UK parcel, and "Arrived at linehaul office", on either side's clock, are
109
+ * not listed; that office stays on China time, as it was checked first.
110
+ *
111
+ * 4PX's page (2026-10-11, two parcels to the US) dates its scans up to the
112
+ * departing flight on China time and the rest in the US. EcoScooting's own
113
+ * instants (two parcels) put its China leg on China time, its order and its
114
+ * Spanish sorting center on Madrid's, and its last mile on the destination's:
115
+ * Lisbon's for a "CNPRT" reference, otherwise Spain's.
116
+ *
117
+ * A scan on a fixed clock borrows no neighbour's: borrowed from a GOFO
118
+ * parcel's Florida scans, the label came 3 hours early and Cainiao's "Order
119
+ * received" 12 hours after it, 19 seconds on China time. One in the
120
+ * destination borrows only from a scan placed there, and otherwise takes its
121
+ * single clock (`wallClockZones`).
97
122
  */
98
123
  const KNOWN_CLOCKS = new Map([
99
- ['gofoexpress', { wording: /^shipping label created$/i, zone: 'America/Los_Angeles' }],
100
- ['cainiao', { wording: new RegExp(`^(?:${[
101
- 'order received successfully', 'package finished', 'leave the warehouse', 'accepted by carrier',
102
- '(?:inbound|outbound) in sorting center', 'last-mile delivery forecast', 'arrived at departure transport hub',
103
- 'export customs clearance started', 'export clearance success', '(?:leaving|left) from departure country/region',
104
- 'arrived at linehaul office',
105
- ].join('|')})$`, 'i'), zone: 'Asia/Shanghai' }],
124
+ ['gofoexpress', [{ wording: /^shipping label created$/i, zone: 'America/Los_Angeles' }]],
125
+ ['cainiao', [
126
+ { wording: wordings([
127
+ 'order received successfully', 'package finished', 'leave the warehouse', 'departed from warehouse', 'accepted by carrier',
128
+ '(?:inbound|outbound) in sorting center', 'package (?:received by|left) sorting center of transit', 'last-mile delivery forecast',
129
+ 'arrived at departure transport hub', 'export customs clearance started', 'export clearance success',
130
+ '(?:leaving|left) from departure country/region', 'arrived at linehaul office', 'cross-solution reroute', '质检真假鉴定',
131
+ 'return warehouse inbound success\\.?', 'parcel has been destroyed in return warehouse\\.?',
132
+ ]), zone: 'Asia/Shanghai' },
133
+ { wording: wordings(['import clearance start', 'import customs clearance complete', 'arrived at customs', 'leaving customs',
134
+ 'we\'re inspecting your order']), zone: DESTINATION },
135
+ { wording: wordings(CAINIAO_LAST_MILE), zone: DESTINATION, except: 'GB' },
136
+ ]],
137
+ ['4pxexpress', [
138
+ { wording: wordings(['parcel information received', 'pickup scanned, in transit to warehouse', 'shipment picked up\\.?',
139
+ 'shipment arrived at facility and measured\\.?', 'depart from facility to service provider\\.?', 'hand over to airline\\.?',
140
+ 'departure from the original airport']), zone: 'Asia/Shanghai' },
141
+ { wording: wordings(['arrival to the destination airport', 'arrived at warehouse', 'released from customs: customs cleared\\.?',
142
+ 'customs clearance agent received the shipment in destnation country\\.?', 'in transit, it\'s progressing through post network',
143
+ 'handed over to last mile']), zone: DESTINATION },
144
+ ]],
145
+ ['ecoscooting', [
146
+ { wording: /^(?:shipment data information received|shipment has left cainiao express's partner facility|shipment is in transit to cainiao express)$/i,
147
+ zone: 'Asia/Shanghai' },
148
+ { wording: /^(?:create|sorting center (?:inbound|outbound))\./i, zone: 'Europe/Madrid' },
149
+ { wording: /^(?:accepted by last mile|out for delivery|delivery attempt failure|delivery success|pudo inbound success|delivered to pudo)\./i,
150
+ zone: DESTINATION, otherwise: 'Europe/Madrid' },
151
+ ]],
106
152
  ]);
107
- function knownClock(name, status) {
108
- const known = KNOWN_CLOCKS.get(nameKey(name));
109
- return known?.wording.test(status.trim()) ? known.zone : null;
153
+ /**
154
+ * A scan's known clock: its zone, null when the destination has several, and
155
+ * for a scan in the destination, that country.
156
+ */
157
+ function knownClock(name, status, destination) {
158
+ const known = KNOWN_CLOCKS.get(nameKey(name))?.find(({ wording }) => wording.test(status.trim()));
159
+ if (!known || known.zone !== DESTINATION)
160
+ return known ? { zone: known.zone } : null;
161
+ if (!destination)
162
+ return known.otherwise ? { zone: known.otherwise } : null;
163
+ return destination === known.except ? null : { zone: countryTimeZone(destination), country: destination };
110
164
  }
111
165
  /**
112
166
  * Networks whose dates are their home clock wherever the scan was: their own
@@ -122,11 +176,13 @@ const HOME_CLOCKS = {
122
176
  /**
123
177
  * Networks whose dates are their scans' local clocks, checked at home only
124
178
  * (2026-10-10): Korea Post's page, Bring's offsets (in Denmark too) and
125
- * Parcelforce's for Royal Mail. Their home clock is a guess, which a location
179
+ * Parcelforce's for Royal Mail; Japan Post's, which its portal gives as local
180
+ * times, at 10:27 for a delivery in Japan that Cainiao dates 10:27 GMT+9 and
181
+ * Ship24 01:27Z (2026-10-11). Their home clock is a guess, which a location
126
182
  * in another country rules out.
127
183
  */
128
184
  const HOME_COUNTRIES = {
129
- southkoreapost: 'Asia/Seoul', postennorgenorwaypost: 'Europe/Oslo', royalmail: 'Europe/London',
185
+ southkoreapost: 'Asia/Seoul', postennorgenorwaypost: 'Europe/Oslo', royalmail: 'Europe/London', japanpost: 'Asia/Tokyo',
130
186
  };
131
187
  const UNITED_STATES = /^(?:us|usa|united states(?: of america)?)$/i;
132
188
  const POSTAL_CODE = /\s*\b(?:\d{5}(?:-\d{4})?|[A-Z]\d[A-Z] ?\d[A-Z]\d)$/i;
@@ -145,18 +201,18 @@ const US_STATE_NAMES = new Map(Object.entries({
145
201
  }));
146
202
  /**
147
203
  * The zone of the US state or Canadian province a location names ("Riverside,
148
- * CA, US", "Charlotte, NC", "Linden, NJ 07036", "Scranton PA", "Mississauga,
149
- * ON, CA"). It is `certain` when the location names the country, the code is
150
- * no country's, or the town lies in that state or province ("Chicago, IL" is
151
- * not in Israel); "Atlanta, GA" could still be in Gabon. A state written out
152
- * counts only after the country ("Example City Minnesota, United States"):
153
- * Georgia is a country too.
204
+ * CA, US", "Charlotte, NC", "Linden, NJ 12345", "Scranton PA", "Mississauga,
205
+ * ON, CA", "Example City, IN, US 12345"). It is `certain` when the location
206
+ * names the country, the code is no country's, or the town lies in that state
207
+ * or province ("Chicago, IL" is not in Israel); "Atlanta, GA" could still be
208
+ * in Gabon. A state written out counts only after the country ("Example City
209
+ * Minnesota, United States"): Georgia is a country too.
154
210
  */
155
211
  function regionClock(location) {
156
212
  const parts = location.split(',').map((part) => part.trim()).filter(Boolean);
157
213
  const last = parts.at(-1) ?? '';
158
214
  // "CA" after a province is Canada; after a town it is California.
159
- const country = parts.length > 1 && UNITED_STATES.test(last) ? 'US'
215
+ const country = parts.length > 1 && UNITED_STATES.test(last.replace(POSTAL_CODE, '')) ? 'US'
160
216
  : (parts.length > 1 && /^canada$/i.test(last)) || (parts.length > 2 && /^ca$/i.test(last)) ? 'CA' : null;
161
217
  if (country)
162
218
  parts.pop();
@@ -179,11 +235,15 @@ function regionClock(location) {
179
235
  /**
180
236
  * The last part of a location that is not a bare postcode: "Example Town,
181
237
  * Denmark, 4600" is in Denmark. Orange Connex names China and the United
182
- * States in full for SpeedPAK, and Canada Post writes "USA".
238
+ * States in full for SpeedPAK, Canada Post writes "USA", 4PX "US 12345", and
239
+ * Hong Kong is not the name Intl gives it.
183
240
  */
184
241
  function lastPlace(location) {
185
242
  const place = location.split(',').map((part) => part.trim()).filter((part) => part && !/^\d[\d\s-]*$/.test(part)).at(-1);
186
- return place && /^people['’]?s republic of china$/i.test(place) ? 'CN' : place && UNITED_STATES.test(place) ? 'US' : place;
243
+ if (!place)
244
+ return place;
245
+ return /^people['’]?s republic of china$/i.test(place) ? 'CN' : UNITED_STATES.test(place.replace(POSTAL_CODE, '')) ? 'US'
246
+ : /^hong\s*kong$/i.test(place) ? 'HK' : place;
187
247
  }
188
248
  /**
189
249
  * A scan's location as the zone steps read it. 4PX writes the country, state
@@ -320,14 +380,29 @@ function guessedZone(payload, state, fallback, number) {
320
380
  return (wall ? sharedClockZone(brand, wall) : null) ?? guess(fallback);
321
381
  }
322
382
  /**
323
- * 4PX's dates are the instants of its own feed, which reads every scan's wall
324
- * clock on China time, its US ones too: the wall clock is that instant's
325
- * digits in Shanghai (checked 2026-10-10 against 4PX's page, which shows each
326
- * with the offset of its place). An instant is right only for a scan on China
327
- * time, which is how a scan nothing places reads when it keeps its label.
383
+ * ParcelsApp relays 4PX's feed two ways (checked 2026-10-11 against 4PX's
384
+ * page, which shows each scan's wall clock with the offset of its place).
385
+ * Where it keeps 4PX's fixed fields ("US NY EXAMPLE CITY 12345", "CN"), its
386
+ * dates are the feed's instants, which read every wall clock on China time,
387
+ * the US ones too: the wall clock is the instant's digits in Shanghai. Where
388
+ * it rewrites them ("United States" for "US", "Hong Kong" for "HONG KONG -
389
+ * HONG KONG"), they are the wall clocks themselves, labeled UTC, as other
390
+ * carriers' are.
391
+ */
392
+ const FOUR_PX_FIELDS = /^[A-Z]{2}(?:\s{2}|$)/;
393
+ /** Whether a reply's 4PX dates are instants: unless its 4PX scans have places, none in 4PX's fields. */
394
+ function fourPxInstants(payload, states) {
395
+ const places = states.flatMap((raw) => isRecord(raw) && nameKey(stateCarrierName(payload, raw)) === '4pxexpress'
396
+ && typeof raw.location === 'string' && raw.location.trim() ? [raw.location.trim()] : []);
397
+ return !places.length || places.some((place) => FOUR_PX_FIELDS.test(place));
398
+ }
399
+ /**
400
+ * A state's date as its wall clock: 4PX's instants as their Shanghai digits.
401
+ * An instant is right only for a scan on China time, which is how a scan
402
+ * nothing places reads when it keeps its label.
328
403
  */
329
- function stateDate(name, date) {
330
- if (nameKey(name) !== '4pxexpress' || typeof date !== 'string')
404
+ function stateDate(name, date, instants = true) {
405
+ if (nameKey(name) !== '4pxexpress' || typeof date !== 'string' || !instants)
331
406
  return date;
332
407
  const instant = DateTime.fromISO(date.trim(), { setZone: true });
333
408
  return instant.isValid && /(?:Z|[+-]\d{2}:\d{2})$/i.test(date.trim())
@@ -340,17 +415,18 @@ function stateDate(name, date) {
340
415
  * gave it, and for a wall-clock scan, routing's zone and any known clock,
341
416
  * which `wallClockZones` falls back on.
342
417
  */
343
- function readState(payload, raw, status, fallback, number, inNorthAmerica = false) {
418
+ function readState(payload, raw, status, fallback, number, inNorthAmerica = false, destination = null, instants = true) {
344
419
  const name = stateCarrierName(payload, raw);
345
- const state = { ...raw, date: stateDate(name, raw.date) };
420
+ const state = { ...raw, date: stateDate(name, raw.date, instants) };
346
421
  const location = scanLocation(name, raw.location);
347
422
  const offset = ownOffset(name, state.date);
348
423
  const wallClock = WALL_CLOCKS.has(nameKey(name)) && !offset;
349
424
  const placed = placedZone(payload, state, number, inNorthAmerica);
350
425
  const zone = placed !== undefined ? placed : wallClock ? null : guessedZone(payload, state, fallback, number);
426
+ const known = wallClock ? knownClock(name, status, destination) : null;
351
427
  return { name, date: state.date, labeled: raw.date, zone, placed: placed ?? null, offset, location, wallClock,
352
428
  local: zone !== null && zone === regionScanZone(name, location, inNorthAmerica)?.zone,
353
- fallback: wallClock ? fitsPlace(fallback, location) : null, known: wallClock ? knownClock(name, status) : null };
429
+ fallback: wallClock ? fitsPlace(fallback, location) : null, known: known?.zone ?? null, arrival: known?.country ?? null };
354
430
  }
355
431
  /**
356
432
  * The zone of each scan. One from a network that is not a wall clock keeps
@@ -359,9 +435,11 @@ function readState(payload, raw, status, fallback, number, inNorthAmerica = fals
359
435
  * 2. that of another carrier's placed scan with the same digits and wording,
360
436
  * which it copies (`copy`);
361
437
  * 3. as a guess, that of the scan of its network just before it by wall
362
- * clock that steps 1 and 2 place, else of the one just after it;
438
+ * clock that steps 1 and 2 place, else of the one just after it; for a
439
+ * scan in the destination, only of one placed there;
363
440
  * 4. the zone routing passes for the parcel;
364
- * 5. its known clock (`KNOWN_CLOCKS`), for a scan that skips step 3;
441
+ * 5. its known clock (`KNOWN_CLOCKS`): a fixed one, for a scan that skips
442
+ * step 3, or the destination's single clock;
365
443
  * 6. none: it keeps ParcelsApp's label, as it did before these steps, the
366
444
  * last guess.
367
445
  * A zone the scan's location rules out is skipped ("USA" takes no Canadian
@@ -370,10 +448,11 @@ function readState(payload, raw, status, fallback, number, inNorthAmerica = fals
370
448
  * own clocks: Canada Post's first scan and one in Chicago took the right
371
449
  * zone, and its last two, in Montana, the Chicago hub's, an hour off USPS's.
372
450
  * SpeedPAK's four took the right one, and a Chinese system event the US one,
373
- * 14 hours off. 4PX's nine did; its four between the departing flight and the
374
- * first US scan took China's, and its first scan the US label's, 12 hours
375
- * off. USPS's "In Transit to Next Facility" was not checked: no source gives
376
- * its clock.
451
+ * 14 hours off. USPS's "In Transit to Next Facility" was not checked: no
452
+ * source gives its clock. 4PX's scans between the departing flight and the
453
+ * first US scan took China's until they were known to be in the destination
454
+ * (2026-10-11); its US scans with no state take the right zone, or one an
455
+ * hour off when the parcel changed zones around them.
377
456
  */
378
457
  function wallClockZones(entries) {
379
458
  const found = entries.map((entry) => !entry.wallClock ? { zone: entry.zone, step: 'carrier' }
@@ -387,12 +466,14 @@ function wallClockZones(entries) {
387
466
  const wall = walls[index];
388
467
  let before;
389
468
  let after;
390
- if (!entry.known) {
469
+ if (!entry.known || entry.arrival) {
391
470
  entries.forEach((other, at) => {
392
471
  const zone = found[at]?.zone;
393
472
  const near = { wall: walls[at], zone: zone ?? '' };
394
473
  if (!zone || !other.wallClock || nameKey(other.name) !== key || !fitsPlace(zone, entry.location, true))
395
474
  return;
475
+ if (entry.arrival && (timeZoneCountry(zone) ?? NORTH_AMERICAN_ZONES.get(zone)) !== entry.arrival)
476
+ return;
396
477
  if (near.wall <= wall) {
397
478
  if (!before || near.wall > before.wall)
398
479
  before = near;
@@ -407,6 +488,16 @@ function wallClockZones(entries) {
407
488
  : known ? { zone: known, step: 'known' } : { zone: null, step: 'labeled' };
408
489
  });
409
490
  }
491
+ /**
492
+ * The country the parcel is bound for, as the reply names it, or for a reply
493
+ * that does not, as an EcoScooting reference does ("CNPRT" for Portugal,
494
+ * "CNESP" for Spain).
495
+ */
496
+ function replyDestination(payload, number) {
497
+ const reference = /^CN(PRT|ESP)\d{20}$/.exec(number)?.[1];
498
+ return countryCode(payload.destinationCode) ?? countryCode(payload.destination)
499
+ ?? (reference === 'PRT' ? 'PT' : reference === 'ESP' ? 'ES' : null);
500
+ }
410
501
  /**
411
502
  * ParcelsApp relays some carriers' labels twice over, TIPSA's as
412
503
  * "ENTREGADOENTREGADO". Only a label made of two identical halves is collapsed.
@@ -437,6 +528,8 @@ function parseHistory(payload, trackingNumber, timezone = null) {
437
528
  return location ? [placeKind(location)] : [];
438
529
  });
439
530
  const inNorthAmerica = kinds.includes('certain') || (kinds.includes('region') && !kinds.includes('abroad'));
531
+ const destination = replyDestination(payload, number);
532
+ const instants = fourPxInstants(payload, payload.states);
440
533
  const entries = [];
441
534
  for (const raw of payload.states) {
442
535
  if (!isRecord(raw))
@@ -450,7 +543,7 @@ function parseHistory(payload, trackingNumber, timezone = null) {
450
543
  continue;
451
544
  }
452
545
  const status = relayedLabel(raw.status);
453
- const state = readState(payload, raw, status, timezone, number, inNorthAmerica);
546
+ const state = readState(payload, raw, status, timezone, number, inNorthAmerica, destination, instants);
454
547
  const scan = carrierScan(typeof state.name === 'string' ? carrierIdFromName(state.name) : undefined, status);
455
548
  entries.push({ ...state, raw, scan, wording: scan?.wording ?? status });
456
549
  }
@@ -594,7 +687,7 @@ export function parseParcelsAppHtml(html, trackingNumber, timezone = null) {
594
687
  const labeled = DateTime.fromFormat(`${date} ${time}`, 'dd LLL yyyy HH:mm', { locale: 'en', zone: 'UTC' });
595
688
  if (!labeled.isValid)
596
689
  throw new SchemaError(SOURCE, 'ParcelsApp returned an invalid event date');
597
- const state = readState({ carriers }, { date: labeled.toISO(), ...(name ? { carrier: carriers.indexOf(name) } : {}) }, description, timezone, numberOf(trackingNumber));
690
+ const state = readState({ carriers }, { date: labeled.toISO(), ...(name ? { carrier: carriers.indexOf(name) } : {}) }, description, timezone, numberOf(trackingNumber), false, replyDestination({}, numberOf(trackingNumber)));
598
691
  // The page prints no location, so no DHL Express scan has a facility to
599
692
  // read its clock in, and no other wall clock a place, a copy to take or a
600
693
  // placed scan of its network to borrow from: routing's zone, its known
@@ -17,7 +17,7 @@ import { isRecord } from '../../core/types.js';
17
17
  import { capturedBodies, captureFailure, loadCapture } from '../shared/capture.js';
18
18
  import { classifyEvent, event, hasPrivateDeliveryDetails, isNotice, numberOf, result, text } from '../shared/result.js';
19
19
  import { courierZone } from '../shared/hints.js';
20
- import { carrierHomeZone, serviceZone } from './clocks.js';
20
+ import { carrierHomeZone, originCopy, serviceZone } from './clocks.js';
21
21
  const SOURCE = 'Postal Ninja';
22
22
  const GET_API = 'https://postal.ninja/track/get';
23
23
  const CHECK_API = 'https://postal.ninja/track/check';
@@ -89,7 +89,7 @@ export function parsePostalNinjaResponse(payload, trackingNumber, timezone = nul
89
89
  if (/(?:Z|[+-]\d{2}:\d{2})$/.test(raw.dt)) {
90
90
  const parsed = event(raw.dt, description);
91
91
  if (parsed)
92
- scans.push({ wall: null, service: text(raw.ds), zone: null, event: parsed });
92
+ scans.push({ wall: null, service: text(raw.ds), origin: false, zone: null, event: parsed });
93
93
  }
94
94
  else {
95
95
  const classified = classifyEvent(description);
@@ -97,8 +97,8 @@ export function parsePostalNinjaResponse(payload, trackingNumber, timezone = nul
97
97
  if (stage !== 'delivered' && hasPrivateDeliveryDetails(description))
98
98
  continue;
99
99
  // A scan's location (loc) is read only for its clock.
100
- const scan = { service: text(raw.ds), wall: raw.dt, location: text(raw.loc), delivered: stage === 'delivered' };
101
- scans.push({ wall: raw.dt, service: scan.service, zone: serviceZone(scan, journey),
100
+ const scan = { service: text(raw.ds), description, wall: raw.dt, location: text(raw.loc), delivered: stage === 'delivered' };
101
+ scans.push({ wall: raw.dt, service: scan.service, origin: originCopy(scan), zone: serviceZone(scan, journey),
102
102
  event: { description: stage === 'delivered' ? 'Delivered' : description, ...classified } });
103
103
  }
104
104
  }
@@ -109,10 +109,12 @@ export function parsePostalNinjaResponse(payload, trackingNumber, timezone = nul
109
109
  // dated when its newest scan names no place. A scan whose hour is unknown
110
110
  // (false) keeps its wall time.
111
111
  const zoneAt = (index, wall) => {
112
- const { zone, service } = scans[index];
112
+ const { zone, service, origin } = scans[index];
113
113
  if (zone !== null)
114
114
  return zone || null;
115
- const established = (other) => other.service === service && typeof other.zone === 'string' ? other.zone : null;
115
+ // A post's copies of the origin post's scans are on another clock than its own, and borrow among themselves.
116
+ const established = (other) => other.service === service && other.origin === origin
117
+ && typeof other.zone === 'string' ? other.zone : null;
116
118
  for (let earlier = index - 1; earlier >= 0; earlier -= 1) {
117
119
  const borrowed = established(scans[earlier]);
118
120
  if (borrowed)
@@ -21,14 +21,19 @@ export declare function carrierHomeZone(carrier: string | null): string | null;
21
21
  export interface Scan {
22
22
  /** Postal Ninja's name for the service that reported it (`ds`). */
23
23
  service: string;
24
+ description: string;
24
25
  /** Its `dt`, without a zone. */
25
26
  wall: string;
26
27
  location: string;
27
28
  delivered: boolean;
28
29
  }
30
+ /** Whether a scan is a UTC post's copy of the origin post's, on the origin office's clock. */
31
+ export declare function originCopy({ service, description }: Pick<Scan, 'service' | 'description'>): boolean;
29
32
  /**
30
33
  * The established zone of a scan's wall time, from the service that reported
31
- * it: its fixed or network clock in any history, else in a history it reports
34
+ * it: the destination post's clock for a scan Swiss Post relays from it, a UTC
35
+ * post's or the origin's that an S10 suffix names for its copies, its fixed
36
+ * or network clock in any history, else in a history it reports
32
37
  * alone its location's clock, or a local clock's country: the destination's,
33
38
  * else the service's (`courierZone`) or the carrier's. A destination on another
34
39
  * clock than the service's may be a parcel that crossed a border, so it
@@ -36,4 +41,4 @@ export interface Scan {
36
41
  * (a date written as midnight, UPS's 12:xx), so that no zone can place the
37
42
  * scan.
38
43
  */
39
- export declare function serviceZone({ service, wall, location, delivered }: Scan, journey: Journey): string | null | false;
44
+ export declare function serviceZone(scan: Scan, journey: Journey): string | null | false;
@@ -8,7 +8,9 @@
8
8
  * missing here has no known clock: its scans borrow a zone in the adapter, as
9
9
  * a guess, or keep their wall time.
10
10
  */
11
+ import { swissPostDestinationZone } from '../../carriers/swiss-post/clock.js';
11
12
  import { CARRIER_DEFINITIONS } from '../../core/catalog/definitions.js';
13
+ import { isValidS10TrackingNumber } from '../../core/detection/s10.js';
12
14
  import { canadaProvinceTimeZone, countryCode, countryTimeZone, sharedClockZone, usStateTimeZone } from '../../core/time/index.js';
13
15
  import { courierZone } from '../shared/hints.js';
14
16
  /** Services whose every scan is at one clock, wherever it happened and whatever other services the history names. */
@@ -17,10 +19,21 @@ const FIXED_CLOCKS = new Map([
17
19
  ['Intelcom', 'UTC'],
18
20
  ['OnTrac', 'UTC'],
19
21
  ['Poste Italiane', 'UTC'],
20
- // Also for their scans abroad: Swiss Post's in El Salvador and China, Singapore Post's in China.
22
+ // Also for their scans abroad: Singapore Post's in China. Swiss Post's own are in
23
+ // Switzerland; the destination post's it relays keep their office's clock (serviceZone).
21
24
  ['Singapore Post', 'Asia/Singapore'],
22
25
  ['Swiss Post', 'Europe/Zurich'],
23
26
  ]);
27
+ /**
28
+ * Posts that date their own scans in UTC and keep the origin post's, which
29
+ * they receive by EDI and mark outbound, at that office's wall clock, as
30
+ * their own trackers do: Correos de El Salvador's arrival in San Salvador at
31
+ * 21:30:17 is UPU's 15:30 local, and its copy of Swiss Post's departure from
32
+ * Geneva keeps Geneva's 12:20 (checked 2026-10-11).
33
+ */
34
+ const UTC_POSTS = new Set(['Correos de El Salvador']);
35
+ /** The origin post's scans, as Postal Ninja translates "(salida)" and "(recibido por EDI)". */
36
+ const OUTBOUND = /\((?:outbound|output|departure|received by EDI)\)$/i;
24
37
  /** Services with one clock per network, by the parcel's destination: GOFO's US network keeps Los Angeles time in New York. */
25
38
  const NETWORK_CLOCKS = new Map([
26
39
  ['GOFO Express', new Map([['US', 'America/Los_Angeles'], ['FR', 'Europe/Paris']])],
@@ -68,7 +81,7 @@ export function carrierHomeZone(carrier) {
68
81
  /**
69
82
  * The clock of a scan location that ends with its country: a US state or
70
83
  * Canadian province before it ("Atlanta, GA, United States", "TACOMA, WA,
71
- * 98424, United States"), or a country with one clock. A two-letter country
84
+ * 12345, United States"), or a country with one clock. A two-letter country
72
85
  * other than the US's could be a state ("Chicago, IL"), so it names none.
73
86
  */
74
87
  function locationZone(location) {
@@ -81,9 +94,15 @@ function locationZone(location) {
81
94
  return parts.length === 3 ? canadaProvinceTimeZone(parts[1]) : null;
82
95
  return countryTimeZone(country);
83
96
  }
97
+ /** Whether a scan is a UTC post's copy of the origin post's, on the origin office's clock. */
98
+ export function originCopy({ service, description }) {
99
+ return UTC_POSTS.has(service) && OUTBOUND.test(description);
100
+ }
84
101
  /**
85
102
  * The established zone of a scan's wall time, from the service that reported
86
- * it: its fixed or network clock in any history, else in a history it reports
103
+ * it: the destination post's clock for a scan Swiss Post relays from it, a UTC
104
+ * post's or the origin's that an S10 suffix names for its copies, its fixed
105
+ * or network clock in any history, else in a history it reports
87
106
  * alone its location's clock, or a local clock's country: the destination's,
88
107
  * else the service's (`courierZone`) or the carrier's. A destination on another
89
108
  * clock than the service's may be a parcel that crossed a border, so it
@@ -91,7 +110,16 @@ function locationZone(location) {
91
110
  * (a date written as midnight, UPS's 12:xx), so that no zone can place the
92
111
  * scan.
93
112
  */
94
- export function serviceZone({ service, wall, location, delivered }, journey) {
113
+ export function serviceZone(scan, journey) {
114
+ const { service, description, wall, location, delivered } = scan;
115
+ const relayed = service === 'Swiss Post' ? swissPostDestinationZone(description, location, journey.destination) : null;
116
+ if (relayed)
117
+ return relayed;
118
+ if (UTC_POSTS.has(service)) {
119
+ if (!originCopy(scan))
120
+ return 'UTC';
121
+ return isValidS10TrackingNumber(journey.number) ? countryTimeZone(journey.number.slice(-2)) : null;
122
+ }
95
123
  const fixed = FIXED_CLOCKS.get(service) ?? (journey.destination ? NETWORK_CLOCKS.get(service)?.get(journey.destination) : undefined);
96
124
  if (fixed)
97
125
  return fixed;
@@ -16,6 +16,7 @@ import { runSteps } from '../../core/runner/index.js';
16
16
  import { countryTimeZone } from '../../core/time/index.js';
17
17
  import { isRecord } from '../../core/types.js';
18
18
  import { trackingPlace } from '../../places/trackingLocation.js';
19
+ import { swissPostDestinationZone } from '../../carriers/swiss-post/clock.js';
19
20
  import { universalCarrierHints } from '../shared/hints.js';
20
21
  import { capturedBodies, captureFailure, loadCapture } from '../shared/capture.js';
21
22
  import { facilityZone, LatestScan, relaysFacilityClock } from '../shared/facilityClock.js';
@@ -88,12 +89,16 @@ function lookupError(code, providerMessage) {
88
89
  ? new SeventeenTrackVerificationError(code, providerMessage)
89
90
  : new SeventeenTrackLookupError('lookup_unavailable', code, providerMessage);
90
91
  }
91
- /** The offset-less clock of a scan whose offset 17TRACK added itself. */
92
- function facilityWall(raw) {
93
- const clock = isRecord(raw.time_raw) && raw.time_raw.timezone === null ? raw.time_raw : null;
92
+ /** A scan's clock as the carrier wrote it, without any offset. */
93
+ function rawClock(raw) {
94
+ const clock = isRecord(raw.time_raw) ? raw.time_raw : null;
94
95
  return clock && typeof clock.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(clock.date)
95
96
  && typeof clock.time === 'string' && /^\d{2}:\d{2}:\d{2}$/.test(clock.time) ? `${clock.date}T${clock.time}` : null;
96
97
  }
98
+ /** The offset-less clock of a scan whose offset 17TRACK added itself. */
99
+ function facilityWall(raw) {
100
+ return isRecord(raw.time_raw) && raw.time_raw.timezone === null ? rawClock(raw) : null;
101
+ }
97
102
  /**
98
103
  * 17TRACK's carrier 100016, Asendia USA, relays A1's tracking: A1 dates every
99
104
  * scan in UTC, its partners' too. A Swiss Post scan at 18:20 +02:00 is A1's
@@ -103,6 +108,30 @@ function facilityWall(raw) {
103
108
  function relaysUtcClock(key) {
104
109
  return key === 100016;
105
110
  }
111
+ /**
112
+ * 17TRACK's carrier 19031, Correo El Salvador, relays Correos de El Salvador's
113
+ * tracker, which dates its own scans in UTC and keeps the origin post's, which
114
+ * it receives by EDI and marks outbound, at that office's wall clock: its
115
+ * arrival in San Salvador at 21:30:17 is UPU's 15:30 local, and its copy of
116
+ * Swiss Post's departure from Geneva keeps Geneva's 12:20. 17TRACK labels them
117
+ * all with -05:00 (checked 2026-10-11). The copies are read on the clock of
118
+ * the origin an S10 suffix names; from a country on several clocks they keep
119
+ * 17TRACK's reading, the last guess.
120
+ */
121
+ const CORREO_EL_SALVADOR = 19031;
122
+ /** The origin post's scans, in Correos de El Salvador's words: "(salida)", "(recibido por EDI)". */
123
+ const OUTBOUND = /\((?:salida|recibido por EDI)\)$/i;
124
+ /**
125
+ * 17TRACK's carrier 19251, Swiss Post, whose leg relays the destination post's
126
+ * scans as Swiss Post does (carriers/swiss-post/clock.ts).
127
+ */
128
+ const SWISS_POST = 19251;
129
+ /** The recipient's country, the only field of the address read. */
130
+ function recipientCountry(shipment) {
131
+ const country = isRecord(shipment.shipping_info) && isRecord(shipment.shipping_info.recipient_address)
132
+ ? shipment.shipping_info.recipient_address.country : null;
133
+ return typeof country === 'string' && /^[A-Z]{2}$/.test(country) ? country : null;
134
+ }
106
135
  /** 17TRACK's carrier 14061, NZ Post. */
107
136
  const NZ_POST = 14061;
108
137
  const NZ_DEPARTURE = /^international departure\b/i;
@@ -161,9 +190,8 @@ function nzPostAbroad(events, number, destination) {
161
190
  * Post's CN.
162
191
  */
163
192
  function nzPostDestination(shipment, providers) {
164
- const recipient = isRecord(shipment.shipping_info) && isRecord(shipment.shipping_info.recipient_address)
165
- ? shipment.shipping_info.recipient_address.country : null;
166
- if (typeof recipient === 'string' && /^[A-Z]{2}$/.test(recipient) && recipient !== 'NZ')
193
+ const recipient = recipientCountry(shipment);
194
+ if (recipient && recipient !== 'NZ')
167
195
  return recipient;
168
196
  const others = new Set(providers.map((leg) => isRecord(leg) && isRecord(leg.provider) && leg.provider.key !== NZ_POST
169
197
  ? leg.provider.country : null).filter((country) => typeof country === 'string' && /^[A-Z]{2}$/.test(country) && country !== 'NZ'));
@@ -277,16 +305,22 @@ export function parse17TrackResponse(payload, trackingNumber, postcode = null) {
277
305
  const abroad = nz && provider.events.length <= MAX_EVENTS
278
306
  ? nzPostAbroad(provider.events, number, nzPostDestination(shipment, tracking.providers)) : new Map();
279
307
  const origin = reported.key === CHINA_POST ? originZone(number) : null;
308
+ const salvador = reported.key === CORREO_EL_SALVADOR;
309
+ const sender = salvador && isValidS10TrackingNumber(number) ? countryTimeZone(number.slice(-2)) : null;
310
+ const bound = reported.key === SWISS_POST ? recipientCountry(shipment) : null;
280
311
  for (const [index, raw] of provider.events.entries()) {
281
312
  if (++count > MAX_EVENTS || !isRecord(raw))
282
313
  throw new SchemaError(SOURCE, '17TRACK returned invalid events');
283
314
  if (undatedScan(raw))
284
315
  undated++;
285
316
  const exchange = origin !== null && ORIGIN_EXCHANGE.test(text(raw.description));
286
- // A facility's clock, A1's UTC or NZ Post's, on which 17TRACK puts one offset for the whole parcel.
287
- const wall = relaysFacilityClock(name) || utc || nz || exchange ? facilityWall(raw) : null;
288
- const zone = !wall ? null : abroad.has(index) ? abroad.get(index) ?? null : exchange ? origin : utc ? 'UTC'
289
- : nz ? 'Pacific/Auckland' : facilityZone(text(raw.location));
317
+ const own = !salvador ? null : OUTBOUND.test(text(raw.description)) ? sender : 'UTC';
318
+ // The destination post's scan Swiss Post relays, its wall clock under Swiss Post's offset.
319
+ const relayed = bound ? swissPostDestinationZone(text(raw.description), text(raw.location), bound) : null;
320
+ // A facility's clock, A1's UTC, NZ Post's or Correos de El Salvador's, on which 17TRACK puts one offset for the whole parcel.
321
+ const wall = relayed ? rawClock(raw) : relaysFacilityClock(name) || utc || nz || exchange || own ? facilityWall(raw) : null;
322
+ const zone = !wall ? null : relayed ?? own ?? (abroad.has(index) ? abroad.get(index) ?? null : exchange ? origin : utc ? 'UTC'
323
+ : nz ? 'Pacific/Auckland' : facilityZone(text(raw.location)));
290
324
  const placed = wall && zone ? DateTime.fromISO(wall, { zone }) : null;
291
325
  let parsed;
292
326
  try {
@@ -20,6 +20,8 @@ import { scrapeUniversalPage } from '../../core/transport/browser.js';
20
20
  import { canadaProvinceTimeZone, countryCode, countryTimeZone, EXPLICIT_OFFSET_PATTERN, regionHasTown, sharedClockZone, timeZoneCountry, usStateTimeZone, } from '../../core/time/index.js';
21
21
  import { isRecord } from '../../core/types.js';
22
22
  import { cttSiteZone, portugalZone } from '../../carriers/ctt/clock.js';
23
+ import { gofoScanZone } from '../../carriers/gofo/clock.js';
24
+ import { swissPostDestinationZone } from '../../carriers/swiss-post/clock.js';
23
25
  import { trackingPlace } from '../../places/trackingLocation.js';
24
26
  import { courierZone, universalCarrierHints } from '../shared/hints.js';
25
27
  import { localEvent, numberOf, place, result } from '../shared/result.js';
@@ -38,7 +40,7 @@ const REFRESH_MS = 15_000;
38
40
  const POSTAL_CODE = /\s*\b(?:\d{5}(?:-\d{4})?|[A-Z]\d[A-Z] ?\d[A-Z]\d)$/i;
39
41
  /**
40
42
  * The clock of the US state or Canadian province a location names ("NORTH
41
- * YORK, ON", "Des Plaines, IL 60018", "Chicago, IL, US"). A code that is also
43
+ * YORK, ON", "Des Plaines, IL 12345", "Chicago, IL, US"). A code that is also
42
44
  * a country's ("IL" is Israel) needs the town to lie in the state, or the
43
45
  * country named after it. Any other code needs a scan in North America: an
44
46
  * Italian province ("MILANO MI") is no state.
@@ -63,15 +65,19 @@ function regionZone(location, northAmerican) {
63
65
  }
64
66
  /** 4PX's way of writing a scan abroad: country, state and town ("US NY EXAMPLE TOWN 12345"). */
65
67
  const COUNTRY_STATE_TOWN = /^([A-Z]{2})\s{2,}([A-Z]{2})\s{2,}(\S.*)$/;
66
- /** A scan location's clock: the single clock of the country it names, else of its US state or Canadian province. */
67
- function locationZone(location, northAmerican) {
68
+ /**
69
+ * A scan location's clock: the single clock of the country it names, else of
70
+ * its US state or Canadian province. A feed's one `country` stands for any
71
+ * country the words name.
72
+ */
73
+ function locationZone(location, northAmerican, country = null) {
68
74
  if (typeof location !== 'string' || !location.trim())
69
75
  return null;
70
76
  const parts = COUNTRY_STATE_TOWN.exec(location.trim());
71
77
  if (parts)
72
78
  return countryTimeZone(parts[1]) ?? regionZone(`${parts[3]}, ${parts[2]}, ${parts[1]}`, northAmerican);
73
- const country = trackingPlace(location).country;
74
- return (country ? countryTimeZone(country) : null) ?? regionZone(location, northAmerican);
79
+ const named = country ?? trackingPlace(location).country;
80
+ return (named ? countryTimeZone(named) : null) ?? regionZone(location, northAmerican);
75
81
  }
76
82
  /** Whether a scan's courier works in the US or Canada only, so its state and province codes are theirs. */
77
83
  function northAmericanCourier(scan) {
@@ -105,7 +111,7 @@ const RETURNED = /\breturn(?:ed)? to sender\b/i;
105
111
  const RELABELLED_FEEDS = {
106
112
  'canada-post-tracking': {},
107
113
  'uniuni-tracking': {},
108
- 'gofo-express-tracking': { own: 'America/Los_Angeles' },
114
+ 'gofo-express-tracking': { country: 'US', place: (location, _journey, status) => gofoScanZone(location, status), own: 'America/Los_Angeles' },
109
115
  'postnl-tracking': {},
110
116
  'amazon-uk-tracking': { home: () => 'Europe/London' },
111
117
  'portugal-ctt-tracking': { place: (location, journey) => cttSiteZone(location, [journey.origin, journey.destination]), home: portugalZone },
@@ -124,6 +130,16 @@ function feedClock(timestamp, feed) {
124
130
  const shifted = DateTime.fromISO(timestamp, { setZone: true }).setZone(feed.shift);
125
131
  return shifted.isValid ? shifted.toISO({ includeOffset: false, suppressMilliseconds: true }) : timestamp;
126
132
  }
133
+ /**
134
+ * The zone of a scan Swiss Post's feed relays from the destination post: the
135
+ * feed keeps Swiss Post's offset, on digits that are that office's wall clock
136
+ * (carriers/swiss-post/clock.ts). Null for any other scan.
137
+ */
138
+ function swissPostRelayZone(scan, journey) {
139
+ if (!isRecord(scan.courier) || scan.courier.crawler_code !== 'swiss-post-tracking')
140
+ return null;
141
+ return swissPostDestinationZone(typeof scan.status === 'string' ? scan.status : '', typeof scan.location === 'string' ? scan.location : '', journey.destination);
142
+ }
127
143
  function journeyOf(data, events, number, couriers, fallback) {
128
144
  // An S10 item's suffix is its origin post's country.
129
145
  const suffix = /^[A-Z]{2}\d{9}[A-Z]{2}$/.test(number) ? replyCountry(number.slice(-2)) : null;
@@ -149,7 +165,7 @@ function upuZone(journey, scan, wall) {
149
165
  /**
150
166
  * The zone of a scan whose timestamp names no offset, or a relabelling
151
167
  * feed's: it is the scanning carrier's wall clock. Its location's clock comes
152
- * first (as the feed reads its own sites, then by the names it holds), then
168
+ * first (as the feed reads its own sites and stations, then by the names it holds), then
153
169
  * the country of a parcel that never leaves it, then the side of the border a
154
170
  * UPU event's words name. A courier name is the feed that relayed the scan,
155
171
  * not where it happened: a national post relays the destination's scans under
@@ -158,8 +174,8 @@ function upuZone(journey, scan, wall) {
158
174
  */
159
175
  function placedZone(journey, scan, wall, feed) {
160
176
  const location = typeof scan.location === 'string' ? scan.location.trim() : '';
161
- const located = feed?.place?.(location, journey)
162
- ?? locationZone(location, journey.northAmerican || northAmericanCourier(scan))
177
+ const located = feed?.place?.(location, journey, typeof scan.status === 'string' ? scan.status : '')
178
+ ?? locationZone(location, journey.northAmerican || northAmericanCourier(scan), feed?.country ?? null)
163
179
  ?? (journey.domestic ? countryTimeZone(journey.domestic) : null) ?? upuZone(journey, scan, wall);
164
180
  return located || journey.international ? located : feed?.home?.(location) ?? null;
165
181
  }
@@ -246,10 +262,12 @@ export function parseShip24Response(payload, trackingNumber, timezone = null) {
246
262
  continue;
247
263
  // datetime can end in Z while still containing the carrier's local time.
248
264
  // timestamp carries the real offset (verified against the public web app),
249
- // except for some carrier legs (Chronopost) that omit it entirely, and the
250
- // feeds that relabel the carrier's wall clock.
265
+ // except for some carrier legs (Chronopost) that omit it entirely, the
266
+ // feeds that relabel the carrier's wall clock, and Swiss Post's relays.
251
267
  const feed = feedOf(raw);
252
- const parsed = localEvent(feedClock(raw.timestamp, feed), raw.status, raw.dispatch_code_id === 7 ? 'Delivered' : undefined);
268
+ const relayed = swissPostRelayZone(raw, journey);
269
+ const clock = relayed && typeof raw.timestamp === 'string' ? raw.timestamp.replace(EXPLICIT_OFFSET_PATTERN, '') : feedClock(raw.timestamp, feed);
270
+ const parsed = localEvent(clock, raw.status, raw.dispatch_code_id === 7 ? 'Delivered' : undefined);
253
271
  if (!parsed)
254
272
  continue;
255
273
  const location = place(raw.location);
@@ -258,8 +276,8 @@ export function parseShip24Response(payload, trackingNumber, timezone = null) {
258
276
  const wall = typeof parsed.local_time === 'string' ? parsed.local_time : null;
259
277
  // Another feed's offset-less scan takes the parcel's named clock at once;
260
278
  // a relabelling feed's first borrows from its neighbours (below).
261
- readings.push({ event: parsed, wall, feed, country: locationCountry(raw.location),
262
- zone: wall ? placedZone(journey, raw, wall, feed) ?? (feed ? null : namedZone(journey, wall)) : null });
279
+ readings.push({ event: parsed, wall, feed, country: feed?.country ?? locationCountry(raw.location),
280
+ zone: wall ? relayed ?? placedZone(journey, raw, wall, feed) ?? (feed ? null : namedZone(journey, wall)) : null });
263
281
  }
264
282
  const events = readings.map((reading) => {
265
283
  const { event, wall, feed } = reading;
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.69.0"
5
+ "version": "0.69.2"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.69.0",
3
+ "version": "0.69.2-main.888",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",