universal-parcel-scraper 0.70.3-main.907 → 0.72.0-main.916

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 (55) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +13 -5
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +40 -0
  5. package/dist/browser/scraper.js +18 -5
  6. package/dist/carriers/box-now/adapter.d.ts +13 -0
  7. package/dist/carriers/box-now/adapter.js +51 -0
  8. package/dist/carriers/box-now/carrier.json +54 -7
  9. package/dist/carriers/box-now/parser.d.ts +6 -0
  10. package/dist/carriers/box-now/parser.js +97 -0
  11. package/dist/carriers/box-now/status.d.ts +11 -0
  12. package/dist/carriers/box-now/status.js +36 -0
  13. package/dist/carriers/box-now/statuses.json +20 -1
  14. package/dist/carriers/fan-courier/adapter.d.ts +12 -0
  15. package/dist/carriers/fan-courier/adapter.js +51 -0
  16. package/dist/carriers/fan-courier/carrier.json +43 -7
  17. package/dist/carriers/fan-courier/numbers.json +6 -6
  18. package/dist/carriers/fan-courier/parser.d.ts +11 -0
  19. package/dist/carriers/fan-courier/parser.js +92 -0
  20. package/dist/carriers/fan-courier/status.d.ts +10 -0
  21. package/dist/carriers/fan-courier/status.js +46 -0
  22. package/dist/carriers/fan-courier/statuses.json +481 -1
  23. package/dist/carriers/foxpost/adapter.d.ts +14 -0
  24. package/dist/carriers/foxpost/adapter.js +56 -0
  25. package/dist/carriers/foxpost/carrier.json +40 -7
  26. package/dist/carriers/foxpost/parser.d.ts +10 -0
  27. package/dist/carriers/foxpost/parser.js +127 -0
  28. package/dist/carriers/foxpost/status.d.ts +22 -0
  29. package/dist/carriers/foxpost/status.js +77 -0
  30. package/dist/carriers/foxpost/statuses.json +32 -1
  31. package/dist/carriers/orlen-paczka/adapter.d.ts +12 -0
  32. package/dist/carriers/orlen-paczka/adapter.js +35 -0
  33. package/dist/carriers/orlen-paczka/carrier.json +36 -7
  34. package/dist/carriers/orlen-paczka/numbers.json +2 -2
  35. package/dist/carriers/orlen-paczka/parser.d.ts +16 -0
  36. package/dist/carriers/orlen-paczka/parser.js +151 -0
  37. package/dist/carriers/orlen-paczka/status.d.ts +6 -0
  38. package/dist/carriers/orlen-paczka/status.js +42 -0
  39. package/dist/carriers/orlen-paczka/statuses.json +119 -1
  40. package/dist/carriers/sameday/adapter.d.ts +17 -0
  41. package/dist/carriers/sameday/adapter.js +55 -0
  42. package/dist/carriers/sameday/carrier.json +35 -7
  43. package/dist/carriers/sameday/parser.d.ts +18 -0
  44. package/dist/carriers/sameday/parser.js +156 -0
  45. package/dist/carriers/sameday/status.d.ts +24 -0
  46. package/dist/carriers/sameday/status.js +98 -0
  47. package/dist/carriers/sameday/statuses.json +241 -1
  48. package/dist/core/catalog/statusMaps.js +9 -4
  49. package/dist/data/catalog.json +13 -5
  50. package/dist/generated/catalog.d.ts +13 -5
  51. package/dist/generated/catalog.js +13 -5
  52. package/dist/generated/recognition.js +5 -0
  53. package/dist/generated/registry.js +15 -5
  54. package/dist/server/openapi.json +1 -1
  55. 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 **120 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **125 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>207 catalog entries · 66 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 3,350 such statuses, each filed under one stage.
158
+ The carrier folders record 3,501 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
@@ -4822,7 +4822,8 @@
4822
4822
  "timezone": "Europe/Athens",
4823
4823
  "tracking": {
4824
4824
  "mode": "automatic",
4825
- "adapter": "universal"
4825
+ "adapter": "box-now",
4826
+ "recognitionRank": 48
4826
4827
  },
4827
4828
  "canaryUrl": "https://boxnow.cy/track",
4828
4829
  "trackingSearchUrl": "https://boxnow.cy/track",
@@ -6068,7 +6069,9 @@
6068
6069
  "timezone": "Europe/Bucharest",
6069
6070
  "tracking": {
6070
6071
  "mode": "automatic",
6071
- "adapter": "universal"
6072
+ "adapter": "fan-courier",
6073
+ "recognitionRank": 51,
6074
+ "localClocks": true
6072
6075
  },
6073
6076
  "canaryUrl": "https://www.fancourier.ro/awb-tracking/",
6074
6077
  "trackingUrlTemplate": "https://www.fancourier.ro/awb-tracking/?tracking={trackingNumber}",
@@ -6108,7 +6111,9 @@
6108
6111
  "timezone": "Europe/Budapest",
6109
6112
  "tracking": {
6110
6113
  "mode": "automatic",
6111
- "adapter": "universal"
6114
+ "adapter": "foxpost",
6115
+ "recognitionRank": 52,
6116
+ "localClocks": true
6112
6117
  },
6113
6118
  "canaryUrl": "https://foxpost.hu/csomagkovetes/",
6114
6119
  "trackingUrlTemplate": "https://foxpost.hu/csomagkovetes/?code={trackingNumber}",
@@ -7215,7 +7220,8 @@
7215
7220
  "timezone": "Europe/Warsaw",
7216
7221
  "tracking": {
7217
7222
  "mode": "automatic",
7218
- "adapter": "universal"
7223
+ "adapter": "orlen-paczka",
7224
+ "recognitionRank": 49
7219
7225
  },
7220
7226
  "canaryUrl": "https://www.orlenpaczka.pl/sledz-paczke/",
7221
7227
  "trackingUrlTemplate": "https://www.orlenpaczka.pl/sledz-paczke/?numer={trackingNumber}",
@@ -7646,9 +7652,11 @@
7646
7652
  "timezone": "Europe/Bucharest",
7647
7653
  "tracking": {
7648
7654
  "mode": "automatic",
7649
- "adapter": "universal"
7655
+ "adapter": "sameday",
7656
+ "recognitionRank": 53
7650
7657
  },
7651
7658
  "canaryUrl": "https://sameday.ro/",
7659
+ "trackingSearchUrl": "https://sameday.ro/",
7652
7660
  "linkRules": [],
7653
7661
  "detectionRules": [
7654
7662
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 207,
3
- "activeAdapters": 120,
3
+ "activeAdapters": 125,
4
4
  "countries": 66,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -8410,6 +8410,9 @@
8410
8410
  "confidence": "low",
8411
8411
  "candidates": [
8412
8412
  "sameday"
8413
+ ],
8414
+ "asked": [
8415
+ "sameday"
8413
8416
  ]
8414
8417
  },
8415
8418
  {
@@ -8418,6 +8421,9 @@
8418
8421
  "confidence": "low",
8419
8422
  "candidates": [
8420
8423
  "sameday"
8424
+ ],
8425
+ "asked": [
8426
+ "sameday"
8421
8427
  ]
8422
8428
  },
8423
8429
  {
@@ -8426,6 +8432,9 @@
8426
8432
  "confidence": "low",
8427
8433
  "candidates": [
8428
8434
  "sameday"
8435
+ ],
8436
+ "asked": [
8437
+ "sameday"
8429
8438
  ]
8430
8439
  },
8431
8440
  {
@@ -8434,6 +8443,9 @@
8434
8443
  "confidence": "low",
8435
8444
  "candidates": [
8436
8445
  "sameday"
8446
+ ],
8447
+ "asked": [
8448
+ "sameday"
8437
8449
  ]
8438
8450
  },
8439
8451
  {
@@ -8442,6 +8454,9 @@
8442
8454
  "confidence": "low",
8443
8455
  "candidates": [
8444
8456
  "sameday"
8457
+ ],
8458
+ "asked": [
8459
+ "sameday"
8445
8460
  ]
8446
8461
  },
8447
8462
  {
@@ -8450,6 +8465,9 @@
8450
8465
  "confidence": "low",
8451
8466
  "candidates": [
8452
8467
  "sameday"
8468
+ ],
8469
+ "asked": [
8470
+ "sameday"
8453
8471
  ]
8454
8472
  },
8455
8473
  {
@@ -8458,6 +8476,9 @@
8458
8476
  "confidence": "low",
8459
8477
  "candidates": [
8460
8478
  "sameday"
8479
+ ],
8480
+ "asked": [
8481
+ "sameday"
8461
8482
  ]
8462
8483
  },
8463
8484
  {
@@ -8466,6 +8487,9 @@
8466
8487
  "confidence": "low",
8467
8488
  "candidates": [
8468
8489
  "sameday"
8490
+ ],
8491
+ "asked": [
8492
+ "sameday"
8469
8493
  ]
8470
8494
  },
8471
8495
  {
@@ -8474,6 +8498,9 @@
8474
8498
  "confidence": "low",
8475
8499
  "candidates": [
8476
8500
  "sameday"
8501
+ ],
8502
+ "asked": [
8503
+ "sameday"
8477
8504
  ]
8478
8505
  },
8479
8506
  {
@@ -8482,6 +8509,9 @@
8482
8509
  "confidence": "low",
8483
8510
  "candidates": [
8484
8511
  "sameday"
8512
+ ],
8513
+ "asked": [
8514
+ "sameday"
8485
8515
  ]
8486
8516
  },
8487
8517
  {
@@ -10022,6 +10052,7 @@
10022
10052
  "orlen-paczka"
10023
10053
  ],
10024
10054
  "asked": [
10055
+ "orlen-paczka",
10025
10056
  "la-poste",
10026
10057
  "delhivery"
10027
10058
  ]
@@ -10039,6 +10070,7 @@
10039
10070
  "orlen-paczka"
10040
10071
  ],
10041
10072
  "asked": [
10073
+ "orlen-paczka",
10042
10074
  "la-poste",
10043
10075
  "delhivery"
10044
10076
  ]
@@ -10056,6 +10088,7 @@
10056
10088
  "orlen-paczka"
10057
10089
  ],
10058
10090
  "asked": [
10091
+ "orlen-paczka",
10059
10092
  "la-poste",
10060
10093
  "delhivery"
10061
10094
  ]
@@ -10074,6 +10107,7 @@
10074
10107
  "orlen-paczka"
10075
10108
  ],
10076
10109
  "asked": [
10110
+ "orlen-paczka",
10077
10111
  "ukrposhta",
10078
10112
  "la-poste",
10079
10113
  "delhivery"
@@ -10093,6 +10127,7 @@
10093
10127
  "orlen-paczka"
10094
10128
  ],
10095
10129
  "asked": [
10130
+ "orlen-paczka",
10096
10131
  "ukrposhta",
10097
10132
  "la-poste",
10098
10133
  "delhivery"
@@ -10112,6 +10147,7 @@
10112
10147
  "orlen-paczka"
10113
10148
  ],
10114
10149
  "asked": [
10150
+ "orlen-paczka",
10115
10151
  "ukrposhta",
10116
10152
  "la-poste",
10117
10153
  "delhivery"
@@ -10130,6 +10166,7 @@
10130
10166
  "orlen-paczka"
10131
10167
  ],
10132
10168
  "asked": [
10169
+ "orlen-paczka",
10133
10170
  "la-poste",
10134
10171
  "delhivery"
10135
10172
  ]
@@ -10148,6 +10185,7 @@
10148
10185
  "orlen-paczka"
10149
10186
  ],
10150
10187
  "asked": [
10188
+ "orlen-paczka",
10151
10189
  "ukrposhta",
10152
10190
  "la-poste",
10153
10191
  "delhivery"
@@ -10166,6 +10204,7 @@
10166
10204
  "orlen-paczka"
10167
10205
  ],
10168
10206
  "asked": [
10207
+ "orlen-paczka",
10169
10208
  "la-poste",
10170
10209
  "delhivery"
10171
10210
  ]
@@ -10191,6 +10230,7 @@
10191
10230
  "orlen-paczka"
10192
10231
  ],
10193
10232
  "asked": [
10233
+ "orlen-paczka",
10194
10234
  "la-poste",
10195
10235
  "delhivery"
10196
10236
  ]
@@ -3953,7 +3953,8 @@ var ye = {
3953
3953
  timezone: "Europe/Athens",
3954
3954
  tracking: {
3955
3955
  mode: "automatic",
3956
- adapter: "universal"
3956
+ adapter: "box-now",
3957
+ recognitionRank: 48
3957
3958
  },
3958
3959
  canaryUrl: "https://boxnow.cy/track",
3959
3960
  trackingSearchUrl: "https://boxnow.cy/track",
@@ -4877,7 +4878,9 @@ var ye = {
4877
4878
  timezone: "Europe/Bucharest",
4878
4879
  tracking: {
4879
4880
  mode: "automatic",
4880
- adapter: "universal"
4881
+ adapter: "fan-courier",
4882
+ recognitionRank: 51,
4883
+ localClocks: !0
4881
4884
  },
4882
4885
  canaryUrl: "https://www.fancourier.ro/awb-tracking/",
4883
4886
  trackingUrlTemplate: "https://www.fancourier.ro/awb-tracking/?tracking={trackingNumber}",
@@ -4905,7 +4908,9 @@ var ye = {
4905
4908
  timezone: "Europe/Budapest",
4906
4909
  tracking: {
4907
4910
  mode: "automatic",
4908
- adapter: "universal"
4911
+ adapter: "foxpost",
4912
+ recognitionRank: 52,
4913
+ localClocks: !0
4909
4914
  },
4910
4915
  canaryUrl: "https://foxpost.hu/csomagkovetes/",
4911
4916
  trackingUrlTemplate: "https://foxpost.hu/csomagkovetes/?code={trackingNumber}",
@@ -5730,7 +5735,8 @@ var ye = {
5730
5735
  timezone: "Europe/Warsaw",
5731
5736
  tracking: {
5732
5737
  mode: "automatic",
5733
- adapter: "universal"
5738
+ adapter: "orlen-paczka",
5739
+ recognitionRank: 49
5734
5740
  },
5735
5741
  canaryUrl: "https://www.orlenpaczka.pl/sledz-paczke/",
5736
5742
  trackingUrlTemplate: "https://www.orlenpaczka.pl/sledz-paczke/?numer={trackingNumber}",
@@ -6056,9 +6062,11 @@ var ye = {
6056
6062
  timezone: "Europe/Bucharest",
6057
6063
  tracking: {
6058
6064
  mode: "automatic",
6059
- adapter: "universal"
6065
+ adapter: "sameday",
6066
+ recognitionRank: 53
6060
6067
  },
6061
6068
  canaryUrl: "https://sameday.ro/",
6069
+ trackingSearchUrl: "https://sameday.ro/",
6062
6070
  linkRules: [],
6063
6071
  detectionRules: [{
6064
6072
  pattern: "^1[A-Z]{3,5}\\d{9,11}$",
@@ -11660,6 +11668,7 @@ var _c = {
11660
11668
  aliexpress: 14,
11661
11669
  "australia-post": 6,
11662
11670
  "austrian-post": 39,
11671
+ "box-now": 48,
11663
11672
  bpost: 37,
11664
11673
  brt: 42,
11665
11674
  "canada-post": 15,
@@ -11680,7 +11689,9 @@ var _c = {
11680
11689
  "dpd-de": 65,
11681
11690
  "dpd-pl": 8,
11682
11691
  emile: 4,
11692
+ "fan-courier": 51,
11683
11693
  "four-px": 13,
11694
+ foxpost: 52,
11684
11695
  "gls-ch": 50,
11685
11696
  "gls-de": 60,
11686
11697
  "gls-fr": 61,
@@ -11696,6 +11707,7 @@ var _c = {
11696
11707
  "nz-post": 32,
11697
11708
  omgo: 1,
11698
11709
  ontrac: 21,
11710
+ "orlen-paczka": 49,
11699
11711
  "poczta-polska": 31,
11700
11712
  "poste-italiane": 46,
11701
11713
  posti: 27,
@@ -11703,6 +11715,7 @@ var _c = {
11703
11715
  postnord: 7,
11704
11716
  purolator: 12,
11705
11717
  "relais-colis": 25,
11718
+ sameday: 53,
11706
11719
  seur: 43,
11707
11720
  sto: 10,
11708
11721
  "swiss-post-cargo": 11,
@@ -0,0 +1,13 @@
1
+ import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
+ import { type StepRecorder } from '../../core/telemetry/index.js';
3
+ export declare const BOX_NOW_ENDPOINT = "https://api-production.boxnow.cy/api/v1/parcels:track";
4
+ export declare class BoxNowTracker {
5
+ private readonly options;
6
+ constructor(options?: {
7
+ fetcher?: typeof fetch;
8
+ recorder?: StepRecorder;
9
+ userAgent?: string;
10
+ });
11
+ fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
12
+ }
13
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,51 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
5
+ import { decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
6
+ import { BOX_NOW_MAX_BYTES, normalizeBoxNowNumber, parseBoxNow, PROVIDER } from './parser.js';
7
+ // The tracker on boxnow.cy posts here. The Greek, Bulgarian and Croatian sites
8
+ // post to their own hosts, which answer the same parcels.
9
+ export const BOX_NOW_ENDPOINT = 'https://api-production.boxnow.cy/api/v1/parcels:track';
10
+ export class BoxNowTracker {
11
+ options;
12
+ constructor(options = {}) {
13
+ this.options = options;
14
+ }
15
+ async fetch(raw, context = {}) {
16
+ const number = normalizeBoxNowNumber(raw);
17
+ return runSteps({ carrier: 'box-now', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
18
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
19
+ let bytes;
20
+ try {
21
+ ({ bytes } = await fetchBounded(BOX_NOW_ENDPOINT, { method: 'POST', signal,
22
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': userAgentOf(this.options.userAgent) },
23
+ body: JSON.stringify({ parcelId: number }) }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(remainingMs)), maxBytes: BOX_NOW_MAX_BYTES, fetcher: this.options.fetcher }));
24
+ }
25
+ catch (error) {
26
+ // An unknown parcel is an empty list; a missing route is not a missing parcel.
27
+ if (error instanceof UpstreamHttpError && [404, 410].includes(error.status)) {
28
+ throw new TransportError(PROVIDER, 'BOX NOW tracking endpoint is unavailable', { cause: error });
29
+ }
30
+ throw error;
31
+ }
32
+ let payload;
33
+ try {
34
+ payload = parseJsonBytes(bytes, PROVIDER);
35
+ }
36
+ catch (cause) {
37
+ // The Greek site sits behind Cloudflare's challenge; the API could too.
38
+ if (/<title>\s*Just a moment|cf-chl-|challenge-platform|cf-turnstile/i.test(decodeText(bytes.subarray(0, 64_000)))) {
39
+ throw new ChallengeError(PROVIDER, 'BOX NOW answered with a browser challenge', { cause });
40
+ }
41
+ throw new SchemaError(PROVIDER, 'BOX NOW returned invalid tracking JSON', { cause });
42
+ }
43
+ return parseBoxNow(payload, number);
44
+ } }]);
45
+ }
46
+ }
47
+ export const adapter = environment => {
48
+ const tracker = new BoxNowTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
49
+ return { id: 'box-now', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context),
50
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeBoxNowNumber(number))) };
51
+ };
@@ -20,17 +20,64 @@
20
20
  "timezone": "Europe/Athens",
21
21
  "tracking": {
22
22
  "mode": "automatic",
23
- "adapter": "universal",
24
- "steps": []
23
+ "adapter": "box-now",
24
+ "steps": [
25
+ "direct"
26
+ ],
27
+ "recognition": {
28
+ "rank": 48
29
+ }
25
30
  },
26
- "capabilities": [],
31
+ "capabilities": [
32
+ "history",
33
+ "location",
34
+ "provider_code",
35
+ "eta",
36
+ "delivered_at",
37
+ "pickup_point",
38
+ "destination_country"
39
+ ],
27
40
  "portal": {
28
41
  "searchUrl": "https://boxnow.cy/track",
29
42
  "canaryUrl": "https://boxnow.cy/track",
30
- "shows": [],
31
- "scraped": [],
32
- "discarded": [],
33
- "unavailable": []
43
+ "shows": [
44
+ "status",
45
+ "history",
46
+ "location",
47
+ "eta",
48
+ "delivered_at",
49
+ "pickup_point",
50
+ "pickup_point_address",
51
+ "pickup_deadline",
52
+ "coordinates",
53
+ "sender_name",
54
+ "payment_details"
55
+ ],
56
+ "scraped": [
57
+ "status",
58
+ "history",
59
+ "location",
60
+ "provider_code",
61
+ "eta",
62
+ "delivered_at",
63
+ "pickup_point",
64
+ "destination_country"
65
+ ],
66
+ "discarded": [
67
+ "pickup_point_address",
68
+ "pickup_deadline",
69
+ "coordinates",
70
+ "sender_name",
71
+ "payment_details",
72
+ "postcodes",
73
+ "size_class",
74
+ "delivery_partner_reference"
75
+ ],
76
+ "unavailable": [
77
+ "weight",
78
+ "service_name",
79
+ "proof_of_delivery"
80
+ ]
34
81
  },
35
82
  "links": [],
36
83
  "detection": []
@@ -0,0 +1,6 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "BOX NOW";
3
+ export declare const BOX_NOW_MAX_BYTES = 256000;
4
+ /** BOX NOW's partner API manual: a parcel id is always ten digits. */
5
+ export declare function normalizeBoxNowNumber(raw: string): string;
6
+ export declare function parseBoxNow(payload: unknown, rawNumber: string): CarrierResult;
@@ -0,0 +1,97 @@
1
+ import { DateTime } from 'luxon';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { lastMovement } from '../../core/result/pickup.js';
5
+ import { languageStageStatus } from '../../core/status/index.js';
6
+ import { countryCode, explicitOffsetTime } from '../../core/time/index.js';
7
+ import { clean } from '../../core/transport/index.js';
8
+ import { isRecord } from '../../core/types.js';
9
+ import { boxNowStatus } from './status.js';
10
+ export const PROVIDER = 'BOX NOW';
11
+ export const BOX_NOW_MAX_BYTES = 256_000;
12
+ /** BOX NOW's partner API manual: a parcel id is always ten digits. */
13
+ export function normalizeBoxNowNumber(raw) {
14
+ const number = normalizeTrackingNumber(raw);
15
+ if (!/^\d{10}$/.test(number))
16
+ throw new InvalidInputError(PROVIDER, 'BOX NOW parcel ids are ten digits');
17
+ return number;
18
+ }
19
+ const code = (value) => {
20
+ const text = clean(value, 64);
21
+ return /^[a-z]+(?:-[a-z]+)*$/.test(text) ? text : '';
22
+ };
23
+ // The trackers stop showing the estimate once a parcel waits in its locker or
24
+ // its journey has ended; only a parcel still on its way keeps one.
25
+ const ON_THE_WAY = new Set(['registered', 'accepted', 'in_transit']);
26
+ export function parseBoxNow(payload, rawNumber) {
27
+ const number = normalizeBoxNowNumber(rawNumber);
28
+ if (!isRecord(payload) || !Array.isArray(payload.data) || payload.data.length > 10)
29
+ throw new SchemaError(PROVIDER);
30
+ // The official tracker reads an empty list as a parcel id that does not exist.
31
+ if (!payload.data.length)
32
+ throw new NotFoundError(PROVIDER);
33
+ const parcel = payload.data[0];
34
+ if (payload.data.length !== 1 || !isRecord(parcel) || parcel.id !== number) {
35
+ throw new SchemaError(PROVIDER, 'BOX NOW did not identify one matching parcel');
36
+ }
37
+ const state = code(parcel.state);
38
+ if (!state)
39
+ throw new SchemaError(PROVIDER, 'BOX NOW returned a parcel without a state');
40
+ const rows = parcel.events ?? [];
41
+ if (!Array.isArray(rows) || rows.length > 200)
42
+ throw new SchemaError(PROVIDER, 'BOX NOW returned an invalid parcel history');
43
+ const scans = [];
44
+ const seen = new Set();
45
+ for (const row of rows) {
46
+ if (!isRecord(row))
47
+ throw new SchemaError(PROVIDER, 'BOX NOW returned an incomplete scan');
48
+ const type = code(row.type);
49
+ const at = explicitOffsetTime(row.createTime);
50
+ if (!type || !at)
51
+ throw new SchemaError(PROVIDER, 'BOX NOW returned a scan without a type or clock');
52
+ const { wording, stage } = boxNowStatus(type);
53
+ // A new parcel's place is its sender. Postcodes are left out.
54
+ const location = type === 'new' ? '' : clean(row.locationDisplayName, 160);
55
+ const event = { time: at.iso, description: wording, provider_code: type,
56
+ ...(location ? { location } : {}), ...(stage ? { stage, stage_source: 'carrier_map' } : {}) };
57
+ const key = JSON.stringify(event);
58
+ if (!seen.has(key)) {
59
+ seen.add(key);
60
+ scans.push({ event, at: at.timestamp, type });
61
+ }
62
+ }
63
+ // The reply lists scans oldest first; equal clocks keep their order, reversed.
64
+ const ordered = scans.map((scan, index) => ({ ...scan, index }))
65
+ .sort((left, right) => right.at - left.at || right.index - left.index);
66
+ const events = ordered.map(({ event }) => event);
67
+ // The trackers show the newest scan's type while the parcel's state only
68
+ // says it is on its way, and the state otherwise.
69
+ const newest = ordered[0]?.type;
70
+ const shown = newest && ['in-transit', 'in-depot'].includes(state)
71
+ && ['accepted-to-locker', 'accepted-for-return', 'in-depot', 'new', 'wait-for-load'].includes(newest) ? newest : state;
72
+ const current = boxNowStatus(shown);
73
+ const stage = current.stage;
74
+ const delivered = stage === 'delivered' ? events.find(event => event.stage === 'delivered') : undefined;
75
+ const deliveredIndex = delivered ? events.indexOf(delivered) : -1;
76
+ // The recipient's locker, by name only, once the parcel waits there or was collected from it.
77
+ const destination = isRecord(parcel.destination) ? parcel.destination : null;
78
+ const collected = deliveredIndex >= 0 && lastMovement(events.slice(deliveredIndex + 1)) === 'ready_for_pickup';
79
+ const locker = destination?.type === 'apm' && (stage === 'ready_for_pickup' || collected) ? clean(destination.name, 160) : '';
80
+ const country = destination ? countryCode(destination.country) : null;
81
+ const eta = typeof parcel.etaDate === 'string' && stage && ON_THE_WAY.has(stage) ? explicitOffsetTime(parcel.etaDate) : null;
82
+ // The trackers print the estimate as a day in Athens.
83
+ const expected = eta ? DateTime.fromMillis(eta.timestamp, { zone: 'Europe/Athens' }).toISODate() : null;
84
+ return {
85
+ status: stage ? languageStageStatus(stage) : 'unknown',
86
+ ...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
87
+ last_status_text: current.wording,
88
+ ...(events.length ? {} : { provider_code: shown, summary_only: true }),
89
+ last_update: events[0]?.time ?? null,
90
+ expected_delivery: expected,
91
+ ...(delivered?.time ? { delivered_at: delivered.time } : {}),
92
+ ...(locker ? { pickup_point: locker } : {}),
93
+ ...(country ? { destination_country: country } : {}),
94
+ events: events.slice(0, 100),
95
+ ...(events.length > 100 ? { history_truncated: true } : {}),
96
+ };
97
+ }
@@ -0,0 +1,11 @@
1
+ import type { Stage } from '../../core/status/index.js';
2
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** One BOX NOW parcel state or event type: the English wording the adapter gives it and its stage. */
4
+ interface BoxNowStatus {
5
+ readonly wording: string;
6
+ readonly stage?: Stage;
7
+ }
8
+ /** The wording and stage of one state or event type; a type the map does not know keeps its code as wording. */
9
+ export declare function boxNowStatus(code: string): BoxNowStatus;
10
+ export declare const statusMap: CarrierStatusMap;
11
+ export {};
@@ -0,0 +1,36 @@
1
+ // The tracking API names each scan and the parcel's state with one vocabulary.
2
+ // Wordings follow the definitions in BOX NOW's partner API manual, else the
3
+ // English wording of its country sites' trackers. A parcel goes to the locker
4
+ // its recipient chose: final-destination is a parcel waiting there.
5
+ const STATUSES = new Map([
6
+ ['new', { wording: 'Parcel has been registered in the system', stage: 'registered' }],
7
+ ['accepted-to-locker', { wording: 'The parcel has been placed in the locker', stage: 'accepted' }],
8
+ ['accepted-in-locker', { wording: 'The parcel has been placed in the locker', stage: 'accepted' }],
9
+ ['in-transit', { wording: 'Parcel is being transferred to a specific location', stage: 'in_transit' }],
10
+ ['in-depot', { wording: 'Parcel is in one of our warehouses', stage: 'in_transit' }],
11
+ // Waiting in a locker for a courier, after a drop-off or an expired pickup.
12
+ ['wait-for-load', { wording: 'Parcel is waiting in a specific APM to be taken over by BOX NOW courier', stage: 'in_transit' }],
13
+ ['final-destination', { wording: 'Parcel has reached its final destination, waiting for pickup', stage: 'ready_for_pickup' }],
14
+ ['in-final-destination', { wording: 'Parcel has reached its final destination, waiting for pickup', stage: 'ready_for_pickup' }],
15
+ ['delivered', { wording: 'Parcel has been delivered', stage: 'delivered' }],
16
+ ['returned', { wording: 'Parcel has been returned to the sender', stage: 'returned' }],
17
+ ['expired', { wording: 'Expired', stage: 'exception' }],
18
+ ['expired-return', { wording: 'Delivery has expired', stage: 'exception' }],
19
+ ['missing', { wording: 'Parcel search - We will update you shortly', stage: 'exception' }],
20
+ ['lost', { wording: 'Lost', stage: 'exception' }],
21
+ ['canceled', { wording: 'Parcel order had been cancelled by the sender', stage: 'exception' }],
22
+ ['cancelled', { wording: 'Parcel order had been cancelled by the sender', stage: 'exception' }],
23
+ ['cancelled-return', { wording: 'Return of parcel, cancelled by sender', stage: 'exception' }],
24
+ ['accepted-for-return', { wording: 'The parcel has been placed in the locker' }],
25
+ ]);
26
+ /** The wording and stage of one state or event type; a type the map does not know keeps its code as wording. */
27
+ export function boxNowStatus(code) {
28
+ return STATUSES.get(code) ?? { wording: code };
29
+ }
30
+ export const statusMap = {
31
+ stage: (code) => (code ? STATUSES.get(code)?.stage : undefined),
32
+ gaps: [{
33
+ code: 'accepted-for-return',
34
+ note: 'A parcel put back into a locker to go back. The trackers word it like a sender drop-off, and no reply showed whether it ends the delivery or starts a return to the sender.',
35
+ }],
36
+ };
@@ -1,4 +1,23 @@
1
1
  {
2
2
  "carrier": "box-now",
3
- "entries": []
3
+ "entries": [
4
+ { "code": "new", "wording": "Parcel has been registered in the system", "language": "en", "stage": "registered", "confirmedBy": "live", "note": "The API sends the code only; the wording is the partner API manual's definition. Its place is the sender, which the adapter drops." },
5
+ { "code": "accepted-to-locker", "wording": "The parcel has been placed in the locker", "language": "en", "stage": "accepted", "confirmedBy": "live", "note": "The sender's drop-off at a locker or BOX NOW store; the wording is the Cypriot tracker's English one." },
6
+ { "code": "accepted-in-locker", "wording": "The parcel has been placed in the locker", "language": "en", "stage": "accepted", "confirmedBy": "bundle", "note": "The Bulgarian tracker shows it like accepted-to-locker." },
7
+ { "code": "in-transit", "wording": "Parcel is being transferred to a specific location", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "Seen as a parcel's state; the trackers then show the newest scan's type." },
8
+ { "code": "in-depot", "wording": "Parcel is in one of our warehouses", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "Its place names the hub or depot." },
9
+ { "code": "wait-for-load", "wording": "Parcel is waiting in a specific APM to be taken over by BOX NOW courier", "language": "en", "stage": "in_transit", "confirmedBy": "official-doc", "note": "The manual adds that the courier takes it back to the sender or on to another locker." },
10
+ { "code": "final-destination", "wording": "Parcel has reached its final destination, waiting for pickup", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "live", "note": "Its place names the recipient's locker." },
11
+ { "code": "in-final-destination", "wording": "Parcel has reached its final destination, waiting for pickup", "language": "en", "stage": "ready_for_pickup", "confirmedBy": "live", "note": "The state of a parcel whose newest scan is final-destination." },
12
+ { "code": "delivered", "wording": "Parcel has been delivered", "language": "en", "stage": "delivered", "confirmedBy": "live", "note": "The recipient took the parcel out of the locker; the scan has no place." },
13
+ { "code": "returned", "wording": "Parcel has been returned to the sender", "language": "en", "stage": "returned", "confirmedBy": "official-doc" },
14
+ { "code": "expired", "wording": "Expired", "language": "en", "stage": "exception", "confirmedBy": "bundle", "note": "The trackers show it in red with the other ended states." },
15
+ { "code": "expired-return", "wording": "Delivery has expired", "language": "en", "stage": "exception", "confirmedBy": "official-doc", "note": "Listed among the manual's states; the wording is the trackers' English one." },
16
+ { "code": "missing", "wording": "Parcel search - We will update you shortly", "language": "en", "stage": "exception", "confirmedBy": "official-doc", "note": "A webhook event in the manual; the wording is the Cypriot tracker's English one." },
17
+ { "code": "lost", "wording": "Lost", "language": "en", "stage": "exception", "confirmedBy": "official-doc", "note": "Listed among the manual's states; the wording is the Bulgarian and Croatian trackers' English one." },
18
+ { "code": "canceled", "wording": "Parcel order had been cancelled by the sender", "language": "en", "stage": "exception", "confirmedBy": "official-doc", "note": "The manual lists both spellings." },
19
+ { "code": "cancelled", "wording": "Parcel order had been cancelled by the sender", "language": "en", "stage": "exception", "confirmedBy": "official-doc" },
20
+ { "code": "cancelled-return", "wording": "Return of parcel, cancelled by sender", "language": "en", "stage": "exception", "confirmedBy": "official-doc" },
21
+ { "code": "accepted-for-return", "wording": "The parcel has been placed in the locker", "language": "en", "confirmedBy": "bundle", "note": "Left without a stage: the trackers show it like a sender drop-off, and no reply showed whether it ends the delivery or starts a return to the sender." }
22
+ ]
4
23
  }