universal-parcel-scraper 0.368.0-main.2003 → 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 (51) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +20 -7
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +3 -0
  5. package/dist/browser/scraper.js +22 -8
  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/ocs-ana/carrier.json +1 -1
  26. package/dist/carriers/shipentegra/adapter.d.ts +15 -0
  27. package/dist/carriers/shipentegra/adapter.js +65 -0
  28. package/dist/carriers/shipentegra/carrier.json +44 -7
  29. package/dist/carriers/shipentegra/numbers.json +8 -8
  30. package/dist/carriers/shipentegra/parser.d.ts +21 -0
  31. package/dist/carriers/shipentegra/parser.js +172 -0
  32. package/dist/carriers/shipentegra/status.d.ts +9 -0
  33. package/dist/carriers/shipentegra/status.js +67 -0
  34. package/dist/carriers/shipentegra/statuses.json +36 -15
  35. package/dist/carriers/veloces/adapter.d.ts +15 -0
  36. package/dist/carriers/veloces/adapter.js +78 -0
  37. package/dist/carriers/veloces/carrier.json +52 -7
  38. package/dist/carriers/veloces/numbers.json +6 -6
  39. package/dist/carriers/veloces/parser.d.ts +12 -0
  40. package/dist/carriers/veloces/parser.js +133 -0
  41. package/dist/carriers/veloces/status.d.ts +7 -0
  42. package/dist/carriers/veloces/status.js +36 -0
  43. package/dist/carriers/veloces/statuses.json +20 -11
  44. package/dist/core/catalog/statusMaps.js +9 -4
  45. package/dist/data/catalog.json +20 -7
  46. package/dist/generated/catalog.d.ts +18 -7
  47. package/dist/generated/catalog.js +21 -8
  48. package/dist/generated/recognition.js +3 -0
  49. package/dist/generated/registry.js +12 -4
  50. package/dist/server/openapi.json +1 -1
  51. 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 **398 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,540 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",
@@ -19892,7 +19903,7 @@
19892
19903
  },
19893
19904
  "ocs-ana": {
19894
19905
  "displayName": "OCS ANA Group",
19895
- "color": "#004c9a",
19906
+ "color": "#0a3382",
19896
19907
  "aliases": [
19897
19908
  "OCS",
19898
19909
  "Overseas Courier Service",
@@ -24198,7 +24209,9 @@
24198
24209
  "timezone": "Europe/Istanbul",
24199
24210
  "tracking": {
24200
24211
  "mode": "automatic",
24201
- "adapter": "universal"
24212
+ "adapter": "shipentegra",
24213
+ "recognitionRank": 833,
24214
+ "localClocks": true
24202
24215
  },
24203
24216
  "canaryUrl": "https://track.shipentegra.com/",
24204
24217
  "trackingUrlTemplate": "https://track.shipentegra.com/?trackno={trackingNumber}",
@@ -27747,7 +27760,7 @@
27747
27760
  "timezone": "America/Bogota",
27748
27761
  "tracking": {
27749
27762
  "mode": "automatic",
27750
- "adapter": "universal"
27763
+ "adapter": "veloces"
27751
27764
  },
27752
27765
  "canaryUrl": "https://tracking.veloces.app/",
27753
27766
  "trackingUrlTemplate": "https://tracking.veloces.app/tracking-client/{trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 727,
3
- "activeAdapters": 398,
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",
@@ -15162,7 +15171,7 @@ var Pe = {
15162
15171
  },
15163
15172
  "ocs-ana": {
15164
15173
  displayName: "OCS ANA Group",
15165
- color: "#004c9a",
15174
+ color: "#0a3382",
15166
15175
  aliases: [
15167
15176
  "OCS",
15168
15177
  "Overseas Courier Service",
@@ -18319,7 +18328,9 @@ var Pe = {
18319
18328
  timezone: "Europe/Istanbul",
18320
18329
  tracking: {
18321
18330
  mode: "automatic",
18322
- adapter: "universal"
18331
+ adapter: "shipentegra",
18332
+ recognitionRank: 833,
18333
+ localClocks: !0
18323
18334
  },
18324
18335
  canaryUrl: "https://track.shipentegra.com/",
18325
18336
  trackingUrlTemplate: "https://track.shipentegra.com/?trackno={trackingNumber}",
@@ -20875,7 +20886,7 @@ var Pe = {
20875
20886
  timezone: "America/Bogota",
20876
20887
  tracking: {
20877
20888
  mode: "automatic",
20878
- adapter: "universal"
20889
+ adapter: "veloces"
20879
20890
  },
20880
20891
  canaryUrl: "https://tracking.veloces.app/",
20881
20892
  trackingUrlTemplate: "https://tracking.veloces.app/tracking-client/{trackingNumber}",
@@ -22467,7 +22478,7 @@ var Pe = {
22467
22478
  "jamaica-post": [],
22468
22479
  janio: ["janio-1"],
22469
22480
  jeebly: ["jeebly-1"],
22470
- "jersey-post": [],
22481
+ "jersey-post": ["jersey-post-s10"],
22471
22482
  "jet-logistic": [],
22472
22483
  "jetline-couriers": [],
22473
22484
  jhgj: ["jhgj-1"],
@@ -27989,6 +28000,7 @@ var pl = {
27989
28000
  "cj-packet": 956,
27990
28001
  "closer-logistics": 849,
27991
28002
  "colis-prive": 740,
28003
+ "connect-couriers": 831,
27992
28004
  coordinadora: 973,
27993
28005
  "correo-argentino": 978,
27994
28006
  "correos-de-mexico": 30,
@@ -28086,6 +28098,7 @@ var pl = {
28086
28098
  "israel-post": 81,
28087
28099
  "j-and-t": 530,
28088
28100
  "j-and-t-cargo": 910,
28101
+ "jersey-post": 832,
28089
28102
  jhgj: 907,
28090
28103
  jitsu: 85,
28091
28104
  "jordan-post": 881,
@@ -28174,6 +28187,7 @@ var pl = {
28174
28187
  seur: 870,
28175
28188
  sfc: 955,
28176
28189
  shadowfax: 595,
28190
+ shipentegra: 833,
28177
28191
  "skroutz-last-mile": 913,
28178
28192
  "slovak-parcel-service": 180,
28179
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
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The stage Connect Couriers' map gives a scan's wording in `normalizeStatusWording` form. */
4
+ export declare function connectCouriersMappedStage(wording: string): Stage | undefined;
5
+ /** A scan's stage: the map, else the shared wording rules, else nothing. */
6
+ export declare function connectCouriersStage(wording: string): ClassifiedWording | null;
7
+ /** Every wording seen is mapped. */
8
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,33 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The wordings Connect Couriers' tracker gives its scans, which carry no
5
+ * code. Only wordings seen in replies are mapped; another falls to the
6
+ * shared wording rules.
7
+ */
8
+ const WORDINGS = new Map([
9
+ ['shipment data received', 'registered'], // The sender's data came before the parcel.
10
+ ['arrived at operations facility - malta', 'in_transit'],
11
+ ['out for delivery', 'out_for_delivery'],
12
+ ['attempted delivery / client not home', 'failed_attempt'],
13
+ // Kept at the depot for the recipient to collect, at their request.
14
+ ['awaiting collection by recipient as requested', 'ready_for_pickup'],
15
+ ['delivered', 'delivered'],
16
+ ]);
17
+ /** The stage Connect Couriers' map gives a scan's wording in `normalizeStatusWording` form. */
18
+ export function connectCouriersMappedStage(wording) {
19
+ return WORDINGS.get(wording);
20
+ }
21
+ /** A scan's stage: the map, else the shared wording rules, else nothing. */
22
+ export function connectCouriersStage(wording) {
23
+ const mapped = connectCouriersMappedStage(normalizeStatusWording(wording));
24
+ if (mapped)
25
+ return { stage: mapped, source: 'carrier_map' };
26
+ const worded = classifyWording(wording, 'in_transit');
27
+ return worded.source === 'none' ? null : worded;
28
+ }
29
+ /** Every wording seen is mapped. */
30
+ export const statusMap = {
31
+ stage: (_code, wording) => connectCouriersMappedStage(wording),
32
+ gaps: [],
33
+ };