universal-parcel-scraper 0.49.2-main.552 → 0.49.2-main.558
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/data/catalog.json +5 -0
- package/data/detection-golden.json +69 -80
- package/dist/browser/scraper.js +6 -0
- package/dist/carriers/aliexpress/status.js +6 -0
- package/dist/carriers/aliexpress/statuses.json +7 -0
- package/dist/carriers/chronopost/identity.d.ts +4 -0
- package/dist/carriers/chronopost/identity.js +15 -0
- package/dist/carriers/chronopost/parser.js +6 -2
- package/dist/carriers/correos-spain/adapter.d.ts +7 -0
- package/dist/carriers/correos-spain/adapter.js +117 -26
- package/dist/carriers/dpd-fr/adapter.js +4 -1
- package/dist/carriers/dpd-fr/identity.d.ts +4 -0
- package/dist/carriers/dpd-fr/identity.js +12 -0
- package/dist/carriers/fedex/adapter.js +29 -2
- package/dist/carriers/fedex/carrier.json +6 -3
- package/dist/carriers/fedex/status.d.ts +7 -0
- package/dist/carriers/fedex/status.js +7 -0
- package/dist/carriers/fedex/statuses.json +5 -0
- package/dist/carriers/usps/carrier.json +6 -0
- package/dist/carriers/usps/numbers.json +86 -56
- package/dist/core/catalog/eventIdentity.js +3 -1
- package/dist/core/catalog/locationIdentity.d.ts +6 -0
- package/dist/core/catalog/locationIdentity.js +16 -4
- package/dist/data/catalog.json +5 -0
- package/dist/generated/catalog.d.ts +4 -0
- package/dist/generated/catalog.js +6 -1
- package/dist/places/hubs.json +3 -1
- package/dist/places/index.js +7 -4
- 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,772 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": "
|
|
11280
|
-
"confidence": "
|
|
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": "
|
|
11296
|
-
"confidence": "
|
|
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": "
|
|
11312
|
-
"confidence": "
|
|
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": "
|
|
11328
|
-
"confidence": "
|
|
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": "
|
|
11344
|
-
"confidence": "
|
|
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": "
|
|
11398
|
-
"confidence": "
|
|
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": "
|
|
11455
|
-
"confidence": "
|
|
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": "
|
|
11471
|
-
"confidence": "
|
|
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
|
{
|
package/dist/browser/scraper.js
CHANGED
|
@@ -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",
|
|
@@ -64,6 +64,8 @@ export const CAINIAO_ACTION_STATUS = new Map([
|
|
|
64
64
|
// Station signed, not the recipient — must never read as delivered.
|
|
65
65
|
['GTMS_STA_SIGNED', 'out_for_delivery'],
|
|
66
66
|
['GTMS_SIGNED', 'delivered'],
|
|
67
|
+
// The courier could not deliver and tries again: a failed attempt, below.
|
|
68
|
+
['GTMS_DEL_FAILURE', 'exception'],
|
|
67
69
|
['GTMS_STA_SIGN_FAILURE', 'exception'],
|
|
68
70
|
['EXCEPTION', 'exception'],
|
|
69
71
|
]);
|
|
@@ -91,6 +93,10 @@ export function cainiaoActionStage(code) {
|
|
|
91
93
|
return 'customs';
|
|
92
94
|
if (code === 'PU_PICKUP_SUCCESS')
|
|
93
95
|
return 'accepted';
|
|
96
|
+
// Cainiao's last-mile gateway words it "Delivery Attempt Failure" in its
|
|
97
|
+
// `delivery_failed` group (seen live through Ecoscooting, another client).
|
|
98
|
+
if (code === 'GTMS_DEL_FAILURE')
|
|
99
|
+
return 'failed_attempt';
|
|
94
100
|
const status = CAINIAO_ACTION_STATUS.get(code);
|
|
95
101
|
return status ? cainiaoStageByStatus()[status] : undefined;
|
|
96
102
|
}
|
|
@@ -381,6 +381,13 @@
|
|
|
381
381
|
"firstSeen": "2026-09-12",
|
|
382
382
|
"note": "The only code that means the recipient has the parcel."
|
|
383
383
|
},
|
|
384
|
+
{
|
|
385
|
+
"code": "GTMS_DEL_FAILURE",
|
|
386
|
+
"stage": "failed_attempt",
|
|
387
|
+
"confirmedBy": "live",
|
|
388
|
+
"firstSeen": "2026-10-08",
|
|
389
|
+
"note": "Seen live through Ecoscooting, a client of the same Cainiao last-mile gateway, as \"Delivery Attempt Failure\" with a reason such as the recipient not at home. No AliExpress reply has carried it yet."
|
|
390
|
+
},
|
|
384
391
|
{
|
|
385
392
|
"code": "GTMS_STA_SIGN_FAILURE",
|
|
386
393
|
"stage": "exception",
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
|
|
2
|
+
/** Whether an office label names one of Chronopost's services rather than a place. */
|
|
3
|
+
export declare function isChronopostService(label: string): boolean;
|
|
4
|
+
export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
// Office labels that name a Chronopost service, not where the scan happened:
|
|
3
|
+
// the shipper's labelling system on preparation scans, the notification
|
|
4
|
+
// service on SMS and e-mail notices, and the partner network abroad.
|
|
5
|
+
const SERVICES = new Set(['web services', "service d'avisage", 'chronopost networks']);
|
|
6
|
+
/** Whether an office label names one of Chronopost's services rather than a place. */
|
|
7
|
+
export function isChronopostService(label) {
|
|
8
|
+
return SERVICES.has(label.replace(/\s+/g, ' ').trim().replace(/’/g, "'").toLocaleLowerCase('fr-FR'));
|
|
9
|
+
}
|
|
10
|
+
// Scans stored with a service as their location lose it and keep their row.
|
|
11
|
+
export const sameInstantIdentityPolicy = {
|
|
12
|
+
sourceCarrierId: 'chronopost', storedSources: ['chronopost'], requireProviderCode: false,
|
|
13
|
+
matchEachScan: true,
|
|
14
|
+
matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isChronopostService),
|
|
15
|
+
};
|
|
@@ -5,6 +5,7 @@ import { languageStageStatus } from '../../core/status/index.js';
|
|
|
5
5
|
import { calendarDay, countryCode, explicitOffsetTime } from '../../core/time/index.js';
|
|
6
6
|
import { clean } from '../../core/transport/index.js';
|
|
7
7
|
import { xmlDocument } from '../../core/transport/xml.js';
|
|
8
|
+
import { isChronopostService } from './identity.js';
|
|
8
9
|
import { chronopostStage, isChronopostNotification, isPickupDropOff } from './status.js';
|
|
9
10
|
export const CHRONOPOST_MAX_BYTES = 2_000_000;
|
|
10
11
|
const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
|
|
@@ -78,14 +79,17 @@ const clockDay = (event) => {
|
|
|
78
79
|
};
|
|
79
80
|
/** Stages on the way to a planned delivery. */
|
|
80
81
|
const ONGOING = new Set(['pending', 'registered', 'accepted', 'in_transit', 'customs', 'out_for_delivery']);
|
|
81
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Keep operational places, never the delivery-point address or shop contact block,
|
|
84
|
+
* nor an office label that names a service ("Web Services", "Service d'avisage").
|
|
85
|
+
*/
|
|
82
86
|
function location(office, place) {
|
|
83
87
|
if (/^[\p{L}\p{M} .,'’()-]{1,100} - [A-Z]{2}(?: \(depot \d{1,6}\))?$/u.test(place))
|
|
84
88
|
return place;
|
|
85
89
|
const shop = /^([\p{L}\p{M} .,'’-]{1,100}) - ([A-Z]{2}) - /u.exec(office);
|
|
86
90
|
if (shop)
|
|
87
91
|
return `${shop[1]}, ${shop[2]}`;
|
|
88
|
-
return /^[\p{L}\p{M} .,'’()/-]{1,160}$/u.test(office) ? office : '';
|
|
92
|
+
return /^[\p{L}\p{M} .,'’()/-]{1,160}$/u.test(office) && !isChronopostService(office) ? office : '';
|
|
89
93
|
}
|
|
90
94
|
export function parseChronopostTrackingXml(xml, rawNumber) {
|
|
91
95
|
const requested = normalizeChronopostNumber(rawNumber);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) => {
|
|
@@ -5,6 +5,7 @@ import { eventPoint } from '../../core/result/index.js';
|
|
|
5
5
|
import { runSteps } from '../../core/runner/index.js';
|
|
6
6
|
import { zonedTime } from '../../core/time/index.js';
|
|
7
7
|
import { TrawlClient, clean, decodeText, fetchBounded } from '../../core/transport/index.js';
|
|
8
|
+
import { isDeliveryStatusCell } from './identity.js';
|
|
8
9
|
import { classifyStatus, comparableText, includesAny } from './status.js';
|
|
9
10
|
// Protocol provenance:
|
|
10
11
|
// - DPD France publishes a server-rendered recipient page rather than a
|
|
@@ -146,7 +147,9 @@ export function parseDPDFranceTrackingHtml(html, rawTrackingNumber) {
|
|
|
146
147
|
const date = clean(cells.eq(0).text(), 32);
|
|
147
148
|
const clock = clean(cells.eq(1).text(), 32);
|
|
148
149
|
const description = clean(cells.eq(2).text());
|
|
149
|
-
const
|
|
150
|
+
const cell = clean(cells.eq(3).text());
|
|
151
|
+
// The delivered row's cell repeats the delivery, which is no place.
|
|
152
|
+
const location = isDeliveryStatusCell(cell) ? '' : cell;
|
|
150
153
|
const time = parsedEventTime(date, clock);
|
|
151
154
|
if (!time || !description)
|
|
152
155
|
return;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
|
|
2
|
+
/** Whether a row's location cell states the delivery instead of a depot: "Livré au destinataire". */
|
|
3
|
+
export declare function isDeliveryStatusCell(location: string): boolean;
|
|
4
|
+
export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
import { comparableText } from './status.js';
|
|
3
|
+
/** Whether a row's location cell states the delivery instead of a depot: "Livré au destinataire". */
|
|
4
|
+
export function isDeliveryStatusCell(location) {
|
|
5
|
+
return comparableText(location) === 'livre au destinataire';
|
|
6
|
+
}
|
|
7
|
+
// Scans stored with that status as their location lose it and keep their row.
|
|
8
|
+
export const sameInstantIdentityPolicy = {
|
|
9
|
+
sourceCarrierId: 'dpd-fr', storedSources: ['dpd-fr'], requireProviderCode: false,
|
|
10
|
+
matchEachScan: true,
|
|
11
|
+
matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isDeliveryStatusCell),
|
|
12
|
+
};
|
|
@@ -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." },
|
|
@@ -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": "
|
|
118
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
136
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
154
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
172
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
190
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
208
|
-
"confidence": "
|
|
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
|
|
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": "
|
|
418
|
-
"confidence": "
|
|
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": "
|
|
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
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { sameInstantIdentityPolicy as aliexpress } from '../../carriers/aliexpress/app.js';
|
|
2
2
|
import { sameInstantIdentityPolicy as australiaPost } from '../../carriers/australia-post/app.js';
|
|
3
|
+
import { sameInstantIdentityPolicy as chronopost } from '../../carriers/chronopost/identity.js';
|
|
3
4
|
import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
|
|
5
|
+
import { sameInstantIdentityPolicy as dpdFrance } from '../../carriers/dpd-fr/identity.js';
|
|
4
6
|
import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
|
|
5
7
|
import { sameInstantIdentityPolicy as mondialRelay } from '../../carriers/mondial-relay/identity.js';
|
|
6
8
|
import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
|
|
7
9
|
import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
|
|
8
10
|
import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
|
|
9
|
-
const policies = new Map([aliexpress, australiaPost, dpd, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
11
|
+
const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
10
12
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
11
13
|
export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
|
|
12
14
|
const policy = policies.get(sourceCarrierId);
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import type { SameInstantScan } from './eventIdentity.js';
|
|
2
2
|
/** Adding a place must keep the scan's wording and known stage; conflicting places do not match. */
|
|
3
3
|
export declare function matchesLocationEnrichment(incoming: SameInstantScan, stored: SameInstantScan): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* A scan whose adapter now drops the text it once gave as the location, a
|
|
6
|
+
* service's name or a status that is no place, must keep its wording and
|
|
7
|
+
* known stage. Only a stored location `dropped` names matches.
|
|
8
|
+
*/
|
|
9
|
+
export declare function matchesDroppedLocation(incoming: SameInstantScan, stored: SameInstantScan, dropped: (location: string) => boolean): boolean;
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
const normalized = (value) => value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
|
|
2
|
+
/** The same wording and the same known stage. */
|
|
3
|
+
function sameScan(incoming, stored) {
|
|
4
|
+
const wording = normalized(incoming.description);
|
|
5
|
+
return Boolean(wording) && !['', 'unknown'].includes(incoming.stage)
|
|
6
|
+
&& incoming.stage === stored.stage && wording === normalized(stored.description);
|
|
7
|
+
}
|
|
2
8
|
/** Adding a place must keep the scan's wording and known stage; conflicting places do not match. */
|
|
3
9
|
export function matchesLocationEnrichment(incoming, stored) {
|
|
4
|
-
const wording = normalized(incoming.description);
|
|
5
10
|
const location = normalized(incoming.location);
|
|
6
11
|
const savedLocation = normalized(stored.location);
|
|
7
|
-
return Boolean(location &&
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
return Boolean(location) && sameScan(incoming, stored) && (!savedLocation || location === savedLocation);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A scan whose adapter now drops the text it once gave as the location, a
|
|
16
|
+
* service's name or a status that is no place, must keep its wording and
|
|
17
|
+
* known stage. Only a stored location `dropped` names matches.
|
|
18
|
+
*/
|
|
19
|
+
export function matchesDroppedLocation(incoming, stored, dropped) {
|
|
20
|
+
const savedLocation = stored.location.trim();
|
|
21
|
+
return !normalized(incoming.location) && Boolean(savedLocation) && dropped(savedLocation) && sameScan(incoming, stored);
|
|
10
22
|
}
|
package/dist/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",
|
|
@@ -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"],
|
package/dist/places/hubs.json
CHANGED
|
@@ -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
|
]
|
package/dist/places/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.49.2-main.558",
|
|
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",
|