universal-parcel-scraper 0.49.0-main.526 → 0.49.0-main.532
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 +1 -1
- package/dist/carriers/correos-spain/status.js +2 -0
- package/dist/carriers/correos-spain/statuses.json +1 -0
- package/dist/carriers/gls-ch/adapter.js +17 -1
- package/dist/carriers/mondial-relay/adapter.js +28 -2
- package/dist/carriers/mondial-relay/app.d.ts +3 -3
- package/dist/carriers/mondial-relay/app.js +26 -5
- package/dist/carriers/mondial-relay/carrier.json +7 -3
- package/dist/carriers/mondial-relay/identity.d.ts +2 -0
- package/dist/carriers/mondial-relay/identity.js +8 -0
- package/dist/carriers/mondial-relay/status.d.ts +8 -0
- package/dist/carriers/mondial-relay/status.js +12 -0
- package/dist/carriers/ups/adapter.js +24 -11
- package/dist/core/catalog/eventIdentity.js +2 -1
- package/dist/providers/seventeentrack/events.js +4 -1
- package/dist/providers/shared/result.d.ts +2 -0
- package/dist/providers/shared/result.js +5 -0
- package/dist/providers/ship24/adapter.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,7 +149,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
|
|
|
149
149
|
<!-- GENERATED:stages -->
|
|
150
150
|
<img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
|
|
151
151
|
|
|
152
|
-
The carrier folders record 2,
|
|
152
|
+
The carrier folders record 2,725 such statuses, each filed under one stage.
|
|
153
153
|
<!-- /GENERATED:stages -->
|
|
154
154
|
|
|
155
155
|
Wording nobody recorded yet goes through a classifier that reads seven European languages.
|
|
@@ -43,6 +43,8 @@ const EVENT_STATUS = {
|
|
|
43
43
|
'ADV0000V': { status: 'in_transit', stage: 'accepted' },
|
|
44
44
|
'H01R390V': { status: 'exception', stage: 'failed_attempt' },
|
|
45
45
|
'H01R420V': { status: 'exception', stage: 'failed_attempt' },
|
|
46
|
+
// Held at the office for the recipient to collect: observed live.
|
|
47
|
+
'G01L020V': { status: 'out_for_delivery', stage: 'ready_for_pickup' },
|
|
46
48
|
// The pickup window closed: the parcel goes back to the sender.
|
|
47
49
|
'L03D320R': { status: 'exception', stage: 'returned' },
|
|
48
50
|
// The sender left the parcel in a Citypaq locker; Correos admits it later.
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
{ "code": "ADV0000V", "wording": "Devolución admitida", "language": "es", "stage": "accepted", "confirmedBy": "live", "firstSeen": "2026-09-26", "note": "First movement of a return shipment's own code, after the pre-registration." },
|
|
42
42
|
{ "code": "H01R390V", "wording": "Realizado intento de entrega", "language": "es", "stage": "failed_attempt", "confirmedBy": "live", "firstSeen": "2026-09-26", "note": "" },
|
|
43
43
|
{ "code": "H01R420V", "wording": "Intento de entrega. Ausente", "language": "es", "stage": "failed_attempt", "confirmedBy": "live", "firstSeen": "2026-09-26", "note": "Recipient absent." },
|
|
44
|
+
{ "code": "G01L020V", "wording": "A disposición del destinatario", "language": "es", "stage": "ready_for_pickup", "confirmedBy": "live", "firstSeen": "2026-10-06", "note": "Held at the office for the recipient to collect, between delivery-unit arrival and collection." },
|
|
44
45
|
{ "code": "L03D320R", "wording": "Finalizado plazo retirada", "language": "es", "stage": "returned", "confirmedBy": "live", "firstSeen": "2026-09-26", "note": "Pickup window closed; the parcel goes back to the sender. Reported as status 'exception', like other carriers' uncollected codes." },
|
|
45
46
|
{ "code": "A170000V", "wording": "Depositado para admisión", "language": "es", "stage": "registered", "confirmedBy": "live", "firstSeen": "2026-10-05", "note": "The sender left the parcel in a Citypaq locker and Correos has not admitted it yet. The public tracker files it under pre-admission, with the pre-registration." },
|
|
46
47
|
{ "code": "E000001V", "wording": "Inicio de tramitación aduanera", "language": "es", "stage": "customs", "confirmedBy": "live", "firstSeen": "2026-10-05", "note": "Arrived at Correos' facilities; customs processing starts." },
|
|
@@ -191,6 +191,22 @@ function eventLocation(raw) {
|
|
|
191
191
|
.join(' ')
|
|
192
192
|
.slice(0, 200);
|
|
193
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* The ParcelShop the tracking page prints for the parcel: its name, then its street and town on
|
|
196
|
+
* their own lines when both are given. Its id and opening hours are not read.
|
|
197
|
+
*/
|
|
198
|
+
function parcelShop(parcel) {
|
|
199
|
+
const address = isRecord(parcel.parcelShop) && isRecord(parcel.parcelShop.address) ? parcel.parcelShop.address : null;
|
|
200
|
+
const name = plainText(address?.name1, 120);
|
|
201
|
+
if (!address || !name)
|
|
202
|
+
return null;
|
|
203
|
+
const area = isRecord(address.postalArea) ? address.postalArea : {};
|
|
204
|
+
const street = plainText(address.street1, 120);
|
|
205
|
+
const city = plainText(area.city, 80);
|
|
206
|
+
if (!street || !city)
|
|
207
|
+
return name;
|
|
208
|
+
return `${name}\n${[street, plainText(address.blockNo1, 20)].filter(Boolean).join(' ')}\n${[plainText(area.postalCodeDisplay, 16), city].filter(Boolean).join(' ')}`;
|
|
209
|
+
}
|
|
194
210
|
function parcelShopName(parcel) {
|
|
195
211
|
for (const event of records(parcel.history)) {
|
|
196
212
|
const name = eventLocation(event);
|
|
@@ -286,7 +302,7 @@ export function parseGLSSwitzerlandTrackingResponse(payload, rawTrackingNumber)
|
|
|
286
302
|
const stage = isReturnParcel(parcel)
|
|
287
303
|
? 'returned'
|
|
288
304
|
: currentMetadata?.stage ?? latestKnownEvent?.event.stage ?? 'in_transit';
|
|
289
|
-
const shop = parcelShopName(parcel);
|
|
305
|
+
const shop = parcelShop(parcel) ?? parcelShopName(parcel);
|
|
290
306
|
const weight = parcelWeight(parcel);
|
|
291
307
|
const pickup = stage === 'ready_for_pickup' && shop ? { pickup_point: shop } : {};
|
|
292
308
|
return {
|
|
@@ -9,7 +9,7 @@ import { isoTime, zonedTime } from '../../core/time/index.js';
|
|
|
9
9
|
import { cleanScalar, TRAWL_TRANSPORT_ALLOWANCE_MS, trawlBody, TrawlClient } from '../../core/transport/index.js';
|
|
10
10
|
import { isRecord } from '../../core/types.js';
|
|
11
11
|
import { MondialRelayAppClient } from './app.js';
|
|
12
|
-
import { classifyStatus, milestoneNumberStatus } from './status.js';
|
|
12
|
+
import { classifyStatus, milestoneNumberStatus, scanSite } from './status.js';
|
|
13
13
|
// Protocol provenance (inspected 2026-08-30):
|
|
14
14
|
// https://www.mondialrelay.fr/versioned-assets/2nMAiuVI9Rv9J3kZacblPYCfCABwzS-qZ3m7eFBQn4A/Scripts/vue/tracking/js/app.js
|
|
15
15
|
// SHA-256: da73008ae548f51bfd27791969c6e53d809f080070cd2faa6779bb7850509f80
|
|
@@ -127,6 +127,24 @@ function expectedDelivery(value) {
|
|
|
127
127
|
const date = DateTime.fromISO(raw, { zone: ZONE });
|
|
128
128
|
return date.isValid ? date.toISODate() : null;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* A relay or locker record, `DetailPointRelais`: its name, then its street and
|
|
132
|
+
* town on their own lines when it gives them. Its contacts, opening hours,
|
|
133
|
+
* services and coordinates are not read.
|
|
134
|
+
*/
|
|
135
|
+
function relayPoint(detail) {
|
|
136
|
+
if (!isRecord(detail) || !isRecord(detail.Adresse))
|
|
137
|
+
return '';
|
|
138
|
+
const address = detail.Adresse;
|
|
139
|
+
const name = plainText(address.Libelle, 120);
|
|
140
|
+
if (!/\p{L}/u.test(name))
|
|
141
|
+
return '';
|
|
142
|
+
const streets = [address.AdresseLigne1, address.AdresseLigne2].map((line) => plainText(line, 120)).filter(Boolean);
|
|
143
|
+
const city = plainText(address.Ville, 80);
|
|
144
|
+
return streets.length && city
|
|
145
|
+
? [name, ...streets, [plainText(address.CodePostal, 16), city].filter(Boolean).join(' ')].join('\n')
|
|
146
|
+
: name;
|
|
147
|
+
}
|
|
130
148
|
function parseEvents(expedition) {
|
|
131
149
|
if (!Array.isArray(expedition.Evenements))
|
|
132
150
|
return [];
|
|
@@ -147,13 +165,14 @@ function parseEvents(expedition) {
|
|
|
147
165
|
parsed.push({
|
|
148
166
|
event: {
|
|
149
167
|
time: time.iso,
|
|
150
|
-
location:
|
|
168
|
+
location: scanSite(description),
|
|
151
169
|
description,
|
|
152
170
|
stage: classified.stage,
|
|
153
171
|
},
|
|
154
172
|
classified,
|
|
155
173
|
timestamp: time.timestamp,
|
|
156
174
|
index,
|
|
175
|
+
relay: relayPoint(rawEvent.DetailPointRelais),
|
|
157
176
|
});
|
|
158
177
|
});
|
|
159
178
|
parsed.sort((left, right) => right.timestamp - left.timestamp || left.index - right.index);
|
|
@@ -211,6 +230,12 @@ function parseTrackingResponse(payload, credential) {
|
|
|
211
230
|
: parsedEvents.find((event) => event.classified.status !== 'unknown')?.classified
|
|
212
231
|
?? milestoneStatus(expedition);
|
|
213
232
|
const status = current?.status ?? 'unknown';
|
|
233
|
+
// The scans since the parcel reached its pickup point, newest first: its
|
|
234
|
+
// arrival there and the locker countdown, which names no relay. Drop-off and
|
|
235
|
+
// collection scans name a relay too, so only one named among these counts.
|
|
236
|
+
const stay = current?.stage === 'ready_for_pickup' ? parsedEvents : [];
|
|
237
|
+
const arrived = stay.findIndex((event) => event.event.stage !== 'ready_for_pickup');
|
|
238
|
+
const pickup = stay.slice(0, arrived < 0 ? undefined : arrived).find((event) => event.relay)?.relay;
|
|
214
239
|
return {
|
|
215
240
|
status,
|
|
216
241
|
// The status vocabulary has no pickup value; without the stage the sync
|
|
@@ -221,6 +246,7 @@ function parseTrackingResponse(payload, credential) {
|
|
|
221
246
|
expected_delivery: ['delivered', 'exception'].includes(status)
|
|
222
247
|
? null
|
|
223
248
|
: expectedDelivery(expedition.EstimatedDeliveryDate),
|
|
249
|
+
...(pickup ? { pickup_point: pickup } : {}),
|
|
224
250
|
timezone: ZONE,
|
|
225
251
|
events,
|
|
226
252
|
source: 'mondial_relay_public_web',
|
|
@@ -11,9 +11,9 @@ export interface MondialRelayAppQuery {
|
|
|
11
11
|
postcode: string;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* The app's parcel detail: a headline, milestones and their dated events
|
|
15
|
-
* carries no delivery estimate, and its
|
|
16
|
-
* never read.
|
|
14
|
+
* The app's parcel detail: a headline, milestones and their dated events, and
|
|
15
|
+
* the delivery point. It carries no delivery estimate, and its pickup code,
|
|
16
|
+
* barcode and contact fields are never read.
|
|
17
17
|
*/
|
|
18
18
|
export declare function parseMondialRelayApp(payload: unknown, uid: string): CarrierResult;
|
|
19
19
|
/**
|
|
@@ -6,7 +6,7 @@ import { CarrierError, ChallengeError, IndeterminateError, SchemaError, Transpor
|
|
|
6
6
|
import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
|
|
7
7
|
import { cleanScalar, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
8
8
|
import { isRecord } from '../../core/types.js';
|
|
9
|
-
import { classifyStatus, milestoneNumberStatus } from './status.js';
|
|
9
|
+
import { classifyStatus, milestoneNumberStatus, scanSite } from './status.js';
|
|
10
10
|
export const MONDIAL_RELAY_APP_API = 'https://mobile-app-bff.mondialrelay.app/api/';
|
|
11
11
|
const TOKEN_URL = 'https://account.inpost-group.com/oauth2/token';
|
|
12
12
|
const CLIENT_ID = 'mondialrelay-mobile';
|
|
@@ -77,9 +77,27 @@ function milestoneStatus(steps) {
|
|
|
77
77
|
return worded.status !== 'unknown' ? worded : milestoneNumberStatus(Number(reached.number));
|
|
78
78
|
}
|
|
79
79
|
/**
|
|
80
|
-
* The
|
|
81
|
-
*
|
|
82
|
-
*
|
|
80
|
+
* The relay or locker the detail names as the parcel's delivery point, as the
|
|
81
|
+
* app shows it: `line1` is its name, the other lines and the town its address.
|
|
82
|
+
* Its opening hours, coordinates and photo are not read.
|
|
83
|
+
*/
|
|
84
|
+
function deliveryPoint(detail) {
|
|
85
|
+
const address = isRecord(detail.deliveryPointModel) ? detail.deliveryPointModel.address : undefined;
|
|
86
|
+
if (!isRecord(address))
|
|
87
|
+
return '';
|
|
88
|
+
const name = cleanScalar(address.line1, 120);
|
|
89
|
+
if (!/\p{L}/u.test(name))
|
|
90
|
+
return '';
|
|
91
|
+
const streets = [address.line2, address.line3, address.line4].map((line) => cleanScalar(line, 120)).filter(Boolean);
|
|
92
|
+
const city = cleanScalar(address.city, 80);
|
|
93
|
+
return streets.length && city
|
|
94
|
+
? [name, ...streets, [cleanScalar(address.postcode, 16), city].filter(Boolean).join(' ')].join('\n')
|
|
95
|
+
: name;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The app's parcel detail: a headline, milestones and their dated events, and
|
|
99
|
+
* the delivery point. It carries no delivery estimate, and its pickup code,
|
|
100
|
+
* barcode and contact fields are never read.
|
|
83
101
|
*/
|
|
84
102
|
export function parseMondialRelayApp(payload, uid) {
|
|
85
103
|
if (!Array.isArray(payload))
|
|
@@ -107,7 +125,7 @@ export function parseMondialRelayApp(payload, uid) {
|
|
|
107
125
|
continue;
|
|
108
126
|
seen.add(identity);
|
|
109
127
|
const classified = classifyStatus(description);
|
|
110
|
-
parsed.push({ event: { time: time.iso, location:
|
|
128
|
+
parsed.push({ event: { time: time.iso, location: scanSite(description), description, stage: classified.stage }, classified, timestamp: time.timestamp });
|
|
111
129
|
}
|
|
112
130
|
}
|
|
113
131
|
parsed.sort((left, right) => right.timestamp - left.timestamp);
|
|
@@ -119,12 +137,15 @@ export function parseMondialRelayApp(payload, uid) {
|
|
|
119
137
|
: parsed.find(({ classified }) => classified.status !== 'unknown')?.classified ?? milestoneStatus(steps);
|
|
120
138
|
if (!events.length && !current)
|
|
121
139
|
throw new IndeterminateError('Mondial Relay', 'Mondial Relay app returned no shipment activity');
|
|
140
|
+
// The delivery point, while the parcel waits there.
|
|
141
|
+
const pickup = current?.stage === 'ready_for_pickup' ? deliveryPoint(parcel.detail) : '';
|
|
122
142
|
return {
|
|
123
143
|
status: current?.status ?? 'unknown',
|
|
124
144
|
...(current ? { current_stage: current.stage } : {}),
|
|
125
145
|
last_status_text: hint || events[0]?.description || 'Tracking information received',
|
|
126
146
|
last_update: events[0]?.time ?? null,
|
|
127
147
|
expected_delivery: null,
|
|
148
|
+
...(pickup ? { pickup_point: pickup } : {}),
|
|
128
149
|
timezone: ZONE,
|
|
129
150
|
events,
|
|
130
151
|
source: 'mondial_relay_app',
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
},
|
|
41
41
|
"capabilities": [
|
|
42
42
|
"history",
|
|
43
|
-
"
|
|
43
|
+
"location",
|
|
44
|
+
"eta",
|
|
45
|
+
"pickup_point"
|
|
44
46
|
],
|
|
45
47
|
"portal": {
|
|
46
48
|
"url": "https://www.mondialrelay.fr/suivi-de-colis/?numeroExpedition={trackingNumber}",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"shows": [
|
|
49
51
|
"status",
|
|
50
52
|
"history",
|
|
53
|
+
"location",
|
|
51
54
|
"eta",
|
|
52
55
|
"pickup_point",
|
|
53
56
|
"recipient_name",
|
|
@@ -56,10 +59,11 @@
|
|
|
56
59
|
"scraped": [
|
|
57
60
|
"status",
|
|
58
61
|
"history",
|
|
59
|
-
"
|
|
62
|
+
"location",
|
|
63
|
+
"eta",
|
|
64
|
+
"pickup_point"
|
|
60
65
|
],
|
|
61
66
|
"discarded": [
|
|
62
|
-
"pickup_point",
|
|
63
67
|
"recipient_name",
|
|
64
68
|
"recipient_address"
|
|
65
69
|
],
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
// Scans stored before their logistics site became their location keep their
|
|
3
|
+
// instant, wording and stage, and gain the site in place.
|
|
4
|
+
export const sameInstantIdentityPolicy = {
|
|
5
|
+
sourceCarrierId: 'mondial-relay', storedSources: ['mondial-relay'], requireProviderCode: false,
|
|
6
|
+
matchEachScan: true,
|
|
7
|
+
matches: matchesLocationEnrichment,
|
|
8
|
+
};
|
|
@@ -23,6 +23,14 @@ export declare const MONDIAL_RELAY_WORDING: readonly {
|
|
|
23
23
|
}[];
|
|
24
24
|
/** The status and stage a Mondial Relay label denotes. */
|
|
25
25
|
export declare function classifyStatus(description: string): ClassifiedStatus;
|
|
26
|
+
/**
|
|
27
|
+
* The logistics site a scan names, the only place a scan states: "Prise en
|
|
28
|
+
* charge de votre colis sur notre site logistique de METZ.", "Colis en cours
|
|
29
|
+
* de traitement sur le site METZ", "Colis expédié depuis le site METZ". A site
|
|
30
|
+
* the parcel is sent towards is not where the scan happened, and "le site
|
|
31
|
+
* logistique" without "de" names none.
|
|
32
|
+
*/
|
|
33
|
+
export declare function scanSite(description: string): string;
|
|
26
34
|
/**
|
|
27
35
|
* `SuiviParEtapes` milestone numbers, used only when no wording on the
|
|
28
36
|
* shipment classified. The numbers are positions on the page's own progress
|
|
@@ -88,6 +88,18 @@ export function classifyStatus(description) {
|
|
|
88
88
|
}
|
|
89
89
|
return { status: 'unknown', stage: 'in_transit' };
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* The logistics site a scan names, the only place a scan states: "Prise en
|
|
93
|
+
* charge de votre colis sur notre site logistique de METZ.", "Colis en cours
|
|
94
|
+
* de traitement sur le site METZ", "Colis expédié depuis le site METZ". A site
|
|
95
|
+
* the parcel is sent towards is not where the scan happened, and "le site
|
|
96
|
+
* logistique" without "de" names none.
|
|
97
|
+
*/
|
|
98
|
+
export function scanSite(description) {
|
|
99
|
+
const site = /\b(?:sur notre|sur le|depuis le) site (?:logistique de )?(\p{L}[\p{L}\p{M} '’-]{1,58}?)\s*\.?$/iu
|
|
100
|
+
.exec(description)?.[1]?.trim() ?? '';
|
|
101
|
+
return /^logistique\b/i.test(site) ? '' : site;
|
|
102
|
+
}
|
|
91
103
|
/**
|
|
92
104
|
* `SuiviParEtapes` milestone numbers, used only when no wording on the
|
|
93
105
|
* shipment classified. The numbers are positions on the page's own progress
|
|
@@ -176,14 +176,13 @@ export function parseUPSTrackingHtml(page, trackingNumber) {
|
|
|
176
176
|
const currentStatus = upsStatus(`${statusText} ${progress}`);
|
|
177
177
|
if (!statusText)
|
|
178
178
|
statusText = progress || 'Tracking information received';
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
179
|
+
// Where a delivered parcel was left. The ship-to block names the recipient's
|
|
180
|
+
// town, not where the parcel is, so it never becomes the banner's place.
|
|
181
|
+
const location = clean($('#stApp_deliveredToAddress').last().text());
|
|
183
182
|
// The banner has no clock, so its event carries none.
|
|
184
183
|
const events = currentStatus === 'unknown'
|
|
185
184
|
? []
|
|
186
|
-
: [{ location, description: statusText }];
|
|
185
|
+
: [{ ...(location ? { location } : {}), description: statusText }];
|
|
187
186
|
return {
|
|
188
187
|
status: currentStatus,
|
|
189
188
|
last_status_text: statusText,
|
|
@@ -331,10 +330,11 @@ export function parseUPSTrackingResponse(payload, trackingNumber, today = new Da
|
|
|
331
330
|
const country = [detail.shipToAddress, detail.deliveryAddress]
|
|
332
331
|
.map((address) => (isRecord(address) ? cleanScalar(address.country, 8).toUpperCase() : ''))
|
|
333
332
|
.find((code) => /^[A-Z]{2}$/.test(code));
|
|
334
|
-
const pickupPoint = stage === 'ready_for_pickup' ?
|
|
333
|
+
const pickupPoint = stage === 'ready_for_pickup' ? accessPoint(detail) : '';
|
|
335
334
|
// Never projected: the ship-to and delivery addresses beyond their country,
|
|
336
335
|
// `receivedBy`, `leftAt`, the proof-of-delivery link, the access point's
|
|
337
|
-
//
|
|
336
|
+
// attention name, hours and coordinates, and `senderShipperNumber`, an
|
|
337
|
+
// account number.
|
|
338
338
|
return {
|
|
339
339
|
status,
|
|
340
340
|
...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
|
|
@@ -348,13 +348,26 @@ export function parseUPSTrackingResponse(payload, trackingNumber, today = new Da
|
|
|
348
348
|
};
|
|
349
349
|
}
|
|
350
350
|
/**
|
|
351
|
-
* The access point
|
|
352
|
-
*
|
|
351
|
+
* The access point, read only while the parcel waits there: its business name,
|
|
352
|
+
* then its street and its town on their own lines when UPS gives both. The
|
|
353
|
+
* attention name is never a fallback: it can name a person.
|
|
353
354
|
*/
|
|
354
|
-
function
|
|
355
|
+
function accessPoint(detail) {
|
|
355
356
|
const point = isRecord(detail.upsAccessPoint) ? detail.upsAccessPoint : null;
|
|
356
357
|
const location = point && isRecord(point.location) ? point.location : null;
|
|
357
|
-
|
|
358
|
+
const name = location ? text(location.companyName, 200) : '';
|
|
359
|
+
if (!location || !name)
|
|
360
|
+
return name;
|
|
361
|
+
const street = text(location.streetAddress1, 120);
|
|
362
|
+
const more = [location.streetAddress2, location.streetAddress3].map((line) => text(line, 120)).filter(Boolean).join(', ');
|
|
363
|
+
const city = text(location.city, 80);
|
|
364
|
+
if (!street || !city)
|
|
365
|
+
return name;
|
|
366
|
+
const postcode = text(location.zipCode, 16);
|
|
367
|
+
// A US state or Canadian province comes before the postcode, after the town.
|
|
368
|
+
const region = text(location.state, 40) || text(location.province, 40);
|
|
369
|
+
const town = region ? `${city}, ${[region, postcode].filter(Boolean).join(' ')}` : [postcode, city].filter(Boolean).join(' ');
|
|
370
|
+
return [name, street, more, town].filter(Boolean).join('\n');
|
|
358
371
|
}
|
|
359
372
|
export class UPSTracker {
|
|
360
373
|
timeoutMs;
|
|
@@ -2,10 +2,11 @@ import { sameInstantIdentityPolicy as aliexpress } from '../../carriers/aliexpre
|
|
|
2
2
|
import { sameInstantIdentityPolicy as australiaPost } from '../../carriers/australia-post/app.js';
|
|
3
3
|
import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
|
|
4
4
|
import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
|
|
5
|
+
import { sameInstantIdentityPolicy as mondialRelay } from '../../carriers/mondial-relay/identity.js';
|
|
5
6
|
import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
|
|
6
7
|
import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
|
|
7
8
|
import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
|
|
8
|
-
const policies = new Map([aliexpress, australiaPost, dpd, indiaPost, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
9
|
+
const policies = new Map([aliexpress, australiaPost, dpd, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
9
10
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
10
11
|
export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
|
|
11
12
|
const policy = policies.get(sourceCarrierId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DateTime } from 'luxon';
|
|
2
2
|
import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
|
|
3
3
|
import { isRecord } from '../../core/types.js';
|
|
4
|
-
import { event, text } from '../shared/result.js';
|
|
4
|
+
import { event, place, text } from '../shared/result.js';
|
|
5
5
|
// Official v2 status vocabulary, plus TransportArrived/Departed observed in
|
|
6
6
|
// public China Post histories. Expired describes tracking age, not a scan.
|
|
7
7
|
const SUB_STAGES = {
|
|
@@ -63,8 +63,11 @@ export function seventeenTrackEvent(raw, operator, placed) {
|
|
|
63
63
|
if (parsed.stage === 'delivered')
|
|
64
64
|
parsed.description = 'Delivered';
|
|
65
65
|
const timeRaw = isRecord(raw.time_raw) ? raw.time_raw : null;
|
|
66
|
+
// `address` is never read: it can be the recipient's street.
|
|
67
|
+
const location = place(raw.location);
|
|
66
68
|
return {
|
|
67
69
|
...parsed,
|
|
70
|
+
...(location ? { location } : {}),
|
|
68
71
|
...(code && /^[A-Za-z_]{1,80}$/.test(code) ? { provider_code: code } : {}),
|
|
69
72
|
...operator,
|
|
70
73
|
...(typeof raw.time_iso === 'string' ? { provider_time_iso: raw.time_iso } : {}),
|
|
@@ -5,6 +5,8 @@ export declare function numberOf(raw: string): string;
|
|
|
5
5
|
export declare function text(value: unknown): string;
|
|
6
6
|
export declare function isNotice(description: string): boolean;
|
|
7
7
|
export declare function hasPrivateDeliveryDetails(description: string): boolean;
|
|
8
|
+
/** The place a provider reports for a scan, unless it holds private delivery details. */
|
|
9
|
+
export declare function place(value: unknown): string;
|
|
8
10
|
export declare function eventStage(description: string): Stage | undefined;
|
|
9
11
|
/** Retain the decision before delivery wording is redacted or a stage is projected. */
|
|
10
12
|
export declare function classifyEvent(description: string, declared?: Stage): {
|
|
@@ -35,6 +35,11 @@ export function isNotice(description) {
|
|
|
35
35
|
export function hasPrivateDeliveryDetails(description) {
|
|
36
36
|
return /\bpin\s*:|(?:access|security|pickup|collection) code|(?:door|house) (?:no\b|number)|signed (?:for )?by|signature|numero civico|firmato da|signe par|signé par|code (?:de retrait|d['’]acc[eè]s)|num[eé]ro de (?:rue|maison)|abholcode|zugangscode|hausnummer|unterschrieben von|codice (?:di ritiro|di accesso)|c[oó]digo (?:pin|secreto|de (?:recogida|retirada|retiro|acceso|acesso|entrega|seguridad|seguran[cç]a|levantamento|recolha|desbloqueo|desbloqueio|apertura|abertura))|firmad[oa] por|firma del destinatari|assinad[oa] por|assinatura|n[uú]mero (?:de (?:portal|puerta|casa|piso|vivienda|pol[ií]cia)|da (?:porta|casa))|(?:porta|puerta) n\.?\s?[º°]|n\.?\s?[º°] (?:de |da )?porta/i.test(description);
|
|
37
37
|
}
|
|
38
|
+
/** The place a provider reports for a scan, unless it holds private delivery details. */
|
|
39
|
+
export function place(value) {
|
|
40
|
+
const location = text(value).slice(0, 200);
|
|
41
|
+
return location && !hasPrivateDeliveryDetails(location) ? location : '';
|
|
42
|
+
}
|
|
38
43
|
function sourceEventStage(description, includeBroadMovement = true) {
|
|
39
44
|
// Public aggregators retain the carrier's French wording even in English.
|
|
40
45
|
const french = description.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
|
|
@@ -20,7 +20,7 @@ import { scrapeUniversalPage } from '../../core/transport/browser.js';
|
|
|
20
20
|
import { countryTimeZone, sharedClockZone } from '../../core/time/index.js';
|
|
21
21
|
import { isRecord } from '../../core/types.js';
|
|
22
22
|
import { brandCarrierForNumber, universalCarrierHints } from '../shared/hints.js';
|
|
23
|
-
import { localEvent, numberOf, result } from '../shared/result.js';
|
|
23
|
+
import { localEvent, numberOf, place, result } from '../shared/result.js';
|
|
24
24
|
import { Ship24HttpClient } from './http.js';
|
|
25
25
|
const SOURCE = 'Ship24';
|
|
26
26
|
const MAX_EVENTS = 1000;
|
|
@@ -70,6 +70,9 @@ export function parseShip24Response(payload, trackingNumber, timezone = null) {
|
|
|
70
70
|
const parsed = localEvent(raw.timestamp, raw.status, raw.dispatch_code_id === 7 ? 'Delivered' : undefined);
|
|
71
71
|
if (!parsed)
|
|
72
72
|
continue;
|
|
73
|
+
const location = place(raw.location);
|
|
74
|
+
if (location)
|
|
75
|
+
parsed.location = location;
|
|
73
76
|
const wall = typeof parsed.local_time === 'string' ? parsed.local_time : null;
|
|
74
77
|
const zone = wall ? wallZone(names.reported_carriers, raw.location, number, wall, timezone) : null;
|
|
75
78
|
const instant = wall && zone ? DateTime.fromISO(wall, { zone }) : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.49.0-main.
|
|
3
|
+
"version": "0.49.0-main.532",
|
|
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",
|