universal-parcel-scraper 0.49.0-main.543 → 0.49.0-main.548

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/NOTICE CHANGED
@@ -14,3 +14,9 @@ Facility coordinates include OpenStreetMap data (https://www.openstreetmap.org/c
14
14
  licensed under the Open Database License. Natural Earth country label points
15
15
  (https://www.naturalearthdata.com) and OurAirports airports
16
16
  (https://ourairports.com/data/) are public domain.
17
+
18
+ Indian PIN code towns (places/pincodes.json) are derived from: Department of
19
+ Posts, Ministry of Communications, 2025, All India Pincode Directory till last
20
+ month, Open Government Data Platform India, 26/06,
21
+ https://www.data.gov.in/resource/all-india-pincode-directory-till-last-month.
22
+ Published under Government Open Data License - India: https://www.data.gov.in/Godl.
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,751 such statuses, each filed under one stage.
152
+ The carrier folders record 2,756 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.
@@ -9404,7 +9404,7 @@
9404
9404
  ]
9405
9405
  },
9406
9406
  {
9407
- "input": "62162057330000611",
9407
+ "input": "62100000000000000",
9408
9408
  "carrier": "unknown",
9409
9409
  "confidence": "low",
9410
9410
  "candidates": [
@@ -170,6 +170,8 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
170
170
  time: scanTime(event),
171
171
  location,
172
172
  description,
173
+ // The code keys the app's review of a scan the map does not stage.
174
+ ...(/^[A-Z0-9_]{1,64}$/.test(code) ? { provider_code: code } : {}),
173
175
  ...(mapped ? { stage: actionStage[mapped] ?? 'in_transit' } : {}),
174
176
  };
175
177
  });
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { CarrierStatus } from '../../core/result/index.js';
18
18
  import type { Stage } from '../../core/status/index.js';
19
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
19
20
  /** Parcel-level order tokens, used only when the newest trace has no action code. */
20
21
  export declare const CAINIAO_STATUS: Map<string, CarrierStatus>;
21
22
  export declare const CAINIAO_ACTION_STATUS: Map<string, CarrierStatus>;
@@ -29,3 +30,9 @@ export declare function cainiaoActionCode(value: unknown): string;
29
30
  * the shipment and to every historical event, exactly as before the move.
30
31
  */
31
32
  export declare function cainiaoStageByStatus(latestActionCode: string): Partial<Record<CarrierStatus, Stage>>;
33
+ /**
34
+ * What the map says about one scan, by its action code. An out-for-delivery
35
+ * code reads as the van or a pickup point by the newest code of the whole
36
+ * reply, so the code alone does not answer it.
37
+ */
38
+ export declare const statusMap: CarrierStatusMap;
@@ -12,6 +12,9 @@ export const CAINIAO_STATUS = new Map([
12
12
  // leg). The parcel-level `status` token vocabulary is unestablished (real
13
13
  // parcels carry DELIVERED/CLEAR_CUSTOMS/transport/pickup/delivered), so the
14
14
  // newest latestTrace.actionCode wins and the token is only a fallback.
15
+ // LH_HO_OUT_SUCCESS, TD_TRANSWH_OUTBOUND, TD_TRANS_ARRIVE_DCP and
16
+ // GTMS_SC_DEPART were seen live on 2026-10-08, between the linehaul's arrival
17
+ // and the local partner's round, each worded as one more leg of the journey.
15
18
  export const CAINIAO_ACTION_STATUS = new Map([
16
19
  ['GWMS_ACCEPT', 'pending'],
17
20
  ['GWMS_PACKAGE', 'pending'],
@@ -32,12 +35,15 @@ export const CAINIAO_ACTION_STATUS = new Map([
32
35
  ['LH_HO_AIRLINE', 'in_transit'],
33
36
  ['LH_DEPART', 'in_transit'],
34
37
  ['LH_ARRIVE', 'in_transit'],
38
+ ['LH_HO_OUT_SUCCESS', 'in_transit'],
35
39
  ['LH_POST_COLLECTION', 'in_transit'],
36
40
  ['COMMON_INTRANSIT', 'in_transit'],
37
41
  ['TD_TRANS_DEPART', 'in_transit'],
38
42
  ['TD_TRANS_ARRIVE', 'in_transit'],
39
43
  ['TD_TRANS_DEPART_C', 'in_transit'],
40
44
  ['TD_TRANS_ARRIVE_C', 'in_transit'],
45
+ ['TD_TRANSWH_OUTBOUND', 'in_transit'],
46
+ ['TD_TRANS_ARRIVE_DCP', 'in_transit'],
41
47
  ['CC_HO_IN_SUCCESS', 'in_transit'],
42
48
  ['CC_IM_START', 'in_transit'],
43
49
  ['CC_IM_SUCCESS', 'in_transit'],
@@ -45,6 +51,7 @@ export const CAINIAO_ACTION_STATUS = new Map([
45
51
  ['CC_IM_FAILURE', 'exception'],
46
52
  ['CC_IM_EXCEPTION', 'exception'],
47
53
  ['GTMS_ACCEPT', 'in_transit'],
54
+ ['GTMS_SC_DEPART', 'in_transit'],
48
55
  ['SC_ARRIVE', 'in_transit'],
49
56
  ['SC_DEPART', 'in_transit'],
50
57
  ['OE_DEPART', 'in_transit'],
@@ -77,3 +84,29 @@ export function cainiaoStageByStatus(latestActionCode) {
77
84
  exception: 'exception',
78
85
  };
79
86
  }
87
+ /**
88
+ * The scans the review queue holds without their action code, from before
89
+ * events kept it, by `normalizeStatusWording` wording without the town
90
+ * bracket. Each is the standard wording Cainiao gives that code.
91
+ */
92
+ const UNCODED_SCANS = new Map([
93
+ ['handed over from linehaul office', 'LH_HO_OUT_SUCCESS'],
94
+ ['leaving transit country/region', 'TD_TRANSWH_OUTBOUND'],
95
+ ['awaiting for transit to final delivery office', 'TD_TRANS_ARRIVE_DCP'],
96
+ ['departed from destination country/region sorting center', 'GTMS_SC_DEPART'],
97
+ ]);
98
+ /**
99
+ * What the map says about one scan, by its action code. An out-for-delivery
100
+ * code reads as the van or a pickup point by the newest code of the whole
101
+ * reply, so the code alone does not answer it.
102
+ */
103
+ export const statusMap = {
104
+ stage: (code, wording) => {
105
+ const action = code ? cainiaoActionCode(code) : UNCODED_SCANS.get(wording.replace(/^\[[^\]]*\]\s*/, ''));
106
+ const status = action ? CAINIAO_ACTION_STATUS.get(action) : undefined;
107
+ if (!status || status === 'out_for_delivery')
108
+ return undefined;
109
+ return cainiaoStageByStatus('')[status] ?? 'in_transit';
110
+ },
111
+ gaps: [],
112
+ };
@@ -183,6 +183,15 @@
183
183
  "firstSeen": "2026-09-12",
184
184
  "note": ""
185
185
  },
186
+ {
187
+ "code": "LH_HO_OUT_SUCCESS",
188
+ "wording": "Handed over from linehaul office",
189
+ "language": "en",
190
+ "stage": "in_transit",
191
+ "confirmedBy": "live",
192
+ "firstSeen": "2026-10-08",
193
+ "note": "Seen live after the linehaul office arrival, before the transit country scans."
194
+ },
186
195
  {
187
196
  "code": "LH_POST_COLLECTION",
188
197
  "stage": "in_transit",
@@ -225,6 +234,24 @@
225
234
  "firstSeen": "2026-09-12",
226
235
  "note": ""
227
236
  },
237
+ {
238
+ "code": "TD_TRANSWH_OUTBOUND",
239
+ "wording": "Leaving transit country/region",
240
+ "language": "en",
241
+ "stage": "in_transit",
242
+ "confirmedBy": "live",
243
+ "firstSeen": "2026-10-08",
244
+ "note": "Seen live between the arrival in and the departure from the transit country."
245
+ },
246
+ {
247
+ "code": "TD_TRANS_ARRIVE_DCP",
248
+ "wording": "Awaiting for transit to final delivery office",
249
+ "language": "en",
250
+ "stage": "in_transit",
251
+ "confirmedBy": "live",
252
+ "firstSeen": "2026-10-08",
253
+ "note": "Seen live after customs or the transit country, before the local partner received the parcel."
254
+ },
228
255
  {
229
256
  "code": "CC_HO_IN_SUCCESS",
230
257
  "stage": "in_transit",
@@ -274,6 +301,15 @@
274
301
  "firstSeen": "2026-09-12",
275
302
  "note": ""
276
303
  },
304
+ {
305
+ "code": "GTMS_SC_DEPART",
306
+ "wording": "Departed from destination country/region sorting center",
307
+ "language": "en",
308
+ "stage": "in_transit",
309
+ "confirmedBy": "live",
310
+ "firstSeen": "2026-10-08",
311
+ "note": "Seen live between the local partner receiving the parcel and its round."
312
+ },
277
313
  {
278
314
  "code": "SC_ARRIVE",
279
315
  "stage": "in_transit",
@@ -122,6 +122,8 @@ export function parseCorreosSpainTrackingResponse(payload, trackingNumber, exped
122
122
  time: time.iso,
123
123
  location: '',
124
124
  description,
125
+ // The code keys the app's review of a scan the map does not stage.
126
+ ...(/^[A-Z0-9.]{1,32}$/.test(code) ? { provider_code: code } : {}),
125
127
  ...(classified ? { stage: classified.stage } : {}),
126
128
  },
127
129
  classified,
@@ -13,5 +13,8 @@
13
13
  * out for delivery → failed attempt → office hold → collected).
14
14
  */
15
15
  import type { ClassifiedStatus } from '../../core/status/index.js';
16
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
16
17
  /** The stage and status for a Correos `codEvento`, or undefined when unmapped. */
17
18
  export declare function classifyCorreosSpainStatus(code: string): ClassifiedStatus | undefined;
19
+ /** What the map says about one scan, by its `codEvento`. */
20
+ export declare const statusMap: CarrierStatusMap;
@@ -63,3 +63,19 @@ const EVENT_STATUS = {
63
63
  export function classifyCorreosSpainStatus(code) {
64
64
  return EVENT_STATUS[code];
65
65
  }
66
+ /**
67
+ * The scans the review queue holds without their code, from before events
68
+ * kept it, by `normalizeStatusWording` wording. Each is the summary Correos
69
+ * gives that code.
70
+ */
71
+ const UNCODED_SCANS = new Map([
72
+ ['a disposición del destinatario', 'G01L020V'],
73
+ ]);
74
+ /** What the map says about one scan, by its `codEvento`. */
75
+ export const statusMap = {
76
+ stage: (code, wording) => {
77
+ const key = code ? code.toLocaleUpperCase('en-US') : UNCODED_SCANS.get(wording);
78
+ return key ? classifyCorreosSpainStatus(key)?.stage : undefined;
79
+ },
80
+ gaps: [],
81
+ };
@@ -121,5 +121,6 @@ export const statusMap = {
121
121
  { code: 'SPE', note: 'A delivery estimate or a changed delivery day, not a scan.' },
122
122
  { code: 'MSDLO', note: 'The notice DPD emailed for the delivery round, not a scan.' },
123
123
  { code: 'MIDLI', note: "The recipient's delivery instruction, not a scan." },
124
+ { code: 'ENA', note: "A data exchange inside DPD's systems, not a scan: it comes before the parcel reaches the origin depot." },
124
125
  ],
125
126
  };
@@ -207,6 +207,14 @@
207
207
  "confirmedBy": "live",
208
208
  "firstSeen": "2026-10-05",
209
209
  "note": "The recipient's delivery instruction, not a scan."
210
+ },
211
+ {
212
+ "code": "ENA",
213
+ "wording": "We are exchanging data internally",
214
+ "language": "en",
215
+ "confirmedBy": "live",
216
+ "firstSeen": "2026-10-02",
217
+ "note": "A data exchange inside DPD's systems, not a scan; seen twice before the parcel reached the origin depot."
210
218
  }
211
219
  ]
212
220
  }
@@ -16,6 +16,7 @@
16
16
  * pages kept by the Internet Archive.
17
17
  */
18
18
  import type { ClassifiedStatus } from '../../core/status/index.js';
19
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
19
20
  /**
20
21
  * Lowercase, accent-free, punctuation-free form used for every wording
21
22
  * comparison. A spacing accent typed for an apostrophe ("l´expéditeur")
@@ -24,3 +25,9 @@ import type { ClassifiedStatus } from '../../core/status/index.js';
24
25
  export declare function comparableText(value: string): string;
25
26
  export declare function includesAny(value: string, phrases: string[]): boolean;
26
27
  export declare function classifyStatus(description: string): ClassifiedStatus;
28
+ /**
29
+ * What the map says about one scan. The trace page has no codes, so the
30
+ * wording is the whole key, and wording the map does not recognize gets no
31
+ * stage, as in the adapter.
32
+ */
33
+ export declare const statusMap: CarrierStatusMap;
@@ -1,4 +1,4 @@
1
- import { clean } from '../../core/transport/index.js';
1
+ import { clean } from '../../core/transport/text.js';
2
2
  /**
3
3
  * Lowercase, accent-free, punctuation-free form used for every wording
4
4
  * comparison. A spacing accent typed for an apostrophe ("l´expéditeur")
@@ -106,3 +106,15 @@ export function classifyStatus(description) {
106
106
  // package's "omit the stage when it is not mapped" rule; the adapter drops it.
107
107
  return { status: 'unknown', stage: 'in_transit' };
108
108
  }
109
+ /**
110
+ * What the map says about one scan. The trace page has no codes, so the
111
+ * wording is the whole key, and wording the map does not recognize gets no
112
+ * stage, as in the adapter.
113
+ */
114
+ export const statusMap = {
115
+ stage: (code, wording) => {
116
+ const classified = code ? null : classifyStatus(wording);
117
+ return classified && classified.status !== 'unknown' ? classified.stage : undefined;
118
+ },
119
+ gaps: [],
120
+ };
@@ -2,7 +2,7 @@
2
2
  "carrier": "hermes",
3
3
  "records": [
4
4
  {
5
- "number": "62162057330000611",
5
+ "number": "62100000000000000",
6
6
  "role": "shipment",
7
7
  "evidence": "synthetic",
8
8
  "derivedFrom": "official_shape",
@@ -14,7 +14,7 @@
14
14
  "dhl-ecommerce"
15
15
  ]
16
16
  },
17
- "note": "Made-up number that follows the published format. Filed under Hermes Einrichtungs-Service, which no rule claims."
17
+ "note": "Made-up number in the 17-digit parcel-number shape. Hermes rejects it, so it opens no order. Filed under Hermes Einrichtungs-Service, which no rule claims."
18
18
  }
19
19
  ]
20
20
  }
@@ -9,7 +9,7 @@ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
9
9
  import { calendarDay } from '../../core/time/index.js';
10
10
  import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, TRAWL_TRANSPORT_ALLOWANCE_MS, TrawlClient } from '../../core/transport/index.js';
11
11
  import { isRecord } from '../../core/types.js';
12
- import { UPS_PROGRESS_STATUS, upsActivityStage, upsStatus } from './status.js';
12
+ import { UPS_PROGRESS_STATUS, upsActivityStage, upsBannerStage, upsStatus } from './status.js';
13
13
  const TRACKING_BASE = 'https://www.ups.com/track';
14
14
  const STATUS_API = 'https://webapis.ups.com/track/api/Track/GetStatus?loc=en_US';
15
15
  const TRAWL_PROVIDER = 'TRAWL while fetching UPS';
@@ -180,9 +180,10 @@ export function parseUPSTrackingHtml(page, trackingNumber) {
180
180
  // town, not where the parcel is, so it never becomes the banner's place.
181
181
  const location = clean($('#stApp_deliveredToAddress').last().text());
182
182
  // The banner has no clock, so its event carries none.
183
+ const bannerStage = upsBannerStage(statusText);
183
184
  const events = currentStatus === 'unknown'
184
185
  ? []
185
- : [{ ...(location ? { location } : {}), description: statusText }];
186
+ : [{ ...(location ? { location } : {}), description: statusText, ...(bannerStage ? { stage: bannerStage } : {}) }];
186
187
  return {
187
188
  status: currentStatus,
188
189
  last_status_text: statusText,
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { CarrierStatus } from '../../core/result/index.js';
15
15
  import type { Stage } from '../../core/status/index.js';
16
- import type { CarrierStatusMap } from '../../core/status/statusMap.js';
16
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
17
17
  /**
18
18
  * Classify UPS status prose. `hasEvents` reports that the shipment already has
19
19
  * a scan history, which is what makes unrecognized wording "moving" rather
@@ -27,5 +27,7 @@ export declare function upsStatus(text: string, hasEvents?: boolean): CarrierSta
27
27
  export declare const UPS_PROGRESS_STATUS: Readonly<Record<string, CarrierStatus>>;
28
28
  /** The stage a scan's `actCode` stands for, or undefined when the code is not mapped. */
29
29
  export declare function upsActivityStage(code: string): Stage | undefined;
30
- /** What the map says about one scan, by its activity code. */
30
+ /** The stage of the rendered page's banner, or undefined for a milestone it does not name. */
31
+ export declare function upsBannerStage(text: string): Stage | undefined;
32
+ /** What the map says about one scan: by its activity code, or as the banner without one. */
31
33
  export declare const statusMap: CarrierStatusMap;
@@ -1,3 +1,4 @@
1
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
1
2
  const EXCEPTION_TERMS = [
2
3
  'return to sender', 'returned', 'delivery attempted', 'we missed you',
3
4
  'not delivered', 'exception', 'action required',
@@ -76,8 +77,23 @@ const ACTIVITY_STAGES = new Map([
76
77
  export function upsActivityStage(code) {
77
78
  return ACTIVITY_STAGES.get(code.trim().toUpperCase());
78
79
  }
79
- /** What the map says about one scan, by its activity code. */
80
+ /**
81
+ * The milestone the rendered page's banner names, the only wording the
82
+ * adapter reads without an activity code. Scan wording without its code is
83
+ * not read: "Arrived at Facility" is both the origin scan and an arrival.
84
+ */
85
+ const BANNER_STAGES = new Map([
86
+ ['label created', 'registered'],
87
+ ['on the way', 'in_transit'],
88
+ ['out for delivery', 'out_for_delivery'],
89
+ ['delivered', 'delivered'],
90
+ ]);
91
+ /** The stage of the rendered page's banner, or undefined for a milestone it does not name. */
92
+ export function upsBannerStage(text) {
93
+ return BANNER_STAGES.get(normalizeStatusWording(text));
94
+ }
95
+ /** What the map says about one scan: by its activity code, or as the banner without one. */
80
96
  export const statusMap = {
81
- stage: (code) => (code ? upsActivityStage(code) : undefined),
97
+ stage: (code, wording) => (code ? upsActivityStage(code) : upsBannerStage(wording)),
82
98
  gaps: [],
83
99
  };
@@ -49,17 +49,17 @@
49
49
  { "wording": "Not Delivered", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "Adapter result status: exception." },
50
50
  { "wording": "Exception", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "Adapter result status: exception." },
51
51
  { "wording": "Action Required", "language": "en", "stage": "failed_attempt", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "Adapter result status: exception." },
52
- { "wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
52
+ { "wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "As the rendered page's banner, which has no code, it gets this stage from the map." },
53
53
  { "wording": "Left at", "language": "en", "stage": "delivered", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "Prefix of UPS's \"Left at: Front Door\" wording; the location detail itself is never retained." },
54
- { "wording": "Out For Delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "Seen as \"Out For Delivery Today\" in fixtures/out-for-delivery.json." },
55
- { "wording": "On the Way", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
54
+ { "wording": "Out For Delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "Seen as \"Out For Delivery Today\" in fixtures/out-for-delivery.json. As the rendered page's banner, which has no code, it gets this stage from the map." },
55
+ { "wording": "On the Way", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "As the rendered page's banner, which has no code, it gets this stage from the map." },
56
56
  { "wording": "In Transit", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
57
57
  { "wording": "We Have Your Package", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
58
58
  { "wording": "First UPS Possession", "language": "en", "stage": "accepted", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "Adapter result status: in_transit." },
59
59
  { "wording": "Departed", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "Seen as \"Departed from Facility\"." },
60
60
  { "wording": "Arrived", "language": "en", "stage": "in_transit", "confirmedBy": "fixture", "firstSeen": "2026-09-12", "note": "Seen as \"Arrived at Facility\"." },
61
61
  { "wording": "Processing at UPS Facility", "language": "en", "stage": "in_transit", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
62
- { "wording": "Label Created", "language": "en", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
62
+ { "wording": "Label Created", "language": "en", "stage": "registered", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "The prose reads it as status pending. As the rendered page's banner, which has no code, it gets this stage from the map." },
63
63
  { "wording": "Manifest Upload", "language": "en", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
64
64
  { "wording": "Shipment Ready for UPS", "language": "en", "stage": "pending", "confirmedBy": "prior-art", "firstSeen": "2026-09-12", "note": "" },
65
65
  { "wording": "Delivered to UPS Access Point™ location and awaiting customer pickup.", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "prior-art", "firstSeen": "2026-09-20", "note": "Checked before the delivered wording, which the sentence also contains." }
@@ -8,10 +8,13 @@
8
8
  * `statusMap` next to its map in `status.ts`; carriers without one answer
9
9
  * `unknown`.
10
10
  */
11
+ import { statusMap as aliexpress } from '../../carriers/aliexpress/status.js';
11
12
  import { statusMap as chronopost } from '../../carriers/chronopost/status.js';
13
+ import { statusMap as correosSpain } from '../../carriers/correos-spain/status.js';
12
14
  import { statusMap as dhlExpress } from '../../carriers/dhl-express/status.js';
13
15
  import { statusMap as dpd } from '../../carriers/dpd/status.js';
14
16
  import { statusMap as dpdDe } from '../../carriers/dpd-de/status.js';
17
+ import { statusMap as dpdFr } from '../../carriers/dpd-fr/status.js';
15
18
  import { statusMap as laPoste } from '../../carriers/la-poste/status.js';
16
19
  import { statusMap as postlogistics } from '../../carriers/postlogistics/status.js';
17
20
  import { statusMap as swissPost } from '../../carriers/swiss-post/status.js';
@@ -22,8 +25,8 @@ import { normalizeStatusWording } from '../status/statusMap.js';
22
25
  import { CARRIER_DEFINITIONS } from './definitions.js';
23
26
  /** By adapter: a carrier served by another's adapter, as Delivengo by La Poste's, reads that map. */
24
27
  export const STATUS_MAPS = {
25
- chronopost, 'dhl-express': dhlExpress, dpd, 'dpd-de': dpdDe, 'la-poste': laPoste, postlogistics, 'swiss-post': swissPost, tnt, ups,
26
- yunexpress,
28
+ aliexpress, chronopost, 'correos-spain': correosSpain, 'dhl-express': dhlExpress, dpd, 'dpd-de': dpdDe, 'dpd-fr': dpdFr,
29
+ 'la-poste': laPoste, postlogistics, 'swiss-post': swissPost, tnt, ups, yunexpress,
27
30
  };
28
31
  const UNKNOWN = { kind: 'unknown' };
29
32
  function carrierStatusMap(carrier) {
@@ -1,5 +1,6 @@
1
1
  [
2
2
  { "country": "FR", "names": ["Roissy"], "town": "Roissy-en-France", "airport": "CDG" },
3
3
  { "country": "GB", "names": ["Heathrow"], "town": "London", "airport": "LHR" },
4
- { "country": "GB", "names": ["East Midlands"], "town": "Castle Donington", "airport": "EMA" }
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
6
  ]
@@ -5,6 +5,7 @@ import facilityList from './facilities.json' with { type: 'json' };
5
5
  import hubList from './hubs.json' with { type: 'json' };
6
6
  import { nameKey, nameKeys } from './names.mjs';
7
7
  const DATA = new URL('./places.tsv.br', import.meta.url);
8
+ const PINCODES = new URL('./pincodes.json', import.meta.url);
8
9
  // Words for facilities and services rather than places ("Agence DPD de La Crau", "PAKETZENTRUM", "ISC NEW YORK NY(USPS)").
9
10
  const FACILITY_WORDS = new Set(`
10
11
  agence agency air airport area branch bureau cedex cidex center centre centro centrum clasificacion colis
@@ -53,9 +54,11 @@ const HUB_THOUSANDS = 50;
53
54
  const NAMESAKE_THOUSANDS = 100;
54
55
  // Swiss Post scans end in the site's six-digit number: "Zürich Briefzentrum 801050".
55
56
  const FACILITIES = new Map(facilityList.map((facility) => [`${facility.country}:${facility.code}`, facility]));
56
- // Names carriers give a hub instead of its town: "ROISSY" is Paris-Charles de Gaulle, not Roissy-en-Brie.
57
+ // 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
59
  const HUBS = new Map(hubList.flatMap((hub) => hub.names.map((name) => [nameKey(name), hub])));
58
60
  let loaded = null;
61
+ let pincodes = null;
59
62
  function gazetteer() {
60
63
  if (loaded)
61
64
  return loaded;
@@ -225,14 +228,16 @@ function cjkPhrases(field) {
225
228
  }
226
229
  return found;
227
230
  }
228
- /** The places a phrase can name. A hub's nickname replaces the towns of its country that share it. */
231
+ /** The places a phrase can name. A hub's name replaces the towns of its country that share it. */
229
232
  function candidates(data, key) {
230
233
  const hub = HUBS.get(key);
231
- const airport = hub && data.airports.get(hub.airport);
234
+ // At its airport, or at its own point when its village is too small for the gazetteer.
235
+ const airport = hub?.airport ? data.airports.get(hub.airport) : undefined;
236
+ const point = airport ?? (hub?.latitude !== undefined && hub.longitude !== undefined ? { latitude: hub.latitude, longitude: hub.longitude } : undefined);
232
237
  const towns = [data.keys.get(key) ?? []].flat().flatMap((entry) => {
233
238
  const index = entry < 0 ? ~entry : entry;
234
239
  const country = data.codes[data.country[index]];
235
- if (airport && country === hub.country)
240
+ if (point && country === hub.country)
236
241
  return [];
237
242
  return [{
238
243
  country, admin1: data.codes[data.admin1[index]] ?? '', admin2: data.codes[data.admin2[index]] ?? '',
@@ -240,11 +245,11 @@ function candidates(data, key) {
240
245
  alternate: entry < 0, name: data.names[index],
241
246
  }];
242
247
  });
243
- if (!airport)
248
+ if (!hub || !point)
244
249
  return towns;
245
250
  return [...towns, {
246
- country: hub.country, admin1: '', admin2: '', latitude: airport.latitude, longitude: airport.longitude,
247
- thousands: HUB_THOUSANDS, alternate: false, name: hub.town, site: airport.name, hub: true,
251
+ country: hub.country, admin1: '', admin2: '', latitude: point.latitude, longitude: point.longitude,
252
+ thousands: HUB_THOUSANDS, alternate: false, name: hub.town, ...(airport ? { site: airport.name } : {}), hub: true,
248
253
  }];
249
254
  }
250
255
  /**
@@ -316,6 +321,8 @@ export function locatePlace(location, hints = {}) {
316
321
  const outer = regions.filter((region) => !regions.some((other) => other.field === region.field
317
322
  && other.start < region.end && other.end > region.start && other.end - other.start > region.end - region.start));
318
323
  let best = null;
324
+ // Whether the text names a town at all, even one nothing confirms.
325
+ let namesTown = false;
319
326
  for (const [fieldIndex, list] of fieldPhrases.entries()) {
320
327
  for (const phrase of list) {
321
328
  const own = data.regions.get(phrase.key) ?? [];
@@ -330,7 +337,9 @@ export function locatePlace(location, hints = {}) {
330
337
  // ("Santa Catarina" on a Brazilian parcel).
331
338
  const named = phrase.whole ? own.filter((region) => (!country || region.country === country)
332
339
  && (fieldIndex > 0 || region.country === country || hinted.includes(region.country))) : [];
333
- for (const place of candidates(data, phrase.key)) {
340
+ const towns = candidates(data, phrase.key);
341
+ namesTown ||= towns.length > 0;
342
+ for (const place of towns) {
334
343
  // A town may still be named like the region it lies in: Moscow in "…, Moscow",
335
344
  // Sofia in its own "Sofia-Capital" rather than the province of Sofia around it.
336
345
  const namesRegion = ` ${(data.regionNames.get(`${place.country}.${place.admin1}`) ?? []).join(' , ')} `.includes(` ${phrase.key} `);
@@ -380,6 +389,13 @@ export function locatePlace(location, hints = {}) {
380
389
  ...(place.site ? { site: place.site } : airport ? { site: airport.name } : {}),
381
390
  };
382
391
  }
392
+ // India Post ends an office's name with its PIN code, and abbreviates some names
393
+ // past reading ("KOL AP TMO 700052"): the PIN's town places a scan that names no
394
+ // town, when the scan or the parcel is in India. A town nothing confirmed is no
395
+ // abbreviation, and its six digits may be a Swiss Post site ("Daillens … 131070").
396
+ const pin = !namesTown && (country === 'IN' || (!country && hinted.includes('IN'))) ? pinPlace(data, rest) : null;
397
+ if (pin)
398
+ return pin;
383
399
  // A region alone places its country: "Guangdong Province", or "Santa Catarina"
384
400
  // on a Brazilian parcel, though Cape Verde has one too.
385
401
  if (!country) {
@@ -423,6 +439,40 @@ function airportFor(data, text, field, town, written, country) {
423
439
  return serving.length === 1 ? serving[0] : null;
424
440
  }
425
441
  const round = (value) => Math.round(value * 1000) / 1000;
442
+ /**
443
+ * The town of the Indian PIN code a scan ends with. A foreign airport's code, alone or
444
+ * after its country's, names an office of exchange abroad ("Office - FRA 400051",
445
+ * "Office - DEFRA …"): the PIN is then the Indian office's, not where the scan happened.
446
+ */
447
+ function pinPlace(data, text) {
448
+ const pin = /(?:^|\s)([1-8]\d{5})$/.exec(text)?.[1];
449
+ // Russian, Chinese and other six-digit postcodes come with their own script.
450
+ if (!pin || /[^\x20-\x7e]/.test(text))
451
+ return null;
452
+ for (const [, prefix, code] of text.matchAll(/\b([A-Z]{2})?([A-Z]{3})\b/g)) {
453
+ const airport = FACILITY_WORDS.has(code.toLowerCase()) ? undefined : data.airports.get(code);
454
+ if (airport && airport.country !== 'IN' && (!prefix || prefix === airport.country))
455
+ return null;
456
+ }
457
+ pincodes ??= JSON.parse(readFileSync(PINCODES, 'utf8'));
458
+ const { ranges, towns } = pincodes;
459
+ const value = Number(pin);
460
+ let low = 0;
461
+ let high = ranges.length - 1;
462
+ while (low <= high) {
463
+ const middle = (low + high) >> 1;
464
+ const [first, last, town] = ranges[middle];
465
+ if (value < first)
466
+ high = middle - 1;
467
+ else if (value > last)
468
+ low = middle + 1;
469
+ else {
470
+ const [name, latitude, longitude] = towns[town];
471
+ return { latitude, longitude, precision: 'city', country: 'IN', name };
472
+ }
473
+ }
474
+ return null;
475
+ }
426
476
  /** A sorting centre the table knows, by its number and the town its name starts with. */
427
477
  function facilityPlace(text) {
428
478
  const match = /^(.*\S)\s+(\d{6})$/.exec(text);