universal-parcel-scraper 0.684.2-main.3246 → 0.686.0-main.3253

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 **616 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **617 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>792 catalog entries · 135 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 16,579 such statuses, each filed under one stage.
158
+ The carrier folders record 16,560 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
@@ -9934,7 +9934,8 @@
9934
9934
  "timezone": "America/Havana",
9935
9935
  "tracking": {
9936
9936
  "mode": "automatic",
9937
- "adapter": "universal"
9937
+ "adapter": "correos-cuba",
9938
+ "localClocks": true
9938
9939
  },
9939
9940
  "canaryUrl": "https://www.correos.cu/rastreador-de-envios/",
9940
9941
  "trackingSearchUrl": "https://www.correos.cu/rastreador-de-envios/",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 792,
3
- "activeAdapters": 616,
3
+ "activeAdapters": 617,
4
4
  "countries": 135,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -7881,7 +7881,8 @@ var Ze = {
7881
7881
  timezone: "America/Havana",
7882
7882
  tracking: {
7883
7883
  mode: "automatic",
7884
- adapter: "universal"
7884
+ adapter: "correos-cuba",
7885
+ localClocks: !0
7885
7886
  },
7886
7887
  canaryUrl: "https://www.correos.cu/rastreador-de-envios/",
7887
7888
  trackingSearchUrl: "https://www.correos.cu/rastreador-de-envios/",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Asendia tracking through Asendia USA's A1 platform, then Asendia UK's tracker.
2
+ * Asendia tracking through Asendia USA's A1 platform.
3
3
  *
4
4
  * Protocol provenance (inspected 2026-09-22):
5
5
  * - The official public page https://a1.asendiausa.com/tracking/ (the same
@@ -24,12 +24,10 @@
24
24
  * backend/src/sources/implementations/asendia.ts) and deingithub/beanstalk
25
25
  * (beanstalk/parcel.py).
26
26
  *
27
- * Coverage: A1 answers for parcels on Asendia USA's platform. The adapter
28
- * then asks Asendia UK's tracker (uk.ts), which serves parcels Asendia UK
29
- * carries, when A1 does not know the number, fails, or holds its registration
30
- * alone. Asendia's global portal track.asendia.com, which serves the
31
- * other subsidiaries, uses an optional browser recovery step (browser.ts).
32
- * Without that service, a number neither HTTP tracker knows is a
27
+ * Coverage: A1 answers for parcels on Asendia USA's platform. Asendia's
28
+ * global portal track.asendia.com, which serves the other subsidiaries and
29
+ * replaced Asendia UK's own tracker, uses an optional browser recovery step
30
+ * (browser.ts). Without that service, a number A1 does not know is a
33
31
  * not-found for this adapter only; routing then tries the universal providers
34
32
  * in the same check.
35
33
  *
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Asendia tracking through Asendia USA's A1 platform, then Asendia UK's tracker.
2
+ * Asendia tracking through Asendia USA's A1 platform.
3
3
  *
4
4
  * Protocol provenance (inspected 2026-09-22):
5
5
  * - The official public page https://a1.asendiausa.com/tracking/ (the same
@@ -24,12 +24,10 @@
24
24
  * backend/src/sources/implementations/asendia.ts) and deingithub/beanstalk
25
25
  * (beanstalk/parcel.py).
26
26
  *
27
- * Coverage: A1 answers for parcels on Asendia USA's platform. The adapter
28
- * then asks Asendia UK's tracker (uk.ts), which serves parcels Asendia UK
29
- * carries, when A1 does not know the number, fails, or holds its registration
30
- * alone. Asendia's global portal track.asendia.com, which serves the
31
- * other subsidiaries, uses an optional browser recovery step (browser.ts).
32
- * Without that service, a number neither HTTP tracker knows is a
27
+ * Coverage: A1 answers for parcels on Asendia USA's platform. Asendia's
28
+ * global portal track.asendia.com, which serves the other subsidiaries and
29
+ * replaced Asendia UK's own tracker, uses an optional browser recovery step
30
+ * (browser.ts). Without that service, a number A1 does not know is a
33
31
  * not-found for this adapter only; routing then tries the universal providers
34
32
  * in the same check.
35
33
  *
@@ -38,7 +36,7 @@
38
36
  * read, and nothing else from the summary is copied except the destination
39
37
  * country, weight and the declared last-mile reference.
40
38
  */
41
- import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
39
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
42
40
  import { carrierIdFromPartner } from '../../core/catalog/hints.js';
43
41
  import { BudgetExceededError, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, SchemaError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
44
42
  import { languageStageStatus, wordingStage } from '../../core/status/index.js';
@@ -47,7 +45,6 @@ import { clean, cleanScalar, decodeText, fetchBounded, parseJsonBytes, userAgent
47
45
  import { isRecord } from '../../core/types.js';
48
46
  import { daiPostScanZone } from '../dai-post/clock.js';
49
47
  import { classifyAsendiaA1Event, comparableText } from './status.js';
50
- import { AsendiaUkTracker } from './uk.js';
51
48
  import { asendiaBrowser } from './browser.js';
52
49
  import { recoverableByDefault, runSteps } from '../../core/runner/index.js';
53
50
  const PROVIDER = 'Asendia';
@@ -458,73 +455,20 @@ export class AsendiaA1Tracker {
458
455
  return remaining;
459
456
  }
460
457
  }
461
- /** The part of a lookup's budget kept for Asendia UK's tracker, at most a third. */
462
- const UK_RESERVE_MS = 8_000;
463
- /** Whether a result shows the parcel past its registration. */
464
- function progressed(result) {
465
- return result.status !== 'pending' && result.status !== 'unknown';
466
- }
467
- /**
468
- * A1 first, then Asendia UK. A1 can hold a UK parcel's registration alone
469
- * while Asendia UK's tracker shows its progress, so a parcel A1 has not seen
470
- * past registration is also looked up there, and the UK history is returned
471
- * when it has progressed further. A not-found means both said so: any other
472
- * A1 failure is reported instead when the UK tracker does not know the number
473
- * either, and the first failure is the one reported when both fail.
474
- */
475
- async function trackEverywhere(a1, uk, rawTrackingNumber, context = {}) {
476
- const trackingNumber = normalizeAsendiaA1TrackingNumber(rawTrackingNumber);
477
- const budget = lookupBudget(context, DEFAULT_BUDGET_MS, PROVIDER);
478
- const reserve = Math.min(UK_RESERVE_MS, Math.floor(budget.remainingMs() / 3));
479
- let failure = null;
480
- let registered = null;
481
- try {
482
- const result = await a1.fetch(trackingNumber, { signal: budget.signal, budgetMs: Math.max(1, budget.remainingMs() - reserve) });
483
- if (progressed(result))
484
- return result;
485
- registered = result;
486
- }
487
- catch (error) {
488
- if (context.signal?.aborted || !(error instanceof Error))
489
- throw error;
490
- if (!(error instanceof NotFoundError))
491
- failure = error;
492
- }
493
- if (budget.signal.aborted) {
494
- if (registered)
495
- return registered;
496
- throw failure ?? new BudgetExceededError(PROVIDER, budget.budgetMs);
497
- }
498
- try {
499
- const result = await uk.fetch(trackingNumber, { signal: budget.signal, budgetMs: budget.remainingMs() });
500
- return registered && !progressed(result) ? registered : result;
501
- }
502
- catch (error) {
503
- if (context.signal?.aborted)
504
- throw error;
505
- // A1's answer stands whatever the UK tracker says.
506
- if (registered)
507
- return registered;
508
- if (error instanceof NotFoundError)
509
- throw failure ?? new NotFoundError(PROVIDER);
510
- throw failure ?? error;
511
- }
512
- }
513
458
  export const adapter = (environment) => {
514
459
  const a1 = new AsendiaA1Tracker({ fetcher: environment.fetcher, userAgent: environment.userAgent });
515
- const uk = new AsendiaUkTracker({ fetcher: environment.fetcher, userAgent: environment.userAgent });
516
460
  return {
517
461
  id: 'asendia',
518
462
  recordsSteps: true,
519
463
  steps: ['direct', 'trawl'],
520
- // A1 and Asendia UK only: recognition never opens the global portal's browser.
521
- recognize: (number, context) => recognizeFromLookup(() => trackEverywhere(a1, uk, number, context), () => accepted(() => normalizeAsendiaA1TrackingNumber(number))),
464
+ // A1 only: recognition never opens the global portal's browser.
465
+ recognize: (number, context) => recognizeFromLookup(() => a1.fetch(number, context), () => accepted(() => normalizeAsendiaA1TrackingNumber(number))),
522
466
  track: (input, context) => {
523
467
  const number = normalizeAsendiaA1TrackingNumber(input.number);
524
468
  const browserEligible = !!environment.trawl && /^[A-Z0-9]{8,40}$/.test(number) && /\d/.test(number);
525
469
  return runSteps({ carrier: 'asendia', signal: context?.signal,
526
470
  budgetMs: context?.budgetMs ?? (browserEligible ? 65_000 : DEFAULT_BUDGET_MS), recorder: environment.recorder }, [
527
- { id: 'direct', run: ({ signal, remainingMs }) => trackEverywhere(a1, uk, number, { signal, budgetMs: Math.min(DEFAULT_BUDGET_MS, browserEligible ? Math.max(1, Math.floor(remainingMs * 0.4)) : remainingMs) }) },
471
+ { id: 'direct', run: ({ signal, remainingMs }) => a1.fetch(number, { signal, budgetMs: Math.min(DEFAULT_BUDGET_MS, browserEligible ? Math.max(1, Math.floor(remainingMs * 0.4)) : remainingMs) }) },
528
472
  { id: 'trawl', enabled: browserEligible,
529
473
  recovers: error => error instanceof NotFoundError || recoverableByDefault(error),
530
474
  run: async ({ signal, remainingMs, previousError }) => {
@@ -532,7 +476,7 @@ export const adapter = (environment) => {
532
476
  return await asendiaBrowser(environment.trawl, number, signal, remainingMs, input.postcode?.trim() || null);
533
477
  }
534
478
  catch (error) {
535
- // The global portal cannot erase a failure from either HTTP source.
479
+ // The global portal cannot erase a failure from A1.
536
480
  if (error instanceof NotFoundError && !(previousError instanceof NotFoundError))
537
481
  throw previousError;
538
482
  throw error;
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Asendia event vocabularies → product stage.
3
3
  *
4
- * Three sources, three maps. The registered adapter reads Asendia USA's A1
5
- * platform through `classifyAsendiaA1Event` and Asendia UK's tracker through
6
- * `classifyAsendiaUkEvent`; `classifyAsendiaStatus` serves the global-portal
7
- * probe.
4
+ * Two sources, two maps. The registered adapter reads Asendia USA's A1
5
+ * platform through `classifyAsendiaA1Event`; `classifyAsendiaStatus` serves
6
+ * the global-portal probe.
8
7
  *
9
8
  * A1: Asendia's own scans carry numeric codes (`1`, `2`, `2.1`, …) and are
10
9
  * mapped by code. Last-mile partners' scans arrive through the same list with
@@ -13,9 +12,6 @@
13
12
  * no event stage, leaving it to the sync's shared wording rules and the
14
13
  * unmapped-wording review.
15
14
  *
16
- * Asendia UK: one fixed English vocabulary for Asendia's own scans and the
17
- * last-mile partner's, mapped by exact wording.
18
- *
19
15
  * Global portal: Asendia's branded portal harmonizes the wording of every partner post into
20
16
  * one English vocabulary (`harmonizedEvent`), which is what this map keys on;
21
17
  * the numeric `harmonizedCode` is retained as the event's `provider_code` but
@@ -43,7 +39,3 @@ export declare function classifyAsendiaStatus(description: string): ClassifiedAs
43
39
  * "10" or "B1" for unrelated scans.
44
40
  */
45
41
  export declare function classifyAsendiaA1Event(source: string, code: string, description: string): ClassifiedAsendiaStatus | null;
46
- /** The explicit mapping for one Asendia UK event, or null when the map does not know its wording. */
47
- export declare function classifyAsendiaUkEvent(description: string): ClassifiedAsendiaStatus | null;
48
- /** Whether an Asendia UK event is a notice rather than a scan. */
49
- export declare function isAsendiaUkNotice(description: string): boolean;
@@ -158,42 +158,3 @@ export function classifyAsendiaA1Event(source, code, description) {
158
158
  const wording = comparableText(description);
159
159
  return Object.hasOwn(PARTNER_WORDING, wording) ? PARTNER_WORDING[wording] : null;
160
160
  }
161
- /** Asendia UK tracker wording seen live or in archived captures, compared with `comparableText`. */
162
- const UK_WORDING = {
163
- 'manifest received': REGISTERED,
164
- // Asendia's own hub sort: the parcel's first physical scan.
165
- 'processed down packet line': ACCEPTED,
166
- // Both follow the hub sort, so the parcel is already with Asendia.
167
- 'not yet received by carrier': IN_TRANSIT,
168
- 'awaiting despatch': IN_TRANSIT,
169
- 'scanned to container': IN_TRANSIT,
170
- // The last-mile partner's pre-advice, recorded after the parcel left the hub.
171
- 'elec advice recd by carrier': IN_TRANSIT,
172
- 'arrived at import gateway': IN_TRANSIT,
173
- 'awaiting customs clearance': CUSTOMS,
174
- 'customs clearance in progress': CUSTOMS,
175
- 'held by customs or governing body': CUSTOMS,
176
- 'customs cleared': IN_TRANSIT,
177
- 'handover by broker to delivery partner': IN_TRANSIT,
178
- 'received by carrier': IN_TRANSIT,
179
- 'received at carrier facility': IN_TRANSIT,
180
- 'sub contractor received': IN_TRANSIT,
181
- // Seen between the partner's own receipt scans, not as a first pickup.
182
- 'collection made': IN_TRANSIT,
183
- 'internal hub scan': IN_TRANSIT,
184
- 'in transit': IN_TRANSIT,
185
- 'arrival at delivery depot': IN_TRANSIT,
186
- 'out for delivery': OUT_FOR_DELIVERY,
187
- delivered: DELIVERED,
188
- };
189
- /** Notices the tracker interleaves with scans, even before the hub sort: they never set the current stage. */
190
- const UK_NOTICES = new Set(['information', 'carrier delays']);
191
- /** The explicit mapping for one Asendia UK event, or null when the map does not know its wording. */
192
- export function classifyAsendiaUkEvent(description) {
193
- const wording = comparableText(description);
194
- return Object.hasOwn(UK_WORDING, wording) ? UK_WORDING[wording] : null;
195
- }
196
- /** Whether an Asendia UK event is a notice rather than a scan. */
197
- export function isAsendiaUkNotice(description) {
198
- return UK_NOTICES.has(comparableText(description));
199
- }
@@ -928,174 +928,6 @@
928
928
  "confirmedBy": "live",
929
929
  "firstSeen": "2026-10-10",
930
930
  "note": "A1 partner scan wording (DAI FP05, Australia), matched exactly; the shared rules read the delivery to come as no scan."
931
- },
932
- {
933
- "wording": "Manifest received",
934
- "language": "en",
935
- "stage": "registered",
936
- "confirmedBy": "live",
937
- "firstSeen": "2026-10-13",
938
- "note": "Asendia UK tracker."
939
- },
940
- {
941
- "wording": "Processed down packet line",
942
- "language": "en",
943
- "stage": "accepted",
944
- "confirmedBy": "portal",
945
- "firstSeen": "2026-10-13",
946
- "note": "Asendia UK tracker. Sorting at Asendia's hub, the first physical scan."
947
- },
948
- {
949
- "wording": "Not yet received by carrier",
950
- "language": "en",
951
- "stage": "in_transit",
952
- "confirmedBy": "portal",
953
- "firstSeen": "2026-10-13",
954
- "note": "Asendia UK tracker. Follows the hub sort; the parcel is with Asendia."
955
- },
956
- {
957
- "wording": "Awaiting Despatch",
958
- "language": "en",
959
- "stage": "in_transit",
960
- "confirmedBy": "portal",
961
- "firstSeen": "2026-10-13",
962
- "note": "Asendia UK tracker. Follows the hub sort; the parcel is with Asendia."
963
- },
964
- {
965
- "wording": "Scanned to container",
966
- "language": "en",
967
- "stage": "in_transit",
968
- "confirmedBy": "portal",
969
- "firstSeen": "2026-10-13",
970
- "note": "Asendia UK tracker. Also written \"Scanned to Container\"."
971
- },
972
- {
973
- "wording": "Elec Advice recd by carrier",
974
- "language": "en",
975
- "stage": "in_transit",
976
- "confirmedBy": "portal",
977
- "firstSeen": "2026-10-13",
978
- "note": "Asendia UK tracker. The last-mile partner's pre-advice, recorded after the parcel left the hub."
979
- },
980
- {
981
- "wording": "Arrived at Import Gateway",
982
- "language": "en",
983
- "stage": "in_transit",
984
- "confirmedBy": "portal",
985
- "firstSeen": "2026-10-13",
986
- "note": "Asendia UK tracker."
987
- },
988
- {
989
- "wording": "Awaiting Customs Clearance",
990
- "language": "en",
991
- "stage": "customs",
992
- "confirmedBy": "portal",
993
- "firstSeen": "2026-10-13",
994
- "note": "Asendia UK tracker."
995
- },
996
- {
997
- "wording": "Customs Clearance in Progress",
998
- "language": "en",
999
- "stage": "customs",
1000
- "confirmedBy": "portal",
1001
- "firstSeen": "2026-10-13",
1002
- "note": "Asendia UK tracker."
1003
- },
1004
- {
1005
- "wording": "Held by customs or governing body",
1006
- "language": "en",
1007
- "stage": "customs",
1008
- "confirmedBy": "portal",
1009
- "firstSeen": "2026-10-13",
1010
- "note": "Asendia UK tracker."
1011
- },
1012
- {
1013
- "wording": "Customs cleared",
1014
- "language": "en",
1015
- "stage": "in_transit",
1016
- "confirmedBy": "portal",
1017
- "firstSeen": "2026-10-13",
1018
- "note": "Asendia UK tracker."
1019
- },
1020
- {
1021
- "wording": "Handover by Broker to Delivery Partner",
1022
- "language": "en",
1023
- "stage": "in_transit",
1024
- "confirmedBy": "portal",
1025
- "firstSeen": "2026-10-13",
1026
- "note": "Asendia UK tracker."
1027
- },
1028
- {
1029
- "wording": "Received by carrier",
1030
- "language": "en",
1031
- "stage": "in_transit",
1032
- "confirmedBy": "portal",
1033
- "firstSeen": "2026-10-13",
1034
- "note": "Asendia UK tracker."
1035
- },
1036
- {
1037
- "wording": "Received at carrier facility",
1038
- "language": "en",
1039
- "stage": "in_transit",
1040
- "confirmedBy": "portal",
1041
- "firstSeen": "2026-10-13",
1042
- "note": "Asendia UK tracker."
1043
- },
1044
- {
1045
- "wording": "Sub-contractor received",
1046
- "language": "en",
1047
- "stage": "in_transit",
1048
- "confirmedBy": "portal",
1049
- "firstSeen": "2026-10-13",
1050
- "note": "Asendia UK tracker."
1051
- },
1052
- {
1053
- "wording": "Collection made",
1054
- "language": "en",
1055
- "stage": "in_transit",
1056
- "confirmedBy": "portal",
1057
- "firstSeen": "2026-10-13",
1058
- "note": "Asendia UK tracker. Seen between the partner's own receipt scans, not as a first pickup."
1059
- },
1060
- {
1061
- "wording": "Internal Hub Scan",
1062
- "language": "en",
1063
- "stage": "in_transit",
1064
- "confirmedBy": "portal",
1065
- "firstSeen": "2026-10-13",
1066
- "note": "Asendia UK tracker."
1067
- },
1068
- {
1069
- "wording": "In transit",
1070
- "language": "en",
1071
- "stage": "in_transit",
1072
- "confirmedBy": "portal",
1073
- "firstSeen": "2026-10-13",
1074
- "note": "Asendia UK tracker."
1075
- },
1076
- {
1077
- "wording": "Arrival At Delivery Depot",
1078
- "language": "en",
1079
- "stage": "in_transit",
1080
- "confirmedBy": "portal",
1081
- "firstSeen": "2026-10-13",
1082
- "note": "Asendia UK tracker."
1083
- },
1084
- {
1085
- "wording": "Out for delivery",
1086
- "language": "en",
1087
- "stage": "out_for_delivery",
1088
- "confirmedBy": "portal",
1089
- "firstSeen": "2026-10-13",
1090
- "note": "Asendia UK tracker."
1091
- },
1092
- {
1093
- "wording": "Delivered",
1094
- "language": "en",
1095
- "stage": "delivered",
1096
- "confirmedBy": "portal",
1097
- "firstSeen": "2026-10-13",
1098
- "note": "Asendia UK tracker."
1099
931
  }
1100
932
  ]
1101
933
  }
@@ -0,0 +1,20 @@
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 tracker page, which gives every visitor the token its lookups carry. */
5
+ export declare const CORREOS_CUBA_PAGE = "https://www.correos.cu/rastreador-de-envios/";
6
+ /** The lookup the page's script posts the code, the year and the token to. */
7
+ export declare const CORREOS_CUBA_LOOKUP = "https://www.correos.cu/wp-json/correos-api/enviosweb/";
8
+ export declare class CorreosCubaTracker {
9
+ private readonly options;
10
+ private token;
11
+ constructor(options?: {
12
+ fetcher?: typeof fetch;
13
+ recorder?: StepRecorder;
14
+ userAgent?: string;
15
+ now?: () => number;
16
+ });
17
+ private pageToken;
18
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
19
+ }
20
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,93 @@
1
+ import { DateTime } from 'luxon';
2
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
3
+ import { ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
4
+ import { runSteps } from '../../core/runner/index.js';
5
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
6
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
7
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
8
+ import { assertNotChallenged, CORREOS_CUBA_MAX_BYTES, correosCubaPageToken, isExpiredCorreosCubaToken, normalizeCorreosCubaNumber, parseCorreosCuba, PROVIDER, } from './parser.js';
9
+ /** The tracker page, which gives every visitor the token its lookups carry. */
10
+ export const CORREOS_CUBA_PAGE = 'https://www.correos.cu/rastreador-de-envios/';
11
+ /** The lookup the page's script posts the code, the year and the token to. */
12
+ export const CORREOS_CUBA_LOOKUP = 'https://www.correos.cu/wp-json/correos-api/enviosweb/';
13
+ const REDIRECTS = [301, 302, 303, 307, 308];
14
+ // The tracker answers an unknown code in its reply: a missing route is not a missing item.
15
+ const GONE = [404, 410];
16
+ const DEFAULT_BUDGET_MS = 20_000;
17
+ const ZONE = 'America/Havana';
18
+ export class CorreosCubaTracker {
19
+ options;
20
+ // The page gives every visitor the same token, which lasts beyond one lookup.
21
+ token;
22
+ constructor(options = {}) {
23
+ this.options = options;
24
+ }
25
+ async pageToken(signal, timeoutMs) {
26
+ const { response, bytes } = await fetchBounded(CORREOS_CUBA_PAGE, { method: 'GET', signal, headers: {
27
+ Accept: 'text/html,application/xhtml+xml', 'Accept-Language': 'es', 'User-Agent': userAgentOf(this.options.userAgent),
28
+ } }, { provider: PROVIDER, timeoutMs, maxBytes: CORREOS_CUBA_MAX_BYTES, redirect: 'manual',
29
+ allowHttpStatuses: [...REDIRECTS, ...GONE], fetcher: this.options.fetcher });
30
+ if (REDIRECTS.includes(response.status) || GONE.includes(response.status)) {
31
+ throw new TransportError(PROVIDER, 'The Correos de Cuba tracker moved', { status: response.status });
32
+ }
33
+ this.token = correosCubaPageToken(decodeText(bytes));
34
+ return this.token;
35
+ }
36
+ async fetch(raw, context = {}) {
37
+ const number = normalizeCorreosCubaNumber(raw);
38
+ // The tracker finds an item in the year asked and the one before; the page asks Havana's current year.
39
+ const year = String(DateTime.fromMillis(this.options.now?.() ?? Date.now(), { zone: ZONE }).year);
40
+ const read = async (signal, timeoutMs) => {
41
+ const deadline = Date.now() + timeoutMs;
42
+ const left = () => Math.max(1, Math.floor(deadline - Date.now()));
43
+ const lookup = async (token) => {
44
+ const { response, bytes } = await fetchBounded(CORREOS_CUBA_LOOKUP, { method: 'POST', signal, headers: {
45
+ Accept: 'application/json, text/javascript, */*; q=0.01', 'Accept-Language': 'es',
46
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest',
47
+ Referer: CORREOS_CUBA_PAGE, 'User-Agent': userAgentOf(this.options.userAgent),
48
+ }, body: new URLSearchParams({ codigo: number, anno: year, token, user: '' }).toString() }, { provider: PROVIDER, timeoutMs: left(), maxBytes: CORREOS_CUBA_MAX_BYTES, redirect: 'manual',
49
+ allowHttpStatuses: [...REDIRECTS, ...GONE], fetcher: this.options.fetcher });
50
+ if (REDIRECTS.includes(response.status) || GONE.includes(response.status)) {
51
+ throw new TransportError(PROVIDER, 'The Correos de Cuba lookup moved', { status: response.status });
52
+ }
53
+ const body = decodeText(bytes);
54
+ if (!body.trim())
55
+ throw new IndeterminateError(PROVIDER, 'Correos de Cuba returned an empty reply');
56
+ try {
57
+ return JSON.parse(body);
58
+ }
59
+ catch (cause) {
60
+ assertNotChallenged(body);
61
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned a reply that is not JSON', { cause });
62
+ }
63
+ };
64
+ const cached = this.token;
65
+ let payload = await lookup(cached ?? await this.pageToken(signal, left()));
66
+ // A reused token the tracker has dropped is replaced once.
67
+ if (isExpiredCorreosCubaToken(payload) && cached) {
68
+ this.token = undefined;
69
+ payload = await lookup(await this.pageToken(signal, left()));
70
+ }
71
+ if (isExpiredCorreosCubaToken(payload)) {
72
+ this.token = undefined;
73
+ throw new ChallengeError(PROVIDER, 'Correos de Cuba refused its own page token');
74
+ }
75
+ return parseCorreosCuba(payload, number);
76
+ };
77
+ // A request that fails to reach the tracker, or hangs, gets one more try:
78
+ // the first gets half of the budget, so the retry fits in the rest.
79
+ return runSteps({ carrier: 'correos-cuba', budgetMs: context.budgetMs ?? DEFAULT_BUDGET_MS, signal: context.signal,
80
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
81
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
82
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
83
+ ]);
84
+ }
85
+ }
86
+ export const adapter = (environment) => {
87
+ const tracker = new CorreosCubaTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
88
+ return {
89
+ id: 'correos-cuba', recordsSteps: true, steps: ['direct', 'retry'],
90
+ track: (input, context) => tracker.fetch(input.number, context),
91
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeCorreosCubaNumber(number))),
92
+ };
93
+ };
@@ -17,17 +17,48 @@
17
17
  "timezone": "America/Havana",
18
18
  "tracking": {
19
19
  "mode": "automatic",
20
- "adapter": "universal",
21
- "steps": []
20
+ "adapter": "correos-cuba",
21
+ "steps": [
22
+ "direct",
23
+ "retry"
24
+ ],
25
+ "localClocks": true
22
26
  },
23
- "capabilities": [],
27
+ "capabilities": [
28
+ "history",
29
+ "location"
30
+ ],
24
31
  "portal": {
25
32
  "searchUrl": "https://www.correos.cu/rastreador-de-envios/",
26
33
  "canaryUrl": "https://www.correos.cu/rastreador-de-envios/",
27
- "shows": [],
28
- "scraped": [],
29
- "discarded": [],
30
- "unavailable": []
34
+ "shows": [
35
+ "status",
36
+ "history",
37
+ "location",
38
+ "origin_country",
39
+ "destination_country",
40
+ "progress_rail"
41
+ ],
42
+ "scraped": [
43
+ "status",
44
+ "history",
45
+ "location"
46
+ ],
47
+ "discarded": [
48
+ "origin_country",
49
+ "destination_country",
50
+ "progress_rail"
51
+ ],
52
+ "unavailable": [
53
+ "eta",
54
+ "weight",
55
+ "service_name",
56
+ "delivered_at",
57
+ "offset_timestamps",
58
+ "recipient_name",
59
+ "sender_name",
60
+ "proof_of_delivery"
61
+ ]
31
62
  },
32
63
  "links": [],
33
64
  "detection": []
@@ -0,0 +1,24 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Correos de Cuba";
3
+ export declare const CORREOS_CUBA_MAX_BYTES: number;
4
+ /** The tracker looks up S10 item codes, Correos de Cuba's own ending CU and those of the posts that send items to Cuba. */
5
+ export declare function normalizeCorreosCubaNumber(raw: string): string;
6
+ /** A firewall's page in place of the site's, which is no answer about the item. */
7
+ export declare function assertNotChallenged(body: string): void;
8
+ /**
9
+ * The token the tracker page gives every visitor for its lookups. The page
10
+ * styles a security plugin's captcha for its other forms, so only a page
11
+ * without the token is checked for a firewall's, by its vendor's markers.
12
+ */
13
+ export declare function correosCubaPageToken(html: string): string;
14
+ /** The tracker's refusal of the page token, which a fresh page replaces. */
15
+ export declare function isExpiredCorreosCubaToken(payload: unknown): boolean;
16
+ /**
17
+ * The tracker's reply to `enviosweb`: the item's scans, newest first, each
18
+ * with a state, a date, the office it left and the one it went to, and the
19
+ * countries and progress rail the page shows above them. The tracker matches
20
+ * the whole code, in the year asked and the one before, and its reply does
21
+ * not repeat the code. It answers a code it holds nothing for in those years
22
+ * with no scans and no error, so an empty history is inconclusive.
23
+ */
24
+ export declare function parseCorreosCuba(payload: unknown, rawNumber: string): CarrierResult;
@@ -0,0 +1,129 @@
1
+ import { load } from 'cheerio';
2
+ import { isValidS10TrackingNumber, 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 { correosCubaStage } from './status.js';
9
+ export const PROVIDER = 'Correos de Cuba';
10
+ // The tracker page is about seventy kilobytes, most of it the site's frame; a reply is a few.
11
+ export const CORREOS_CUBA_MAX_BYTES = 1024 * 1024;
12
+ const MAX_ROWS = 500;
13
+ // A firewall's, CDN's or bot check's page in place of the site's, which can come back with HTTP 200.
14
+ const BLOCKED = /<title>\s*(?:Request Rejected|Access Denied|Attention Required|Just a moment|Web Page Blocked)|requested URL was rejected|_cf_chl_opt|cf-turnstile|g-recaptcha|h-captcha/i;
15
+ // The tracker's answer to a page token it does not take, or to none.
16
+ const EXPIRED_TOKEN = /token ha caducado/i;
17
+ // "2026-05-04 09:12:30", the office's wall clock, which the tracker gives without a zone.
18
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?$/;
19
+ // The office the tracker names for an item sent abroad; the page shows the destination country in its place.
20
+ const EXPORT_OFFICE = 'exportacionIPS';
21
+ // The scan for an item an office received; the page names the receiving office, the other scans the one they left.
22
+ const RECEIVED = 'RECEPCIONADO';
23
+ /** The tracker looks up S10 item codes, Correos de Cuba's own ending CU and those of the posts that send items to Cuba. */
24
+ export function normalizeCorreosCubaNumber(raw) {
25
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
26
+ if (isValidS10TrackingNumber(number))
27
+ return number;
28
+ throw new InvalidInputError(PROVIDER, 'Correos de Cuba tracks S10 item codes with a valid check digit');
29
+ }
30
+ /** A firewall's page in place of the site's, which is no answer about the item. */
31
+ export function assertNotChallenged(body) {
32
+ if (BLOCKED.test(body.slice(0, 64_000)))
33
+ throw new ChallengeError(PROVIDER, 'Correos de Cuba answered with a firewall page');
34
+ }
35
+ /**
36
+ * The token the tracker page gives every visitor for its lookups. The page
37
+ * styles a security plugin's captcha for its other forms, so only a page
38
+ * without the token is checked for a firewall's, by its vendor's markers.
39
+ */
40
+ export function correosCubaPageToken(html) {
41
+ const token = load(html)('input#side').attr('value')?.trim() ?? '';
42
+ if (/^[A-Za-z0-9]{6,64}$/.test(token))
43
+ return token;
44
+ assertNotChallenged(html);
45
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned a tracker page without its token');
46
+ }
47
+ /** The tracker's refusal of the page token, which a fresh page replaces. */
48
+ export function isExpiredCorreosCubaToken(payload) {
49
+ return isRecord(payload) && typeof payload.error === 'string' && EXPIRED_TOKEN.test(payload.error);
50
+ }
51
+ /** A scan's date as the tracker gives it: the office's wall clock, without an offset. */
52
+ function localTime(value) {
53
+ const match = typeof value === 'string' ? DATE.exec(value.trim()) : null;
54
+ const day = match ? calendarDay(Number(match[1]), Number(match[2]), Number(match[3])) : null;
55
+ if (!match || !day)
56
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an invalid scan date');
57
+ const [hours, minutes, seconds = '00'] = match.slice(4);
58
+ if (hours === undefined || minutes === undefined)
59
+ return day;
60
+ if (Number(hours) > 23 || Number(minutes) > 59 || Number(seconds) > 59) {
61
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an invalid scan time');
62
+ }
63
+ return `${day}T${hours}:${minutes}:${seconds}`;
64
+ }
65
+ function text(value, maxLength) {
66
+ if (value != null && typeof value !== 'string')
67
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an invalid scan field');
68
+ return clean(value, maxLength);
69
+ }
70
+ /**
71
+ * The tracker's reply to `enviosweb`: the item's scans, newest first, each
72
+ * with a state, a date, the office it left and the one it went to, and the
73
+ * countries and progress rail the page shows above them. The tracker matches
74
+ * the whole code, in the year asked and the one before, and its reply does
75
+ * not repeat the code. It answers a code it holds nothing for in those years
76
+ * with no scans and no error, so an empty history is inconclusive.
77
+ */
78
+ export function parseCorreosCuba(payload, rawNumber) {
79
+ normalizeCorreosCubaNumber(rawNumber);
80
+ if (!isRecord(payload) || !Array.isArray(payload.datos))
81
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an unexpected reply');
82
+ if (payload.datos.length > MAX_ROWS)
83
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an oversized history');
84
+ if (!payload.datos.length) {
85
+ if (payload.error != null && typeof payload.error !== 'string')
86
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an invalid error');
87
+ if (clean(payload.error, 200))
88
+ throw new IndeterminateError(PROVIDER, 'Correos de Cuba refused the lookup');
89
+ throw new NoHistoryError(PROVIDER, 'Correos de Cuba holds no scans for the item this year or last');
90
+ }
91
+ const country = text(payload.p_destino, 80);
92
+ const events = [];
93
+ const seen = new Set();
94
+ for (const row of payload.datos) {
95
+ if (!isRecord(row))
96
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned an invalid history row');
97
+ const description = text(row.estado, 200);
98
+ if (!description)
99
+ throw new SchemaError(PROVIDER, 'Correos de Cuba returned a scan without a state');
100
+ const local = localTime(row.fecha);
101
+ const from = text(row.oficina_origen, 120), to = text(row.oficina_destino, 120);
102
+ // Where the page puts the scan: the office that received the item, else the one it left, the destination country for an export.
103
+ const location = description.toUpperCase() === RECEIVED ? to || from : from === EXPORT_OFFICE ? country : from;
104
+ const key = [local, location, description].join('\n');
105
+ // A scan listed twice is kept once.
106
+ if (seen.has(key))
107
+ continue;
108
+ seen.add(key);
109
+ const staged = correosCubaStage(description);
110
+ events.push({
111
+ local_time: local,
112
+ ...(location ? { location } : {}),
113
+ description,
114
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
115
+ });
116
+ }
117
+ const newest = events[0];
118
+ const current = events.find((event) => event.stage);
119
+ const stage = current?.stage;
120
+ return {
121
+ status: stage ? languageStageStatus(stage) : 'unknown',
122
+ ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
123
+ last_status_text: newest.description ?? null,
124
+ last_update: null,
125
+ last_update_local: newest.local_time,
126
+ expected_delivery: null,
127
+ events,
128
+ };
129
+ }
@@ -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 the map gives a scan's state, if it knows it. */
4
+ export declare function correosCubaMappedStage(wording: string): Stage | undefined;
5
+ /** A scan's stage from its state, else the shared wording rules, else nothing, which keeps it visible for review. */
6
+ export declare function correosCubaStage(wording: string): ClassifiedWording | null;
7
+ /** The tracker shows states only; the map leaves none out on purpose. */
8
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,41 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The states of the Correos de Cuba tracker, which carries no codes. Only
5
+ * states seen on the tracker or explained in a guide to it are mapped;
6
+ * another falls to the wording rules.
7
+ */
8
+ const WORDINGS = new Map([
9
+ // Posted: "imponer" is to post an item, not a tax.
10
+ ['Impuesto', 'accepted'],
11
+ ['Clasificado', 'in_transit'],
12
+ // Sent on to the office the scan names.
13
+ ['Facturado', 'in_transit'],
14
+ // Handed to a private small business that distributes parcels for the post.
15
+ ['Facturado a mipyme', 'in_transit'],
16
+ ['Recepcionado', 'in_transit'],
17
+ ['Entregado a aduana', 'customs'],
18
+ ['Entregado a aduana en exportaciones', 'customs'],
19
+ // Released by customs, which sends the item on.
20
+ ['Salida aduana', 'in_transit'],
21
+ ['Entregado', 'delivered'],
22
+ ].map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
23
+ /** The stage the map gives a scan's state, if it knows it. */
24
+ export function correosCubaMappedStage(wording) {
25
+ return WORDINGS.get(normalizeStatusWording(wording));
26
+ }
27
+ /** A scan's stage from its state, else the shared wording rules, else nothing, which keeps it visible for review. */
28
+ export function correosCubaStage(wording) {
29
+ if (!wording)
30
+ return null;
31
+ const mapped = correosCubaMappedStage(wording);
32
+ if (mapped)
33
+ return { stage: mapped, source: 'carrier_map' };
34
+ const worded = classifyWording(wording, 'in_transit');
35
+ return worded.source === 'none' ? null : worded;
36
+ }
37
+ /** The tracker shows states only; the map leaves none out on purpose. */
38
+ export const statusMap = {
39
+ stage: (_code, wording) => correosCubaMappedStage(wording),
40
+ gaps: [],
41
+ };
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "carrier": "correos-cuba",
3
3
  "entries": [
4
- {"wording": "Recepción", "stage": "accepted", "language": "es", "confirmedBy": "official-doc", "note": "Step label of the progress rail on the carrier's tracker page."},
5
- {"wording": "En camino", "stage": "in_transit", "language": "es", "confirmedBy": "official-doc", "note": "Step label of the progress rail on the carrier's tracker page."},
6
- {"wording": "En entrega", "stage": "out_for_delivery", "language": "es", "confirmedBy": "official-doc", "note": "Step label of the progress rail on the carrier's tracker page."},
7
- {"wording": "Entregado", "stage": "delivered", "language": "es", "confirmedBy": "official-doc", "note": "Step label of the progress rail on the carrier's tracker page."},
8
- {"wording": "Clasificado", "stage": "in_transit", "language": "es", "confirmedBy": "source", "note": "Processed at the international office of exchange in Havana and ready for the sorting or delivery centre. Explained in a Cuban guide to the tracker's messages."},
4
+ {"wording": "IMPUESTO", "stage": "accepted", "language": "es", "confirmedBy": "portal", "note": "Posted at the office the scan names."},
5
+ {"wording": "CLASIFICADO", "stage": "in_transit", "language": "es", "confirmedBy": "portal", "note": "Sorted at an office, including the international office of exchange in Havana."},
6
+ {"wording": "FACTURADO", "stage": "in_transit", "language": "es", "confirmedBy": "portal", "note": "Sent on to the office the scan names."},
9
7
  {"wording": "Facturado a mipyme", "stage": "in_transit", "language": "es", "confirmedBy": "source", "note": "Delivery handed to a private small business that distributes parcels for the post. Explained in a Cuban guide to the tracker's messages."},
10
- {"wording": "entregado a aduana en exportaciones", "stage": "customs", "language": "es", "confirmedBy": "source", "note": "Handed to export customs, for an item leaving Cuba. Quoted in a sender's comment on a Cuban tracking guide."}
8
+ {"wording": "RECEPCIONADO", "stage": "in_transit", "language": "es", "confirmedBy": "portal", "note": "Received at the office the scan names."},
9
+ {"wording": "ENTREGADO A ADUANA", "stage": "customs", "language": "es", "confirmedBy": "portal"},
10
+ {"wording": "entregado a aduana en exportaciones", "stage": "customs", "language": "es", "confirmedBy": "source", "note": "Handed to export customs, for an item leaving Cuba. Quoted in a sender's comment on a Cuban tracking guide."},
11
+ {"wording": "SALIDA ADUANA", "stage": "in_transit", "language": "es", "confirmedBy": "portal", "note": "Released by customs, which sends the item on."},
12
+ {"wording": "ENTREGADO", "stage": "delivered", "language": "es", "confirmedBy": "portal"}
11
13
  ]
12
14
  }
@@ -150,6 +150,8 @@ export function parseMondialRelayApp(payload, uid) {
150
150
  throw new IndeterminateError('Mondial Relay', 'Mondial Relay app returned no shipment activity');
151
151
  // The delivery point, while the parcel waits there.
152
152
  const pickup = current?.stage === 'ready_for_pickup' ? deliveryPoint(parcel.detail) : '';
153
+ // As on the website, a delivered parcel takes its delivery scan's clock.
154
+ const deliveredAt = current?.status === 'delivered' ? events.find((event) => event.stage === 'delivered')?.time : undefined;
153
155
  return {
154
156
  status: current?.status ?? 'unknown',
155
157
  ...(current ? { current_stage: current.stage } : {}),
@@ -157,6 +159,7 @@ export function parseMondialRelayApp(payload, uid) {
157
159
  last_update: events[0]?.time ?? null,
158
160
  expected_delivery: null,
159
161
  ...(pickup ? { pickup_point: pickup } : {}),
162
+ ...(deliveredAt ? { delivered_at: deliveredAt } : {}),
160
163
  timezone: ZONE,
161
164
  events,
162
165
  source: 'mondial_relay_app',
@@ -106,6 +106,7 @@ import { statusMap as copaCourier } from '../../carriers/copa-courier/status.js'
106
106
  import { statusMap as corre } from '../../carriers/corre/status.js';
107
107
  import { statusMap as correoArgentino } from '../../carriers/correo-argentino/status.js';
108
108
  import { statusMap as correoUruguayo } from '../../carriers/correo-uruguayo/status.js';
109
+ import { statusMap as correosCuba } from '../../carriers/correos-cuba/status.js';
109
110
  import { statusMap as correosDeMexico } from '../../carriers/correos-de-mexico/status.js';
110
111
  import { statusMap as correosElSalvador } from '../../carriers/correos-el-salvador/status.js';
111
112
  import { statusMap as correosExpress } from '../../carriers/correos-express/status.js';
@@ -574,7 +575,7 @@ export const STATUS_MAPS = {
574
575
  'chit-chats': chitChats, chronodiali, chronopost, chukou1, 'chunghwa-post': chunghwaPost, chunil, 'city-express-rs': cityExpressRs, 'citylink-express': citylinkExpress, citymail,
575
576
  'cj-logistics': cjLogistics, 'cj-packet': cjPacket, 'closer-logistics': closerLogistics, 'connect-couriers': connectCouriers, coordinadora,
576
577
  'copa-courier': copaCourier, corre,
577
- 'correo-argentino': correoArgentino, 'correo-uruguayo': correoUruguayo, 'correos-de-mexico': correosDeMexico, 'correos-el-salvador': correosElSalvador, 'correos-express': correosExpress, 'correos-spain': correosSpain,
578
+ 'correo-argentino': correoArgentino, 'correo-uruguayo': correoUruguayo, 'correos-cuba': correosCuba, 'correos-de-mexico': correosDeMexico, 'correos-el-salvador': correosElSalvador, 'correos-express': correosExpress, 'correos-spain': correosSpain,
578
579
  'courier-center': courierCenter, couriersplease: couriersPlease, 'cruz-del-sur': cruzDelSur, 'cse-russia': cseRussia, cttyex,
579
580
  'cyprus-post': cyprusPost, 'd-express': dExpress, 'dai-post': daiPost, 'danske-fragtmaend': danskeFragtmaend, dao, 'day-ross': dayRoss,
580
581
  dayone, 'daylight-transport': daylightTransport, 'dayton-freight': daytonFreight, dellin, delnext, deppon, 'dhl-express': dhlExpress,
@@ -9934,7 +9934,8 @@
9934
9934
  "timezone": "America/Havana",
9935
9935
  "tracking": {
9936
9936
  "mode": "automatic",
9937
- "adapter": "universal"
9937
+ "adapter": "correos-cuba",
9938
+ "localClocks": true
9938
9939
  },
9939
9940
  "canaryUrl": "https://www.correos.cu/rastreador-de-envios/",
9940
9941
  "trackingSearchUrl": "https://www.correos.cu/rastreador-de-envios/",
@@ -6474,7 +6474,8 @@ export declare const CARRIER_CATALOG: {
6474
6474
  readonly timezone: "America/Havana";
6475
6475
  readonly tracking: {
6476
6476
  readonly mode: "automatic";
6477
- readonly adapter: "universal";
6477
+ readonly adapter: "correos-cuba";
6478
+ readonly localClocks: true;
6478
6479
  };
6479
6480
  readonly canaryUrl: "https://www.correos.cu/rastreador-de-envios/";
6480
6481
  readonly trackingSearchUrl: "https://www.correos.cu/rastreador-de-envios/";
@@ -9935,7 +9935,8 @@ export const CARRIER_CATALOG = {
9935
9935
  "timezone": "America/Havana",
9936
9936
  "tracking": {
9937
9937
  "mode": "automatic",
9938
- "adapter": "universal"
9938
+ "adapter": "correos-cuba",
9939
+ "localClocks": true
9939
9940
  },
9940
9941
  "canaryUrl": "https://www.correos.cu/rastreador-de-envios/",
9941
9942
  "trackingSearchUrl": "https://www.correos.cu/rastreador-de-envios/",
@@ -109,6 +109,7 @@ import { adapter as correiosBr } from '../carriers/correios-br/adapter.js';
109
109
  import { adapter as correoArgentino } from '../carriers/correo-argentino/adapter.js';
110
110
  import { adapter as correoUruguayo } from '../carriers/correo-uruguayo/adapter.js';
111
111
  import { adapter as correosChile } from '../carriers/correos-chile/adapter.js';
112
+ import { adapter as correosCuba } from '../carriers/correos-cuba/adapter.js';
112
113
  import { adapter as correosDeMexico } from '../carriers/correos-de-mexico/adapter.js';
113
114
  import { adapter as correosElSalvador } from '../carriers/correos-el-salvador/adapter.js';
114
115
  import { adapter as correosExpress } from '../carriers/correos-express/adapter.js';
@@ -727,6 +728,7 @@ export const REGISTRY = {
727
728
  "correo-argentino": correoArgentino,
728
729
  "correo-uruguayo": correoUruguayo,
729
730
  "correos-chile": correosChile,
731
+ "correos-cuba": correosCuba,
730
732
  "correos-de-mexico": correosDeMexico,
731
733
  "correos-el-salvador": correosElSalvador,
732
734
  "correos-express": correosExpress,
@@ -1373,7 +1375,7 @@ export const REGISTRY = {
1373
1375
  "correos-bolivia": "universal",
1374
1376
  "correos-chile": "correos-chile",
1375
1377
  "correos-costa-rica": "universal",
1376
- "correos-cuba": "universal",
1378
+ "correos-cuba": "correos-cuba",
1377
1379
  "correos-de-mexico": "correos-de-mexico",
1378
1380
  "correos-el-salvador": "correos-el-salvador",
1379
1381
  "correos-express": "correos-express",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.684.2"
5
+ "version": "0.686.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.684.2-main.3246",
3
+ "version": "0.686.0-main.3253",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,20 +0,0 @@
1
- import { type TrackingContext } from '../../core/adapter/index.js';
2
- import type { CarrierResult } from '../../core/result/index.js';
3
- export declare function normalizeAsendiaUkTrackingNumber(raw: string): string;
4
- /** The tracker's own result page for one number. */
5
- export declare function asendiaUkTrackingUrl(rawTrackingNumber: string): string;
6
- /** Pure projection of the tracker's result page for the requested number. */
7
- export declare function parseAsendiaUkTrackingPage(html: string, rawTrackingNumber: string): CarrierResult;
8
- export interface AsendiaUkTrackerOptions {
9
- /** Test seam; production uses the global fetch. */
10
- fetcher?: typeof fetch;
11
- budgetMs?: number;
12
- userAgent?: string;
13
- }
14
- export declare class AsendiaUkTracker {
15
- private readonly fetcher?;
16
- private readonly budgetMs;
17
- private readonly userAgent;
18
- constructor(options?: AsendiaUkTrackerOptions);
19
- fetch(rawTrackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
20
- }
@@ -1,222 +0,0 @@
1
- /**
2
- * Asendia tracking through Asendia UK's own tracker.
3
- *
4
- * Protocol provenance:
5
- * - Asendia UK (formerly wnDirect) runs https://uktracking.asendia.com/
6
- * tracking.php on the group's domain; archived shipper links point to it.
7
- * The page's form is a plain GET with `ref` and `submit`; the reply is
8
- * server-rendered HTML with no challenge, cookie or session. `tracking-pb.php` and `tracking-dpd.php` are skins of the same
9
- * search.
10
- * - It covers parcels Asendia UK carries, whichever post or courier delivers
11
- * them, with Asendia's harmonized history. Parcels of the other European
12
- * subsidiaries are not on it: their only public tracker is track.asendia.com,
13
- * which needs a Turnstile token for every search (see probe.ts).
14
- * - The search also matches order and other references that the page does not
15
- * echo, so a parcel is bound only through its echoed "Tracking Code".
16
- * - A number the tracker does not know answers HTTP 200 with the notice
17
- * "No tracking information found". Parcels drop off the tracker some months
18
- * after posting and then get the same notice.
19
- *
20
- * Clocks: event times have no offset and do not share one zone (the same scan
21
- * can be listed twice an hour apart), so they are kept as wall clocks in
22
- * `local_time`, in page order.
23
- *
24
- * Privacy: the destination block's order reference, city, state and postal
25
- * code are never read; only its country code is.
26
- */
27
- import { load } from 'cheerio';
28
- import { DateTime } from 'luxon';
29
- import { lookupBudget } from '../../core/adapter/index.js';
30
- import { carrierIdFromPartner } from '../../core/catalog/hints.js';
31
- import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
32
- import { languageStageStatus, wordingStage } from '../../core/status/index.js';
33
- import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
34
- import { classifyAsendiaUkEvent, isAsendiaUkNotice } from './status.js';
35
- const PROVIDER = 'Asendia UK';
36
- const PAGE_URL = 'https://uktracking.asendia.com/tracking.php';
37
- const DEFAULT_BUDGET_MS = 15_000;
38
- const MAX_REQUEST_MS = 10_000;
39
- const MAX_RESPONSE_BYTES = 1_000_000;
40
- const MAX_EVENTS_TO_INSPECT = 500;
41
- const MAX_EVENTS_TO_RETURN = 100;
42
- const NOT_FOUND_NOTICE = 'No tracking information found';
43
- const HISTORY_HEADINGS = '|Description|Date';
44
- const ASENDIA_HOST = /(?:^|\.)asendia\.(?:com|co\.uk)$/i;
45
- /** Letters and digits only, upper-cased: how tracking codes are compared. */
46
- function comparable(value) {
47
- return value.toLocaleUpperCase('en-US').replace(/[^A-Z0-9]/g, '');
48
- }
49
- export function normalizeAsendiaUkTrackingNumber(raw) {
50
- const value = comparable(clean(raw, 80));
51
- if (!/^[A-Z0-9]{6,40}$/.test(value)) {
52
- throw new InvalidInputError(PROVIDER, 'Asendia tracking numbers must contain 6 to 40 ASCII letters and digits');
53
- }
54
- return value;
55
- }
56
- /** The tracker's own result page for one number. */
57
- export function asendiaUkTrackingUrl(rawTrackingNumber) {
58
- const url = new URL(PAGE_URL);
59
- url.searchParams.set('ref', normalizeAsendiaUkTrackingNumber(rawTrackingNumber));
60
- url.searchParams.set('submit', '');
61
- return url.toString();
62
- }
63
- /** "08:09 Mon 12 Oct 2026" → "2026-10-12T08:09:00", validated without assigning a zone. */
64
- function wallTime(raw) {
65
- const date = /^\d{2}:\d{2} [A-Z][a-z]{2} \d{2} [A-Z][a-z]{2} \d{4}$/.test(raw)
66
- ? DateTime.fromFormat(raw, 'HH:mm EEE dd MMM yyyy', { zone: 'UTC', locale: 'en-US' })
67
- : null;
68
- // Luxon also rejects a weekday that does not match the date.
69
- if (!date?.isValid)
70
- throw new SchemaError(PROVIDER, 'Asendia UK returned an invalid event date');
71
- return date.toFormat("yyyy-MM-dd'T'HH:mm:ss");
72
- }
73
- function cellTexts($, cells) {
74
- return cells.map((_, cell) => clean($(cell).text(), 500)).get();
75
- }
76
- function historyEvents($, parcelRow) {
77
- const tables = parcelRow.next('tr').children('td.parcel-right[colspan="2"]').find('table');
78
- if (tables.length !== 1)
79
- throw new SchemaError(PROVIDER, 'Asendia UK returned no history table');
80
- const rows = tables.find('tr');
81
- const headings = rows.first().children('th');
82
- if (cellTexts($, headings).join('|') !== HISTORY_HEADINGS) {
83
- throw new SchemaError(PROVIDER, 'Asendia UK returned an unexpected history table');
84
- }
85
- const scans = rows.slice(1);
86
- if (scans.length > MAX_EVENTS_TO_INSPECT)
87
- throw new SchemaError(PROVIDER, 'Asendia UK returned too many events');
88
- const events = [];
89
- const seen = new Set();
90
- for (const row of scans.toArray()) {
91
- const cells = $(row).children('td');
92
- if (cells.length !== 3 || cells.filter('[colspan], [rowspan]').length) {
93
- throw new SchemaError(PROVIDER, 'Asendia UK returned an invalid event row');
94
- }
95
- const [location, description, date] = cellTexts($, cells);
96
- if (!description)
97
- throw new SchemaError(PROVIDER, 'Asendia UK returned an empty event');
98
- const localTime = wallTime(date);
99
- const key = JSON.stringify([localTime, description, location]);
100
- if (seen.has(key))
101
- continue;
102
- seen.add(key);
103
- const classified = classifyAsendiaUkEvent(description);
104
- events.push({
105
- local_time: localTime,
106
- description,
107
- ...(location ? { location: clean(location, 200) } : {}),
108
- ...(classified ? { stage: classified.stage } : {}),
109
- });
110
- }
111
- return events;
112
- }
113
- /** The destination country code, the one field of the order block that is read. */
114
- function destinationCountry($, parcelRow) {
115
- const order = parcelRow.closest('td.order');
116
- const labels = order.children('span.order-field').filter((_, field) => clean($(field).text()) === 'Country Code:');
117
- if (labels.length !== 1)
118
- return null;
119
- const value = clean(labels.next('span.order-data').text(), 8).toUpperCase();
120
- return /^[A-Z]{2}$/.test(value) ? value : null;
121
- }
122
- function deliveryCarrier(parcelRow) {
123
- const links = parcelRow.children('td.parcel-right').find('span.carrier-link a');
124
- if (links.length !== 1)
125
- return null;
126
- const name = clean(links.text(), 80);
127
- let href = clean(links.attr('href'), 500);
128
- try {
129
- // Some partners are linked through Asendia's own portal: name them by the label alone.
130
- if (href && ASENDIA_HOST.test(new URL(href).hostname))
131
- href = '';
132
- }
133
- catch {
134
- href = '';
135
- }
136
- const partner = carrierIdFromPartner(name, href);
137
- return partner && partner !== 'asendia' ? partner : null;
138
- }
139
- /** Pure projection of the tracker's result page for the requested number. */
140
- export function parseAsendiaUkTrackingPage(html, rawTrackingNumber) {
141
- const number = normalizeAsendiaUkTrackingNumber(rawTrackingNumber);
142
- const $ = load(html);
143
- if (/just a moment|attention required|access denied|captcha/i.test($('title').text())
144
- || $('.g-recaptcha, .h-captcha, .cf-turnstile, iframe[src*="captcha"]').length)
145
- throw new ChallengeError(PROVIDER);
146
- $('script, style, noscript').remove();
147
- const results = $('div#tracking-results').first();
148
- if ($('form#subForm input[name="ref"]').length !== 1 || results.length !== 1) {
149
- throw new SchemaError(PROVIDER, 'Asendia UK returned an unexpected page');
150
- }
151
- const parcels = results.find('td.parcel-left');
152
- const notices = results.find('h3').filter((_, heading) => clean($(heading).text()) === NOT_FOUND_NOTICE);
153
- if (!parcels.length) {
154
- if (notices.length === 1)
155
- throw new NotFoundError(PROVIDER);
156
- throw new SchemaError(PROVIDER, 'Asendia UK did not answer the search');
157
- }
158
- if (notices.length)
159
- throw new SchemaError(PROVIDER, 'Asendia UK returned parcels and a not-found notice');
160
- const matches = parcels.filter((_, cell) => {
161
- const parcel = /^Parcel: \d+ of \d+ Tracking Code: (\S{1,60})$/.exec(clean($(cell).text(), 200));
162
- if (!parcel)
163
- throw new SchemaError(PROVIDER, 'Asendia UK returned an invalid parcel');
164
- return comparable(parcel[1]) === number;
165
- });
166
- // The search matched another reference, such as an order number: the page
167
- // names no parcel this number identifies.
168
- if (!matches.length)
169
- throw new IndeterminateError(PROVIDER, 'Asendia UK listed parcels under other tracking codes');
170
- if (matches.length > 1)
171
- throw new SchemaError(PROVIDER, 'Asendia UK listed the tracking code more than once');
172
- const parcelRow = matches.closest('tr');
173
- const events = historyEvents($, parcelRow);
174
- if (!events.length)
175
- throw new NoHistoryError(PROVIDER);
176
- // Newest first on the page. Clocks are not sorted: they do not share a zone.
177
- const current = events.find((event) => !isAsendiaUkNotice(event.description)) ?? events[0];
178
- const explicit = classifyAsendiaUkEvent(current.description);
179
- const stage = explicit?.stage ?? wordingStage(current.description);
180
- const status = explicit?.status ?? languageStageStatus(stage);
181
- const country = destinationCountry($, parcelRow);
182
- const partner = deliveryCarrier(parcelRow);
183
- return {
184
- status,
185
- ...(explicit ? { current_stage: explicit.stage } : {}),
186
- last_status_text: current.description,
187
- last_update: null,
188
- last_update_local: events[0].local_time,
189
- expected_delivery: null,
190
- ...(country ? { destination_country: country } : {}),
191
- ...(partner ? { delivery_carrier: partner } : {}),
192
- events: events.slice(0, MAX_EVENTS_TO_RETURN),
193
- };
194
- }
195
- export class AsendiaUkTracker {
196
- fetcher;
197
- budgetMs;
198
- userAgent;
199
- constructor(options = {}) {
200
- this.fetcher = options.fetcher;
201
- this.budgetMs = options.budgetMs ?? DEFAULT_BUDGET_MS;
202
- if (!Number.isFinite(this.budgetMs) || this.budgetMs <= 0)
203
- throw new TypeError('Asendia UK budget must be positive');
204
- this.userAgent = userAgentOf(options.userAgent);
205
- }
206
- async fetch(rawTrackingNumber, context = {}) {
207
- const trackingNumber = normalizeAsendiaUkTrackingNumber(rawTrackingNumber);
208
- const budget = lookupBudget(context, this.budgetMs, PROVIDER);
209
- const url = asendiaUkTrackingUrl(trackingNumber);
210
- const { bytes } = await fetchBounded(url, {
211
- signal: budget.signal,
212
- headers: { Accept: 'text/html', 'User-Agent': this.userAgent },
213
- }, {
214
- provider: 'Asendia UK tracker', timeoutMs: Math.min(MAX_REQUEST_MS, budget.remainingMs()),
215
- maxBytes: MAX_RESPONSE_BYTES, fetcher: this.fetcher,
216
- });
217
- const result = parseAsendiaUkTrackingPage(decodeText(bytes), trackingNumber);
218
- result.tracking_url = url;
219
- result.tracking_source = 'rendered-page';
220
- return result;
221
- }
222
- }