universal-parcel-scraper 0.366.0-main.1999 → 0.372.0-main.2012

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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +24 -9
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +3 -0
  5. package/dist/browser/scraper.js +27 -10
  6. package/dist/carriers/atlantic-courier/carrier.json +1 -1
  7. package/dist/carriers/connect-couriers/adapter.d.ts +17 -0
  8. package/dist/carriers/connect-couriers/adapter.js +63 -0
  9. package/dist/carriers/connect-couriers/carrier.json +52 -7
  10. package/dist/carriers/connect-couriers/numbers.json +2 -2
  11. package/dist/carriers/connect-couriers/parser.d.ts +15 -0
  12. package/dist/carriers/connect-couriers/parser.js +188 -0
  13. package/dist/carriers/connect-couriers/status.d.ts +8 -0
  14. package/dist/carriers/connect-couriers/status.js +33 -0
  15. package/dist/carriers/connect-couriers/statuses.json +8 -1
  16. package/dist/carriers/jersey-post/adapter.d.ts +15 -0
  17. package/dist/carriers/jersey-post/adapter.js +55 -0
  18. package/dist/carriers/jersey-post/carrier.json +50 -8
  19. package/dist/carriers/jersey-post/numbers.json +2 -2
  20. package/dist/carriers/jersey-post/parser.d.ts +15 -0
  21. package/dist/carriers/jersey-post/parser.js +147 -0
  22. package/dist/carriers/jersey-post/status.d.ts +7 -0
  23. package/dist/carriers/jersey-post/status.js +44 -0
  24. package/dist/carriers/jersey-post/statuses.json +10 -2
  25. package/dist/carriers/kyoungdong/adapter.d.ts +15 -0
  26. package/dist/carriers/kyoungdong/adapter.js +56 -0
  27. package/dist/carriers/kyoungdong/carrier.json +57 -7
  28. package/dist/carriers/kyoungdong/numbers.json +8 -8
  29. package/dist/carriers/kyoungdong/parser.d.ts +23 -0
  30. package/dist/carriers/kyoungdong/parser.js +139 -0
  31. package/dist/carriers/kyoungdong/status.d.ts +7 -0
  32. package/dist/carriers/kyoungdong/status.js +42 -0
  33. package/dist/carriers/kyoungdong/statuses.json +5 -5
  34. package/dist/carriers/nova-global/adapter.d.ts +15 -0
  35. package/dist/carriers/nova-global/adapter.js +62 -0
  36. package/dist/carriers/nova-global/carrier.json +54 -7
  37. package/dist/carriers/nova-global/parser.d.ts +14 -0
  38. package/dist/carriers/nova-global/parser.js +166 -0
  39. package/dist/carriers/nova-global/status.d.ts +7 -0
  40. package/dist/carriers/nova-global/status.js +51 -0
  41. package/dist/carriers/nova-global/statuses.json +47 -3
  42. package/dist/carriers/ocs-ana/carrier.json +1 -1
  43. package/dist/carriers/shipentegra/adapter.d.ts +15 -0
  44. package/dist/carriers/shipentegra/adapter.js +65 -0
  45. package/dist/carriers/shipentegra/carrier.json +44 -7
  46. package/dist/carriers/shipentegra/numbers.json +8 -8
  47. package/dist/carriers/shipentegra/parser.d.ts +21 -0
  48. package/dist/carriers/shipentegra/parser.js +172 -0
  49. package/dist/carriers/shipentegra/status.d.ts +9 -0
  50. package/dist/carriers/shipentegra/status.js +67 -0
  51. package/dist/carriers/shipentegra/statuses.json +36 -15
  52. package/dist/carriers/veloces/adapter.d.ts +15 -0
  53. package/dist/carriers/veloces/adapter.js +78 -0
  54. package/dist/carriers/veloces/carrier.json +52 -7
  55. package/dist/carriers/veloces/numbers.json +6 -6
  56. package/dist/carriers/veloces/parser.d.ts +12 -0
  57. package/dist/carriers/veloces/parser.js +133 -0
  58. package/dist/carriers/veloces/status.d.ts +7 -0
  59. package/dist/carriers/veloces/status.js +36 -0
  60. package/dist/carriers/veloces/statuses.json +20 -11
  61. package/dist/core/catalog/statusMaps.js +12 -5
  62. package/dist/data/catalog.json +24 -9
  63. package/dist/generated/catalog.d.ts +22 -9
  64. package/dist/generated/catalog.js +25 -10
  65. package/dist/generated/recognition.js +4 -0
  66. package/dist/generated/registry.js +18 -6
  67. package/dist/server/openapi.json +1 -1
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **396 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **402 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>727 catalog entries · 133 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <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">
157
157
 
158
- The carrier folders record 13,496 such statuses, each filed under one stage.
158
+ The carrier folders record 13,584 such statuses, each filed under one stage.
159
159
  <!-- /GENERATED:stages -->
160
160
 
161
161
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -6581,7 +6581,7 @@
6581
6581
  },
6582
6582
  "atlantic-courier": {
6583
6583
  "displayName": "Atlantic International Express",
6584
- "color": "#0066b3",
6584
+ "color": "#e31e24",
6585
6585
  "aliases": [
6586
6586
  "Atlantic International Express Courier"
6587
6587
  ],
@@ -8881,7 +8881,9 @@
8881
8881
  "timezone": "Europe/Malta",
8882
8882
  "tracking": {
8883
8883
  "mode": "automatic",
8884
- "adapter": "universal"
8884
+ "adapter": "connect-couriers",
8885
+ "recognitionRank": 831,
8886
+ "localClocks": true
8885
8887
  },
8886
8888
  "canaryUrl": "https://www.connectcouriers.eu/tracking/",
8887
8889
  "trackingUrlTemplate": "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
@@ -16391,7 +16393,9 @@
16391
16393
  "timezone": "Europe/Jersey",
16392
16394
  "tracking": {
16393
16395
  "mode": "automatic",
16394
- "adapter": "universal"
16396
+ "adapter": "jersey-post",
16397
+ "recognitionRank": 832,
16398
+ "localClocks": true
16395
16399
  },
16396
16400
  "canaryUrl": "https://www.jerseypost.com/tools/track-trace/",
16397
16401
  "trackingUrlTemplate": "https://www.jerseypost.com/tools/track-trace/?tq={trackingNumber}",
@@ -16407,7 +16411,14 @@
16407
16411
  ]
16408
16412
  }
16409
16413
  ],
16410
- "detectionRules": []
16414
+ "detectionRules": [
16415
+ {
16416
+ "pattern": "^[A-Z]{2}\\d{9}JE$",
16417
+ "confidence": "low",
16418
+ "checksum": "s10",
16419
+ "preferred": true
16420
+ }
16421
+ ]
16411
16422
  },
16412
16423
  "jet-logistic": {
16413
16424
  "displayName": "Jet Logistic",
@@ -17174,7 +17185,7 @@
17174
17185
  "timezone": "Asia/Seoul",
17175
17186
  "tracking": {
17176
17187
  "mode": "automatic",
17177
- "adapter": "universal"
17188
+ "adapter": "kyoungdong"
17178
17189
  },
17179
17190
  "canaryUrl": "https://kdexp.com/index.do",
17180
17191
  "trackingUrlTemplate": "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -19610,7 +19621,9 @@
19610
19621
  "timezone": "Europe/Kyiv",
19611
19622
  "tracking": {
19612
19623
  "mode": "automatic",
19613
- "adapter": "universal"
19624
+ "adapter": "nova-global",
19625
+ "recognitionRank": 834,
19626
+ "localClocks": true
19614
19627
  },
19615
19628
  "canaryUrl": "https://nova.global/track/",
19616
19629
  "trackingUrlTemplate": "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -19890,7 +19903,7 @@
19890
19903
  },
19891
19904
  "ocs-ana": {
19892
19905
  "displayName": "OCS ANA Group",
19893
- "color": "#004c9a",
19906
+ "color": "#0a3382",
19894
19907
  "aliases": [
19895
19908
  "OCS",
19896
19909
  "Overseas Courier Service",
@@ -24196,7 +24209,9 @@
24196
24209
  "timezone": "Europe/Istanbul",
24197
24210
  "tracking": {
24198
24211
  "mode": "automatic",
24199
- "adapter": "universal"
24212
+ "adapter": "shipentegra",
24213
+ "recognitionRank": 833,
24214
+ "localClocks": true
24200
24215
  },
24201
24216
  "canaryUrl": "https://track.shipentegra.com/",
24202
24217
  "trackingUrlTemplate": "https://track.shipentegra.com/?trackno={trackingNumber}",
@@ -27745,7 +27760,7 @@
27745
27760
  "timezone": "America/Bogota",
27746
27761
  "tracking": {
27747
27762
  "mode": "automatic",
27748
- "adapter": "universal"
27763
+ "adapter": "veloces"
27749
27764
  },
27750
27765
  "canaryUrl": "https://tracking.veloces.app/",
27751
27766
  "trackingUrlTemplate": "https://tracking.veloces.app/tracking-client/{trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 727,
3
- "activeAdapters": 396,
3
+ "activeAdapters": 402,
4
4
  "countries": 133,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -74750,6 +74750,7 @@
74750
74750
  "intl-post"
74751
74751
  ],
74752
74752
  "asked": [
74753
+ "jersey-post",
74753
74754
  "chronopost"
74754
74755
  ]
74755
74756
  },
@@ -74788,6 +74789,7 @@
74788
74789
  "intl-post"
74789
74790
  ],
74790
74791
  "asked": [
74792
+ "jersey-post",
74791
74793
  "chronopost"
74792
74794
  ]
74793
74795
  },
@@ -84900,6 +84902,7 @@
84900
84902
  "intl-post"
84901
84903
  ],
84902
84904
  "asked": [
84905
+ "jersey-post",
84903
84906
  "chronopost"
84904
84907
  ]
84905
84908
  },
@@ -5376,7 +5376,7 @@ var Pe = {
5376
5376
  },
5377
5377
  "atlantic-courier": {
5378
5378
  displayName: "Atlantic International Express",
5379
- color: "#0066b3",
5379
+ color: "#e31e24",
5380
5380
  aliases: ["Atlantic International Express Courier"],
5381
5381
  countries: ["IN"],
5382
5382
  selectable: !0,
@@ -7015,7 +7015,9 @@ var Pe = {
7015
7015
  timezone: "Europe/Malta",
7016
7016
  tracking: {
7017
7017
  mode: "automatic",
7018
- adapter: "universal"
7018
+ adapter: "connect-couriers",
7019
+ recognitionRank: 831,
7020
+ localClocks: !0
7019
7021
  },
7020
7022
  canaryUrl: "https://www.connectcouriers.eu/tracking/",
7021
7023
  trackingUrlTemplate: "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
@@ -12556,7 +12558,9 @@ var Pe = {
12556
12558
  timezone: "Europe/Jersey",
12557
12559
  tracking: {
12558
12560
  mode: "automatic",
12559
- adapter: "universal"
12561
+ adapter: "jersey-post",
12562
+ recognitionRank: 832,
12563
+ localClocks: !0
12560
12564
  },
12561
12565
  canaryUrl: "https://www.jerseypost.com/tools/track-trace/",
12562
12566
  trackingUrlTemplate: "https://www.jerseypost.com/tools/track-trace/?tq={trackingNumber}",
@@ -12566,7 +12570,12 @@ var Pe = {
12566
12570
  pathPattern: "^/tools/track-trace/?$",
12567
12571
  params: ["tq"]
12568
12572
  }],
12569
- detectionRules: []
12573
+ detectionRules: [{
12574
+ pattern: "^[A-Z]{2}\\d{9}JE$",
12575
+ confidence: "low",
12576
+ checksum: "s10",
12577
+ preferred: !0
12578
+ }]
12570
12579
  },
12571
12580
  "jet-logistic": {
12572
12581
  displayName: "Jet Logistic",
@@ -13154,7 +13163,7 @@ var Pe = {
13154
13163
  timezone: "Asia/Seoul",
13155
13164
  tracking: {
13156
13165
  mode: "automatic",
13157
- adapter: "universal"
13166
+ adapter: "kyoungdong"
13158
13167
  },
13159
13168
  canaryUrl: "https://kdexp.com/index.do",
13160
13169
  trackingUrlTemplate: "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -14957,7 +14966,9 @@ var Pe = {
14957
14966
  timezone: "Europe/Kyiv",
14958
14967
  tracking: {
14959
14968
  mode: "automatic",
14960
- adapter: "universal"
14969
+ adapter: "nova-global",
14970
+ recognitionRank: 834,
14971
+ localClocks: !0
14961
14972
  },
14962
14973
  canaryUrl: "https://nova.global/track/",
14963
14974
  trackingUrlTemplate: "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -15160,7 +15171,7 @@ var Pe = {
15160
15171
  },
15161
15172
  "ocs-ana": {
15162
15173
  displayName: "OCS ANA Group",
15163
- color: "#004c9a",
15174
+ color: "#0a3382",
15164
15175
  aliases: [
15165
15176
  "OCS",
15166
15177
  "Overseas Courier Service",
@@ -18317,7 +18328,9 @@ var Pe = {
18317
18328
  timezone: "Europe/Istanbul",
18318
18329
  tracking: {
18319
18330
  mode: "automatic",
18320
- adapter: "universal"
18331
+ adapter: "shipentegra",
18332
+ recognitionRank: 833,
18333
+ localClocks: !0
18321
18334
  },
18322
18335
  canaryUrl: "https://track.shipentegra.com/",
18323
18336
  trackingUrlTemplate: "https://track.shipentegra.com/?trackno={trackingNumber}",
@@ -20873,7 +20886,7 @@ var Pe = {
20873
20886
  timezone: "America/Bogota",
20874
20887
  tracking: {
20875
20888
  mode: "automatic",
20876
- adapter: "universal"
20889
+ adapter: "veloces"
20877
20890
  },
20878
20891
  canaryUrl: "https://tracking.veloces.app/",
20879
20892
  trackingUrlTemplate: "https://tracking.veloces.app/tracking-client/{trackingNumber}",
@@ -22465,7 +22478,7 @@ var Pe = {
22465
22478
  "jamaica-post": [],
22466
22479
  janio: ["janio-1"],
22467
22480
  jeebly: ["jeebly-1"],
22468
- "jersey-post": [],
22481
+ "jersey-post": ["jersey-post-s10"],
22469
22482
  "jet-logistic": [],
22470
22483
  "jetline-couriers": [],
22471
22484
  jhgj: ["jhgj-1"],
@@ -27987,6 +28000,7 @@ var pl = {
27987
28000
  "cj-packet": 956,
27988
28001
  "closer-logistics": 849,
27989
28002
  "colis-prive": 740,
28003
+ "connect-couriers": 831,
27990
28004
  coordinadora: 973,
27991
28005
  "correo-argentino": 978,
27992
28006
  "correos-de-mexico": 30,
@@ -28084,6 +28098,7 @@ var pl = {
28084
28098
  "israel-post": 81,
28085
28099
  "j-and-t": 530,
28086
28100
  "j-and-t-cargo": 910,
28101
+ "jersey-post": 832,
28087
28102
  jhgj: 907,
28088
28103
  jitsu: 85,
28089
28104
  "jordan-post": 881,
@@ -28116,6 +28131,7 @@ var pl = {
28116
28131
  nipost: 908,
28117
28132
  "nippon-express": 941,
28118
28133
  "norsk-global": 845,
28134
+ "nova-global": 834,
28119
28135
  "nova-poshta": 685,
28120
28136
  nox: 874,
28121
28137
  "nz-post": 760,
@@ -28171,6 +28187,7 @@ var pl = {
28171
28187
  seur: 870,
28172
28188
  sfc: 955,
28173
28189
  shadowfax: 595,
28190
+ shipentegra: 833,
28174
28191
  "skroutz-last-mile": 913,
28175
28192
  "slovak-parcel-service": 180,
28176
28193
  "slovenska-posta": 170,
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "brand": {
13
- "color": "#0066b3"
13
+ "color": "#e31e24"
14
14
  },
15
15
  "selectable": true,
16
16
  "timezone": "Asia/Kolkata",
@@ -0,0 +1,17 @@
1
+ import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ /** The WordPress endpoint the tracking page on connectcouriers.eu posts the number to. */
5
+ export declare const CONNECT_COURIERS_API = "https://www.connectcouriers.eu/wp-admin/admin-ajax.php";
6
+ /** The tracking plugin's action. */
7
+ export declare const CONNECT_COURIERS_ACTION = "public_connect_tracking_tracking_action";
8
+ export declare class ConnectCouriersTracker {
9
+ private readonly options;
10
+ constructor(options?: {
11
+ fetcher?: typeof fetch;
12
+ recorder?: StepRecorder;
13
+ userAgent?: string;
14
+ });
15
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
16
+ }
17
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,63 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
7
+ import { CONNECT_COURIERS_MAX_BYTES, normalizeConnectCouriersNumber, parseConnectCouriers, PROVIDER } from './parser.js';
8
+ /** The WordPress endpoint the tracking page on connectcouriers.eu posts the number to. */
9
+ export const CONNECT_COURIERS_API = 'https://www.connectcouriers.eu/wp-admin/admin-ajax.php';
10
+ /** The tracking plugin's action. */
11
+ export const CONNECT_COURIERS_ACTION = 'public_connect_tracking_tracking_action';
12
+ const REDIRECTS = [301, 302, 303, 307, 308];
13
+ // Bot checks Cloudflare, in front of the site, could put in place of the reply.
14
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*Just a moment|<title>\s*Attention Required/i;
15
+ function json(body) {
16
+ try {
17
+ return JSON.parse(body);
18
+ }
19
+ catch (cause) {
20
+ if (CHALLENGE.test(body.slice(0, 64_000)))
21
+ throw new ChallengeError(PROVIDER, 'Connect Couriers answered with a browser check', { cause });
22
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a reply that is not JSON', { cause });
23
+ }
24
+ }
25
+ export class ConnectCouriersTracker {
26
+ options;
27
+ constructor(options = {}) {
28
+ this.options = options;
29
+ }
30
+ async fetch(raw, context = {}) {
31
+ const number = normalizeConnectCouriersNumber(raw);
32
+ const read = async (signal, timeoutMs) => {
33
+ const { response, bytes } = await fetchBounded(CONNECT_COURIERS_API, { method: 'POST', signal, headers: {
34
+ Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': userAgentOf(this.options.userAgent),
35
+ }, body: new URLSearchParams({ action: CONNECT_COURIERS_ACTION, tracking_number: number }).toString() }, { provider: PROVIDER,
36
+ timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: CONNECT_COURIERS_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
37
+ allowHttpStatuses: [...REDIRECTS, 400, 404, 410] });
38
+ if (REDIRECTS.includes(response.status))
39
+ throw new TransportError(PROVIDER, 'Connect Couriers redirected its tracking lookup');
40
+ // The tracker answers every number with HTTP 200. WordPress answers an
41
+ // action no plugin registers with HTTP 400 and `0`; a 404 is a moved route.
42
+ if (response.status === 400 || response.status === 404 || response.status === 410) {
43
+ throw new TransportError(PROVIDER, 'Connect Couriers tracking lookup is unavailable');
44
+ }
45
+ return parseConnectCouriers(json(decodeText(bytes)), number);
46
+ };
47
+ // A request that fails to reach the site, or hangs, gets one more try: the
48
+ // first gets half of the budget, so the retry fits in the rest.
49
+ return runSteps({ carrier: 'connect-couriers', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
50
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
51
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
52
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
53
+ ]);
54
+ }
55
+ }
56
+ export const adapter = (environment) => {
57
+ const tracker = new ConnectCouriersTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
58
+ return {
59
+ id: 'connect-couriers', recordsSteps: true, steps: ['direct', 'retry'],
60
+ track: (input, context) => tracker.fetch(input.number, context),
61
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeConnectCouriersNumber(number))),
62
+ };
63
+ };
@@ -18,18 +18,63 @@
18
18
  "timezone": "Europe/Malta",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "connect-couriers",
22
+ "steps": [
23
+ "direct",
24
+ "retry"
25
+ ],
26
+ "recognition": {
27
+ "rank": 831
28
+ },
29
+ "localClocks": true
23
30
  },
24
- "capabilities": [],
31
+ "capabilities": [
32
+ "history",
33
+ "destination_country"
34
+ ],
25
35
  "portal": {
26
36
  "url": "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
27
37
  "searchUrl": "https://www.connectcouriers.eu/tracking/",
28
38
  "canaryUrl": "https://www.connectcouriers.eu/tracking/",
29
- "shows": [],
30
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
39
+ "shows": [
40
+ "status",
41
+ "history",
42
+ "progress_rail",
43
+ "origin",
44
+ "destination",
45
+ "destination_country",
46
+ "parcel_count",
47
+ "departure_date",
48
+ "weight",
49
+ "dimensions"
50
+ ],
51
+ "scraped": [
52
+ "status",
53
+ "history",
54
+ "destination_country"
55
+ ],
56
+ "discarded": [
57
+ "progress_rail",
58
+ "origin",
59
+ "destination",
60
+ "parcel_count",
61
+ "departure_date",
62
+ "weight",
63
+ "dimensions"
64
+ ],
65
+ "unavailable": [
66
+ "location",
67
+ "provider_code",
68
+ "eta",
69
+ "delivered_at",
70
+ "offset_timestamps",
71
+ "service_name",
72
+ "pickup_point",
73
+ "sender_name",
74
+ "recipient_name",
75
+ "recipient_address",
76
+ "proof_of_delivery"
77
+ ]
33
78
  },
34
79
  "links": [
35
80
  {
@@ -12,7 +12,7 @@
12
12
  "access": "full_page"
13
13
  },
14
14
  "context": {
15
- "assessment": "candidate"
15
+ "assessment": "confirmed"
16
16
  },
17
17
  "expect": {
18
18
  "carrier": "unknown",
@@ -28,7 +28,7 @@
28
28
  "ppl"
29
29
  ]
30
30
  },
31
- "note": "An archived lookup on Connect Couriers' own tracker; the capture was opened but shows only the page shell, so the history is not visible."
31
+ "note": "An archived lookup on Connect Couriers' own tracker address; the capture shows only the page shell, and Connect Couriers' tracker returns a delivered history for it."
32
32
  }
33
33
  ]
34
34
  }
@@ -0,0 +1,15 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Connect Couriers";
3
+ export declare const CONNECT_COURIERS_MAX_BYTES: number;
4
+ /** A number for Connect Couriers' tracker, digits only; anything else is not one it issues. */
5
+ export declare function normalizeConnectCouriersNumber(raw: string): string;
6
+ /** A scan's wording without a signatory or phone numbers. */
7
+ export declare function connectCouriersWording(raw: string): string;
8
+ /**
9
+ * The reply of the tracking page's WordPress action for one number. It
10
+ * answers HTTP 200 with `{"success":false}` for a number it does not know,
11
+ * which the page reports as an invalid tracking number. A found reply has
12
+ * the consignment's pieces, a progress rail and the scans grouped by day;
13
+ * the reply and the piece must both name the number asked.
14
+ */
15
+ export declare function parseConnectCouriers(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,188 @@
1
+ import { DateTime } from 'luxon';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { countryCode } from '../../core/time/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { connectCouriersStage } from './status.js';
9
+ export const PROVIDER = 'Connect Couriers';
10
+ // A long history is a few kilobytes.
11
+ export const CONNECT_COURIERS_MAX_BYTES = 256 * 1024;
12
+ // Digits only: the tracking page's field takes nothing else.
13
+ const NUMBER = /^\d{8,20}$/;
14
+ // The scan's wall clock, to the second; a fraction of a second is dropped.
15
+ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d{1,7})?$/;
16
+ const MAX_ROWS = 500;
17
+ const MAX_EVENTS = 100;
18
+ /** A number for Connect Couriers' tracker, digits only; anything else is not one it issues. */
19
+ export function normalizeConnectCouriersNumber(raw) {
20
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
21
+ if (!NUMBER.test(number))
22
+ throw new InvalidInputError(PROVIDER, 'Connect Couriers numbers are eight to twenty digits');
23
+ return number;
24
+ }
25
+ /** A required text field; anything but a string is a changed reply. */
26
+ function text(row, key, maxLength = 300) {
27
+ const value = row[key];
28
+ if (typeof value !== 'string')
29
+ throw new SchemaError(PROVIDER, `Connect Couriers returned an invalid ${key}`);
30
+ return clean(value, maxLength);
31
+ }
32
+ /** Whether a tracking number in the reply is the number asked. */
33
+ function sameNumber(value, number) {
34
+ return typeof value === 'string' && value.length <= 64 && normalizeTrackingNumber(value) === number;
35
+ }
36
+ // A run of nine or more digits, with spaces, brackets, dots or hyphens
37
+ // between them, as phone numbers are written.
38
+ const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
39
+ // Who signed for the parcel, after a delivery's wording.
40
+ const SIGNATORY = /\s*[,;:.\-–(]?\s*\bsigned\s+(?:for\s+)?by\b[\s\S]*$/i;
41
+ /** A scan's wording without a signatory or phone numbers. */
42
+ export function connectCouriersWording(raw) {
43
+ return clean(raw.replace(SIGNATORY, '').replace(PHONE, '')).replace(/^[\s,;:.\-–/]+|[\s,;:\-–/]+$/g, '');
44
+ }
45
+ /**
46
+ * A scan's clock. `stageDateTime` is a wall clock with no zone, which the
47
+ * page shows as it is, so it stays local.
48
+ */
49
+ function scanClock(row) {
50
+ const match = WALL_CLOCK.exec(text(row, 'stageDateTime', 40));
51
+ const local = match?.[1] ?? '';
52
+ // Parsing in UTC checks the calendar digits only; the wall clock keeps no offset.
53
+ const parsed = DateTime.fromISO(local, { zone: 'UTC' });
54
+ // Luxon rolls 24:00:00 over to the next day; only a clock that reads back unchanged is kept.
55
+ if (!match || !parsed.isValid || parsed.year < 2000 || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local) {
56
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid scan time');
57
+ }
58
+ return local;
59
+ }
60
+ /**
61
+ * Whether a scan concerns the parcel asked. A scan lists the pieces of the
62
+ * consignment it was made for; one that lists none concerns them all.
63
+ */
64
+ function concerns(row, number) {
65
+ const pieces = row.packages ?? [];
66
+ if (!Array.isArray(pieces) || pieces.length > MAX_ROWS)
67
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a scan with invalid pieces');
68
+ if (!pieces.length)
69
+ return true;
70
+ return pieces.some((piece) => {
71
+ if (!isRecord(piece) || typeof piece.trackingNumber !== 'string')
72
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid piece');
73
+ return sameNumber(piece.trackingNumber, number);
74
+ });
75
+ }
76
+ function projectRow(row) {
77
+ const description = connectCouriersWording(text(row, 'stageStatus'));
78
+ if (!description)
79
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a scan without wording');
80
+ const local = scanClock(row);
81
+ const staged = connectCouriersStage(description);
82
+ const event = {
83
+ local_time: local,
84
+ description,
85
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
86
+ };
87
+ return { key: JSON.stringify([local, description]), event };
88
+ }
89
+ /** The scans of the parcel asked, from the history's date groups. */
90
+ function scans(data, number) {
91
+ const groups = data.trackingHistory ?? [];
92
+ if (!Array.isArray(groups) || groups.length > MAX_ROWS)
93
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history');
94
+ const seen = new Set();
95
+ const events = [];
96
+ for (const group of groups) {
97
+ if (!isRecord(group))
98
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history group');
99
+ // The page skips a group without items.
100
+ const rows = group.items ?? [];
101
+ if (!Array.isArray(rows) || rows.length > MAX_ROWS)
102
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history group');
103
+ for (const row of rows) {
104
+ if (!isRecord(row))
105
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid scan');
106
+ if (!concerns(row, number))
107
+ continue;
108
+ const scan = projectRow(row);
109
+ if (seen.has(scan.key))
110
+ continue;
111
+ seen.add(scan.key);
112
+ events.push(scan.event);
113
+ if (events.length > MAX_ROWS)
114
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an oversized history');
115
+ }
116
+ }
117
+ // Newest first; the groups are by day, and the scans within one are in no order.
118
+ return events
119
+ .map((event, index) => ({ event, index }))
120
+ .sort((a, b) => b.event.local_time.localeCompare(a.event.local_time) || a.index - b.index)
121
+ .map(({ event }) => event);
122
+ }
123
+ /** The piece of the consignment that is the parcel asked. */
124
+ function piece(data, number) {
125
+ const pieces = data.packageData;
126
+ if (!Array.isArray(pieces) || pieces.length > MAX_ROWS)
127
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without its pieces');
128
+ const found = pieces.find((entry) => isRecord(entry) && sameNumber(entry.trackingNumber, number));
129
+ if (!isRecord(found))
130
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without the parcel asked');
131
+ return found;
132
+ }
133
+ /** The headline status's word for a parcel whose journey ended or stopped. */
134
+ function headlineStage(found) {
135
+ if (typeof found.stageStatus !== 'string')
136
+ return undefined;
137
+ const stage = connectCouriersStage(connectCouriersWording(found.stageStatus))?.stage;
138
+ return stage === 'delivered' || stage === 'returned' || stage === 'exception' ? stage : undefined;
139
+ }
140
+ /** The destination country the page flags, from the recipient's country code. */
141
+ function destination(found) {
142
+ if (!isRecord(found.recipient))
143
+ return null;
144
+ return countryCode(clean(found.recipient.countryCode, 8)) ?? countryCode(clean(found.recipient.country, 60));
145
+ }
146
+ /**
147
+ * The reply of the tracking page's WordPress action for one number. It
148
+ * answers HTTP 200 with `{"success":false}` for a number it does not know,
149
+ * which the page reports as an invalid tracking number. A found reply has
150
+ * the consignment's pieces, a progress rail and the scans grouped by day;
151
+ * the reply and the piece must both name the number asked.
152
+ */
153
+ export function parseConnectCouriers(payload, raw) {
154
+ const number = normalizeConnectCouriersNumber(raw);
155
+ if (!isRecord(payload) || typeof payload.success !== 'boolean')
156
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid envelope');
157
+ if (!payload.success) {
158
+ // WordPress's error reply carries its reason in `data`; the tracker sends none for an unknown number.
159
+ if (payload.data !== undefined && payload.data !== null)
160
+ throw new IndeterminateError(PROVIDER, 'Connect Couriers did not complete the lookup');
161
+ throw new NotFoundError(PROVIDER);
162
+ }
163
+ const data = payload.data;
164
+ if (!isRecord(data) || typeof data.trackingNumber !== 'string')
165
+ throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without its number');
166
+ if (!sameNumber(data.trackingNumber, number))
167
+ throw new IndeterminateError(PROVIDER, 'Connect Couriers answered for another shipment');
168
+ const found = piece(data, number);
169
+ const events = scans(data, number);
170
+ if (!events.length)
171
+ throw new NoHistoryError(PROVIDER);
172
+ const newest = events[0];
173
+ const scanned = events.find((event) => event.stage);
174
+ const headline = headlineStage(found);
175
+ const stage = (headline ?? scanned?.stage);
176
+ const source = headline && headline !== scanned?.stage ? 'carrier_map' : scanned?.stage_source;
177
+ const country = destination(found);
178
+ return {
179
+ status: stage ? languageStageStatus(stage) : 'unknown',
180
+ ...(stage ? { current_stage: stage, current_stage_source: source ?? 'carrier_map' } : {}),
181
+ last_status_text: newest.description,
182
+ last_update: null,
183
+ last_update_local: newest.local_time,
184
+ ...(country ? { destination_country: country } : {}),
185
+ events: events.slice(0, MAX_EVENTS),
186
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
187
+ };
188
+ }