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 +2 -2
- package/data/catalog.json +2 -1
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +2 -1
- package/dist/carriers/asendia/adapter.d.ts +5 -7
- package/dist/carriers/asendia/adapter.js +10 -66
- package/dist/carriers/asendia/status.d.ts +3 -11
- package/dist/carriers/asendia/status.js +0 -39
- package/dist/carriers/asendia/statuses.json +0 -168
- package/dist/carriers/correos-cuba/adapter.d.ts +20 -0
- package/dist/carriers/correos-cuba/adapter.js +93 -0
- package/dist/carriers/correos-cuba/carrier.json +38 -7
- package/dist/carriers/correos-cuba/parser.d.ts +24 -0
- package/dist/carriers/correos-cuba/parser.js +129 -0
- package/dist/carriers/correos-cuba/status.d.ts +8 -0
- package/dist/carriers/correos-cuba/status.js +41 -0
- package/dist/carriers/correos-cuba/statuses.json +8 -6
- package/dist/carriers/mondial-relay/app.js +3 -0
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/data/catalog.json +2 -1
- package/dist/generated/catalog.d.ts +2 -1
- package/dist/generated/catalog.js +2 -1
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
- package/dist/carriers/asendia/uk.d.ts +0 -20
- package/dist/carriers/asendia/uk.js +0 -222
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
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,
|
|
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": "
|
|
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/",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -7881,7 +7881,8 @@ var Ze = {
|
|
|
7881
7881
|
timezone: "America/Havana",
|
|
7882
7882
|
tracking: {
|
|
7883
7883
|
mode: "automatic",
|
|
7884
|
-
adapter: "
|
|
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
|
|
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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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,
|
|
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
|
|
521
|
-
recognize: (number, context) => recognizeFromLookup(() =>
|
|
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 }) =>
|
|
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
|
|
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
|
-
*
|
|
5
|
-
* platform through `classifyAsendiaA1Event`
|
|
6
|
-
*
|
|
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": "
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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": "
|
|
5
|
-
{"wording": "
|
|
6
|
-
{"wording": "
|
|
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": "
|
|
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,
|
package/dist/data/catalog.json
CHANGED
|
@@ -9934,7 +9934,8 @@
|
|
|
9934
9934
|
"timezone": "America/Havana",
|
|
9935
9935
|
"tracking": {
|
|
9936
9936
|
"mode": "automatic",
|
|
9937
|
-
"adapter": "
|
|
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: "
|
|
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": "
|
|
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": "
|
|
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",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
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
|
-
}
|