universal-parcel-scraper 0.4.0-main.320 → 0.5.0-main.322

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/data/catalog.json CHANGED
@@ -468,7 +468,8 @@
468
468
  "timezone": "UTC",
469
469
  "tracking": {
470
470
  "mode": "automatic",
471
- "adapter": "dhl-ecommerce"
471
+ "adapter": "dhl-ecommerce",
472
+ "recognitionRank": 23
472
473
  },
473
474
  "canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
474
475
  "trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
@@ -1973,7 +1974,8 @@
1973
1974
  "timezone": "Europe/Helsinki",
1974
1975
  "tracking": {
1975
1976
  "mode": "automatic",
1976
- "adapter": "posti"
1977
+ "adapter": "posti",
1978
+ "recognitionRank": 27
1977
1979
  },
1978
1980
  "canaryUrl": "https://www.posti.fi/en/tracking",
1979
1981
  "trackingUrlTemplate": "https://www.posti.fi/en/tracking/{trackingNumber}",
@@ -1985,7 +1987,14 @@
1985
1987
  "path": "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$"
1986
1988
  }
1987
1989
  ],
1988
- "detectionRules": []
1990
+ "detectionRules": [
1991
+ {
1992
+ "pattern": "^[A-Z]{2}\\d{9}FI$",
1993
+ "confidence": "low",
1994
+ "checksum": "s10",
1995
+ "preferred": true
1996
+ }
1997
+ ]
1989
1998
  },
1990
1999
  "correos-express": {
1991
2000
  "displayName": "Correos Express",
@@ -142,6 +142,7 @@
142
142
  "asked": [
143
143
  "tnt",
144
144
  "correos-express",
145
+ "dhl-ecommerce",
145
146
  "canada-post"
146
147
  ]
147
148
  },
@@ -1124,6 +1125,7 @@
1124
1125
  "asked": [
1125
1126
  "tnt",
1126
1127
  "correos-express",
1128
+ "dhl-ecommerce",
1127
1129
  "canada-post"
1128
1130
  ]
1129
1131
  },
@@ -1140,6 +1142,7 @@
1140
1142
  "asked": [
1141
1143
  "tnt",
1142
1144
  "correos-express",
1145
+ "dhl-ecommerce",
1143
1146
  "canada-post"
1144
1147
  ]
1145
1148
  },
@@ -1622,6 +1625,7 @@
1622
1625
  "asked": [
1623
1626
  "tnt",
1624
1627
  "correos-express",
1628
+ "dhl-ecommerce",
1625
1629
  "canada-post"
1626
1630
  ]
1627
1631
  },
@@ -1638,6 +1642,7 @@
1638
1642
  "asked": [
1639
1643
  "tnt",
1640
1644
  "correos-express",
1645
+ "dhl-ecommerce",
1641
1646
  "canada-post"
1642
1647
  ]
1643
1648
  },
@@ -1731,6 +1736,9 @@
1731
1736
  "confidence": "low",
1732
1737
  "candidates": [
1733
1738
  "dhl-ecommerce"
1739
+ ],
1740
+ "asked": [
1741
+ "dhl-ecommerce"
1734
1742
  ]
1735
1743
  },
1736
1744
  {
@@ -1746,6 +1754,7 @@
1746
1754
  "asked": [
1747
1755
  "tnt",
1748
1756
  "correos-express",
1757
+ "dhl-ecommerce",
1749
1758
  "canada-post"
1750
1759
  ]
1751
1760
  },
@@ -1922,6 +1931,7 @@
1922
1931
  "asked": [
1923
1932
  "tnt",
1924
1933
  "correos-express",
1934
+ "dhl-ecommerce",
1925
1935
  "canada-post"
1926
1936
  ]
1927
1937
  },
@@ -2345,6 +2355,9 @@
2345
2355
  "confidence": "low",
2346
2356
  "candidates": [
2347
2357
  "dhl-ecommerce"
2358
+ ],
2359
+ "asked": [
2360
+ "dhl-ecommerce"
2348
2361
  ]
2349
2362
  },
2350
2363
  {
@@ -2438,6 +2451,7 @@
2438
2451
  "asked": [
2439
2452
  "tnt",
2440
2453
  "correos-express",
2454
+ "dhl-ecommerce",
2441
2455
  "canada-post"
2442
2456
  ]
2443
2457
  },
@@ -2454,6 +2468,7 @@
2454
2468
  "asked": [
2455
2469
  "tnt",
2456
2470
  "correos-express",
2471
+ "dhl-ecommerce",
2457
2472
  "canada-post"
2458
2473
  ]
2459
2474
  },
@@ -2594,6 +2609,7 @@
2594
2609
  "asked": [
2595
2610
  "tnt",
2596
2611
  "correos-express",
2612
+ "dhl-ecommerce",
2597
2613
  "canada-post"
2598
2614
  ]
2599
2615
  },
@@ -2970,6 +2986,7 @@
2970
2986
  "asked": [
2971
2987
  "tnt",
2972
2988
  "correos-express",
2989
+ "dhl-ecommerce",
2973
2990
  "canada-post"
2974
2991
  ]
2975
2992
  },
@@ -3101,6 +3118,9 @@
3101
3118
  "confidence": "low",
3102
3119
  "candidates": [
3103
3120
  "dhl-ecommerce"
3121
+ ],
3122
+ "asked": [
3123
+ "dhl-ecommerce"
3104
3124
  ]
3105
3125
  },
3106
3126
  {
@@ -3112,7 +3132,8 @@
3112
3132
  "colis-prive"
3113
3133
  ],
3114
3134
  "asked": [
3115
- "colis-prive"
3135
+ "colis-prive",
3136
+ "dhl-ecommerce"
3116
3137
  ]
3117
3138
  },
3118
3139
  {
@@ -3281,6 +3302,9 @@
3281
3302
  "confidence": "high",
3282
3303
  "candidates": [
3283
3304
  "intl-post"
3305
+ ],
3306
+ "asked": [
3307
+ "chronopost"
3284
3308
  ]
3285
3309
  },
3286
3310
  {
@@ -3428,6 +3452,9 @@
3428
3452
  "confidence": "high",
3429
3453
  "candidates": [
3430
3454
  "intl-post"
3455
+ ],
3456
+ "asked": [
3457
+ "chronopost"
3431
3458
  ]
3432
3459
  },
3433
3460
  {
@@ -3473,6 +3500,29 @@
3473
3500
  "spring-gds"
3474
3501
  ]
3475
3502
  },
3503
+ {
3504
+ "input": "CP123456785FI",
3505
+ "carrier": "intl-post",
3506
+ "confidence": "high",
3507
+ "candidates": [
3508
+ "intl-post"
3509
+ ],
3510
+ "asked": [
3511
+ "posti",
3512
+ "chronopost"
3513
+ ]
3514
+ },
3515
+ {
3516
+ "input": "CP123456789FI",
3517
+ "carrier": "unknown",
3518
+ "confidence": "low",
3519
+ "candidates": [
3520
+ "chronopost"
3521
+ ],
3522
+ "asked": [
3523
+ "chronopost"
3524
+ ]
3525
+ },
3476
3526
  {
3477
3527
  "input": "CP123456789PL",
3478
3528
  "carrier": "unknown",
@@ -3490,6 +3540,9 @@
3490
3540
  "confidence": "high",
3491
3541
  "candidates": [
3492
3542
  "intl-post"
3543
+ ],
3544
+ "asked": [
3545
+ "chronopost"
3493
3546
  ]
3494
3547
  },
3495
3548
  {
@@ -4177,6 +4230,9 @@
4177
4230
  "confidence": "high",
4178
4231
  "candidates": [
4179
4232
  "intl-post"
4233
+ ],
4234
+ "asked": [
4235
+ "chronopost"
4180
4236
  ]
4181
4237
  },
4182
4238
  {
@@ -4204,6 +4260,9 @@
4204
4260
  "confidence": "high",
4205
4261
  "candidates": [
4206
4262
  "intl-post"
4263
+ ],
4264
+ "asked": [
4265
+ "chronopost"
4207
4266
  ]
4208
4267
  },
4209
4268
  {
@@ -4220,6 +4279,9 @@
4220
4279
  "confidence": "high",
4221
4280
  "candidates": [
4222
4281
  "intl-post"
4282
+ ],
4283
+ "asked": [
4284
+ "chronopost"
4223
4285
  ]
4224
4286
  },
4225
4287
  {
@@ -4236,6 +4298,9 @@
4236
4298
  "confidence": "high",
4237
4299
  "candidates": [
4238
4300
  "intl-post"
4301
+ ],
4302
+ "asked": [
4303
+ "chronopost"
4239
4304
  ]
4240
4305
  },
4241
4306
  {
@@ -4332,6 +4397,9 @@
4332
4397
  "confidence": "high",
4333
4398
  "candidates": [
4334
4399
  "intl-post"
4400
+ ],
4401
+ "asked": [
4402
+ "chronopost"
4335
4403
  ]
4336
4404
  },
4337
4405
  {
@@ -4639,6 +4707,9 @@
4639
4707
  "confidence": "high",
4640
4708
  "candidates": [
4641
4709
  "intl-post"
4710
+ ],
4711
+ "asked": [
4712
+ "chronopost"
4642
4713
  ]
4643
4714
  },
4644
4715
  {
@@ -5040,6 +5111,9 @@
5040
5111
  "confidence": "high",
5041
5112
  "candidates": [
5042
5113
  "intl-post"
5114
+ ],
5115
+ "asked": [
5116
+ "chronopost"
5043
5117
  ]
5044
5118
  },
5045
5119
  {
@@ -479,7 +479,8 @@ var ue = {
479
479
  timezone: "UTC",
480
480
  tracking: {
481
481
  mode: "automatic",
482
- adapter: "dhl-ecommerce"
482
+ adapter: "dhl-ecommerce",
483
+ recognitionRank: 23
483
484
  },
484
485
  canaryUrl: "https://www.dhl.com/ch-en/home/tracking.html",
485
486
  trackingUrlTemplate: "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
@@ -1595,7 +1596,8 @@ var ue = {
1595
1596
  timezone: "Europe/Helsinki",
1596
1597
  tracking: {
1597
1598
  mode: "automatic",
1598
- adapter: "posti"
1599
+ adapter: "posti",
1600
+ recognitionRank: 27
1599
1601
  },
1600
1602
  canaryUrl: "https://www.posti.fi/en/tracking",
1601
1603
  trackingUrlTemplate: "https://www.posti.fi/en/tracking/{trackingNumber}",
@@ -1603,7 +1605,12 @@ var ue = {
1603
1605
  domains: ["posti.fi"],
1604
1606
  path: "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$"
1605
1607
  }],
1606
- detectionRules: []
1608
+ detectionRules: [{
1609
+ pattern: "^[A-Z]{2}\\d{9}FI$",
1610
+ confidence: "low",
1611
+ checksum: "s10",
1612
+ preferred: !0
1613
+ }]
1607
1614
  },
1608
1615
  "correos-express": {
1609
1616
  displayName: "Correos Express",
@@ -7427,6 +7434,7 @@ var Lo = {
7427
7434
  "colis-prive": 30,
7428
7435
  "correos-express": 36,
7429
7436
  "ctt-express": 35,
7437
+ "dhl-ecommerce": 23,
7430
7438
  dpd: 70,
7431
7439
  "gls-ch": 60,
7432
7440
  "gls-de": 50,
@@ -7437,6 +7445,7 @@ var Lo = {
7437
7445
  "ninja-van": 28,
7438
7446
  "nz-post": 32,
7439
7447
  "poczta-polska": 31,
7448
+ posti: 27,
7440
7449
  postlogistics: 26,
7441
7450
  "relais-colis": 25,
7442
7451
  seur: 43,
@@ -7446,20 +7455,26 @@ var Lo = {
7446
7455
  uniuni: 33
7447
7456
  }, Ro = 5;
7448
7457
  function zo(e, t = {}) {
7449
- let n = b(e);
7450
- if (n.confidence !== "low") return [];
7451
- let r = new Set(n.preferred.filter((e) => Lo[e] === void 0).map((e) => Io(e)).filter(Boolean)), i = (e) => [
7458
+ let n = b(e), r = n.carrier === "intl-post";
7459
+ if (n.confidence !== "low" && !r) return [];
7460
+ let i = g(e), a = r ? Object.entries(_).flatMap(([t, n]) => {
7461
+ let r = n.detectionRules.find((t) => t.confidence === "low" && (!t.checksum || t.checksum === "s10") && new RegExp(t.pattern).test(i) && (!t.rawPattern || new RegExp(t.rawPattern).test(e.trim().toUpperCase())));
7462
+ return r ? [{
7463
+ carrier: t,
7464
+ preferred: r.preferred === !0
7465
+ }] : [];
7466
+ }) : [], o = r ? a.map(({ carrier: e }) => e) : n.candidates, s = r ? a.filter((e) => e.preferred).map(({ carrier: e }) => e) : n.preferred, c = new Set(s.filter((e) => Lo[e] === void 0).map((e) => Io(e)).filter(Boolean)), l = (e) => [
7452
7467
  +(e === t.hint),
7453
- +!!n.preferred.includes(e),
7468
+ +!!s.includes(e),
7454
7469
  Lo[e] ?? 0
7455
7470
  ];
7456
- return n.candidates.filter((e) => Lo[e] !== void 0 && we.has(e) && xe(e) !== "universal" && !r.has(Io(e)) && !t.skip?.(e)).map((e) => ({
7471
+ return o.filter((e) => Lo[e] !== void 0 && we.has(e) && xe(e) !== "universal" && !c.has(Io(e)) && !t.skip?.(e)).map((e) => ({
7457
7472
  carrier: e,
7458
- score: i(e)
7473
+ score: l(e)
7459
7474
  })).sort((e, t) => t.score[0] - e.score[0] || t.score[1] - e.score[1] || t.score[2] - e.score[2]).map(({ carrier: t }) => ({
7460
7475
  carrier: t,
7461
7476
  needsInput: Ce(t, e)[0]?.field ?? null,
7462
- preferred: n.preferred.includes(t)
7477
+ preferred: s.includes(t)
7463
7478
  }));
7464
7479
  }
7465
7480
  //#endregion
@@ -1,20 +1,12 @@
1
1
  /**
2
- * DHL eCommerce, through the public recipient endpoint the global tracking
3
- * page calls (`www.dhl.com/utapi`).
4
- *
5
- * The endpoint answers every direct server request with an Akamai crypto
6
- * proof-of-work challenge (HTTP 428) that plain HTTP cannot solve (verified
7
- * 2026-09-10: cookie replay and visiting the page first both still return
8
- * 428), and browser clearance is not transferable back to Node. The lookup
9
- * therefore has a single step: a local Chromium session loads the tracking
10
- * page, the site solves its own challenge and calls the API in that session,
11
- * and the response to the exact requested URL is parsed.
2
+ * DHL eCommerce, through its anonymous Americas Webtrack API first, then
3
+ * the global tracking page's UTAPI for shipments outside Webtrack's scope.
12
4
  *
13
5
  * DHL may answer with a customer-confirmation id instead of the queried
14
6
  * alias, so only one eCommerce shipment from that exact request URL is
15
7
  * accepted, and the id itself is never retained.
16
8
  */
17
- import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
9
+ import { type AdapterFactory, type Recognition, type TrackingContext } from '../../core/adapter/index.js';
18
10
  import { ChallengeError, type CarrierErrorOptions } from '../../core/errors/index.js';
19
11
  import type { CarrierResult } from '../../core/result/index.js';
20
12
  import { type StepRecorder } from '../../core/telemetry/index.js';
@@ -24,6 +16,11 @@ export declare function dhlEcommerceTrackingUrl(number: string): string;
24
16
  /** Only call for the response to the exact requested UTAPI URL. DHL can return
25
17
  * a customer-confirmation id which differs from every queried parcel alias. */
26
18
  export declare function parseDHLEcommerceResponse(payload: unknown): CarrierResult;
19
+ /** Webtrack scopes the read to the requested alias and returns that alias on
20
+ * each package, as its own client requires. An alias can differ from all of
21
+ * the package's carrier identifiers, so accept it only on one identified,
22
+ * unambiguous package. Never accept a bare query echo or a fabricated shell. */
23
+ export declare function parseDHLEcommerceWebtrackResponse(payload: unknown, trackingNumber: string): CarrierResult;
27
24
  /**
28
25
  * The tracking page answered with a challenge status instead of the tracking
29
26
  * application. The name is part of the host's error metadata contract: it
@@ -37,6 +34,8 @@ export declare class DHLEcommerceSessionError extends ChallengeError {
37
34
  export interface DHLEcommerceTrackerOptions extends Omit<UniversalBrowserOptions, 'signal'> {
38
35
  budgetMs?: number;
39
36
  recorder?: StepRecorder;
37
+ fetcher?: typeof fetch;
38
+ userAgent?: string;
40
39
  }
41
40
  export declare class DHLEcommerceTracker {
42
41
  readonly options: DHLEcommerceTrackerOptions;
@@ -45,6 +44,9 @@ export declare class DHLEcommerceTracker {
45
44
  constructor(options?: DHLEcommerceTrackerOptions);
46
45
  /** One browser at a time per instance: a batch must not spawn a Chromium per parcel. */
47
46
  fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
47
+ /** Recognition never starts the global page or spends a browser budget. */
48
+ recognize(trackingNumber: string, context?: TrackingContext): Promise<Recognition>;
49
+ private direct;
48
50
  private browser;
49
51
  }
50
52
  export declare const adapter: AdapterFactory;
@@ -1,30 +1,25 @@
1
1
  /**
2
- * DHL eCommerce, through the public recipient endpoint the global tracking
3
- * page calls (`www.dhl.com/utapi`).
4
- *
5
- * The endpoint answers every direct server request with an Akamai crypto
6
- * proof-of-work challenge (HTTP 428) that plain HTTP cannot solve (verified
7
- * 2026-09-10: cookie replay and visiting the page first both still return
8
- * 428), and browser clearance is not transferable back to Node. The lookup
9
- * therefore has a single step: a local Chromium session loads the tracking
10
- * page, the site solves its own challenge and calls the API in that session,
11
- * and the response to the exact requested URL is parsed.
2
+ * DHL eCommerce, through its anonymous Americas Webtrack API first, then
3
+ * the global tracking page's UTAPI for shipments outside Webtrack's scope.
12
4
  *
13
5
  * DHL may answer with a customer-confirmation id instead of the queried
14
6
  * alias, so only one eCommerce shipment from that exact request URL is
15
7
  * accepted, and the id itself is never retained.
16
8
  */
17
9
  import { DateTime } from 'luxon';
18
- import { ChallengeError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
10
+ import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
11
+ import { ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
19
12
  import { runSteps, singleFlight, takeTurn } from '../../core/runner/index.js';
20
13
  import { countryTimeZone } from '../../core/time/index.js';
21
14
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
22
- import { clean as cleanText, UpstreamHttpError } from '../../core/transport/index.js';
15
+ import { clean as cleanText, fetchBounded, parseJsonBytes, UpstreamHttpError, UpstreamNetworkError, userAgentOf } from '../../core/transport/index.js';
23
16
  import { scrapeUniversalPage } from '../../core/transport/browser.js';
24
17
  import { isRecord } from '../../core/types.js';
25
18
  import { stageFor, statusFor } from './status.js';
26
19
  const PROVIDER = 'DHL eCommerce';
27
20
  const API = 'https://www.dhl.com/utapi';
21
+ const WEBTRACK_API = 'https://api.dhlecs.com/webtrack/v4/tracking';
22
+ const DIRECT_TIMEOUT_MS = 15_000;
28
23
  /** Statuses DHL answers with while its challenge is unsolved. */
29
24
  const CHALLENGE_STATUSES = [401, 403, 419, 428];
30
25
  /**
@@ -117,6 +112,82 @@ export function parseDHLEcommerceResponse(payload) {
117
112
  ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
118
113
  };
119
114
  }
115
+ function webtrackClock(event) {
116
+ const date = clean(event.date, 32);
117
+ const time = clean(event.time, 32);
118
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)
119
+ || !/^\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/.test(time))
120
+ return null;
121
+ const raw = `${date}T${time}`;
122
+ if (!DateTime.fromISO(raw, { zone: 'UTC', setZone: true }).isValid)
123
+ return null;
124
+ const location = clean(event.location, 160);
125
+ const country = location.split(',').at(-1)?.trim().toUpperCase() ?? '';
126
+ const instant = eventTime({ timestamp: raw, location: { address: {
127
+ addressLocality: location, ...(/^[A-Z]{2}$/.test(country) ? { countryCode: country } : {}),
128
+ } } });
129
+ return instant ? { time: instant } : { local_time: raw };
130
+ }
131
+ /** Webtrack scopes the read to the requested alias and returns that alias on
132
+ * each package, as its own client requires. An alias can differ from all of
133
+ * the package's carrier identifiers, so accept it only on one identified,
134
+ * unambiguous package. Never accept a bare query echo or a fabricated shell. */
135
+ export function parseDHLEcommerceWebtrackResponse(payload, trackingNumber) {
136
+ const number = normalizeDHLEcommerceNumber(trackingNumber);
137
+ if (!isRecord(payload) || !Array.isArray(payload.packages) || !Number.isInteger(payload.total)
138
+ || Number(payload.total) < 0 || payload.packages.length > 50 || payload.offset !== 0
139
+ || (payload.errors !== undefined && (!Array.isArray(payload.errors) || payload.errors.length > 0))) {
140
+ throw new SchemaError(PROVIDER, 'DHL eCommerce returned an invalid Webtrack response');
141
+ }
142
+ if (payload.total === 0 && payload.packages.length === 0) {
143
+ // Webtrack covers the Americas network; the global endpoint may still
144
+ // know an item this regional endpoint does not list.
145
+ throw new IndeterminateError(PROVIDER, 'DHL eCommerce Webtrack does not list this shipment', { reason: 'webtrack_not_found' });
146
+ }
147
+ if (payload.total !== 1 || payload.packages.length !== 1 || !isRecord(payload.packages[0])) {
148
+ throw new SchemaError(PROVIDER, 'DHL eCommerce did not return one unambiguous Webtrack package');
149
+ }
150
+ const shipment = payload.packages[0];
151
+ const identifiers = [shipment.trackingId, shipment.packageId, shipment.dhlPackageId, shipment.deliveryConfirmationNumber];
152
+ const matches = (value) => typeof value === 'string'
153
+ && value.toUpperCase().replace(/[\s.-]/g, '') === number;
154
+ if (!clean(shipment.tmiUid) || !identifiers.some((value) => clean(value))
155
+ || ![shipment.trackedValue, ...identifiers].some(matches)) {
156
+ throw new SchemaError(PROVIDER, 'DHL eCommerce returned a different or unidentified Webtrack package');
157
+ }
158
+ const summary = clean(shipment.status);
159
+ if (!summary || !Array.isArray(shipment.events) || shipment.events.length > 500
160
+ || shipment.events.some((event) => !isRecord(event))) {
161
+ throw new SchemaError(PROVIDER, 'DHL eCommerce returned invalid Webtrack tracking history');
162
+ }
163
+ const events = shipment.events.flatMap((event) => {
164
+ const description = clean(event.primaryEventDescription);
165
+ const clock = webtrackClock(event);
166
+ if (!description || !clock)
167
+ return [];
168
+ const stage = stageFor({ description });
169
+ return [{ ...clock, description: stage === 'delivered' ? 'Delivered' : description,
170
+ location: clean(event.location, 160), stage }];
171
+ }).sort((left, right) => left.time && right.time ? right.time.localeCompare(left.time) : 0).slice(0, 100);
172
+ const summaryStage = stageFor({ description: summary, statusCode: summary === 'Delivered' ? 'delivered' : undefined });
173
+ // A published scan carries finer semantics than Webtrack's coarse summary.
174
+ const stage = ['delivered', 'returned'].includes(summaryStage) ? summaryStage : events[0]?.stage ?? summaryStage;
175
+ if (events.length === 0) {
176
+ throw new IndeterminateError(PROVIDER, 'DHL eCommerce has not published Webtrack tracking history', { reason: 'webtrack_no_history' });
177
+ }
178
+ const expected = clean(shipment.estimatedDeliveryDate, 32);
179
+ const sender = clean(isRecord(shipment.sender) ? shipment.sender.name : null, 200);
180
+ return {
181
+ status: statusFor(stage), current_stage: stage,
182
+ last_status_text: stage === 'delivered' ? 'Delivered' : summary,
183
+ last_update: events.find((event) => event.time)?.time ?? null,
184
+ expected_delivery: !['delivered', 'returned'].includes(stage) && /^\d{4}-\d{2}-\d{2}$/.test(expected)
185
+ && DateTime.fromISO(expected).isValid ? expected : null,
186
+ timezone: 'UTC', events,
187
+ ...(sender ? { sender_name: sender } : {}),
188
+ ...(stage === 'delivered' && events[0]?.stage === 'delivered' && events[0].time ? { delivered_at: events[0].time } : {}),
189
+ };
190
+ }
120
191
  /**
121
192
  * The tracking page answered with a challenge status instead of the tracking
122
193
  * application. The name is part of the host's error metadata contract: it
@@ -149,7 +220,47 @@ export class DHLEcommerceTracker {
149
220
  async fetch(trackingNumber, context = {}) {
150
221
  const number = normalizeDHLEcommerceNumber(trackingNumber);
151
222
  const timeoutMs = this.options.timeoutMs ?? 45_000;
152
- return takeTurn(this.serialize, PROVIDER, context, ({ signal, budgetMs }) => runSteps({ carrier: 'dhl-ecommerce', budgetMs: budgetMs ?? this.options.budgetMs ?? timeoutMs + 15_000, signal, recorder: this.recorder }, [{ id: 'browser', run: (step) => this.browser(number, Math.max(1, Math.floor(Math.min(timeoutMs, step.remainingMs))), step.signal) }]));
223
+ return takeTurn(this.serialize, PROVIDER, context, ({ signal, budgetMs }) => runSteps({ carrier: 'dhl-ecommerce', budgetMs: budgetMs ?? this.options.budgetMs ?? timeoutMs + 15_000, signal, recorder: this.recorder }, [
224
+ { id: 'direct', run: (step) => this.direct(number, Math.min(DIRECT_TIMEOUT_MS, step.remainingMs), step.signal) },
225
+ { id: 'browser', recovers: (error) => error instanceof ChallengeError || error instanceof UpstreamNetworkError
226
+ || error instanceof UpstreamHttpError && error.status >= 500
227
+ || error instanceof IndeterminateError && ['webtrack_not_found', 'webtrack_no_history'].includes(error.reason ?? ''),
228
+ run: (step) => this.browser(number, Math.max(1, Math.floor(Math.min(timeoutMs, step.remainingMs))), step.signal) },
229
+ ]));
230
+ }
231
+ /** Recognition never starts the global page or spends a browser budget. */
232
+ async recognize(trackingNumber, context = {}) {
233
+ return recognizeFromLookup(async () => {
234
+ const number = normalizeDHLEcommerceNumber(trackingNumber);
235
+ const budget = lookupBudget(context, DIRECT_TIMEOUT_MS, PROVIDER);
236
+ try {
237
+ return await this.direct(number, Math.min(DIRECT_TIMEOUT_MS, budget.remainingMs()), budget.signal);
238
+ }
239
+ catch (error) {
240
+ // A clean regional miss is unknown to this recognition check; it is
241
+ // never a claim that the global DHL network does not know the number.
242
+ if (error instanceof IndeterminateError && error.reason === 'webtrack_not_found')
243
+ throw new NotFoundError(PROVIDER);
244
+ throw error;
245
+ }
246
+ }, () => accepted(() => normalizeDHLEcommerceNumber(trackingNumber)));
247
+ }
248
+ async direct(number, timeoutMs, signal) {
249
+ try {
250
+ const { response, bytes } = await fetchBounded(WEBTRACK_API, { method: 'POST', signal,
251
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': userAgentOf(this.options.userAgent) },
252
+ body: JSON.stringify({ trackedValue: number, offset: 0, locale: 'en-US' }),
253
+ }, { provider: PROVIDER, timeoutMs, maxBytes: 1_000_000, fetcher: this.options.fetcher, allowHttpStatuses: [404, 410] });
254
+ if (!response.ok)
255
+ throw new IndeterminateError(PROVIDER, 'DHL eCommerce Webtrack endpoint is unavailable');
256
+ return parseDHLEcommerceWebtrackResponse(parseJsonBytes(bytes, PROVIDER), number);
257
+ }
258
+ catch (error) {
259
+ if (error instanceof UpstreamHttpError && CHALLENGE_STATUSES.includes(error.status)) {
260
+ throw new DHLEcommerceSessionError(error.status, { cause: error });
261
+ }
262
+ throw error;
263
+ }
153
264
  }
154
265
  async browser(number, timeoutMs, signal) {
155
266
  try {
@@ -170,10 +281,12 @@ export class DHLEcommerceTracker {
170
281
  export const adapter = (environment) => {
171
282
  const tracker = new DHLEcommerceTracker({
172
283
  executablePath: environment.browserExecutablePath ?? undefined, recorder: environment.recorder,
284
+ fetcher: environment.fetcher, userAgent: environment.userAgent,
173
285
  });
174
286
  return {
175
287
  id: 'dhl-ecommerce', recordsSteps: true,
176
- steps: ['browser'],
288
+ steps: ['direct', 'browser'],
177
289
  track: (input, context) => tracker.fetch(input.number, context),
290
+ recognize: (number, context) => tracker.recognize(number, context),
178
291
  };
179
292
  };
@@ -21,8 +21,12 @@
21
21
  "mode": "automatic",
22
22
  "adapter": "dhl-ecommerce",
23
23
  "steps": [
24
+ "direct",
24
25
  "browser"
25
- ]
26
+ ],
27
+ "recognition": {
28
+ "rank": 23
29
+ }
26
30
  },
27
31
  "capabilities": [
28
32
  "history",
@@ -10,7 +10,7 @@ export declare class DPDChallengeError extends ChallengeError {
10
10
  /**
11
11
  * The guest API answered, but the answer proves nothing about the shipment
12
12
  * (unreachable, malformed, unauthenticated, or an HTTP status the guest flow
13
- * handles itself). The rendered page is allowed to recover from it.
13
+ * handles itself, except HTTP 503). The rendered page is allowed to recover from it.
14
14
  */
15
15
  export declare class DPDAPIError extends IndeterminateError {
16
16
  constructor(message: string, options?: CarrierErrorOptions);
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { load } from 'cheerio';
4
4
  import { lookupBudget } from '../../core/adapter/index.js';
5
- import { BudgetExceededError, carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
5
+ import { BudgetExceededError, carrierErrorKind, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
6
6
  import { runSteps } from '../../core/runner/index.js';
7
7
  import { isoTime, explicitOffsetTime, zonedTime } from '../../core/time/index.js';
8
8
  import { TrawlClient, decodeText, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
@@ -19,7 +19,8 @@ import { API_LABELS, PROOF_OF_DELIVERY_SCAN, apiStage, apiStatus, scanStage, wor
19
19
  // `continueWithoutVerification=true`. A rejected postcode (HTTP 400) is
20
20
  // retried once without verification and reported as unverified rather than
21
21
  // failing the lookup.
22
- // - The consignee web page is the fallback when the guest API is inconclusive.
22
+ // - The consignee web page is the fallback when the guest API is inconclusive,
23
+ // except HTTP 503: the service is down, irrespective of the parcel number.
23
24
  // It sits behind Cloudflare, so it is fetched through the browser service's
24
25
  // legacy command API when one is configured and directly otherwise.
25
26
  const TRACKING_BASE = 'https://www.dpdgroup.com/ch/mydpd/my-parcels/incoming';
@@ -54,7 +55,7 @@ export class DPDChallengeError extends ChallengeError {
54
55
  /**
55
56
  * The guest API answered, but the answer proves nothing about the shipment
56
57
  * (unreachable, malformed, unauthenticated, or an HTTP status the guest flow
57
- * handles itself). The rendered page is allowed to recover from it.
58
+ * handles itself, except HTTP 503). The rendered page is allowed to recover from it.
58
59
  */
59
60
  export class DPDAPIError extends IndeterminateError {
60
61
  constructor(message, options) {
@@ -778,6 +779,10 @@ export class DPDTracker {
778
779
  catch (error) {
779
780
  throw new DPDAPIError('DPD guest API is unreachable', { cause: error });
780
781
  }
782
+ // A 503 describes DPD's availability, so the page must not turn it into
783
+ // an answer about the parcel or hold up the caller's other lookups.
784
+ if (result.response.status === 503)
785
+ throw new UpstreamHttpError('DPD guest API', 503);
781
786
  if (!result.response.ok)
782
787
  throw new DPDAPIHttpError(result.response.status);
783
788
  let payload;
@@ -1,4 +1,4 @@
1
- import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
1
+ import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
3
  import type { StepRecorder } from '../../core/telemetry/index.js';
4
4
  export declare function normalizePostiTrackingNumber(raw: string): string;
@@ -1,3 +1,4 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
1
2
  import { CarrierError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
2
3
  import { runSteps } from '../../core/runner/index.js';
3
4
  import { explicitOffsetTime } from '../../core/time/index.js';
@@ -191,5 +192,9 @@ export class PostiTracker {
191
192
  }
192
193
  export const adapter = (environment) => {
193
194
  const tracker = new PostiTracker({ fetcher: environment.fetcher, recorder: environment.recorder });
194
- return { id: 'posti', recordsSteps: true, steps: ['direct', 'refresh'], track: (input, context) => tracker.fetch(input.number, context) };
195
+ return {
196
+ id: 'posti', recordsSteps: true, steps: ['direct', 'refresh'],
197
+ track: (input, context) => tracker.fetch(input.number, context),
198
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizePostiTrackingNumber(number))),
199
+ };
195
200
  };
@@ -18,7 +18,10 @@
18
18
  "steps": [
19
19
  "direct",
20
20
  "refresh"
21
- ]
21
+ ],
22
+ "recognition": {
23
+ "rank": 27
24
+ }
22
25
  },
23
26
  "capabilities": [
24
27
  "history",
@@ -65,5 +68,13 @@
65
68
  "path": "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$"
66
69
  }
67
70
  ],
68
- "detection": []
71
+ "detection": [
72
+ {
73
+ "id": "posti-1",
74
+ "pattern": "^[A-Z]{2}\\d{9}FI$",
75
+ "confidence": "low",
76
+ "preferred": true,
77
+ "checksum": "s10"
78
+ }
79
+ ]
69
80
  }
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "carrier": "posti",
3
3
  "records": [
4
+ {
5
+ "number": "CP123456785FI",
6
+ "role": "shipment",
7
+ "evidence": "synthetic",
8
+ "derivedFrom": "official_shape",
9
+ "expect": {
10
+ "carrier": "intl-post",
11
+ "confidence": "high"
12
+ },
13
+ "note": "Invented checksum-valid Finnish-issued S10 number. The issuer makes Posti a recognition candidate; offline detection stays with the unknown postal carrier."
14
+ },
15
+ {
16
+ "number": "CP123456789FI",
17
+ "role": "negative",
18
+ "evidence": "synthetic",
19
+ "derivedFrom": "official_shape",
20
+ "expect": {
21
+ "carrier": "unknown",
22
+ "confidence": "low",
23
+ "candidates": [
24
+ "chronopost"
25
+ ],
26
+ "not": [
27
+ "posti"
28
+ ]
29
+ },
30
+ "note": "Invented Finnish-shaped identifier with a failed S10 check digit. It cannot suggest Posti."
31
+ },
4
32
  {
5
33
  "number": "00264300487130023858",
6
34
  "role": "shipment",
@@ -12,7 +12,8 @@ export interface RecognitionCandidate {
12
12
  * The low-confidence candidates worth asking, best first: the carrier a
13
13
  * universal provider named, then the ones number evidence backs, then the
14
14
  * catalog's popularity rank. Only carriers that declare `tracking.recognition`
15
- * qualify. A high-confidence number needs no recognition.
15
+ * qualify. A high-confidence dedicated carrier needs no recognition; the
16
+ * unknown postal carrier still needs a direct carrier to confirm it.
16
17
  */
17
18
  export declare function recognitionCandidates(number: string, options?: {
18
19
  hint?: string;
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import { CARRIER_RECOGNITION_RANKS } from '../../generated/recognition.js';
11
11
  import { detectCarrierMatch } from '../detection/detect.js';
12
- import { AUTOMATIC_CARRIER_IDS, carrierAdapter, requiredRequirements } from './definitions.js';
12
+ import { normalizeTrackingNumber } from '../detection/normalize.js';
13
+ import { AUTOMATIC_CARRIER_IDS, CARRIER_DEFINITIONS, carrierAdapter, requiredRequirements } from './definitions.js';
13
14
  import { carrierBrand } from './networks.js';
14
15
  /** Carriers asked at once. */
15
16
  export const MAX_RECOGNITIONS = 5;
@@ -17,24 +18,43 @@ export const MAX_RECOGNITIONS = 5;
17
18
  * The low-confidence candidates worth asking, best first: the carrier a
18
19
  * universal provider named, then the ones number evidence backs, then the
19
20
  * catalog's popularity rank. Only carriers that declare `tracking.recognition`
20
- * qualify. A high-confidence number needs no recognition.
21
+ * qualify. A high-confidence dedicated carrier needs no recognition; the
22
+ * unknown postal carrier still needs a direct carrier to confirm it.
21
23
  */
22
24
  export function recognitionCandidates(number, options = {}) {
23
25
  const detected = detectCarrierMatch(number);
24
- if (detected.confidence !== 'low')
26
+ const unknownPostalCarrier = detected.carrier === 'intl-post';
27
+ if (detected.confidence !== 'low' && !unknownPostalCarrier)
25
28
  return [];
29
+ // The high-confidence S10 fallback hides low-confidence carrier rules from
30
+ // detection. Recover those candidates without assigning the issuer's
31
+ // postal carrier as the deliverer: each candidate must confirm this number.
32
+ // intl-post already checked the S10 checksum, so only S10 or shape-only rules
33
+ // can contribute here.
34
+ const normalized = normalizeTrackingNumber(number);
35
+ const postalMatches = unknownPostalCarrier ? Object.entries(CARRIER_DEFINITIONS)
36
+ .flatMap(([carrier, definition]) => {
37
+ const rule = definition.detectionRules.find((candidate) => candidate.confidence === 'low'
38
+ && (!candidate.checksum || candidate.checksum === 's10')
39
+ && new RegExp(candidate.pattern).test(normalized)
40
+ && (!candidate.rawPattern || new RegExp(candidate.rawPattern).test(number.trim().toUpperCase())));
41
+ return rule ? [{ carrier, preferred: rule.preferred === true }] : [];
42
+ }) : [];
43
+ const candidates = unknownPostalCarrier ? postalMatches.map(({ carrier }) => carrier) : detected.candidates;
44
+ const preferred = unknownPostalCarrier
45
+ ? postalMatches.filter((match) => match.preferred).map(({ carrier }) => carrier) : detected.preferred;
26
46
  // A carrier the number points to but that cannot be asked (DPD France) keeps
27
47
  // its brand's other networks out: DPD's guest API also answers for DPD
28
48
  // France parcels, and would file one under DPD Switzerland.
29
- const shadowed = new Set(detected.preferred
49
+ const shadowed = new Set(preferred
30
50
  .filter((carrier) => CARRIER_RECOGNITION_RANKS[carrier] === undefined)
31
51
  .map((carrier) => carrierBrand(carrier)).filter(Boolean));
32
52
  const score = (carrier) => [
33
53
  carrier === options.hint ? 1 : 0,
34
- detected.preferred.includes(carrier) ? 1 : 0,
54
+ preferred.includes(carrier) ? 1 : 0,
35
55
  CARRIER_RECOGNITION_RANKS[carrier] ?? 0,
36
56
  ];
37
- return detected.candidates
57
+ return candidates
38
58
  .filter((carrier) => CARRIER_RECOGNITION_RANKS[carrier] !== undefined && AUTOMATIC_CARRIER_IDS.has(carrier)
39
59
  && carrierAdapter(carrier) !== 'universal' && !shadowed.has(carrierBrand(carrier)) && !options.skip?.(carrier))
40
60
  .map((carrier) => ({ carrier, score: score(carrier) }))
@@ -43,7 +63,7 @@ export function recognitionCandidates(number, options = {}) {
43
63
  .map(({ carrier }) => ({
44
64
  carrier,
45
65
  needsInput: requiredRequirements(carrier, number)[0]?.field ?? null,
46
- preferred: detected.preferred.includes(carrier),
66
+ preferred: preferred.includes(carrier),
47
67
  }));
48
68
  }
49
69
  /** The carriers the detect route asks about a number, best first; empty when none can answer. */
@@ -2,7 +2,8 @@ import { carrierBrand } from '../catalog/networks.js';
2
2
  import { CARRIER_RECOGNITION_RANKS } from '../../generated/recognition.js';
3
3
  export { MAX_RECOGNITIONS, recognitionCandidates, } from '../catalog/recognition.js';
4
4
  /**
5
- * Carrier recognition: when a number's shape fits several carriers, ask the
5
+ * Carrier recognition: when a number's shape fits several carriers or names
6
+ * only an international postal item, ask the
6
7
  * ones that can answer cheaply whether they know it. The Add sheet runs it
7
8
  * while the user is still in the form, the first sync runs it again after
8
9
  * saving, and routing keeps retrying it while the filed carrier cannot track
@@ -468,7 +468,8 @@
468
468
  "timezone": "UTC",
469
469
  "tracking": {
470
470
  "mode": "automatic",
471
- "adapter": "dhl-ecommerce"
471
+ "adapter": "dhl-ecommerce",
472
+ "recognitionRank": 23
472
473
  },
473
474
  "canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
474
475
  "trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
@@ -1973,7 +1974,8 @@
1973
1974
  "timezone": "Europe/Helsinki",
1974
1975
  "tracking": {
1975
1976
  "mode": "automatic",
1976
- "adapter": "posti"
1977
+ "adapter": "posti",
1978
+ "recognitionRank": 27
1977
1979
  },
1978
1980
  "canaryUrl": "https://www.posti.fi/en/tracking",
1979
1981
  "trackingUrlTemplate": "https://www.posti.fi/en/tracking/{trackingNumber}",
@@ -1985,7 +1987,14 @@
1985
1987
  "path": "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$"
1986
1988
  }
1987
1989
  ],
1988
- "detectionRules": []
1990
+ "detectionRules": [
1991
+ {
1992
+ "pattern": "^[A-Z]{2}\\d{9}FI$",
1993
+ "confidence": "low",
1994
+ "checksum": "s10",
1995
+ "preferred": true
1996
+ }
1997
+ ]
1989
1998
  },
1990
1999
  "correos-express": {
1991
2000
  "displayName": "Correos Express",
@@ -129,7 +129,7 @@ export function createTracker(options = {}) {
129
129
  if (typeof carrier !== 'string')
130
130
  throw new TypeError('Invalid carrier');
131
131
  carrierDefinition(carrier);
132
- if (!input.carrier && detectCarrierMatch(number).confidence !== 'high') {
132
+ if (!input.carrier && recognitionCandidates(number).length > 0) {
133
133
  try {
134
134
  const answer = await recognize(number, { signal, budgetMs: Math.min(10_000, ms) });
135
135
  if (answer.carrier)
@@ -308,6 +308,7 @@ export declare const CARRIER_CATALOG: {
308
308
  readonly tracking: {
309
309
  readonly mode: "automatic";
310
310
  readonly adapter: "dhl-ecommerce";
311
+ readonly recognitionRank: 23;
311
312
  };
312
313
  readonly canaryUrl: "https://www.dhl.com/ch-en/home/tracking.html";
313
314
  readonly trackingUrlTemplate: "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1";
@@ -1293,6 +1294,7 @@ export declare const CARRIER_CATALOG: {
1293
1294
  readonly tracking: {
1294
1295
  readonly mode: "automatic";
1295
1296
  readonly adapter: "posti";
1297
+ readonly recognitionRank: 27;
1296
1298
  };
1297
1299
  readonly canaryUrl: "https://www.posti.fi/en/tracking";
1298
1300
  readonly trackingUrlTemplate: "https://www.posti.fi/en/tracking/{trackingNumber}";
@@ -1300,7 +1302,12 @@ export declare const CARRIER_CATALOG: {
1300
1302
  readonly domains: readonly ["posti.fi"];
1301
1303
  readonly path: "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$";
1302
1304
  }];
1303
- readonly detectionRules: readonly [];
1305
+ readonly detectionRules: readonly [{
1306
+ readonly pattern: "^[A-Z]{2}\\d{9}FI$";
1307
+ readonly confidence: "low";
1308
+ readonly checksum: "s10";
1309
+ readonly preferred: true;
1310
+ }];
1304
1311
  };
1305
1312
  readonly "correos-express": {
1306
1313
  readonly displayName: "Correos Express";
@@ -469,7 +469,8 @@ export const CARRIER_CATALOG = {
469
469
  "timezone": "UTC",
470
470
  "tracking": {
471
471
  "mode": "automatic",
472
- "adapter": "dhl-ecommerce"
472
+ "adapter": "dhl-ecommerce",
473
+ "recognitionRank": 23
473
474
  },
474
475
  "canaryUrl": "https://www.dhl.com/ch-en/home/tracking.html",
475
476
  "trackingUrlTemplate": "https://www.dhl.com/ch-en/home/tracking.html?tracking-id={trackingNumber}&submit=1",
@@ -1974,7 +1975,8 @@ export const CARRIER_CATALOG = {
1974
1975
  "timezone": "Europe/Helsinki",
1975
1976
  "tracking": {
1976
1977
  "mode": "automatic",
1977
- "adapter": "posti"
1978
+ "adapter": "posti",
1979
+ "recognitionRank": 27
1978
1980
  },
1979
1981
  "canaryUrl": "https://www.posti.fi/en/tracking",
1980
1982
  "trackingUrlTemplate": "https://www.posti.fi/en/tracking/{trackingNumber}",
@@ -1986,7 +1988,14 @@ export const CARRIER_CATALOG = {
1986
1988
  "path": "^/(?:[a-z]{2}/)?tracking/([^/?#]+)(?:/[0-9]+)?/?$"
1987
1989
  }
1988
1990
  ],
1989
- "detectionRules": []
1991
+ "detectionRules": [
1992
+ {
1993
+ "pattern": "^[A-Z]{2}\\d{9}FI$",
1994
+ "confidence": "low",
1995
+ "checksum": "s10",
1996
+ "preferred": true
1997
+ }
1998
+ ]
1990
1999
  },
1991
2000
  "correos-express": {
1992
2001
  "displayName": "Correos Express",
@@ -11,6 +11,7 @@ export const CARRIER_RECOGNITION_RANKS = {
11
11
  "colis-prive": 30,
12
12
  "correos-express": 36,
13
13
  "ctt-express": 35,
14
+ "dhl-ecommerce": 23,
14
15
  "dpd": 70,
15
16
  "gls-ch": 60,
16
17
  "gls-de": 50,
@@ -21,6 +22,7 @@ export const CARRIER_RECOGNITION_RANKS = {
21
22
  "ninja-van": 28,
22
23
  "nz-post": 32,
23
24
  "poczta-polska": 31,
25
+ "posti": 27,
24
26
  "postlogistics": 26,
25
27
  "relais-colis": 25,
26
28
  "seur": 43,
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.4.0"
5
+ "version": "0.5.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.4.0-main.320",
3
+ "version": "0.5.0-main.322",
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",