universal-parcel-scraper 0.69.1-main.880 → 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 +2 -2
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +30 -17
- package/dist/carriers/gofo/clock.d.ts +9 -0
- package/dist/carriers/gofo/clock.js +31 -0
- package/dist/carriers/swiss-post/adapter.js +9 -23
- package/dist/carriers/swiss-post/clock.d.ts +19 -0
- package/dist/carriers/swiss-post/clock.js +29 -0
- package/dist/providers/coverage.json +11 -1
- package/dist/providers/parcelsapp/adapter.js +146 -53
- package/dist/providers/postal-ninja/adapter.js +8 -6
- package/dist/providers/postal-ninja/clocks.d.ts +7 -2
- package/dist/providers/postal-ninja/clocks.js +32 -4
- package/dist/providers/seventeentrack/adapter.js +44 -10
- package/dist/providers/ship24/adapter.js +32 -14
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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/dist/browser/scraper.js
CHANGED
|
@@ -12394,25 +12394,38 @@ var $c = { carriers: [
|
|
|
12394
12394
|
carrier: "dhl-ecommerce",
|
|
12395
12395
|
direct: "Yes",
|
|
12396
12396
|
sample: "✓ 16",
|
|
12397
|
-
references: [
|
|
12398
|
-
|
|
12399
|
-
|
|
12400
|
-
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
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":
|
|
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:
|
|
13395
|
+
ParcelsApp: 30,
|
|
13383
13396
|
"17TRACK": 44,
|
|
13384
13397
|
"Postal Ninja": "no_history",
|
|
13385
13398
|
UPU: "n/a"
|
|
@@ -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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
201
|
+
return time;
|
|
207
202
|
const zone = countryTimeZone(destination);
|
|
208
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|
|
@@ -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":
|
|
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).
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
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',
|
|
89
|
-
*
|
|
90
|
-
* parcel is injected (a Florida parcel's at -07:00).
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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',
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
149
|
-
* ON, CA"). It is `certain` when the location
|
|
150
|
-
* no country's, or the town lies in that state
|
|
151
|
-
* not in Israel); "Atlanta, GA" could still be
|
|
152
|
-
* counts only after the country ("Example City
|
|
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,
|
|
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
|
-
|
|
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
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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:
|
|
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
|
|
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.
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* its
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
*
|
|
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:
|
|
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(
|
|
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
|
-
/**
|
|
92
|
-
function
|
|
93
|
-
const clock = isRecord(raw.time_raw)
|
|
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 =
|
|
165
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
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
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
|
74
|
-
return (
|
|
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,
|
|
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
|
|
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;
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.69.
|
|
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",
|