universal-parcel-scraper 0.861.0-main.4621 → 0.862.0-main.4623

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 (37) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +9 -3
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +36 -0
  5. package/dist/browser/scraper.js +12 -3
  6. package/dist/carriers/consegnia/adapter.d.ts +17 -0
  7. package/dist/carriers/consegnia/adapter.js +107 -0
  8. package/dist/carriers/consegnia/carrier.json +38 -7
  9. package/dist/carriers/consegnia/parser.d.ts +29 -0
  10. package/dist/carriers/consegnia/parser.js +169 -0
  11. package/dist/carriers/consegnia/status.d.ts +7 -0
  12. package/dist/carriers/consegnia/status.js +23 -0
  13. package/dist/carriers/consegnia/statuses.json +8 -1
  14. package/dist/carriers/fmx/adapter.d.ts +18 -0
  15. package/dist/carriers/fmx/adapter.js +50 -0
  16. package/dist/carriers/fmx/carrier.json +43 -7
  17. package/dist/carriers/fmx/parser.d.ts +17 -0
  18. package/dist/carriers/fmx/parser.js +140 -0
  19. package/dist/carriers/fmx/status.d.ts +7 -0
  20. package/dist/carriers/fmx/status.js +30 -0
  21. package/dist/carriers/fmx/statuses.json +5 -1
  22. package/dist/carriers/hitch/adapter.d.ts +18 -0
  23. package/dist/carriers/hitch/adapter.js +49 -0
  24. package/dist/carriers/hitch/carrier.json +43 -7
  25. package/dist/carriers/hitch/parser.d.ts +19 -0
  26. package/dist/carriers/hitch/parser.js +151 -0
  27. package/dist/carriers/hitch/status.d.ts +7 -0
  28. package/dist/carriers/hitch/status.js +22 -0
  29. package/dist/carriers/hitch/statuses.json +9 -1
  30. package/dist/core/catalog/statusMaps.js +6 -3
  31. package/dist/data/catalog.json +9 -3
  32. package/dist/generated/catalog.d.ts +9 -3
  33. package/dist/generated/catalog.js +9 -3
  34. package/dist/generated/recognition.js +3 -0
  35. package/dist/generated/registry.js +9 -3
  36. package/dist/server/openapi.json +1 -1
  37. 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 **658 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **661 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>1045 catalog entries · 145 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -163,7 +163,7 @@ the command line's `track --source ParcelsApp` does the same.
163
163
  <!-- GENERATED:stages -->
164
164
  <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">
165
165
 
166
- The carrier folders record 17,879 such statuses, each filed under one stage.
166
+ The carrier folders record 17,895 such statuses, each filed under one stage.
167
167
  <!-- /GENERATED:stages -->
168
168
 
169
169
  Wording nobody recorded yet goes through a classifier that reads seven European languages.
package/data/catalog.json CHANGED
@@ -11611,7 +11611,9 @@
11611
11611
  "timezone": "Asia/Manila",
11612
11612
  "tracking": {
11613
11613
  "mode": "automatic",
11614
- "adapter": "universal"
11614
+ "adapter": "consegnia",
11615
+ "recognitionRank": 57,
11616
+ "localClocks": true
11615
11617
  },
11616
11618
  "canaryUrl": "https://clc-express.com/",
11617
11619
  "trackingUrlTemplate": "https://clc-express.com/track-delivery?awb={trackingNumber}",
@@ -18128,7 +18130,9 @@
18128
18130
  "timezone": "Asia/Kuala_Lumpur",
18129
18131
  "tracking": {
18130
18132
  "mode": "automatic",
18131
- "adapter": "universal"
18133
+ "adapter": "fmx",
18134
+ "recognitionRank": 147,
18135
+ "localClocks": true
18132
18136
  },
18133
18137
  "canaryUrl": "https://www.fmx.asia/",
18134
18138
  "trackingUrlTemplate": "https://www.fmx.asia/result_detail.php?p=1&conno={trackingNumber}",
@@ -20577,7 +20581,9 @@
20577
20581
  "timezone": "Asia/Manila",
20578
20582
  "tracking": {
20579
20583
  "mode": "automatic",
20580
- "adapter": "universal"
20584
+ "adapter": "hitch",
20585
+ "recognitionRank": 117,
20586
+ "localClocks": true
20581
20587
  },
20582
20588
  "canaryUrl": "https://hitch.com.ph/",
20583
20589
  "trackingUrlTemplate": "https://hitch.com.ph/tracking?no={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 1045,
3
- "activeAdapters": 658,
3
+ "activeAdapters": 661,
4
4
  "countries": 145,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -87063,6 +87063,9 @@
87063
87063
  "confidence": "low",
87064
87064
  "candidates": [
87065
87065
  "fmx"
87066
+ ],
87067
+ "asked": [
87068
+ "fmx"
87066
87069
  ]
87067
87070
  },
87068
87071
  {
@@ -87071,6 +87074,9 @@
87071
87074
  "confidence": "low",
87072
87075
  "candidates": [
87073
87076
  "fmx"
87077
+ ],
87078
+ "asked": [
87079
+ "fmx"
87074
87080
  ]
87075
87081
  },
87076
87082
  {
@@ -87079,6 +87085,9 @@
87079
87085
  "confidence": "low",
87080
87086
  "candidates": [
87081
87087
  "fmx"
87088
+ ],
87089
+ "asked": [
87090
+ "fmx"
87082
87091
  ]
87083
87092
  },
87084
87093
  {
@@ -100584,6 +100593,9 @@
100584
100593
  "confidence": "low",
100585
100594
  "candidates": [
100586
100595
  "hitch"
100596
+ ],
100597
+ "asked": [
100598
+ "hitch"
100587
100599
  ]
100588
100600
  },
100589
100601
  {
@@ -100592,6 +100604,9 @@
100592
100604
  "confidence": "low",
100593
100605
  "candidates": [
100594
100606
  "hitch"
100607
+ ],
100608
+ "asked": [
100609
+ "hitch"
100595
100610
  ]
100596
100611
  },
100597
100612
  {
@@ -100600,6 +100615,9 @@
100600
100615
  "confidence": "low",
100601
100616
  "candidates": [
100602
100617
  "hitch"
100618
+ ],
100619
+ "asked": [
100620
+ "hitch"
100603
100621
  ]
100604
100622
  },
100605
100623
  {
@@ -100608,6 +100626,9 @@
100608
100626
  "confidence": "low",
100609
100627
  "candidates": [
100610
100628
  "hitch"
100629
+ ],
100630
+ "asked": [
100631
+ "hitch"
100611
100632
  ]
100612
100633
  },
100613
100634
  {
@@ -100616,6 +100637,9 @@
100616
100637
  "confidence": "low",
100617
100638
  "candidates": [
100618
100639
  "hitch"
100640
+ ],
100641
+ "asked": [
100642
+ "hitch"
100619
100643
  ]
100620
100644
  },
100621
100645
  {
@@ -100624,6 +100648,9 @@
100624
100648
  "confidence": "low",
100625
100649
  "candidates": [
100626
100650
  "hitch"
100651
+ ],
100652
+ "asked": [
100653
+ "hitch"
100627
100654
  ]
100628
100655
  },
100629
100656
  {
@@ -100632,6 +100659,9 @@
100632
100659
  "confidence": "low",
100633
100660
  "candidates": [
100634
100661
  "hitch"
100662
+ ],
100663
+ "asked": [
100664
+ "hitch"
100635
100665
  ]
100636
100666
  },
100637
100667
  {
@@ -100640,6 +100670,9 @@
100640
100670
  "confidence": "low",
100641
100671
  "candidates": [
100642
100672
  "hitch"
100673
+ ],
100674
+ "asked": [
100675
+ "hitch"
100643
100676
  ]
100644
100677
  },
100645
100678
  {
@@ -100648,6 +100681,9 @@
100648
100681
  "confidence": "low",
100649
100682
  "candidates": [
100650
100683
  "hitch"
100684
+ ],
100685
+ "asked": [
100686
+ "hitch"
100651
100687
  ]
100652
100688
  },
100653
100689
  {
@@ -9243,7 +9243,9 @@ var tt = {
9243
9243
  timezone: "Asia/Manila",
9244
9244
  tracking: {
9245
9245
  mode: "automatic",
9246
- adapter: "universal"
9246
+ adapter: "consegnia",
9247
+ recognitionRank: 57,
9248
+ localClocks: !0
9247
9249
  },
9248
9250
  canaryUrl: "https://clc-express.com/",
9249
9251
  trackingUrlTemplate: "https://clc-express.com/track-delivery?awb={trackingNumber}",
@@ -14105,7 +14107,9 @@ var tt = {
14105
14107
  timezone: "Asia/Kuala_Lumpur",
14106
14108
  tracking: {
14107
14109
  mode: "automatic",
14108
- adapter: "universal"
14110
+ adapter: "fmx",
14111
+ recognitionRank: 147,
14112
+ localClocks: !0
14109
14113
  },
14110
14114
  canaryUrl: "https://www.fmx.asia/",
14111
14115
  trackingUrlTemplate: "https://www.fmx.asia/result_detail.php?p=1&conno={trackingNumber}",
@@ -15930,7 +15934,9 @@ var tt = {
15930
15934
  timezone: "Asia/Manila",
15931
15935
  tracking: {
15932
15936
  mode: "automatic",
15933
- adapter: "universal"
15937
+ adapter: "hitch",
15938
+ recognitionRank: 117,
15939
+ localClocks: !0
15934
15940
  },
15935
15941
  canaryUrl: "https://hitch.com.ph/",
15936
15942
  trackingUrlTemplate: "https://hitch.com.ph/tracking?no={trackingNumber}",
@@ -39169,6 +39175,7 @@ var ed = {
39169
39175
  "closer-logistics": 849,
39170
39176
  "colis-prive": 740,
39171
39177
  "connect-couriers": 831,
39178
+ consegnia: 57,
39172
39179
  coordinadora: 973,
39173
39180
  "copa-courier": 773,
39174
39181
  corre: 587,
@@ -39259,6 +39266,7 @@ var ed = {
39259
39266
  fleetex: 571,
39260
39267
  fleetoptics: 763,
39261
39268
  flippost: 568,
39269
+ fmx: 147,
39262
39270
  "forward-air": 755,
39263
39271
  "forza-delivery": 633,
39264
39272
  "four-px": 570,
@@ -39301,6 +39309,7 @@ var ed = {
39301
39309
  hermes: 602,
39302
39310
  "hermes-at": 876,
39303
39311
  "hermes-de": 840,
39312
+ hitch: 117,
39304
39313
  "houng-ah-loun": 637,
39305
39314
  hovership: 687,
39306
39315
  "hr-parcel": 887,
@@ -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 tracking page, which opens the session its lookup needs and carries the session's token. */
5
+ export declare const CONSEGNIA_PAGE = "https://clc-express.com/track-delivery";
6
+ /** The lookup the tracking page's script posts the number to. */
7
+ export declare const CONSEGNIA_LOOKUP = "https://clc-express.com/track-delivery-result";
8
+ export declare class ConsegniaTracker {
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,107 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, IndeterminateError, NotFoundError, 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 { CARRIER, CONSEGNIA_MAX_BYTES, consegniaJson, consegniaToken, isBlockPage, isUnknownShipment, normalizeConsegniaNumber, parseConsegnia, PROVIDER, } from './parser.js';
8
+ const ORIGIN = 'https://clc-express.com';
9
+ /** The tracking page, which opens the session its lookup needs and carries the session's token. */
10
+ export const CONSEGNIA_PAGE = `${ORIGIN}/track-delivery`;
11
+ /** The lookup the tracking page's script posts the number to. */
12
+ export const CONSEGNIA_LOOKUP = `${ORIGIN}/track-delivery-result`;
13
+ const REDIRECTS = [301, 302, 303, 307, 308];
14
+ // The lookup answers an unknown number with its own HTTP 404; any other is a missing route.
15
+ const GONE = [404, 410];
16
+ // Laravel's refusal of a token or session it did not issue, or one that expired.
17
+ const REFUSED_SESSION = 419;
18
+ const COOKIE_NAME = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
19
+ const COOKIE_VALUE = /^[^\s;,]{1,4096}$/;
20
+ const MAX_COOKIES = 8;
21
+ const DEFAULT_BUDGET_MS = 15_000;
22
+ /**
23
+ * The cookies the tracking page sets, as the lookup sends them back: Laravel's
24
+ * session, its token cookie and the site's own. Null without the session.
25
+ */
26
+ function sessionCookies(response) {
27
+ const pairs = [];
28
+ for (const header of response.headers.getSetCookie()) {
29
+ const pair = header.split(';', 1)[0].trim();
30
+ const separator = pair.indexOf('=');
31
+ const name = pair.slice(0, separator);
32
+ if (separator > 0 && COOKIE_NAME.test(name) && COOKIE_VALUE.test(pair.slice(separator + 1)) && pairs.length < MAX_COOKIES)
33
+ pairs.push(pair);
34
+ }
35
+ return pairs.some((pair) => /^[^=]+_session=/.test(pair)) ? pairs.join('; ') : null;
36
+ }
37
+ export class ConsegniaTracker {
38
+ options;
39
+ constructor(options = {}) {
40
+ this.options = options;
41
+ }
42
+ async fetch(raw, context = {}) {
43
+ const number = normalizeConsegniaNumber(raw);
44
+ const userAgent = userAgentOf(this.options.userAgent);
45
+ // One attempt opens a session on the tracking page and posts the number with its token, as the page's script does.
46
+ const attempt = async (signal, timeoutMs) => {
47
+ const deadline = performance.now() + timeoutMs;
48
+ // A request left without time fails as a timeout, which the retry can recover.
49
+ const left = () => Math.max(1, Math.floor(deadline - performance.now()));
50
+ const page = await fetchBounded(CONSEGNIA_PAGE, { method: 'GET', signal, headers: {
51
+ Accept: 'text/html,application/xhtml+xml', 'User-Agent': userAgent,
52
+ } }, { provider: PROVIDER, timeoutMs: left(), maxBytes: CONSEGNIA_MAX_BYTES, redirect: 'manual',
53
+ allowHttpStatuses: [...REDIRECTS, ...GONE, 403], fetcher: this.options.fetcher });
54
+ if (page.response.status === 403)
55
+ throw new ChallengeError(PROVIDER, 'Consegnia refused its tracking page', { status: 403 });
56
+ if (page.response.status !== 200) {
57
+ throw new TransportError(PROVIDER, 'Consegnia tracking page is unavailable', { status: page.response.status });
58
+ }
59
+ const html = decodeText(page.bytes);
60
+ const token = consegniaToken(html);
61
+ if (!token) {
62
+ if (isBlockPage(html))
63
+ throw new ChallengeError(PROVIDER, 'Consegnia answered the tracking page with a browser check');
64
+ throw new SchemaError(PROVIDER, 'Consegnia tracking page carries no lookup token');
65
+ }
66
+ const cookie = sessionCookies(page.response);
67
+ if (!cookie)
68
+ throw new SchemaError(PROVIDER, 'Consegnia tracking page opened no session');
69
+ const lookup = await fetchBounded(CONSEGNIA_LOOKUP, { method: 'POST', signal, headers: {
70
+ Accept: 'application/json, text/javascript, */*; q=0.01', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
71
+ 'User-Agent': userAgent, Cookie: cookie, 'X-Requested-With': 'XMLHttpRequest', Origin: ORIGIN, Referer: CONSEGNIA_PAGE,
72
+ }, body: new URLSearchParams({ _token: token, awb_number: number }).toString() }, { provider: PROVIDER, timeoutMs: left(),
73
+ maxBytes: CONSEGNIA_MAX_BYTES, redirect: 'manual', allowHttpStatuses: [...REDIRECTS, ...GONE, 403, REFUSED_SESSION],
74
+ fetcher: this.options.fetcher });
75
+ const { status } = lookup.response;
76
+ const body = decodeText(lookup.bytes).replace(/^\uFEFF/, '').trim();
77
+ if (REDIRECTS.includes(status) || status === 410)
78
+ throw new TransportError(PROVIDER, 'Consegnia tracking lookup is unavailable', { status });
79
+ if (status === 403)
80
+ throw new ChallengeError(PROVIDER, 'Consegnia refused the lookup', { status });
81
+ // The session the page just opened was refused: it says nothing about the shipment.
82
+ if (status === REFUSED_SESSION)
83
+ throw new IndeterminateError(PROVIDER, 'Consegnia refused the tracking page’s session', { status });
84
+ if (status === 404) {
85
+ if (isUnknownShipment(body))
86
+ throw new NotFoundError(PROVIDER);
87
+ throw new TransportError(PROVIDER, 'Consegnia tracking lookup is unavailable', { status });
88
+ }
89
+ return parseConsegnia(consegniaJson(body), number);
90
+ };
91
+ // An attempt that fails to reach Consegnia, or hangs, gets one more in a new
92
+ // session: the first gets half of the budget, so the retry fits in the rest.
93
+ return runSteps({ carrier: CARRIER, budgetMs: context.budgetMs ?? DEFAULT_BUDGET_MS, signal: context.signal,
94
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
95
+ { id: 'direct', run: ({ signal, remainingMs }) => attempt(signal, firstAttemptMs(remainingMs)) },
96
+ networkRetryStep(({ signal, remainingMs }) => attempt(signal, remainingMs)),
97
+ ]);
98
+ }
99
+ }
100
+ export const adapter = (environment) => {
101
+ const tracker = new ConsegniaTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
102
+ return {
103
+ id: CARRIER, recordsSteps: true, steps: ['direct', 'retry'],
104
+ track: (input, context) => tracker.fetch(input.number, context),
105
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeConsegniaNumber(number))),
106
+ };
107
+ };
@@ -18,18 +18,49 @@
18
18
  "timezone": "Asia/Manila",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "consegnia",
22
+ "steps": [
23
+ "direct",
24
+ "retry"
25
+ ],
26
+ "recognition": {
27
+ "rank": 57
28
+ },
29
+ "localClocks": true
23
30
  },
24
- "capabilities": [],
31
+ "capabilities": [
32
+ "history"
33
+ ],
25
34
  "portal": {
26
35
  "url": "https://clc-express.com/track-delivery?awb={trackingNumber}",
27
36
  "searchUrl": "https://clc-express.com/track-delivery",
28
37
  "canaryUrl": "https://clc-express.com/",
29
- "shows": [],
30
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
38
+ "shows": [
39
+ "status",
40
+ "history",
41
+ "failed_delivery_reason",
42
+ "awb_date",
43
+ "delivered_at"
44
+ ],
45
+ "scraped": [
46
+ "status",
47
+ "history",
48
+ "failed_delivery_reason"
49
+ ],
50
+ "discarded": [
51
+ "awb_date",
52
+ "delivered_at"
53
+ ],
54
+ "unavailable": [
55
+ "location",
56
+ "eta",
57
+ "offset_timestamps",
58
+ "provider_code",
59
+ "weight",
60
+ "sender_name",
61
+ "recipient_name",
62
+ "proof_of_delivery"
63
+ ]
33
64
  },
34
65
  "links": [
35
66
  {
@@ -0,0 +1,29 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Consegnia Logistics";
3
+ export declare const CARRIER = "consegnia";
4
+ export declare const CONSEGNIA_MAX_BYTES: number;
5
+ /** A number for Consegnia's tracker: CLC and nine digits. */
6
+ export declare function normalizeConsegniaNumber(raw: string): string;
7
+ /** A firewall or bot-check page in place of the tracking page or the lookup's JSON. */
8
+ export declare function isBlockPage(body: string): boolean;
9
+ /** The token the tracking page's form sends with a number, or null when the page has none. */
10
+ export declare function consegniaToken(html: string): string | null;
11
+ /**
12
+ * The lookup's reply for one number, `{ success, data: { html } }`: a fragment
13
+ * its server renders with the number, the current status, the last update, the
14
+ * booking day, the delivery clock once delivered, and the history, newest
15
+ * first, each row a status, the reason for a failed delivery and a wall clock
16
+ * to the minute with no zone. It names no person, place or phone number.
17
+ *
18
+ * The fragment must carry the whole number asked. The delivery clock is not
19
+ * read: a delivered parcel's newest row gives it, and it has no zone either.
20
+ */
21
+ export declare function parseConsegnia(payload: unknown, raw: string): CarrierResult;
22
+ /**
23
+ * The lookup's answer to a number it does not hold: HTTP 404 with `success`
24
+ * false and its message. The message does not quote the number. Any other 404
25
+ * is a missing route.
26
+ */
27
+ export declare function isUnknownShipment(body: string): boolean;
28
+ /** The lookup's reply as JSON, or the reason it is not one. */
29
+ export declare function consegniaJson(body: string): unknown;
@@ -0,0 +1,169 @@
1
+ import { load } from 'cheerio';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { calendarDay } from '../../core/time/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { consegniaStage } from './status.js';
9
+ export const PROVIDER = 'Consegnia Logistics';
10
+ export const CARRIER = 'consegnia';
11
+ // The tracking page is about 20 kB and a reply with its history a few.
12
+ export const CONSEGNIA_MAX_BYTES = 256 * 1024;
13
+ // CLC and nine digits, as its tracker prints them.
14
+ const NUMBER = /^CLC\d{9}$/;
15
+ const MONTHS = new Map(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].map((month, index) => [month, index + 1]));
16
+ // "Nov 18, 2025" and "06:05 PM": a wall clock with no zone.
17
+ const DAY = /^([A-Z][a-z]{2}) (\d{1,2}), (\d{4})$/;
18
+ const TIME = /^(\d{1,2}):(\d{2}) ([AP]M)$/;
19
+ const DAY_AND_TIME = /^(.+, \d{4}) (\d{1,2}:\d{2} [AP]M)$/;
20
+ // The token Laravel puts in the page's form for its session.
21
+ const TOKEN = /^[A-Za-z0-9]{20,128}$/;
22
+ // Firewall and bot-check pages that could stand in for the page or the lookup's JSON. Cloudflare's
23
+ // script for its challenge platform also runs on the ordinary page, so this is read only where the
24
+ // page's form or the lookup's JSON is missing.
25
+ const BLOCK = /<title>\s*(?:Just a moment|Attention Required|Access Denied)|_cf_chl_opt|cf-turnstile|challenge-platform|captcha/i;
26
+ // The lookup's answer to a number it does not hold.
27
+ const NOT_FOUND = /^No information about your package\b/;
28
+ const MAX_EVENTS = 200;
29
+ /** A number for Consegnia's tracker: CLC and nine digits. */
30
+ export function normalizeConsegniaNumber(raw) {
31
+ const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
32
+ if (!NUMBER.test(number))
33
+ throw new InvalidInputError(PROVIDER, 'Consegnia tracking numbers are CLC and nine digits');
34
+ return number;
35
+ }
36
+ /** A firewall or bot-check page in place of the tracking page or the lookup's JSON. */
37
+ export function isBlockPage(body) {
38
+ return BLOCK.test(body.slice(0, 64_000));
39
+ }
40
+ /** The token the tracking page's form sends with a number, or null when the page has none. */
41
+ export function consegniaToken(html) {
42
+ const $ = load(html);
43
+ const token = clean($('form#track-form input[name="_token"]').attr('value'), 200) || clean($('meta[name="csrf-token"]').attr('content'), 200);
44
+ return TOKEN.test(token) ? token : null;
45
+ }
46
+ /** A day the tracker prints, "Nov 18, 2025", as `YYYY-MM-DD`. */
47
+ function dayOf(text) {
48
+ const match = DAY.exec(text);
49
+ const month = match ? MONTHS.get(match[1]) : undefined;
50
+ const date = match && month && Number(match[3]) >= 2000 ? calendarDay(Number(match[3]), month, Number(match[2])) : null;
51
+ if (!date)
52
+ throw new SchemaError(PROVIDER, 'Consegnia returned an invalid scan time');
53
+ return date;
54
+ }
55
+ /** A day and a time of day on twelve hours, "06:05 PM", as one wall time. The tracker names no zone. */
56
+ function wallClock(day, time) {
57
+ const date = dayOf(day);
58
+ const match = TIME.exec(time);
59
+ const hour = match ? Number(match[1]) : 0;
60
+ if (!match || hour < 1 || hour > 12 || Number(match[2]) > 59)
61
+ throw new SchemaError(PROVIDER, 'Consegnia returned an invalid scan time');
62
+ const hours = (hour % 12) + (match[3] === 'PM' ? 12 : 0);
63
+ return `${date}T${String(hours).padStart(2, '0')}:${match[2]}:00`;
64
+ }
65
+ /** The summary's fields by their label, lower case. */
66
+ function summaryFields($) {
67
+ const fields = new Map();
68
+ for (const row of $('dl.track-details > .track-details__row').toArray()) {
69
+ const name = clean($(row).children('dt').text(), 40).toLowerCase();
70
+ const value = clean($(row).children('dd').text(), 200);
71
+ if (name && !fields.has(name))
72
+ fields.set(name, value);
73
+ }
74
+ return fields;
75
+ }
76
+ /** A history row: its day and time, its status and the reason shown beside a failed delivery. */
77
+ function scan($, item) {
78
+ const clock = item.children('.track-timeline__date').children('span').toArray().map((span) => clean($(span).text(), 40));
79
+ const content = item.children('.track-timeline__content');
80
+ const status = clean(content.children('.track-timeline__status').text(), 120);
81
+ if (clock.length !== 2 || !status)
82
+ throw new SchemaError(PROVIDER, 'Consegnia returned an invalid history row');
83
+ const reasons = [...new Set(content.find('.track-pill').toArray().map((pill) => clean($(pill).text(), 160)).filter(Boolean))];
84
+ const staged = consegniaStage(status);
85
+ return {
86
+ local_time: wallClock(clock[0], clock[1]),
87
+ description: reasons.length ? `${status}: ${reasons.join(', ')}` : status,
88
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
89
+ };
90
+ }
91
+ /**
92
+ * The lookup's reply for one number, `{ success, data: { html } }`: a fragment
93
+ * its server renders with the number, the current status, the last update, the
94
+ * booking day, the delivery clock once delivered, and the history, newest
95
+ * first, each row a status, the reason for a failed delivery and a wall clock
96
+ * to the minute with no zone. It names no person, place or phone number.
97
+ *
98
+ * The fragment must carry the whole number asked. The delivery clock is not
99
+ * read: a delivered parcel's newest row gives it, and it has no zone either.
100
+ */
101
+ export function parseConsegnia(payload, raw) {
102
+ const number = normalizeConsegniaNumber(raw);
103
+ if (!isRecord(payload) || payload.success !== true || !isRecord(payload.data) || typeof payload.data.html !== 'string') {
104
+ throw new SchemaError(PROVIDER, 'Consegnia returned an unexpected reply');
105
+ }
106
+ const $ = load(payload.data.html);
107
+ const shown = $('.track-card__awb');
108
+ if (shown.length !== 1)
109
+ throw new SchemaError(PROVIDER, 'Consegnia returned a shipment without its number');
110
+ if (normalizeTrackingNumber(clean(shown.text(), 64)).toUpperCase() !== number) {
111
+ throw new IndeterminateError(PROVIDER, 'Consegnia answered for another shipment');
112
+ }
113
+ const fields = summaryFields($);
114
+ const summary = clean(fields.get('current status'), 120);
115
+ if (!summary)
116
+ throw new SchemaError(PROVIDER, 'Consegnia returned a shipment without its status');
117
+ const rows = $('ol.track-timeline').children('li.track-timeline__item').toArray();
118
+ if (rows.length > MAX_EVENTS || (!rows.length && !$('.track-card__empty').length)) {
119
+ throw new SchemaError(PROVIDER, 'Consegnia returned an invalid history');
120
+ }
121
+ // Newest first, as the tracker lists them: the sort keeps rows of one minute in its order.
122
+ const events = rows.map((row) => scan($, $(row))).sort((left, right) => right.local_time.localeCompare(left.local_time));
123
+ // The summary is the carrier's current status; the newest staged row decides only one no rule reads.
124
+ const staged = events.find((event) => event.stage);
125
+ const current = consegniaStage(summary) ?? (staged ? { stage: staged.stage, source: staged.stage_source } : null);
126
+ const state = current
127
+ ? { status: languageStageStatus(current.stage), current_stage: current.stage, current_stage_source: current.source }
128
+ : { status: 'unknown' };
129
+ const newest = events[0];
130
+ if (!newest) {
131
+ // A shipment with a status and no history yet, dated by its last update, else its booking day.
132
+ if (!current)
133
+ throw new NoHistoryError(PROVIDER);
134
+ const update = DAY_AND_TIME.exec(fields.get('last update') ?? '');
135
+ const booked = fields.get('booked on');
136
+ const clock = update ? wallClock(update[1], update[2]) : booked ? dayOf(booked) : null;
137
+ return {
138
+ ...state, last_status_text: summary, last_update: null, ...(clock ? { last_update_local: clock } : {}),
139
+ expected_delivery: null, summary_only: true, events: [],
140
+ };
141
+ }
142
+ return { ...state, last_status_text: newest.description, last_update: null, last_update_local: newest.local_time, expected_delivery: null, events };
143
+ }
144
+ /**
145
+ * The lookup's answer to a number it does not hold: HTTP 404 with `success`
146
+ * false and its message. The message does not quote the number. Any other 404
147
+ * is a missing route.
148
+ */
149
+ export function isUnknownShipment(body) {
150
+ let payload;
151
+ try {
152
+ payload = JSON.parse(body);
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ return isRecord(payload) && payload.success === false && NOT_FOUND.test(clean(payload.message, 300));
158
+ }
159
+ /** The lookup's reply as JSON, or the reason it is not one. */
160
+ export function consegniaJson(body) {
161
+ try {
162
+ return JSON.parse(body);
163
+ }
164
+ catch (cause) {
165
+ if (isBlockPage(body))
166
+ throw new ChallengeError(PROVIDER, 'Consegnia answered with a browser check', { cause });
167
+ throw new SchemaError(PROVIDER, 'Consegnia returned a reply that is not JSON', { cause });
168
+ }
169
+ }
@@ -0,0 +1,7 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The stage Consegnia's map gives a status, with or without the reason after it; one it does not list has none. */
4
+ export declare function consegniaMappedStage(wording: string): Stage | undefined;
5
+ /** A status's stage from the map, else from the shared English wording rules, else nothing. */
6
+ export declare function consegniaStage(status: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,23 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ import statuses from './statuses.json' with { type: 'json' };
4
+ // The statuses Consegnia's tracker shows, as it words them. The adapter writes a
5
+ // failed delivery's reason after its status and a colon.
6
+ const STAGES = new Map(statuses.entries.map((entry) => [normalizeStatusWording(entry.wording), entry.stage]));
7
+ /** The stage Consegnia's map gives a status, with or without the reason after it; one it does not list has none. */
8
+ export function consegniaMappedStage(wording) {
9
+ const key = normalizeStatusWording(wording);
10
+ return STAGES.get(key) ?? STAGES.get(key.split(': ', 1)[0]);
11
+ }
12
+ /** A status's stage from the map, else from the shared English wording rules, else nothing. */
13
+ export function consegniaStage(status) {
14
+ const mapped = consegniaMappedStage(status);
15
+ if (mapped)
16
+ return { stage: mapped, source: 'carrier_map' };
17
+ const worded = classifyWording(status);
18
+ return worded.source === 'none' ? null : worded;
19
+ }
20
+ export const statusMap = {
21
+ stage: (_code, wording) => consegniaMappedStage(wording),
22
+ gaps: [],
23
+ };
@@ -1,4 +1,11 @@
1
1
  {
2
2
  "carrier": "consegnia",
3
- "entries": []
3
+ "entries": [
4
+ {"wording": "Ready for Pick-up", "language": "en", "stage": "registered", "confirmedBy": "live", "note": "Booked and waiting for Consegnia's rider to collect it from the shipper: a new booking's first and only scan, at its booking clock. Histories copied from its former tracker list it among their other scans at the copy's one clock."},
5
+ {"wording": "Arrived at Hub", "language": "en", "stage": "in_transit", "confirmedBy": "live"},
6
+ {"wording": "Out for Delivery", "language": "en", "stage": "out_for_delivery", "confirmedBy": "live"},
7
+ {"wording": "Delivery Failed", "language": "en", "stage": "failed_attempt", "confirmedBy": "live", "note": "The tracker shows the reason beside the status; the adapter adds it after a colon."},
8
+ {"wording": "Delivery Failed: COT - Courier Out of Time", "language": "en", "stage": "failed_attempt", "confirmedBy": "live", "note": "The courier ran out of time on the round."},
9
+ {"wording": "Delivered", "language": "en", "stage": "delivered", "confirmedBy": "live"}
10
+ ]
4
11
  }