universal-parcel-scraper 0.49.2-main.551 → 0.49.2-main.556

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,758 such statuses, each filed under one stage.
152
+ The carrier folders record 2,771 such statuses, each filed under one stage.
153
153
  <!-- /GENERATED:stages -->
154
154
 
155
155
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -2584,6 +2584,11 @@
2584
2584
  }
2585
2585
  ],
2586
2586
  "detectionRules": [
2587
+ {
2588
+ "pattern": "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$",
2589
+ "confidence": "high",
2590
+ "checksum": "usps"
2591
+ },
2587
2592
  {
2588
2593
  "pattern": "^9[1-5]\\d{20}(?:\\d{4})?$",
2589
2594
  "confidence": "low",
@@ -11032,6 +11032,14 @@
11032
11032
  "austrian-post"
11033
11033
  ]
11034
11034
  },
11035
+ {
11036
+ "input": "9101900000000123456788",
11037
+ "carrier": "usps",
11038
+ "confidence": "high",
11039
+ "candidates": [
11040
+ "usps"
11041
+ ]
11042
+ },
11035
11043
  {
11036
11044
  "input": "91346097123456789012",
11037
11045
  "carrier": "planzer",
@@ -11051,6 +11059,43 @@
11051
11059
  "usps"
11052
11060
  ]
11053
11061
  },
11062
+ {
11063
+ "input": "9205510000000012345670",
11064
+ "carrier": "unknown",
11065
+ "confidence": "low",
11066
+ "candidates": [
11067
+ "usps",
11068
+ "austrian-post",
11069
+ "estafeta"
11070
+ ],
11071
+ "preferred": [
11072
+ "usps"
11073
+ ],
11074
+ "asked": [
11075
+ "austrian-post"
11076
+ ]
11077
+ },
11078
+ {
11079
+ "input": "9205590000000012345672",
11080
+ "carrier": "usps",
11081
+ "confidence": "high",
11082
+ "candidates": [
11083
+ "usps"
11084
+ ]
11085
+ },
11086
+ {
11087
+ "input": "9205590000000012345673",
11088
+ "carrier": "unknown",
11089
+ "confidence": "low",
11090
+ "candidates": [
11091
+ "austrian-post",
11092
+ "usps",
11093
+ "estafeta"
11094
+ ],
11095
+ "asked": [
11096
+ "austrian-post"
11097
+ ]
11098
+ },
11054
11099
  {
11055
11100
  "input": "9261269903500013618305",
11056
11101
  "carrier": "unknown",
@@ -11206,6 +11251,14 @@
11206
11251
  "heppner"
11207
11252
  ]
11208
11253
  },
11254
+ {
11255
+ "input": "9300100000012345678902",
11256
+ "carrier": "usps",
11257
+ "confidence": "high",
11258
+ "candidates": [
11259
+ "usps"
11260
+ ]
11261
+ },
11209
11262
  {
11210
11263
  "input": "93005001081690801339400",
11211
11264
  "carrier": "unknown",
@@ -11276,82 +11329,42 @@
11276
11329
  },
11277
11330
  {
11278
11331
  "input": "9400111201080805483016",
11279
- "carrier": "unknown",
11280
- "confidence": "low",
11332
+ "carrier": "usps",
11333
+ "confidence": "high",
11281
11334
  "candidates": [
11282
- "usps",
11283
- "austrian-post",
11284
- "estafeta"
11285
- ],
11286
- "preferred": [
11287
11335
  "usps"
11288
- ],
11289
- "asked": [
11290
- "austrian-post"
11291
11336
  ]
11292
11337
  },
11293
11338
  {
11294
11339
  "input": "9400111206206406260787",
11295
- "carrier": "unknown",
11296
- "confidence": "low",
11340
+ "carrier": "usps",
11341
+ "confidence": "high",
11297
11342
  "candidates": [
11298
- "usps",
11299
- "austrian-post",
11300
- "estafeta"
11301
- ],
11302
- "preferred": [
11303
11343
  "usps"
11304
- ],
11305
- "asked": [
11306
- "austrian-post"
11307
11344
  ]
11308
11345
  },
11309
11346
  {
11310
11347
  "input": "9405803699300124287899",
11311
- "carrier": "unknown",
11312
- "confidence": "low",
11348
+ "carrier": "usps",
11349
+ "confidence": "high",
11313
11350
  "candidates": [
11314
- "usps",
11315
- "austrian-post",
11316
- "estafeta"
11317
- ],
11318
- "preferred": [
11319
11351
  "usps"
11320
- ],
11321
- "asked": [
11322
- "austrian-post"
11323
11352
  ]
11324
11353
  },
11325
11354
  {
11326
11355
  "input": "9434611206206406227577",
11327
- "carrier": "unknown",
11328
- "confidence": "low",
11356
+ "carrier": "usps",
11357
+ "confidence": "high",
11329
11358
  "candidates": [
11330
- "usps",
11331
- "austrian-post",
11332
- "estafeta"
11333
- ],
11334
- "preferred": [
11335
11359
  "usps"
11336
- ],
11337
- "asked": [
11338
- "austrian-post"
11339
11360
  ]
11340
11361
  },
11341
11362
  {
11342
11363
  "input": "9434611206217011894085",
11343
- "carrier": "unknown",
11344
- "confidence": "low",
11364
+ "carrier": "usps",
11365
+ "confidence": "high",
11345
11366
  "candidates": [
11346
- "usps",
11347
- "austrian-post",
11348
- "estafeta"
11349
- ],
11350
- "preferred": [
11351
11367
  "usps"
11352
- ],
11353
- "asked": [
11354
- "austrian-post"
11355
11368
  ]
11356
11369
  },
11357
11370
  {
@@ -11394,18 +11407,10 @@
11394
11407
  },
11395
11408
  {
11396
11409
  "input": "9500113562366007585132",
11397
- "carrier": "unknown",
11398
- "confidence": "low",
11410
+ "carrier": "usps",
11411
+ "confidence": "high",
11399
11412
  "candidates": [
11400
- "usps",
11401
- "austrian-post",
11402
- "estafeta"
11403
- ],
11404
- "preferred": [
11405
11413
  "usps"
11406
- ],
11407
- "asked": [
11408
- "austrian-post"
11409
11414
  ]
11410
11415
  },
11411
11416
  {
@@ -11451,34 +11456,18 @@
11451
11456
  },
11452
11457
  {
11453
11458
  "input": "9589071052700675045600",
11454
- "carrier": "unknown",
11455
- "confidence": "low",
11459
+ "carrier": "usps",
11460
+ "confidence": "high",
11456
11461
  "candidates": [
11457
- "usps",
11458
- "austrian-post",
11459
- "estafeta"
11460
- ],
11461
- "preferred": [
11462
11462
  "usps"
11463
- ],
11464
- "asked": [
11465
- "austrian-post"
11466
11463
  ]
11467
11464
  },
11468
11465
  {
11469
11466
  "input": "9589071052702449080343",
11470
- "carrier": "unknown",
11471
- "confidence": "low",
11467
+ "carrier": "usps",
11468
+ "confidence": "high",
11472
11469
  "candidates": [
11473
- "usps",
11474
- "austrian-post",
11475
- "estafeta"
11476
- ],
11477
- "preferred": [
11478
11470
  "usps"
11479
- ],
11480
- "asked": [
11481
- "austrian-post"
11482
11471
  ]
11483
11472
  },
11484
11473
  {
@@ -2146,6 +2146,11 @@ var ve = {
2146
2146
  params: ["tLabels"]
2147
2147
  }],
2148
2148
  detectionRules: [
2149
+ {
2150
+ pattern: "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$",
2151
+ confidence: "high",
2152
+ checksum: "usps"
2153
+ },
2149
2154
  {
2150
2155
  pattern: "^9[1-5]\\d{20}(?:\\d{4})?$",
2151
2156
  confidence: "low",
@@ -4180,6 +4185,7 @@ var ve = {
4180
4185
  tipsa: ["tipsa-1", "tipsa-2"],
4181
4186
  ukrposhta: ["ukrposhta-1"],
4182
4187
  usps: [
4188
+ "usps-impb",
4183
4189
  "usps-3",
4184
4190
  "usps-1",
4185
4191
  "usps-2",
@@ -13,11 +13,18 @@ export declare class CorreosSpainTracker {
13
13
  readonly timeoutMs: number;
14
14
  readonly fetcher: typeof fetch | undefined;
15
15
  private readonly userAgent;
16
+ /** An office's address does not change. */
17
+ private readonly offices;
16
18
  constructor(options?: {
17
19
  timeoutMs?: number;
18
20
  fetcher?: typeof fetch;
19
21
  userAgent?: string;
20
22
  });
21
23
  fetch(rawTrackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
24
+ /**
25
+ * The office locator places its search text and lists the offices around it, so the
26
+ * office's own name finds it. The reply is read only for the office of that unit code.
27
+ */
28
+ private officeAddress;
22
29
  }
23
30
  export declare const adapter: AdapterFactory;
@@ -2,7 +2,7 @@ import { lookupBudget } from '../../core/adapter/index.js';
2
2
  import { isCorreosSpainExpeditionCode } from '../../core/detection/correosSpain.js';
3
3
  import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
4
  import { zonedTime } from '../../core/time/index.js';
5
- import { clean, fetchBounded, parseJsonBytes, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
5
+ import { clean, cleanScalar, fetchBounded, parseJsonBytes, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
6
6
  import { isRecord } from '../../core/types.js';
7
7
  import { classifyCorreosSpainStatus } from './status.js';
8
8
  // Protocol provenance:
@@ -20,6 +20,15 @@ const TRACKING_ENDPOINT = 'https://localizador.correos.es/canonico/eventos_envio
20
20
  // The localizador only knows parcel codes. The public tracker's own search
21
21
  // names the parcels of an expedition code, and answers 204 for an unknown one.
22
22
  const EXPEDITION_ENDPOINT = 'https://api1.correos.es/digital-services/searchengines/api/v1/envios';
23
+ // The office locator's search on correos.es, keyless like the tracker's. It
24
+ // places free text and lists the offices around that place.
25
+ const OFFICE_SEARCH_ENDPOINT = 'https://api1.correos.es/digital-services/searchloc/api/v1/offices';
26
+ /** The locator answers within a second. */
27
+ const OFFICE_TIMEOUT_MS = 3_000;
28
+ /** Offices whose address a tracker remembers: a waiting parcel is looked up again and again. */
29
+ const MAX_REMEMBERED_OFFICES = 500;
30
+ /** Metres around the place an office's name points to: ten kilometres reach a city's branches from its centre. */
31
+ const OFFICE_SEARCH_RADIUS_M = 10_000;
23
32
  const DEFAULT_TIMEOUT_MS = 15_000;
24
33
  /** `fetchBounded` repeats a request that failed in transit once, after this pause. */
25
34
  const TRANSIENT_RETRY_DELAY_MS = 1_000;
@@ -72,8 +81,31 @@ export function parseCorreosSpainExpeditionResponse(payload, expeditionCode) {
72
81
  }
73
82
  return parcel;
74
83
  }
84
+ /**
85
+ * The street and town of the office the locator's reply lists under `codired`, a line
86
+ * each. Its phone, email, opening hours and coordinates are not read.
87
+ */
88
+ function officeAddress(payload, codired) {
89
+ const others = isRecord(payload) ? payload.others : undefined;
90
+ const offices = isRecord(others) ? others.offices : undefined;
91
+ if (!Array.isArray(offices))
92
+ return '';
93
+ // The locator's officeId is the unit code: correos.es books an office's appointments with it as codired.
94
+ const office = offices.find((candidate) => isRecord(candidate) && cleanScalar(candidate.officeId, 16) === codired);
95
+ if (!isRecord(office))
96
+ return '';
97
+ const street = clean(office.address, 120);
98
+ const town = clean(office.cityName, 80);
99
+ if (!street || !town)
100
+ return '';
101
+ return `${street}\n${[clean(office.postalCode, 10), town].filter(Boolean).join(' ')}`;
102
+ }
75
103
  /** `expeditionCode` binds a parcel found through its expedition to that expedition. */
76
104
  export function parseCorreosSpainTrackingResponse(payload, trackingNumber, expeditionCode) {
105
+ return parseTracking(payload, trackingNumber, expeditionCode).result;
106
+ }
107
+ /** `office` is the unit code of the office the result names as its pickup point, else empty. */
108
+ function parseTracking(payload, trackingNumber, expeditionCode) {
77
109
  const requested = normalizeCorreosSpainTrackingNumber(trackingNumber);
78
110
  // The endpoint answers a single-element array; bare objects are accepted too
79
111
  // since some error bodies come back unwrapped.
@@ -139,6 +171,7 @@ export function parseCorreosSpainTrackingResponse(payload, trackingNumber, exped
139
171
  // the envelope. The office is only a pickup signal when the parcel is
140
172
  // actually awaiting collection; weight/dims are operational parcel data.
141
173
  const office = clean(envelope.nom_codired, 300) || null;
174
+ const officeCode = cleanScalar(envelope.codired, 16);
142
175
  const grams = Number(envelope.peso);
143
176
  const weightKg = Number.isFinite(grams) && grams > 0 ? Math.round((grams / 1000) * 1000) / 1000 : null;
144
177
  const dims = [envelope.largo, envelope.ancho, envelope.alto].map((value) => Number(value));
@@ -153,45 +186,59 @@ export function parseCorreosSpainTrackingResponse(payload, trackingNumber, exped
153
186
  ...(weightKg != null ? { weight_kg: weightKg } : {}),
154
187
  ...(dimensionsText ? { dimensions_text: dimensionsText } : {}),
155
188
  };
156
- const pickupExtras = (stage) => (stage === 'ready_for_pickup' && office ? { pickup_point: office } : {});
189
+ // codired is the unit holding the parcel, whose address the office locator gives.
190
+ const waiting = latest?.event.stage === 'ready_for_pickup' && office;
191
+ const pickupExtras = waiting ? { pickup_point: office } : {};
192
+ const pickupOffice = waiting && /^\d{1,16}$/.test(officeCode) ? officeCode : '';
157
193
  if (!latest) {
158
194
  return {
159
- status: 'unknown',
160
- last_status_text: clean(envelope.resumen_ultimo, 500) || 'Tracking information received',
161
- last_update: null,
162
- expected_delivery: null,
163
- ...extras,
164
- events,
195
+ result: {
196
+ status: 'unknown',
197
+ last_status_text: clean(envelope.resumen_ultimo, 500) || 'Tracking information received',
198
+ last_update: null,
199
+ expected_delivery: null,
200
+ ...extras,
201
+ events,
202
+ },
203
+ office: '',
165
204
  };
166
205
  }
167
206
  if (!latest.classified) {
168
207
  return {
169
- status: 'unknown',
208
+ result: {
209
+ status: 'unknown',
210
+ last_status_text: latest.event.description,
211
+ last_update: latest.event.time ?? null,
212
+ expected_delivery: null,
213
+ ...extras,
214
+ ...pickupExtras,
215
+ events,
216
+ },
217
+ office: pickupOffice,
218
+ };
219
+ }
220
+ const deliveredAt = latest.classified.status === 'delivered' ? latest.event.time ?? null : null;
221
+ return {
222
+ result: {
223
+ status: latest.classified.status,
224
+ current_stage: latest.classified.stage,
170
225
  last_status_text: latest.event.description,
171
226
  last_update: latest.event.time ?? null,
172
227
  expected_delivery: null,
173
228
  ...extras,
174
- ...pickupExtras(latest.event.stage),
229
+ ...pickupExtras,
230
+ ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
175
231
  events,
176
- };
177
- }
178
- const deliveredAt = latest.classified.status === 'delivered' ? latest.event.time ?? null : null;
179
- return {
180
- status: latest.classified.status,
181
- current_stage: latest.classified.stage,
182
- last_status_text: latest.event.description,
183
- last_update: latest.event.time ?? null,
184
- expected_delivery: null,
185
- ...extras,
186
- ...pickupExtras(latest.classified.stage),
187
- ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
188
- events,
232
+ },
233
+ office: pickupOffice,
189
234
  };
190
235
  }
191
236
  export class CorreosSpainTracker {
192
237
  timeoutMs;
193
238
  fetcher;
194
239
  userAgent;
240
+ /** An office's address does not change. */
241
+ offices = new Map();
195
242
  constructor(options = {}) {
196
243
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
197
244
  this.fetcher = options.fetcher;
@@ -203,8 +250,8 @@ export class CorreosSpainTracker {
203
250
  async fetch(rawTrackingNumber, context = {}) {
204
251
  const trackingNumber = normalizeCorreosSpainTrackingNumber(rawTrackingNumber);
205
252
  const expedition = isCorreosSpainExpeditionCode(trackingNumber);
206
- // The default budget covers each request, its pause and its one transient retry.
207
- const budget = lookupBudget(context, (expedition ? 2 : 1) * (2 * this.timeoutMs + TRANSIENT_RETRY_DELAY_MS));
253
+ // The default budget covers each request, its pause and its one transient retry, and the office's address.
254
+ const budget = lookupBudget(context, (expedition ? 2 : 1) * (2 * this.timeoutMs + TRANSIENT_RETRY_DELAY_MS) + OFFICE_TIMEOUT_MS);
208
255
  const request = async (url) => {
209
256
  const { response, bytes } = await fetchBounded(url, {
210
257
  signal: budget.signal,
@@ -235,7 +282,51 @@ export class CorreosSpainTracker {
235
282
  const { bytes } = await request(`${TRACKING_ENDPOINT}/${encodeURIComponent(parcel)}`
236
283
  + '?codAplicacion=60&codCanal=3&codIdioma=ES&indUltEvento=N');
237
284
  // A code the search returns as its own parcel is tracked as any parcel code.
238
- return parseCorreosSpainTrackingResponse(parseJsonBytes(bytes, 'Correos tracking'), parcel, parcel === trackingNumber ? undefined : trackingNumber);
285
+ const { result, office } = parseTracking(parseJsonBytes(bytes, 'Correos tracking'), parcel, parcel === trackingNumber ? undefined : trackingNumber);
286
+ // The localizador names the office holding the parcel without its address, which the office locator adds.
287
+ if (office && result.pickup_point) {
288
+ const address = await this.officeAddress(office, result.pickup_point, budget.signal, budget.remainingMs());
289
+ if (address)
290
+ result.pickup_point = `${result.pickup_point}\n${address}`;
291
+ }
292
+ return result;
293
+ }
294
+ /**
295
+ * The office locator places its search text and lists the offices around it, so the
296
+ * office's own name finds it. The reply is read only for the office of that unit code.
297
+ */
298
+ async officeAddress(codired, name, signal, remainingMs) {
299
+ const known = this.offices.get(codired);
300
+ if (known)
301
+ return known;
302
+ const timeoutMs = Math.floor(Math.min(OFFICE_TIMEOUT_MS, remainingMs / 2));
303
+ if (timeoutMs < 1)
304
+ return '';
305
+ try {
306
+ // The parameters correos.es's map sends to list offices only, over a wider radius.
307
+ const query = new URLSearchParams({ text: name, searchType: 'otros', specialSearch: 'true', sendDelivery: 'OFI',
308
+ distance: String(OFFICE_SEARCH_RADIUS_M) });
309
+ const { bytes } = await fetchBounded(`${OFFICE_SEARCH_ENDPOINT}?${query}`, {
310
+ signal,
311
+ headers: { Accept: 'application/json, text/plain, */*', 'Accept-Language': 'en-US,en;q=0.9', 'User-Agent': this.userAgent },
312
+ }, {
313
+ provider: 'Correos office locator',
314
+ timeoutMs,
315
+ maxBytes: MAX_RESPONSE_BYTES,
316
+ fetcher: this.fetcher,
317
+ });
318
+ const address = officeAddress(parseJsonBytes(bytes, 'Correos office locator'), codired);
319
+ if (address) {
320
+ if (this.offices.size >= MAX_REMEMBERED_OFFICES)
321
+ this.offices.delete(this.offices.keys().next().value);
322
+ this.offices.set(codired, address);
323
+ }
324
+ return address;
325
+ }
326
+ catch {
327
+ signal.throwIfAborted();
328
+ return '';
329
+ }
239
330
  }
240
331
  }
241
332
  export const adapter = (environment) => {
@@ -21,8 +21,9 @@ import { FEDEX_CODE_STAGE, fedexStage, fedexStatus } from './status.js';
21
21
  *
22
22
  * Response shape provenance: the page bundle's package model (`trackingNbr`,
23
23
  * `keyStatus`/`keyStatusCD`, `scanEventList` items with
24
- * `date`/`time`/`gmtOffset`/`scanLocation`/`status`/`statusCD`/`scanDetails`),
25
- * inspected 2026-09-20.
24
+ * `date`/`time`/`gmtOffset`/`scanLocation`/`status`/`statusCD`/`scanDetails`,
25
+ * and the hold location's `halCmpnyName`/`halAddress`), inspected 2026-09-20
26
+ * and 2026-10-08.
26
27
  */
27
28
  const TRACKING_BASE = 'https://www.fedex.com/fedextrack/';
28
29
  const TRACK_API = 'https://api.fedex.com/track/v2/shipments';
@@ -84,6 +85,30 @@ function expectedDelivery(value) {
84
85
  function deliveredAt(value) {
85
86
  return explicitOffsetTime(value)?.iso ?? null;
86
87
  }
88
+ /**
89
+ * The hold location, read only while the parcel waits there: its name, then
90
+ * its street and its town on their own lines when FedEx gives both. These are
91
+ * the `halCmpnyName` and `halAddress` the page reads for a held parcel. The
92
+ * recipient and shipper blocks are never a fallback, and a residential hold
93
+ * address is someone's home, so it is never projected.
94
+ */
95
+ function holdLocation(shipment) {
96
+ const name = clean(shipment.halCmpnyName, 200);
97
+ const address = isRecord(shipment.halAddress) ? shipment.halAddress : {};
98
+ if (!name || shipment.halresidential === true || address.residential === true)
99
+ return '';
100
+ const street = Array.isArray(address.streetLines)
101
+ ? address.streetLines.map((line) => clean(line, 120)).filter(Boolean)
102
+ : [];
103
+ const city = clean(address.city, 80);
104
+ if (street.length === 0 || !city)
105
+ return name;
106
+ const postcode = cleanScalar(address.postalCode, 16);
107
+ // A US state or Canadian province comes before the postcode, after the town.
108
+ const region = clean(address.stateOrProvinceCode, 40);
109
+ const town = region ? `${city}, ${[region, postcode].filter(Boolean).join(' ')}` : [postcode, city].filter(Boolean).join(' ');
110
+ return [name, street[0], street.slice(1).join(', '), town].filter(Boolean).join('\n');
111
+ }
87
112
  function errorCode(error) {
88
113
  return isRecord(error) ? cleanScalar(error.code).toLocaleUpperCase('en-US') : '';
89
114
  }
@@ -170,6 +195,7 @@ export function parseFedExTrackingResponse(payload, trackingNumber) {
170
195
  const status = fedexStatus(keyStatusCD, `${keyStatus} ${statusDetails}`, trimmed.length > 0);
171
196
  const delivered = stage === 'delivered';
172
197
  const estimate = delivered ? null : expectedDelivery(shipment.estDeliveryDt);
198
+ const pickupPoint = stage === 'ready_for_pickup' ? holdLocation(shipment) : '';
173
199
  return {
174
200
  status,
175
201
  ...(stage ? { current_stage: stage } : {}),
@@ -178,6 +204,7 @@ export function parseFedExTrackingResponse(payload, trackingNumber) {
178
204
  expected_delivery: estimate,
179
205
  ...(delivered && deliveredAt(shipment.actDeliveryDt)
180
206
  ? { delivered_at: deliveredAt(shipment.actDeliveryDt) } : {}),
207
+ ...(pickupPoint ? { pickup_point: pickupPoint } : {}),
181
208
  events: trimmed,
182
209
  };
183
210
  }
@@ -24,7 +24,8 @@
24
24
  "history",
25
25
  "location",
26
26
  "eta",
27
- "delivered_at"
27
+ "delivered_at",
28
+ "pickup_point"
28
29
  ],
29
30
  "portal": {
30
31
  "url": "https://www.fedex.com/fedextrack/?trknbr={trackingNumber}",
@@ -33,13 +34,15 @@
33
34
  "status",
34
35
  "history",
35
36
  "eta",
36
- "location"
37
+ "location",
38
+ "pickup_point"
37
39
  ],
38
40
  "scraped": [
39
41
  "status",
40
42
  "history",
41
43
  "eta",
42
- "location"
44
+ "location",
45
+ "pickup_point"
43
46
  ],
44
47
  "discarded": [
45
48
  "recipient name and address",
@@ -12,6 +12,13 @@
12
12
  * delay flags, `DL`/`OD`/`OC`/`PU`/`AR`/`DP` come from the page bundle and
13
13
  * FedEx's tracking documentation; `IT`/`SE`/`CA` match the official Track API
14
14
  * vocabulary (prior art). `statuses.json` holds the full list.
15
+ *
16
+ * Hold at location: FedEx documents `HP` as "Ready for pickup (Hold at
17
+ * Location)", and the page puts an `HL` package on its "Ready for pickup"
18
+ * step. `RR` (delivery option requested) and `HA` (hold request accepted)
19
+ * come before, while the parcel still travels to the location. A hold for a
20
+ * problem, such as clearance or a wrong address, carries another code (`SE`,
21
+ * `DE`, `CD`) and is never read as ready for pickup.
15
22
  */
16
23
  import type { CarrierStatus } from '../../core/result/index.js';
17
24
  import type { Stage } from '../../core/status/index.js';
@@ -5,7 +5,11 @@ export const FEDEX_CODE_STAGE = {
5
5
  AR: 'in_transit',
6
6
  DP: 'in_transit',
7
7
  IT: 'in_transit',
8
+ RR: 'in_transit',
9
+ HA: 'in_transit',
8
10
  OD: 'out_for_delivery',
11
+ HL: 'ready_for_pickup',
12
+ HP: 'ready_for_pickup',
9
13
  DL: 'delivered',
10
14
  DE: 'failed_attempt',
11
15
  DY: 'exception',
@@ -35,6 +39,7 @@ const EXCEPTION_TERMS = [
35
39
  'action required',
36
40
  'clearance delay',
37
41
  ];
42
+ const READY_FOR_PICKUP_TERMS = ['ready for pickup'];
38
43
  const DELIVERED_TERMS = ['delivered', 'signed for', 'left at', 'received by'];
39
44
  const CUSTOMS_TERMS = ['clearance', 'customs'];
40
45
  const OUT_FOR_DELIVERY_TERMS = ['out for delivery', 'on fedex vehicle for delivery', 'on vehicle for delivery'];
@@ -70,6 +75,8 @@ export function fedexStage(text) {
70
75
  return 'returned';
71
76
  if (FAILED_ATTEMPT_TERMS.some((term) => value.includes(term)))
72
77
  return 'failed_attempt';
78
+ if (READY_FOR_PICKUP_TERMS.some((term) => value.includes(term)))
79
+ return 'ready_for_pickup';
73
80
  if (DELIVERED_TERMS.some((term) => value.includes(term)))
74
81
  return 'delivered';
75
82
  if (OUT_FOR_DELIVERY_TERMS.some((term) => value.includes(term)))
@@ -7,6 +7,10 @@
7
7
  { "code": "DP", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "keyStatusCD; departed FedEx location." },
8
8
  { "code": "IT", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "keyStatusCD; in transit." },
9
9
  { "code": "OD", "language": "en", "stage": "out_for_delivery", "confirmedBy": "bundle", "firstSeen": "2026-09-20", "note": "keyStatusCD; on FedEx vehicle for delivery." },
10
+ { "code": "RR", "language": "en", "stage": "in_transit", "confirmedBy": "official-doc", "firstSeen": "2026-10-08", "note": "Delivery option requested. With exception code A12 the recipient asked to hold the parcel at a location." },
11
+ { "code": "HA", "language": "en", "stage": "in_transit", "confirmedBy": "official-doc", "firstSeen": "2026-10-08", "note": "Hold at location request accepted; the parcel still travels to the location." },
12
+ { "code": "HL", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "bundle", "firstSeen": "2026-10-08", "note": "keyStatusCD; the page puts it on its Ready for pickup step and shows the hold location. Adapter result status: out_for_delivery." },
13
+ { "code": "HP", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "official-doc", "firstSeen": "2026-10-08", "note": "Ready for pickup (Hold at Location); the page dates its ready-for-pickup step from this scan. Adapter result status: out_for_delivery." },
10
14
  { "code": "DL", "language": "en", "stage": "delivered", "confirmedBy": "bundle", "firstSeen": "2026-09-20", "note": "keyStatusCD; terminal, outranks the wording." },
11
15
  { "code": "DE", "language": "en", "stage": "failed_attempt", "confirmedBy": "bundle", "firstSeen": "2026-09-20", "note": "keyStatusCD; drives the page's own delivery-exception flag. Adapter result status: exception." },
12
16
  { "code": "DY", "language": "en", "stage": "exception", "confirmedBy": "bundle", "firstSeen": "2026-09-20", "note": "keyStatusCD; drives the page's own delay flag." },
@@ -23,6 +27,7 @@
23
27
  { "wording": "Clearance delay", "language": "en", "stage": "customs", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "Adapter result status: in_transit." },
24
28
  { "wording": "Clearance in progress", "language": "en", "stage": "customs", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "Adapter result status: in_transit." },
25
29
  { "wording": "On FedEx vehicle for delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "" },
30
+ { "wording": "Ready for pickup", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "official-doc", "firstSeen": "2026-10-08", "note": "Checked before the delivered and exception wordings. Adapter result status: out_for_delivery." },
26
31
  { "wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "A delivered scan's description is replaced by this word because the original names the signatory." },
27
32
  { "wording": "Delivery exception", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "Adapter result status: exception." },
28
33
  { "wording": "Customer not available or business closed", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "Adapter result status: exception." },
@@ -15,18 +15,20 @@
15
15
  * does echo the delivery postcode, which must agree with the requested one.
16
16
  *
17
17
  * Privacy: the loader payload carries the retailer, the recipient's name,
18
- * e-mail, phone and address, and per-event `variables` that repeat them.
19
- * `parse()` copies nothing from those objects: each event is rebuilt from its
20
- * timestamp, our own description and the mapped stage, and the only order-level
21
- * fields read are the delivery postcode, compared and never copied, the
22
- * delivery country, which picks the window's clock, and the delivery window.
18
+ * e-mail, phone and address, the PaackGo Point pickup code and QR link, and
19
+ * per-event `variables` that repeat them. `parse()` copies nothing from those:
20
+ * each event is rebuilt from its timestamp, our own description and the mapped
21
+ * stage, and the only order-level fields read are the delivery postcode,
22
+ * compared and never copied, the delivery country, which picks the window's
23
+ * clock, the delivery window, and the PaackGo Point's name and address while
24
+ * the parcel waits there.
23
25
  */
24
26
  import { load } from 'cheerio';
25
27
  import { DateTime } from 'luxon';
26
28
  import { lookupBudget } from '../../core/adapter/index.js';
27
29
  import { IndeterminateError, InputRequiredError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
28
30
  import { countryCode, countryTimeZone, explicitOffsetTime } from '../../core/time/index.js';
29
- import { decodeText, fetchBounded, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
31
+ import { clean, decodeText, fetchBounded, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
30
32
  import { isRecord } from '../../core/types.js';
31
33
  import { classifyPaackEvent } from './status.js';
32
34
  const TRACKING_ENDPOINT = 'https://mydeliveries.paack.app/tracking/order';
@@ -196,6 +198,22 @@ function redirectTarget(location, from, trackingNumber, postcode) {
196
198
  return null;
197
199
  return { url: trackingOrderUrl(`${target.origin}${target.pathname}`, trackingNumber, postcode) };
198
200
  }
201
+ /**
202
+ * The PaackGo Point the page names: `pudo_name`, then `pudo_address` on the
203
+ * following lines. The address is one string Paack formats itself; the page
204
+ * prints it as it comes and breaks it only where it holds a line break. The
205
+ * pickup code (`pudo_passcode`), QR link and collection deadline beside them
206
+ * are never read.
207
+ */
208
+ function pickupPoint(order) {
209
+ const name = clean(order.pudo_name, 120);
210
+ if (!name)
211
+ return '';
212
+ const address = typeof order.pudo_address === 'string'
213
+ ? order.pudo_address.split('\n').map((line) => clean(line, 200)).filter(Boolean)
214
+ : [];
215
+ return [name, ...address].join('\n');
216
+ }
199
217
  /** Letters and digits only, so "SW1A 1AA" and "4445-027" compare by content. */
200
218
  function postcodeKey(value) {
201
219
  return value.toLocaleUpperCase('en-US').replace(/[^A-Z0-9]/g, '');
@@ -258,12 +276,14 @@ export function parsePaackTrackingResponse(payload, rawPostcode) {
258
276
  const expected = isRecord(order.expected_delivery_ts) && !['delivered', 'exception'].includes(current.status)
259
277
  ? expectedDelivery(order.expected_delivery_ts, zone, newest)
260
278
  : null;
279
+ const pickup = current.stage === 'ready_for_pickup' ? pickupPoint(order) : '';
261
280
  return {
262
281
  status: current.status,
263
282
  current_stage: current.stage,
264
283
  last_status_text: current.description,
265
284
  last_update: activeTime?.iso ?? events[0]?.time ?? null,
266
285
  expected_delivery: expected,
286
+ ...(pickup ? { pickup_point: pickup } : {}),
267
287
  timezone: zone,
268
288
  events,
269
289
  };
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "capabilities": [
42
42
  "history",
43
- "eta"
43
+ "eta",
44
+ "pickup_point"
44
45
  ],
45
46
  "portal": {
46
47
  "url": "https://mydeliveries.paack.app/tracking?tracking_number={trackingNumber}",
@@ -50,6 +51,8 @@
50
51
  "history",
51
52
  "eta",
52
53
  "eta_window",
54
+ "pickup_point",
55
+ "pickup_code",
53
56
  "sender_name",
54
57
  "recipient_name",
55
58
  "recipient_address"
@@ -58,9 +61,11 @@
58
61
  "status",
59
62
  "history",
60
63
  "eta",
61
- "eta_window"
64
+ "eta_window",
65
+ "pickup_point"
62
66
  ],
63
67
  "discarded": [
68
+ "pickup_code",
64
69
  "sender_name",
65
70
  "recipient_name",
66
71
  "recipient_address"
@@ -10,6 +10,10 @@
10
10
  *
11
11
  * Order matters: return and failure identifiers are tested before the broader
12
12
  * delivery ones, so "notDelivered" can never match the "delivered" substring.
13
+ * PaackGo Point identifiers come from the page's own vocabulary and are tested
14
+ * before the generic words they contain: "rejectedByPudo" is a retried
15
+ * delivery, not a rejection, and "collectedByCustomer" is the recipient
16
+ * collecting the parcel, not a transit collection.
13
17
  * The provider's own wording is never returned; each mapped entry supplies the
14
18
  * English description we display.
15
19
  *
@@ -15,6 +15,7 @@ const OUT_FOR_DELIVERY = {
15
15
  description: 'Out for delivery',
16
16
  };
17
17
  const DELIVERED = { status: 'delivered', stage: 'delivered', description: 'Delivered' };
18
+ const DELIVERY_ISSUE = { status: 'exception', stage: 'failed_attempt', description: 'Delivery issue' };
18
19
  /** Paack's English timeline labels, keyed like `statusKey`, with the identifier each one renders. */
19
20
  const RELAYED = new Map([
20
21
  ['orderdetailsreceived', REGISTERED], // manifested
@@ -36,6 +37,18 @@ export function classifyPaackEvent(value) {
36
37
  ])) {
37
38
  return { status: 'exception', stage: 'returned', description: 'Shipment returned' };
38
39
  }
40
+ // Expired, refused or unpaid at the PaackGo Point: on its way back to the retailer.
41
+ if (includesAny(key, [
42
+ 'inpudotoreturnexpired',
43
+ 'inpudotoreturnrejected',
44
+ 'inpudotoreturncodnotaccepted',
45
+ ])) {
46
+ return { status: 'exception', stage: 'returned', description: 'Returning to sender' };
47
+ }
48
+ // The point was closed, full or refused the parcel; Paack tries again.
49
+ if (includesAny(key, ['pudoclosed', 'pudofull', 'rejectedbypudo'])) {
50
+ return { ...DELIVERY_ISSUE };
51
+ }
39
52
  if (includesAny(key, [
40
53
  'incorrectaddress',
41
54
  'notaccepted',
@@ -59,10 +72,10 @@ export function classifyPaackEvent(value) {
59
72
  'notdelivered',
60
73
  'undelivered',
61
74
  ]))
62
- return { status: 'exception', stage: 'failed_attempt', description: 'Delivery issue' };
63
- if (includesAny(key, ['delivered', 'deliverycompleted']))
75
+ return { ...DELIVERY_ISSUE };
76
+ if (includesAny(key, ['delivered', 'deliverycompleted', 'collectedbycustomer']))
64
77
  return { ...DELIVERED };
65
- if (includesAny(key, ['readyforpickup', 'atpickuppoint'])) {
78
+ if (includesAny(key, ['droppedinpudo'])) {
66
79
  return { status: 'out_for_delivery', stage: 'ready_for_pickup', description: 'Ready for pickup' };
67
80
  }
68
81
  if (includesAny(key, ['outfordelivery', 'indelivery', 'driverassigned', 'inprogress']))
@@ -25,6 +25,54 @@
25
25
  "firstSeen": "2026-09-12",
26
26
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
27
27
  },
28
+ {
29
+ "code": "inpudotoreturnexpired",
30
+ "language": "en",
31
+ "stage": "returned",
32
+ "confirmedBy": "bundle",
33
+ "firstSeen": "2026-10-08",
34
+ "note": "Page label inPudoToReturnExpired, \"Expired at PaackGo Point\": not collected in time, returned to the retailer."
35
+ },
36
+ {
37
+ "code": "inpudotoreturnrejected",
38
+ "language": "en",
39
+ "stage": "returned",
40
+ "confirmedBy": "bundle",
41
+ "firstSeen": "2026-10-08",
42
+ "note": "Page label inPudoToReturnRejected: refused at the PaackGo Point, returned to the retailer. Tested before the rejected exception rule."
43
+ },
44
+ {
45
+ "code": "inpudotoreturncodnotaccepted",
46
+ "language": "en",
47
+ "stage": "returned",
48
+ "confirmedBy": "bundle",
49
+ "firstSeen": "2026-10-08",
50
+ "note": "Page label inPudoToReturnCODNotAccepted: cash on delivery not paid at the PaackGo Point, returned to the retailer."
51
+ },
52
+ {
53
+ "code": "pudoclosed",
54
+ "language": "en",
55
+ "stage": "failed_attempt",
56
+ "confirmedBy": "bundle",
57
+ "firstSeen": "2026-10-08",
58
+ "note": "Page label pudoClosed, \"Has been rescheduled\": the PaackGo Point was closed; Paack tries again."
59
+ },
60
+ {
61
+ "code": "pudofull",
62
+ "language": "en",
63
+ "stage": "failed_attempt",
64
+ "confirmedBy": "bundle",
65
+ "firstSeen": "2026-10-08",
66
+ "note": "Page label pudoFull, \"Has been rescheduled\": the PaackGo Point was full; Paack tries again."
67
+ },
68
+ {
69
+ "code": "rejectedbypudo",
70
+ "language": "en",
71
+ "stage": "failed_attempt",
72
+ "confirmedBy": "bundle",
73
+ "firstSeen": "2026-10-08",
74
+ "note": "Page label rejectedByPudo, \"Has been rescheduled\": the PaackGo Point refused the parcel; Paack tries again. Tested before the rejected exception rule."
75
+ },
28
76
  {
29
77
  "code": "returntosenderscheduled",
30
78
  "language": "en",
@@ -145,6 +193,22 @@
145
193
  "firstSeen": "2026-09-12",
146
194
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded. Reclassified to exception 2026-09-12."
147
195
  },
196
+ {
197
+ "code": "inpudotoreturndamaged",
198
+ "language": "en",
199
+ "stage": "exception",
200
+ "confirmedBy": "bundle",
201
+ "firstSeen": "2026-10-08",
202
+ "note": "Page label inPudoToReturnDamaged, \"Logistic issue\": no longer available at the PaackGo Point. Reached through damaged."
203
+ },
204
+ {
205
+ "code": "lostbypudo",
206
+ "language": "en",
207
+ "stage": "exception",
208
+ "confirmedBy": "bundle",
209
+ "firstSeen": "2026-10-08",
210
+ "note": "Page label lostByPudo, \"Logistic issue\". Reached through lost."
211
+ },
148
212
  {
149
213
  "code": "integrationerror",
150
214
  "language": "en",
@@ -186,20 +250,20 @@
186
250
  "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
187
251
  },
188
252
  {
189
- "code": "readyforpickup",
253
+ "code": "collectedbycustomer",
190
254
  "language": "en",
191
- "stage": "ready_for_pickup",
192
- "confirmedBy": "prior-art",
193
- "firstSeen": "2026-09-12",
194
- "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
255
+ "stage": "delivered",
256
+ "confirmedBy": "bundle",
257
+ "firstSeen": "2026-10-08",
258
+ "note": "Page label collectedByCustomer, \"Collected from PaackGo Point\". Tested before the collected transit rule."
195
259
  },
196
260
  {
197
- "code": "atpickuppoint",
261
+ "code": "droppedinpudo",
198
262
  "language": "en",
199
263
  "stage": "ready_for_pickup",
200
- "confirmedBy": "prior-art",
201
- "firstSeen": "2026-09-12",
202
- "note": "Identifier substring inherited from the pre-package adapter; source not recorded."
264
+ "confirmedBy": "bundle",
265
+ "firstSeen": "2026-10-08",
266
+ "note": "Page label droppedInPudo, \"At PaackGo Point\": the parcel waits at the point named by pudo_name and pudo_address."
203
267
  },
204
268
  {
205
269
  "code": "outfordelivery",
@@ -63,6 +63,12 @@
63
63
  }
64
64
  ],
65
65
  "detection": [
66
+ {
67
+ "id": "usps-impb",
68
+ "pattern": "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$",
69
+ "confidence": "high",
70
+ "checksum": "usps"
71
+ },
66
72
  {
67
73
  "id": "usps-3",
68
74
  "pattern": "^9[1-5]\\d{20}(?:\\d{4})?$",
@@ -114,15 +114,10 @@
114
114
  "url": "https://github.com/jkeen/tracking_number_data/blob/main/couriers/usps.json"
115
115
  },
116
116
  "expect": {
117
- "carrier": "unknown",
118
- "confidence": "low",
119
- "candidates": [
120
- "usps",
121
- "austrian-post",
122
- "estafeta"
123
- ]
117
+ "carrier": "usps",
118
+ "confidence": "high"
124
119
  },
125
- "note": "Open-source project example, not a real shipment. The source attributes it to USPS, which stays one candidate among several."
120
+ "note": "Open-source project example, not a real shipment. The source attributes it to USPS; its PIC structure and check digit select USPS."
126
121
  },
127
122
  {
128
123
  "number": "9400111206206406260787",
@@ -132,15 +127,10 @@
132
127
  "url": "https://github.com/jkeen/tracking_number_data/blob/main/couriers/usps.json"
133
128
  },
134
129
  "expect": {
135
- "carrier": "unknown",
136
- "confidence": "low",
137
- "candidates": [
138
- "usps",
139
- "austrian-post",
140
- "estafeta"
141
- ]
130
+ "carrier": "usps",
131
+ "confidence": "high"
142
132
  },
143
- "note": "Open-source project example, not a real shipment. The source attributes it to USPS, which stays one candidate among several."
133
+ "note": "Open-source project example, not a real shipment. The source attributes it to USPS; its PIC structure and check digit select USPS."
144
134
  },
145
135
  {
146
136
  "number": "9405803699300124287899",
@@ -150,15 +140,10 @@
150
140
  "url": "https://github.com/jkeen/tracking_number_data/blob/main/couriers/usps.json"
151
141
  },
152
142
  "expect": {
153
- "carrier": "unknown",
154
- "confidence": "low",
155
- "candidates": [
156
- "usps",
157
- "austrian-post",
158
- "estafeta"
159
- ]
143
+ "carrier": "usps",
144
+ "confidence": "high"
160
145
  },
161
- "note": "Open-source project example, not a real shipment. The source attributes it to USPS, which stays one candidate among several."
146
+ "note": "Open-source project example, not a real shipment. The source attributes it to USPS; its PIC structure and check digit select USPS."
162
147
  },
163
148
  {
164
149
  "number": "9434611206206406227577",
@@ -168,15 +153,10 @@
168
153
  "url": "https://github.com/jkeen/tracking_number_data/blob/main/couriers/usps.json"
169
154
  },
170
155
  "expect": {
171
- "carrier": "unknown",
172
- "confidence": "low",
173
- "candidates": [
174
- "usps",
175
- "austrian-post",
176
- "estafeta"
177
- ]
156
+ "carrier": "usps",
157
+ "confidence": "high"
178
158
  },
179
- "note": "Open-source project example, not a real shipment. The source attributes it to USPS, which stays one candidate among several."
159
+ "note": "Open-source project example, not a real shipment. The source attributes it to USPS; its PIC structure and check digit select USPS."
180
160
  },
181
161
  {
182
162
  "number": "9500113562366007585132",
@@ -186,15 +166,10 @@
186
166
  "url": "https://www.govinfo.gov/content/pkg/USCOURTS-ilsd-3_25-cv-02196/pdf/USCOURTS-ilsd-3_25-cv-02196-0.pdf"
187
167
  },
188
168
  "expect": {
189
- "carrier": "unknown",
190
- "confidence": "low",
191
- "candidates": [
192
- "usps",
193
- "austrian-post",
194
- "estafeta"
195
- ]
169
+ "carrier": "usps",
170
+ "confidence": "high"
196
171
  },
197
- "note": "Tracking number quoted in a public court record. The source attributes it to USPS, which stays one candidate among several."
172
+ "note": "Tracking number quoted in a public court record. The source attributes it to USPS; its PIC structure and check digit select USPS."
198
173
  },
199
174
  {
200
175
  "number": "9589071052702449080343",
@@ -204,15 +179,10 @@
204
179
  "url": "https://www.govinfo.gov/content/pkg/USCOURTS-ilsd-3_25-cv-02196/pdf/USCOURTS-ilsd-3_25-cv-02196-0.pdf"
205
180
  },
206
181
  "expect": {
207
- "carrier": "unknown",
208
- "confidence": "low",
209
- "candidates": [
210
- "usps",
211
- "austrian-post",
212
- "estafeta"
213
- ]
182
+ "carrier": "usps",
183
+ "confidence": "high"
214
184
  },
215
- "note": "Tracking number quoted in a public court record. The source attributes it to USPS, which stays one candidate among several."
185
+ "note": "Tracking number quoted in a public court record. The source attributes it to USPS; its PIC structure and check digit select USPS."
216
186
  },
217
187
  {
218
188
  "number": "4201002334249200190132607600833457",
@@ -414,13 +384,8 @@
414
384
  "assessment": "candidate"
415
385
  },
416
386
  "expect": {
417
- "carrier": "unknown",
418
- "confidence": "low",
419
- "candidates": [
420
- "usps",
421
- "austrian-post",
422
- "estafeta"
423
- ]
387
+ "carrier": "usps",
388
+ "confidence": "high"
424
389
  },
425
390
  "note": "Public sandbox example; not evidence of a consumer shipment."
426
391
  },
@@ -524,6 +489,54 @@
524
489
  "context": {
525
490
  "assessment": "candidate"
526
491
  },
492
+ "expect": {
493
+ "carrier": "usps",
494
+ "confidence": "high"
495
+ },
496
+ "note": "A public court exhibit contains a USPS tracking printout for this identifier."
497
+ },
498
+ {
499
+ "number": "9205590000000012345672",
500
+ "role": "shipment",
501
+ "evidence": "synthetic",
502
+ "derivedFrom": "official_shape",
503
+ "shapeConfirmed": "2026-10-08",
504
+ "expect": {
505
+ "carrier": "usps",
506
+ "confidence": "high"
507
+ },
508
+ "note": "Made-up commercial PIC: channel 92, a service type code, a nine-digit Mailer ID starting with 9, a seven-digit serial and the check digit."
509
+ },
510
+ {
511
+ "number": "9300100000012345678902",
512
+ "role": "shipment",
513
+ "evidence": "synthetic",
514
+ "derivedFrom": "official_shape",
515
+ "shapeConfirmed": "2026-10-08",
516
+ "expect": {
517
+ "carrier": "usps",
518
+ "confidence": "high"
519
+ },
520
+ "note": "Made-up commercial PIC: channel 93, a service type code, a six-digit Mailer ID starting with 0 to 8, a ten-digit serial and the check digit."
521
+ },
522
+ {
523
+ "number": "9101900000000123456788",
524
+ "role": "shipment",
525
+ "evidence": "synthetic",
526
+ "derivedFrom": "official_shape",
527
+ "shapeConfirmed": "2026-10-08",
528
+ "expect": {
529
+ "carrier": "usps",
530
+ "confidence": "high"
531
+ },
532
+ "note": "Made-up legacy PIC: channel 91, a two-digit service type code, a nine-digit Mailer ID starting with 9, an eight-digit serial and the check digit."
533
+ },
534
+ {
535
+ "number": "9205510000000012345670",
536
+ "role": "negative",
537
+ "evidence": "synthetic",
538
+ "derivedFrom": "official_shape",
539
+ "shapeConfirmed": "2026-10-08",
527
540
  "expect": {
528
541
  "carrier": "unknown",
529
542
  "confidence": "low",
@@ -533,7 +546,24 @@
533
546
  "estafeta"
534
547
  ]
535
548
  },
536
- "note": "A public court exhibit contains a USPS tracking printout for this identifier."
549
+ "note": "Made-up channel 92 PIC whose nine-digit Mailer ID does not start with 9. The check digit passes, so USPS is listed first but not selected."
550
+ },
551
+ {
552
+ "number": "9205590000000012345673",
553
+ "role": "negative",
554
+ "evidence": "synthetic",
555
+ "derivedFrom": "official_shape",
556
+ "shapeConfirmed": "2026-10-08",
557
+ "expect": {
558
+ "carrier": "unknown",
559
+ "confidence": "low",
560
+ "candidates": [
561
+ "austrian-post",
562
+ "usps",
563
+ "estafeta"
564
+ ]
565
+ },
566
+ "note": "Made-up channel 92 PIC with a wrong check digit, so USPS is one suggestion among several."
537
567
  }
538
568
  ]
539
569
  }
@@ -2584,6 +2584,11 @@
2584
2584
  }
2585
2585
  ],
2586
2586
  "detectionRules": [
2587
+ {
2588
+ "pattern": "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$",
2589
+ "confidence": "high",
2590
+ "checksum": "usps"
2591
+ },
2587
2592
  {
2588
2593
  "pattern": "^9[1-5]\\d{20}(?:\\d{4})?$",
2589
2594
  "confidence": "low",
@@ -1724,6 +1724,10 @@ export declare const CARRIER_CATALOG: {
1724
1724
  readonly params: readonly ["tLabels"];
1725
1725
  }];
1726
1726
  readonly detectionRules: readonly [{
1727
+ readonly pattern: "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$";
1728
+ readonly confidence: "high";
1729
+ readonly checksum: "usps";
1730
+ }, {
1727
1731
  readonly pattern: "^9[1-5]\\d{20}(?:\\d{4})?$";
1728
1732
  readonly confidence: "low";
1729
1733
  readonly checksum: "usps";
@@ -2585,6 +2585,11 @@ export const CARRIER_CATALOG = {
2585
2585
  }
2586
2586
  ],
2587
2587
  "detectionRules": [
2588
+ {
2589
+ "pattern": "^(?!9[23]61)(?:91\\d{2}9\\d{17}|92\\d{3}9\\d{16}|93\\d{3}[0-8]\\d{16}|9[45]\\d{20})$",
2590
+ "confidence": "high",
2591
+ "checksum": "usps"
2592
+ },
2588
2593
  {
2589
2594
  "pattern": "^9[1-5]\\d{20}(?:\\d{4})?$",
2590
2595
  "confidence": "low",
@@ -5272,7 +5277,7 @@ export const DETECTION_RULE_IDS = {
5272
5277
  "ecoscooting": ["ecoscooting-1", "ecoscooting-2"],
5273
5278
  "tipsa": ["tipsa-1", "tipsa-2"],
5274
5279
  "ukrposhta": ["ukrposhta-1"],
5275
- "usps": ["usps-3", "usps-1", "usps-2", "usps-4"],
5280
+ "usps": ["usps-impb", "usps-3", "usps-1", "usps-2", "usps-4"],
5276
5281
  "canada-post": ["canada-post-1", "canada-post-2"],
5277
5282
  "purolator": ["purolator-1", "purolator-2"],
5278
5283
  "canpar": ["canpar-1"],
@@ -2,5 +2,7 @@
2
2
  { "country": "FR", "names": ["Roissy"], "town": "Roissy-en-France", "airport": "CDG" },
3
3
  { "country": "GB", "names": ["Heathrow"], "town": "London", "airport": "LHR" },
4
4
  { "country": "GB", "names": ["East Midlands"], "town": "Castle Donington", "airport": "EMA" },
5
- { "country": "PL", "names": ["Sękocin Stary"], "town": "Sękocin Stary", "latitude": 52.108, "longitude": 20.88, "source": "https://download.geonames.org/export/zip/PL.zip" }
5
+ { "country": "PL", "names": ["Sękocin Stary"], "town": "Sękocin Stary", "latitude": 52.108, "longitude": 20.88, "source": "https://download.geonames.org/export/zip/PL.zip" },
6
+ { "country": "CN", "names": ["Fenggang Town"], "town": "Fenggang", "latitude": 22.757, "longitude": 114.149, "source": "https://www.geonames.org/1916217" },
7
+ { "country": "FR", "names": ["Terrasson"], "town": "Terrasson-Lavilledieu", "latitude": 45.129, "longitude": 1.32, "source": "https://www.openstreetmap.org/relation/19894168" }
6
8
  ]
@@ -47,15 +47,14 @@ const UNCONFIRMED_MIN_THOUSANDS = 15;
47
47
  const POINT_MAX_KM = 30;
48
48
  // An airport a scan names has to be this close to the town it names.
49
49
  const AIRPORT_MAX_KM = 60;
50
- // A hub nickname ranks like a town of this many thousand people, so a longer
51
- // name that contains it ("Roissy-en-Brie") still wins.
50
+ // A hub's name ranks like a town of this many thousand people.
52
51
  const HUB_THOUSANDS = 50;
53
52
  /** The city a region is named after ("Santo Domingo Province") has at least this many thousand people. */
54
53
  const NAMESAKE_THOUSANDS = 100;
55
54
  // Swiss Post scans end in the site's six-digit number: "Zürich Briefzentrum 801050".
56
55
  const FACILITIES = new Map(facilityList.map((facility) => [`${facility.country}:${facility.code}`, facility]));
57
56
  // Names carriers give a hub instead of its town ("ROISSY" is Paris-Charles de Gaulle, not
58
- // Roissy-en-Brie), and hubs in villages the gazetteer is too coarse for ("SEKOCIN STARY").
57
+ // Roissy-en-Brie), and hubs the gazetteer cannot place by name ("SEKOCIN STARY", "Fenggang Town", "TERRASSON").
59
58
  const HUBS = new Map(hubList.flatMap((hub) => hub.names.map((name) => [nameKey(name), hub])));
60
59
  let loaded = null;
61
60
  let pincodes = null;
@@ -337,7 +336,11 @@ export function locatePlace(location, hints = {}) {
337
336
  // ("Santa Catarina" on a Brazilian parcel).
338
337
  const named = phrase.whole ? own.filter((region) => (!country || region.country === country)
339
338
  && (fieldIndex > 0 || region.country === country || hinted.includes(region.country))) : [];
340
- const towns = candidates(data, phrase.key);
339
+ // A hub's name inside a longer place name is part of that name: "Beauregard-de-Terrasson"
340
+ // is a village, not the agency at Terrasson, and "Roissy-en-Brie" is no airport.
341
+ const partOfName = list.some((other) => other.start <= phrase.start && other.end >= phrase.end
342
+ && other.key.length > phrase.key.length && data.keys.has(other.key));
343
+ const towns = candidates(data, phrase.key).filter((place) => !place.hub || !partOfName);
341
344
  namesTown ||= towns.length > 0;
342
345
  for (const place of towns) {
343
346
  // A town may still be named like the region it lies in: Moscow in "…, Moscow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.2-main.551",
3
+ "version": "0.49.2-main.556",
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",