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 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,724 such statuses, each filed under one stage.
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. It
15
- * carries no delivery estimate, and its relay, barcode and contact fields are
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 app's parcel detail: a headline, milestones and their dated events. It
81
- * carries no delivery estimate, and its relay, barcode and contact fields are
82
- * never read.
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: '', description, stage: classified.stage }, classified, timestamp: time.timestamp });
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
- "eta"
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
- "eta"
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,2 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -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
- let location = clean($('#stApp_deliveredToAddress').last().text());
180
- if (!location) {
181
- location = clean(`${$('#stApp_txtAddress').last().text()} ${$('#stApp_txtCountry').last().text()}`);
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' ? accessPointName(detail) : '';
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
- // address and coordinates, and `senderShipperNumber`, an account number.
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's business name; only read while the parcel waits there.
352
- * The attention name is never a fallback: it can name a person.
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 accessPointName(detail) {
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
- return location ? text(location.companyName, 200) : '';
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.526",
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",