universal-parcel-scraper 0.368.0-main.2003 → 0.372.0-main.2012
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/data/catalog.json +20 -7
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +3 -0
- package/dist/browser/scraper.js +22 -8
- package/dist/carriers/atlantic-courier/carrier.json +1 -1
- package/dist/carriers/connect-couriers/adapter.d.ts +17 -0
- package/dist/carriers/connect-couriers/adapter.js +63 -0
- package/dist/carriers/connect-couriers/carrier.json +52 -7
- package/dist/carriers/connect-couriers/numbers.json +2 -2
- package/dist/carriers/connect-couriers/parser.d.ts +15 -0
- package/dist/carriers/connect-couriers/parser.js +188 -0
- package/dist/carriers/connect-couriers/status.d.ts +8 -0
- package/dist/carriers/connect-couriers/status.js +33 -0
- package/dist/carriers/connect-couriers/statuses.json +8 -1
- package/dist/carriers/jersey-post/adapter.d.ts +15 -0
- package/dist/carriers/jersey-post/adapter.js +55 -0
- package/dist/carriers/jersey-post/carrier.json +50 -8
- package/dist/carriers/jersey-post/numbers.json +2 -2
- package/dist/carriers/jersey-post/parser.d.ts +15 -0
- package/dist/carriers/jersey-post/parser.js +147 -0
- package/dist/carriers/jersey-post/status.d.ts +7 -0
- package/dist/carriers/jersey-post/status.js +44 -0
- package/dist/carriers/jersey-post/statuses.json +10 -2
- package/dist/carriers/ocs-ana/carrier.json +1 -1
- package/dist/carriers/shipentegra/adapter.d.ts +15 -0
- package/dist/carriers/shipentegra/adapter.js +65 -0
- package/dist/carriers/shipentegra/carrier.json +44 -7
- package/dist/carriers/shipentegra/numbers.json +8 -8
- package/dist/carriers/shipentegra/parser.d.ts +21 -0
- package/dist/carriers/shipentegra/parser.js +172 -0
- package/dist/carriers/shipentegra/status.d.ts +9 -0
- package/dist/carriers/shipentegra/status.js +67 -0
- package/dist/carriers/shipentegra/statuses.json +36 -15
- package/dist/carriers/veloces/adapter.d.ts +15 -0
- package/dist/carriers/veloces/adapter.js +78 -0
- package/dist/carriers/veloces/carrier.json +52 -7
- package/dist/carriers/veloces/numbers.json +6 -6
- package/dist/carriers/veloces/parser.d.ts +12 -0
- package/dist/carriers/veloces/parser.js +133 -0
- package/dist/carriers/veloces/status.d.ts +7 -0
- package/dist/carriers/veloces/status.js +36 -0
- package/dist/carriers/veloces/statuses.json +20 -11
- package/dist/core/catalog/statusMaps.js +9 -4
- package/dist/data/catalog.json +20 -7
- package/dist/generated/catalog.d.ts +18 -7
- package/dist/generated/catalog.js +21 -8
- package/dist/generated/recognition.js +3 -0
- package/dist/generated/registry.js +12 -4
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **402 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>727 catalog entries · 133 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
|
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
|
|
|
155
155
|
<!-- GENERATED:stages -->
|
|
156
156
|
<img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
|
|
157
157
|
|
|
158
|
-
The carrier folders record 13,
|
|
158
|
+
The carrier folders record 13,584 such statuses, each filed under one stage.
|
|
159
159
|
<!-- /GENERATED:stages -->
|
|
160
160
|
|
|
161
161
|
Wording nobody recorded yet goes through a classifier that reads seven European languages.
|
package/data/catalog.json
CHANGED
|
@@ -6581,7 +6581,7 @@
|
|
|
6581
6581
|
},
|
|
6582
6582
|
"atlantic-courier": {
|
|
6583
6583
|
"displayName": "Atlantic International Express",
|
|
6584
|
-
"color": "#
|
|
6584
|
+
"color": "#e31e24",
|
|
6585
6585
|
"aliases": [
|
|
6586
6586
|
"Atlantic International Express Courier"
|
|
6587
6587
|
],
|
|
@@ -8881,7 +8881,9 @@
|
|
|
8881
8881
|
"timezone": "Europe/Malta",
|
|
8882
8882
|
"tracking": {
|
|
8883
8883
|
"mode": "automatic",
|
|
8884
|
-
"adapter": "
|
|
8884
|
+
"adapter": "connect-couriers",
|
|
8885
|
+
"recognitionRank": 831,
|
|
8886
|
+
"localClocks": true
|
|
8885
8887
|
},
|
|
8886
8888
|
"canaryUrl": "https://www.connectcouriers.eu/tracking/",
|
|
8887
8889
|
"trackingUrlTemplate": "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
|
|
@@ -16391,7 +16393,9 @@
|
|
|
16391
16393
|
"timezone": "Europe/Jersey",
|
|
16392
16394
|
"tracking": {
|
|
16393
16395
|
"mode": "automatic",
|
|
16394
|
-
"adapter": "
|
|
16396
|
+
"adapter": "jersey-post",
|
|
16397
|
+
"recognitionRank": 832,
|
|
16398
|
+
"localClocks": true
|
|
16395
16399
|
},
|
|
16396
16400
|
"canaryUrl": "https://www.jerseypost.com/tools/track-trace/",
|
|
16397
16401
|
"trackingUrlTemplate": "https://www.jerseypost.com/tools/track-trace/?tq={trackingNumber}",
|
|
@@ -16407,7 +16411,14 @@
|
|
|
16407
16411
|
]
|
|
16408
16412
|
}
|
|
16409
16413
|
],
|
|
16410
|
-
"detectionRules": [
|
|
16414
|
+
"detectionRules": [
|
|
16415
|
+
{
|
|
16416
|
+
"pattern": "^[A-Z]{2}\\d{9}JE$",
|
|
16417
|
+
"confidence": "low",
|
|
16418
|
+
"checksum": "s10",
|
|
16419
|
+
"preferred": true
|
|
16420
|
+
}
|
|
16421
|
+
]
|
|
16411
16422
|
},
|
|
16412
16423
|
"jet-logistic": {
|
|
16413
16424
|
"displayName": "Jet Logistic",
|
|
@@ -19892,7 +19903,7 @@
|
|
|
19892
19903
|
},
|
|
19893
19904
|
"ocs-ana": {
|
|
19894
19905
|
"displayName": "OCS ANA Group",
|
|
19895
|
-
"color": "#
|
|
19906
|
+
"color": "#0a3382",
|
|
19896
19907
|
"aliases": [
|
|
19897
19908
|
"OCS",
|
|
19898
19909
|
"Overseas Courier Service",
|
|
@@ -24198,7 +24209,9 @@
|
|
|
24198
24209
|
"timezone": "Europe/Istanbul",
|
|
24199
24210
|
"tracking": {
|
|
24200
24211
|
"mode": "automatic",
|
|
24201
|
-
"adapter": "
|
|
24212
|
+
"adapter": "shipentegra",
|
|
24213
|
+
"recognitionRank": 833,
|
|
24214
|
+
"localClocks": true
|
|
24202
24215
|
},
|
|
24203
24216
|
"canaryUrl": "https://track.shipentegra.com/",
|
|
24204
24217
|
"trackingUrlTemplate": "https://track.shipentegra.com/?trackno={trackingNumber}",
|
|
@@ -27747,7 +27760,7 @@
|
|
|
27747
27760
|
"timezone": "America/Bogota",
|
|
27748
27761
|
"tracking": {
|
|
27749
27762
|
"mode": "automatic",
|
|
27750
|
-
"adapter": "
|
|
27763
|
+
"adapter": "veloces"
|
|
27751
27764
|
},
|
|
27752
27765
|
"canaryUrl": "https://tracking.veloces.app/",
|
|
27753
27766
|
"trackingUrlTemplate": "https://tracking.veloces.app/tracking-client/{trackingNumber}",
|
|
@@ -74750,6 +74750,7 @@
|
|
|
74750
74750
|
"intl-post"
|
|
74751
74751
|
],
|
|
74752
74752
|
"asked": [
|
|
74753
|
+
"jersey-post",
|
|
74753
74754
|
"chronopost"
|
|
74754
74755
|
]
|
|
74755
74756
|
},
|
|
@@ -74788,6 +74789,7 @@
|
|
|
74788
74789
|
"intl-post"
|
|
74789
74790
|
],
|
|
74790
74791
|
"asked": [
|
|
74792
|
+
"jersey-post",
|
|
74791
74793
|
"chronopost"
|
|
74792
74794
|
]
|
|
74793
74795
|
},
|
|
@@ -84900,6 +84902,7 @@
|
|
|
84900
84902
|
"intl-post"
|
|
84901
84903
|
],
|
|
84902
84904
|
"asked": [
|
|
84905
|
+
"jersey-post",
|
|
84903
84906
|
"chronopost"
|
|
84904
84907
|
]
|
|
84905
84908
|
},
|
package/dist/browser/scraper.js
CHANGED
|
@@ -5376,7 +5376,7 @@ var Pe = {
|
|
|
5376
5376
|
},
|
|
5377
5377
|
"atlantic-courier": {
|
|
5378
5378
|
displayName: "Atlantic International Express",
|
|
5379
|
-
color: "#
|
|
5379
|
+
color: "#e31e24",
|
|
5380
5380
|
aliases: ["Atlantic International Express Courier"],
|
|
5381
5381
|
countries: ["IN"],
|
|
5382
5382
|
selectable: !0,
|
|
@@ -7015,7 +7015,9 @@ var Pe = {
|
|
|
7015
7015
|
timezone: "Europe/Malta",
|
|
7016
7016
|
tracking: {
|
|
7017
7017
|
mode: "automatic",
|
|
7018
|
-
adapter: "
|
|
7018
|
+
adapter: "connect-couriers",
|
|
7019
|
+
recognitionRank: 831,
|
|
7020
|
+
localClocks: !0
|
|
7019
7021
|
},
|
|
7020
7022
|
canaryUrl: "https://www.connectcouriers.eu/tracking/",
|
|
7021
7023
|
trackingUrlTemplate: "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
|
|
@@ -12556,7 +12558,9 @@ var Pe = {
|
|
|
12556
12558
|
timezone: "Europe/Jersey",
|
|
12557
12559
|
tracking: {
|
|
12558
12560
|
mode: "automatic",
|
|
12559
|
-
adapter: "
|
|
12561
|
+
adapter: "jersey-post",
|
|
12562
|
+
recognitionRank: 832,
|
|
12563
|
+
localClocks: !0
|
|
12560
12564
|
},
|
|
12561
12565
|
canaryUrl: "https://www.jerseypost.com/tools/track-trace/",
|
|
12562
12566
|
trackingUrlTemplate: "https://www.jerseypost.com/tools/track-trace/?tq={trackingNumber}",
|
|
@@ -12566,7 +12570,12 @@ var Pe = {
|
|
|
12566
12570
|
pathPattern: "^/tools/track-trace/?$",
|
|
12567
12571
|
params: ["tq"]
|
|
12568
12572
|
}],
|
|
12569
|
-
detectionRules: [
|
|
12573
|
+
detectionRules: [{
|
|
12574
|
+
pattern: "^[A-Z]{2}\\d{9}JE$",
|
|
12575
|
+
confidence: "low",
|
|
12576
|
+
checksum: "s10",
|
|
12577
|
+
preferred: !0
|
|
12578
|
+
}]
|
|
12570
12579
|
},
|
|
12571
12580
|
"jet-logistic": {
|
|
12572
12581
|
displayName: "Jet Logistic",
|
|
@@ -15162,7 +15171,7 @@ var Pe = {
|
|
|
15162
15171
|
},
|
|
15163
15172
|
"ocs-ana": {
|
|
15164
15173
|
displayName: "OCS ANA Group",
|
|
15165
|
-
color: "#
|
|
15174
|
+
color: "#0a3382",
|
|
15166
15175
|
aliases: [
|
|
15167
15176
|
"OCS",
|
|
15168
15177
|
"Overseas Courier Service",
|
|
@@ -18319,7 +18328,9 @@ var Pe = {
|
|
|
18319
18328
|
timezone: "Europe/Istanbul",
|
|
18320
18329
|
tracking: {
|
|
18321
18330
|
mode: "automatic",
|
|
18322
|
-
adapter: "
|
|
18331
|
+
adapter: "shipentegra",
|
|
18332
|
+
recognitionRank: 833,
|
|
18333
|
+
localClocks: !0
|
|
18323
18334
|
},
|
|
18324
18335
|
canaryUrl: "https://track.shipentegra.com/",
|
|
18325
18336
|
trackingUrlTemplate: "https://track.shipentegra.com/?trackno={trackingNumber}",
|
|
@@ -20875,7 +20886,7 @@ var Pe = {
|
|
|
20875
20886
|
timezone: "America/Bogota",
|
|
20876
20887
|
tracking: {
|
|
20877
20888
|
mode: "automatic",
|
|
20878
|
-
adapter: "
|
|
20889
|
+
adapter: "veloces"
|
|
20879
20890
|
},
|
|
20880
20891
|
canaryUrl: "https://tracking.veloces.app/",
|
|
20881
20892
|
trackingUrlTemplate: "https://tracking.veloces.app/tracking-client/{trackingNumber}",
|
|
@@ -22467,7 +22478,7 @@ var Pe = {
|
|
|
22467
22478
|
"jamaica-post": [],
|
|
22468
22479
|
janio: ["janio-1"],
|
|
22469
22480
|
jeebly: ["jeebly-1"],
|
|
22470
|
-
"jersey-post": [],
|
|
22481
|
+
"jersey-post": ["jersey-post-s10"],
|
|
22471
22482
|
"jet-logistic": [],
|
|
22472
22483
|
"jetline-couriers": [],
|
|
22473
22484
|
jhgj: ["jhgj-1"],
|
|
@@ -27989,6 +28000,7 @@ var pl = {
|
|
|
27989
28000
|
"cj-packet": 956,
|
|
27990
28001
|
"closer-logistics": 849,
|
|
27991
28002
|
"colis-prive": 740,
|
|
28003
|
+
"connect-couriers": 831,
|
|
27992
28004
|
coordinadora: 973,
|
|
27993
28005
|
"correo-argentino": 978,
|
|
27994
28006
|
"correos-de-mexico": 30,
|
|
@@ -28086,6 +28098,7 @@ var pl = {
|
|
|
28086
28098
|
"israel-post": 81,
|
|
28087
28099
|
"j-and-t": 530,
|
|
28088
28100
|
"j-and-t-cargo": 910,
|
|
28101
|
+
"jersey-post": 832,
|
|
28089
28102
|
jhgj: 907,
|
|
28090
28103
|
jitsu: 85,
|
|
28091
28104
|
"jordan-post": 881,
|
|
@@ -28174,6 +28187,7 @@ var pl = {
|
|
|
28174
28187
|
seur: 870,
|
|
28175
28188
|
sfc: 955,
|
|
28176
28189
|
shadowfax: 595,
|
|
28190
|
+
shipentegra: 833,
|
|
28177
28191
|
"skroutz-last-mile": 913,
|
|
28178
28192
|
"slovak-parcel-service": 180,
|
|
28179
28193
|
"slovenska-posta": 170,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The WordPress endpoint the tracking page on connectcouriers.eu posts the number to. */
|
|
5
|
+
export declare const CONNECT_COURIERS_API = "https://www.connectcouriers.eu/wp-admin/admin-ajax.php";
|
|
6
|
+
/** The tracking plugin's action. */
|
|
7
|
+
export declare const CONNECT_COURIERS_ACTION = "public_connect_tracking_tracking_action";
|
|
8
|
+
export declare class ConnectCouriersTracker {
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(options?: {
|
|
11
|
+
fetcher?: typeof fetch;
|
|
12
|
+
recorder?: StepRecorder;
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
});
|
|
15
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { CONNECT_COURIERS_MAX_BYTES, normalizeConnectCouriersNumber, parseConnectCouriers, PROVIDER } from './parser.js';
|
|
8
|
+
/** The WordPress endpoint the tracking page on connectcouriers.eu posts the number to. */
|
|
9
|
+
export const CONNECT_COURIERS_API = 'https://www.connectcouriers.eu/wp-admin/admin-ajax.php';
|
|
10
|
+
/** The tracking plugin's action. */
|
|
11
|
+
export const CONNECT_COURIERS_ACTION = 'public_connect_tracking_tracking_action';
|
|
12
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
13
|
+
// Bot checks Cloudflare, in front of the site, could put in place of the reply.
|
|
14
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*Just a moment|<title>\s*Attention Required/i;
|
|
15
|
+
function json(body) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(body);
|
|
18
|
+
}
|
|
19
|
+
catch (cause) {
|
|
20
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
21
|
+
throw new ChallengeError(PROVIDER, 'Connect Couriers answered with a browser check', { cause });
|
|
22
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a reply that is not JSON', { cause });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class ConnectCouriersTracker {
|
|
26
|
+
options;
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
}
|
|
30
|
+
async fetch(raw, context = {}) {
|
|
31
|
+
const number = normalizeConnectCouriersNumber(raw);
|
|
32
|
+
const read = async (signal, timeoutMs) => {
|
|
33
|
+
const { response, bytes } = await fetchBounded(CONNECT_COURIERS_API, { method: 'POST', signal, headers: {
|
|
34
|
+
Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
35
|
+
}, body: new URLSearchParams({ action: CONNECT_COURIERS_ACTION, tracking_number: number }).toString() }, { provider: PROVIDER,
|
|
36
|
+
timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: CONNECT_COURIERS_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
|
|
37
|
+
allowHttpStatuses: [...REDIRECTS, 400, 404, 410] });
|
|
38
|
+
if (REDIRECTS.includes(response.status))
|
|
39
|
+
throw new TransportError(PROVIDER, 'Connect Couriers redirected its tracking lookup');
|
|
40
|
+
// The tracker answers every number with HTTP 200. WordPress answers an
|
|
41
|
+
// action no plugin registers with HTTP 400 and `0`; a 404 is a moved route.
|
|
42
|
+
if (response.status === 400 || response.status === 404 || response.status === 410) {
|
|
43
|
+
throw new TransportError(PROVIDER, 'Connect Couriers tracking lookup is unavailable');
|
|
44
|
+
}
|
|
45
|
+
return parseConnectCouriers(json(decodeText(bytes)), number);
|
|
46
|
+
};
|
|
47
|
+
// A request that fails to reach the site, or hangs, gets one more try: the
|
|
48
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
49
|
+
return runSteps({ carrier: 'connect-couriers', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
50
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
51
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
52
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export const adapter = (environment) => {
|
|
57
|
+
const tracker = new ConnectCouriersTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
58
|
+
return {
|
|
59
|
+
id: 'connect-couriers', recordsSteps: true, steps: ['direct', 'retry'],
|
|
60
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
61
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeConnectCouriersNumber(number))),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
@@ -18,18 +18,63 @@
|
|
|
18
18
|
"timezone": "Europe/Malta",
|
|
19
19
|
"tracking": {
|
|
20
20
|
"mode": "automatic",
|
|
21
|
-
"adapter": "
|
|
22
|
-
"steps": [
|
|
21
|
+
"adapter": "connect-couriers",
|
|
22
|
+
"steps": [
|
|
23
|
+
"direct",
|
|
24
|
+
"retry"
|
|
25
|
+
],
|
|
26
|
+
"recognition": {
|
|
27
|
+
"rank": 831
|
|
28
|
+
},
|
|
29
|
+
"localClocks": true
|
|
23
30
|
},
|
|
24
|
-
"capabilities": [
|
|
31
|
+
"capabilities": [
|
|
32
|
+
"history",
|
|
33
|
+
"destination_country"
|
|
34
|
+
],
|
|
25
35
|
"portal": {
|
|
26
36
|
"url": "https://www.connectcouriers.eu/tracking/?tracking_number={trackingNumber}",
|
|
27
37
|
"searchUrl": "https://www.connectcouriers.eu/tracking/",
|
|
28
38
|
"canaryUrl": "https://www.connectcouriers.eu/tracking/",
|
|
29
|
-
"shows": [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
"shows": [
|
|
40
|
+
"status",
|
|
41
|
+
"history",
|
|
42
|
+
"progress_rail",
|
|
43
|
+
"origin",
|
|
44
|
+
"destination",
|
|
45
|
+
"destination_country",
|
|
46
|
+
"parcel_count",
|
|
47
|
+
"departure_date",
|
|
48
|
+
"weight",
|
|
49
|
+
"dimensions"
|
|
50
|
+
],
|
|
51
|
+
"scraped": [
|
|
52
|
+
"status",
|
|
53
|
+
"history",
|
|
54
|
+
"destination_country"
|
|
55
|
+
],
|
|
56
|
+
"discarded": [
|
|
57
|
+
"progress_rail",
|
|
58
|
+
"origin",
|
|
59
|
+
"destination",
|
|
60
|
+
"parcel_count",
|
|
61
|
+
"departure_date",
|
|
62
|
+
"weight",
|
|
63
|
+
"dimensions"
|
|
64
|
+
],
|
|
65
|
+
"unavailable": [
|
|
66
|
+
"location",
|
|
67
|
+
"provider_code",
|
|
68
|
+
"eta",
|
|
69
|
+
"delivered_at",
|
|
70
|
+
"offset_timestamps",
|
|
71
|
+
"service_name",
|
|
72
|
+
"pickup_point",
|
|
73
|
+
"sender_name",
|
|
74
|
+
"recipient_name",
|
|
75
|
+
"recipient_address",
|
|
76
|
+
"proof_of_delivery"
|
|
77
|
+
]
|
|
33
78
|
},
|
|
34
79
|
"links": [
|
|
35
80
|
{
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"access": "full_page"
|
|
13
13
|
},
|
|
14
14
|
"context": {
|
|
15
|
-
"assessment": "
|
|
15
|
+
"assessment": "confirmed"
|
|
16
16
|
},
|
|
17
17
|
"expect": {
|
|
18
18
|
"carrier": "unknown",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"ppl"
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
|
-
"note": "An archived lookup on Connect Couriers' own tracker; the capture
|
|
31
|
+
"note": "An archived lookup on Connect Couriers' own tracker address; the capture shows only the page shell, and Connect Couriers' tracker returns a delivered history for it."
|
|
32
32
|
}
|
|
33
33
|
]
|
|
34
34
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Connect Couriers";
|
|
3
|
+
export declare const CONNECT_COURIERS_MAX_BYTES: number;
|
|
4
|
+
/** A number for Connect Couriers' tracker, digits only; anything else is not one it issues. */
|
|
5
|
+
export declare function normalizeConnectCouriersNumber(raw: string): string;
|
|
6
|
+
/** A scan's wording without a signatory or phone numbers. */
|
|
7
|
+
export declare function connectCouriersWording(raw: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The reply of the tracking page's WordPress action for one number. It
|
|
10
|
+
* answers HTTP 200 with `{"success":false}` for a number it does not know,
|
|
11
|
+
* which the page reports as an invalid tracking number. A found reply has
|
|
12
|
+
* the consignment's pieces, a progress rail and the scans grouped by day;
|
|
13
|
+
* the reply and the piece must both name the number asked.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseConnectCouriers(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { countryCode } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
import { connectCouriersStage } from './status.js';
|
|
9
|
+
export const PROVIDER = 'Connect Couriers';
|
|
10
|
+
// A long history is a few kilobytes.
|
|
11
|
+
export const CONNECT_COURIERS_MAX_BYTES = 256 * 1024;
|
|
12
|
+
// Digits only: the tracking page's field takes nothing else.
|
|
13
|
+
const NUMBER = /^\d{8,20}$/;
|
|
14
|
+
// The scan's wall clock, to the second; a fraction of a second is dropped.
|
|
15
|
+
const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d{1,7})?$/;
|
|
16
|
+
const MAX_ROWS = 500;
|
|
17
|
+
const MAX_EVENTS = 100;
|
|
18
|
+
/** A number for Connect Couriers' tracker, digits only; anything else is not one it issues. */
|
|
19
|
+
export function normalizeConnectCouriersNumber(raw) {
|
|
20
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
21
|
+
if (!NUMBER.test(number))
|
|
22
|
+
throw new InvalidInputError(PROVIDER, 'Connect Couriers numbers are eight to twenty digits');
|
|
23
|
+
return number;
|
|
24
|
+
}
|
|
25
|
+
/** A required text field; anything but a string is a changed reply. */
|
|
26
|
+
function text(row, key, maxLength = 300) {
|
|
27
|
+
const value = row[key];
|
|
28
|
+
if (typeof value !== 'string')
|
|
29
|
+
throw new SchemaError(PROVIDER, `Connect Couriers returned an invalid ${key}`);
|
|
30
|
+
return clean(value, maxLength);
|
|
31
|
+
}
|
|
32
|
+
/** Whether a tracking number in the reply is the number asked. */
|
|
33
|
+
function sameNumber(value, number) {
|
|
34
|
+
return typeof value === 'string' && value.length <= 64 && normalizeTrackingNumber(value) === number;
|
|
35
|
+
}
|
|
36
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens
|
|
37
|
+
// between them, as phone numbers are written.
|
|
38
|
+
const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
39
|
+
// Who signed for the parcel, after a delivery's wording.
|
|
40
|
+
const SIGNATORY = /\s*[,;:.\-–(]?\s*\bsigned\s+(?:for\s+)?by\b[\s\S]*$/i;
|
|
41
|
+
/** A scan's wording without a signatory or phone numbers. */
|
|
42
|
+
export function connectCouriersWording(raw) {
|
|
43
|
+
return clean(raw.replace(SIGNATORY, '').replace(PHONE, '')).replace(/^[\s,;:.\-–/]+|[\s,;:\-–/]+$/g, '');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A scan's clock. `stageDateTime` is a wall clock with no zone, which the
|
|
47
|
+
* page shows as it is, so it stays local.
|
|
48
|
+
*/
|
|
49
|
+
function scanClock(row) {
|
|
50
|
+
const match = WALL_CLOCK.exec(text(row, 'stageDateTime', 40));
|
|
51
|
+
const local = match?.[1] ?? '';
|
|
52
|
+
// Parsing in UTC checks the calendar digits only; the wall clock keeps no offset.
|
|
53
|
+
const parsed = DateTime.fromISO(local, { zone: 'UTC' });
|
|
54
|
+
// Luxon rolls 24:00:00 over to the next day; only a clock that reads back unchanged is kept.
|
|
55
|
+
if (!match || !parsed.isValid || parsed.year < 2000 || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local) {
|
|
56
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid scan time');
|
|
57
|
+
}
|
|
58
|
+
return local;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether a scan concerns the parcel asked. A scan lists the pieces of the
|
|
62
|
+
* consignment it was made for; one that lists none concerns them all.
|
|
63
|
+
*/
|
|
64
|
+
function concerns(row, number) {
|
|
65
|
+
const pieces = row.packages ?? [];
|
|
66
|
+
if (!Array.isArray(pieces) || pieces.length > MAX_ROWS)
|
|
67
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a scan with invalid pieces');
|
|
68
|
+
if (!pieces.length)
|
|
69
|
+
return true;
|
|
70
|
+
return pieces.some((piece) => {
|
|
71
|
+
if (!isRecord(piece) || typeof piece.trackingNumber !== 'string')
|
|
72
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid piece');
|
|
73
|
+
return sameNumber(piece.trackingNumber, number);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function projectRow(row) {
|
|
77
|
+
const description = connectCouriersWording(text(row, 'stageStatus'));
|
|
78
|
+
if (!description)
|
|
79
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a scan without wording');
|
|
80
|
+
const local = scanClock(row);
|
|
81
|
+
const staged = connectCouriersStage(description);
|
|
82
|
+
const event = {
|
|
83
|
+
local_time: local,
|
|
84
|
+
description,
|
|
85
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
86
|
+
};
|
|
87
|
+
return { key: JSON.stringify([local, description]), event };
|
|
88
|
+
}
|
|
89
|
+
/** The scans of the parcel asked, from the history's date groups. */
|
|
90
|
+
function scans(data, number) {
|
|
91
|
+
const groups = data.trackingHistory ?? [];
|
|
92
|
+
if (!Array.isArray(groups) || groups.length > MAX_ROWS)
|
|
93
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history');
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
const events = [];
|
|
96
|
+
for (const group of groups) {
|
|
97
|
+
if (!isRecord(group))
|
|
98
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history group');
|
|
99
|
+
// The page skips a group without items.
|
|
100
|
+
const rows = group.items ?? [];
|
|
101
|
+
if (!Array.isArray(rows) || rows.length > MAX_ROWS)
|
|
102
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid history group');
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
if (!isRecord(row))
|
|
105
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid scan');
|
|
106
|
+
if (!concerns(row, number))
|
|
107
|
+
continue;
|
|
108
|
+
const scan = projectRow(row);
|
|
109
|
+
if (seen.has(scan.key))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(scan.key);
|
|
112
|
+
events.push(scan.event);
|
|
113
|
+
if (events.length > MAX_ROWS)
|
|
114
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an oversized history');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Newest first; the groups are by day, and the scans within one are in no order.
|
|
118
|
+
return events
|
|
119
|
+
.map((event, index) => ({ event, index }))
|
|
120
|
+
.sort((a, b) => b.event.local_time.localeCompare(a.event.local_time) || a.index - b.index)
|
|
121
|
+
.map(({ event }) => event);
|
|
122
|
+
}
|
|
123
|
+
/** The piece of the consignment that is the parcel asked. */
|
|
124
|
+
function piece(data, number) {
|
|
125
|
+
const pieces = data.packageData;
|
|
126
|
+
if (!Array.isArray(pieces) || pieces.length > MAX_ROWS)
|
|
127
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without its pieces');
|
|
128
|
+
const found = pieces.find((entry) => isRecord(entry) && sameNumber(entry.trackingNumber, number));
|
|
129
|
+
if (!isRecord(found))
|
|
130
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without the parcel asked');
|
|
131
|
+
return found;
|
|
132
|
+
}
|
|
133
|
+
/** The headline status's word for a parcel whose journey ended or stopped. */
|
|
134
|
+
function headlineStage(found) {
|
|
135
|
+
if (typeof found.stageStatus !== 'string')
|
|
136
|
+
return undefined;
|
|
137
|
+
const stage = connectCouriersStage(connectCouriersWording(found.stageStatus))?.stage;
|
|
138
|
+
return stage === 'delivered' || stage === 'returned' || stage === 'exception' ? stage : undefined;
|
|
139
|
+
}
|
|
140
|
+
/** The destination country the page flags, from the recipient's country code. */
|
|
141
|
+
function destination(found) {
|
|
142
|
+
if (!isRecord(found.recipient))
|
|
143
|
+
return null;
|
|
144
|
+
return countryCode(clean(found.recipient.countryCode, 8)) ?? countryCode(clean(found.recipient.country, 60));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The reply of the tracking page's WordPress action for one number. It
|
|
148
|
+
* answers HTTP 200 with `{"success":false}` for a number it does not know,
|
|
149
|
+
* which the page reports as an invalid tracking number. A found reply has
|
|
150
|
+
* the consignment's pieces, a progress rail and the scans grouped by day;
|
|
151
|
+
* the reply and the piece must both name the number asked.
|
|
152
|
+
*/
|
|
153
|
+
export function parseConnectCouriers(payload, raw) {
|
|
154
|
+
const number = normalizeConnectCouriersNumber(raw);
|
|
155
|
+
if (!isRecord(payload) || typeof payload.success !== 'boolean')
|
|
156
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned an invalid envelope');
|
|
157
|
+
if (!payload.success) {
|
|
158
|
+
// WordPress's error reply carries its reason in `data`; the tracker sends none for an unknown number.
|
|
159
|
+
if (payload.data !== undefined && payload.data !== null)
|
|
160
|
+
throw new IndeterminateError(PROVIDER, 'Connect Couriers did not complete the lookup');
|
|
161
|
+
throw new NotFoundError(PROVIDER);
|
|
162
|
+
}
|
|
163
|
+
const data = payload.data;
|
|
164
|
+
if (!isRecord(data) || typeof data.trackingNumber !== 'string')
|
|
165
|
+
throw new SchemaError(PROVIDER, 'Connect Couriers returned a shipment without its number');
|
|
166
|
+
if (!sameNumber(data.trackingNumber, number))
|
|
167
|
+
throw new IndeterminateError(PROVIDER, 'Connect Couriers answered for another shipment');
|
|
168
|
+
const found = piece(data, number);
|
|
169
|
+
const events = scans(data, number);
|
|
170
|
+
if (!events.length)
|
|
171
|
+
throw new NoHistoryError(PROVIDER);
|
|
172
|
+
const newest = events[0];
|
|
173
|
+
const scanned = events.find((event) => event.stage);
|
|
174
|
+
const headline = headlineStage(found);
|
|
175
|
+
const stage = (headline ?? scanned?.stage);
|
|
176
|
+
const source = headline && headline !== scanned?.stage ? 'carrier_map' : scanned?.stage_source;
|
|
177
|
+
const country = destination(found);
|
|
178
|
+
return {
|
|
179
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
180
|
+
...(stage ? { current_stage: stage, current_stage_source: source ?? 'carrier_map' } : {}),
|
|
181
|
+
last_status_text: newest.description,
|
|
182
|
+
last_update: null,
|
|
183
|
+
last_update_local: newest.local_time,
|
|
184
|
+
...(country ? { destination_country: country } : {}),
|
|
185
|
+
events: events.slice(0, MAX_EVENTS),
|
|
186
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage Connect Couriers' map gives a scan's wording in `normalizeStatusWording` form. */
|
|
4
|
+
export declare function connectCouriersMappedStage(wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: the map, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function connectCouriersStage(wording: string): ClassifiedWording | null;
|
|
7
|
+
/** Every wording seen is mapped. */
|
|
8
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The wordings Connect Couriers' tracker gives its scans, which carry no
|
|
5
|
+
* code. Only wordings seen in replies are mapped; another falls to the
|
|
6
|
+
* shared wording rules.
|
|
7
|
+
*/
|
|
8
|
+
const WORDINGS = new Map([
|
|
9
|
+
['shipment data received', 'registered'], // The sender's data came before the parcel.
|
|
10
|
+
['arrived at operations facility - malta', 'in_transit'],
|
|
11
|
+
['out for delivery', 'out_for_delivery'],
|
|
12
|
+
['attempted delivery / client not home', 'failed_attempt'],
|
|
13
|
+
// Kept at the depot for the recipient to collect, at their request.
|
|
14
|
+
['awaiting collection by recipient as requested', 'ready_for_pickup'],
|
|
15
|
+
['delivered', 'delivered'],
|
|
16
|
+
]);
|
|
17
|
+
/** The stage Connect Couriers' map gives a scan's wording in `normalizeStatusWording` form. */
|
|
18
|
+
export function connectCouriersMappedStage(wording) {
|
|
19
|
+
return WORDINGS.get(wording);
|
|
20
|
+
}
|
|
21
|
+
/** A scan's stage: the map, else the shared wording rules, else nothing. */
|
|
22
|
+
export function connectCouriersStage(wording) {
|
|
23
|
+
const mapped = connectCouriersMappedStage(normalizeStatusWording(wording));
|
|
24
|
+
if (mapped)
|
|
25
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
26
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
27
|
+
return worded.source === 'none' ? null : worded;
|
|
28
|
+
}
|
|
29
|
+
/** Every wording seen is mapped. */
|
|
30
|
+
export const statusMap = {
|
|
31
|
+
stage: (_code, wording) => connectCouriersMappedStage(wording),
|
|
32
|
+
gaps: [],
|
|
33
|
+
};
|