universal-parcel-scraper 0.554.0-main.2562 → 0.556.0-main.2575
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 +10 -3
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +56 -0
- package/dist/browser/scraper.js +12 -3
- package/dist/carriers/austrian-post/carrier.json +6 -0
- package/dist/carriers/cacesa/status.js +3 -1
- package/dist/carriers/cacesa/statuses.json +2 -1
- package/dist/carriers/citylink-express/adapter.d.ts +15 -0
- package/dist/carriers/citylink-express/adapter.js +73 -0
- package/dist/carriers/citylink-express/carrier.json +58 -7
- package/dist/carriers/citylink-express/parser.d.ts +22 -0
- package/dist/carriers/citylink-express/parser.js +178 -0
- package/dist/carriers/citylink-express/status.d.ts +7 -0
- package/dist/carriers/citylink-express/status.js +52 -0
- package/dist/carriers/citylink-express/statuses.json +10 -0
- package/dist/carriers/fedex-freight/adapter.d.ts +16 -0
- package/dist/carriers/fedex-freight/adapter.js +77 -0
- package/dist/carriers/fedex-freight/carrier.json +57 -7
- package/dist/carriers/fedex-freight/parser.d.ts +12 -0
- package/dist/carriers/fedex-freight/parser.js +248 -0
- package/dist/carriers/fedex-freight/status.d.ts +11 -0
- package/dist/carriers/fedex-freight/status.js +48 -0
- package/dist/carriers/fedex-freight/statuses.json +11 -1
- package/dist/carriers/gofo/status.js +1 -1
- package/dist/carriers/gofo/statuses.json +8 -0
- package/dist/carriers/mrw/identity.js +2 -2
- package/dist/carriers/mrw/parser.js +40 -4
- package/dist/carriers/mrw/status.js +9 -0
- package/dist/carriers/mrw/statuses.json +7 -0
- package/dist/carriers/sf-express/carrier.json +1 -2
- package/dist/carriers/sf-express/numbers.json +76 -0
- package/dist/carriers/sf-express/parser.js +31 -21
- package/dist/carriers/sf-express/status.d.ts +1 -1
- package/dist/carriers/sf-express/status.js +20 -5
- package/dist/carriers/sf-express/statuses.json +77 -0
- package/dist/core/catalog/statusMaps.js +4 -2
- package/dist/data/catalog.json +10 -3
- package/dist/generated/catalog.d.ts +9 -3
- package/dist/generated/catalog.js +11 -4
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +6 -2
- 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 **600 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>774 catalog entries · 134 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,079 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
|
@@ -2339,6 +2339,11 @@
|
|
|
2339
2339
|
"trackingSearchUrl": "https://www.post.at/en/s/track-and-trace-search",
|
|
2340
2340
|
"linkRules": [],
|
|
2341
2341
|
"detectionRules": [
|
|
2342
|
+
{
|
|
2343
|
+
"pattern": "^10\\d{20}$",
|
|
2344
|
+
"confidence": "low",
|
|
2345
|
+
"preferred": true
|
|
2346
|
+
},
|
|
2342
2347
|
{
|
|
2343
2348
|
"pattern": "^\\d{22}$",
|
|
2344
2349
|
"confidence": "low"
|
|
@@ -3871,7 +3876,6 @@
|
|
|
3871
3876
|
"tracking": {
|
|
3872
3877
|
"mode": "automatic",
|
|
3873
3878
|
"adapter": "sf-express",
|
|
3874
|
-
"localClocks": true,
|
|
3875
3879
|
"browser": true
|
|
3876
3880
|
},
|
|
3877
3881
|
"canaryUrl": "https://htm.sf-express.com/tw/en/dynamic_function/waybill/",
|
|
@@ -9020,7 +9024,9 @@
|
|
|
9020
9024
|
"timezone": "Asia/Kuala_Lumpur",
|
|
9021
9025
|
"tracking": {
|
|
9022
9026
|
"mode": "automatic",
|
|
9023
|
-
"adapter": "
|
|
9027
|
+
"adapter": "citylink-express",
|
|
9028
|
+
"recognitionRank": 533,
|
|
9029
|
+
"localClocks": true
|
|
9024
9030
|
},
|
|
9025
9031
|
"canaryUrl": "https://www.citylinkexpress.com/track-your-shipment/",
|
|
9026
9032
|
"trackingUrlTemplate": "https://www.citylinkexpress.com/tracking-result/?track0={trackingNumber}",
|
|
@@ -13595,7 +13601,8 @@
|
|
|
13595
13601
|
"timezone": "America/Chicago",
|
|
13596
13602
|
"tracking": {
|
|
13597
13603
|
"mode": "automatic",
|
|
13598
|
-
"adapter": "
|
|
13604
|
+
"adapter": "fedex-freight",
|
|
13605
|
+
"localClocks": true
|
|
13599
13606
|
},
|
|
13600
13607
|
"canaryUrl": "https://www.fedexfreight.com/en-us/tracking",
|
|
13601
13608
|
"trackingUrlTemplate": "https://www.fedexfreight.com/fedextrack/?trknbr={trackingNumber}",
|
|
@@ -10469,6 +10469,9 @@
|
|
|
10469
10469
|
"usps",
|
|
10470
10470
|
"estafeta"
|
|
10471
10471
|
],
|
|
10472
|
+
"preferred": [
|
|
10473
|
+
"austrian-post"
|
|
10474
|
+
],
|
|
10472
10475
|
"asked": [
|
|
10473
10476
|
"austrian-post"
|
|
10474
10477
|
]
|
|
@@ -10923,6 +10926,9 @@
|
|
|
10923
10926
|
"usps",
|
|
10924
10927
|
"estafeta"
|
|
10925
10928
|
],
|
|
10929
|
+
"preferred": [
|
|
10930
|
+
"austrian-post"
|
|
10931
|
+
],
|
|
10926
10932
|
"asked": [
|
|
10927
10933
|
"austrian-post"
|
|
10928
10934
|
]
|
|
@@ -10990,6 +10996,9 @@
|
|
|
10990
10996
|
"usps",
|
|
10991
10997
|
"estafeta"
|
|
10992
10998
|
],
|
|
10999
|
+
"preferred": [
|
|
11000
|
+
"austrian-post"
|
|
11001
|
+
],
|
|
10993
11002
|
"asked": [
|
|
10994
11003
|
"austrian-post"
|
|
10995
11004
|
]
|
|
@@ -11232,6 +11241,9 @@
|
|
|
11232
11241
|
"usps",
|
|
11233
11242
|
"estafeta"
|
|
11234
11243
|
],
|
|
11244
|
+
"preferred": [
|
|
11245
|
+
"austrian-post"
|
|
11246
|
+
],
|
|
11235
11247
|
"asked": [
|
|
11236
11248
|
"austrian-post"
|
|
11237
11249
|
]
|
|
@@ -11423,6 +11435,9 @@
|
|
|
11423
11435
|
"usps",
|
|
11424
11436
|
"estafeta"
|
|
11425
11437
|
],
|
|
11438
|
+
"preferred": [
|
|
11439
|
+
"austrian-post"
|
|
11440
|
+
],
|
|
11426
11441
|
"asked": [
|
|
11427
11442
|
"austrian-post"
|
|
11428
11443
|
]
|
|
@@ -11476,6 +11491,9 @@
|
|
|
11476
11491
|
"usps",
|
|
11477
11492
|
"estafeta"
|
|
11478
11493
|
],
|
|
11494
|
+
"preferred": [
|
|
11495
|
+
"austrian-post"
|
|
11496
|
+
],
|
|
11479
11497
|
"asked": [
|
|
11480
11498
|
"austrian-post"
|
|
11481
11499
|
]
|
|
@@ -11489,6 +11507,9 @@
|
|
|
11489
11507
|
"usps",
|
|
11490
11508
|
"estafeta"
|
|
11491
11509
|
],
|
|
11510
|
+
"preferred": [
|
|
11511
|
+
"austrian-post"
|
|
11512
|
+
],
|
|
11492
11513
|
"asked": [
|
|
11493
11514
|
"austrian-post"
|
|
11494
11515
|
]
|
|
@@ -11502,6 +11523,9 @@
|
|
|
11502
11523
|
"usps",
|
|
11503
11524
|
"estafeta"
|
|
11504
11525
|
],
|
|
11526
|
+
"preferred": [
|
|
11527
|
+
"austrian-post"
|
|
11528
|
+
],
|
|
11505
11529
|
"asked": [
|
|
11506
11530
|
"austrian-post"
|
|
11507
11531
|
]
|
|
@@ -85975,6 +85999,14 @@
|
|
|
85975
85999
|
"sf-express"
|
|
85976
86000
|
]
|
|
85977
86001
|
},
|
|
86002
|
+
{
|
|
86003
|
+
"input": "SF6047696957042",
|
|
86004
|
+
"carrier": "sf-express",
|
|
86005
|
+
"confidence": "high",
|
|
86006
|
+
"candidates": [
|
|
86007
|
+
"sf-express"
|
|
86008
|
+
]
|
|
86009
|
+
},
|
|
85978
86010
|
{
|
|
85979
86011
|
"input": "SF6047789135544",
|
|
85980
86012
|
"carrier": "sf-express",
|
|
@@ -85983,6 +86015,30 @@
|
|
|
85983
86015
|
"sf-express"
|
|
85984
86016
|
]
|
|
85985
86017
|
},
|
|
86018
|
+
{
|
|
86019
|
+
"input": "SF6047826456714",
|
|
86020
|
+
"carrier": "sf-express",
|
|
86021
|
+
"confidence": "high",
|
|
86022
|
+
"candidates": [
|
|
86023
|
+
"sf-express"
|
|
86024
|
+
]
|
|
86025
|
+
},
|
|
86026
|
+
{
|
|
86027
|
+
"input": "SF6047918078324",
|
|
86028
|
+
"carrier": "sf-express",
|
|
86029
|
+
"confidence": "high",
|
|
86030
|
+
"candidates": [
|
|
86031
|
+
"sf-express"
|
|
86032
|
+
]
|
|
86033
|
+
},
|
|
86034
|
+
{
|
|
86035
|
+
"input": "SF6047933110963",
|
|
86036
|
+
"carrier": "sf-express",
|
|
86037
|
+
"confidence": "high",
|
|
86038
|
+
"candidates": [
|
|
86039
|
+
"sf-express"
|
|
86040
|
+
]
|
|
86041
|
+
},
|
|
85986
86042
|
{
|
|
85987
86043
|
"input": "SF632454478AJI",
|
|
85988
86044
|
"carrier": "shadowfax",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -2152,6 +2152,11 @@ var Je = {
|
|
|
2152
2152
|
trackingSearchUrl: "https://www.post.at/en/s/track-and-trace-search",
|
|
2153
2153
|
linkRules: [],
|
|
2154
2154
|
detectionRules: [
|
|
2155
|
+
{
|
|
2156
|
+
pattern: "^10\\d{20}$",
|
|
2157
|
+
confidence: "low",
|
|
2158
|
+
preferred: !0
|
|
2159
|
+
},
|
|
2155
2160
|
{
|
|
2156
2161
|
pattern: "^\\d{22}$",
|
|
2157
2162
|
confidence: "low"
|
|
@@ -3367,7 +3372,6 @@ var Je = {
|
|
|
3367
3372
|
tracking: {
|
|
3368
3373
|
mode: "automatic",
|
|
3369
3374
|
adapter: "sf-express",
|
|
3370
|
-
localClocks: !0,
|
|
3371
3375
|
browser: !0
|
|
3372
3376
|
},
|
|
3373
3377
|
canaryUrl: "https://htm.sf-express.com/tw/en/dynamic_function/waybill/",
|
|
@@ -7177,7 +7181,9 @@ var Je = {
|
|
|
7177
7181
|
timezone: "Asia/Kuala_Lumpur",
|
|
7178
7182
|
tracking: {
|
|
7179
7183
|
mode: "automatic",
|
|
7180
|
-
adapter: "
|
|
7184
|
+
adapter: "citylink-express",
|
|
7185
|
+
recognitionRank: 533,
|
|
7186
|
+
localClocks: !0
|
|
7181
7187
|
},
|
|
7182
7188
|
canaryUrl: "https://www.citylinkexpress.com/track-your-shipment/",
|
|
7183
7189
|
trackingUrlTemplate: "https://www.citylinkexpress.com/tracking-result/?track0={trackingNumber}",
|
|
@@ -10533,7 +10539,8 @@ var Je = {
|
|
|
10533
10539
|
timezone: "America/Chicago",
|
|
10534
10540
|
tracking: {
|
|
10535
10541
|
mode: "automatic",
|
|
10536
|
-
adapter: "
|
|
10542
|
+
adapter: "fedex-freight",
|
|
10543
|
+
localClocks: !0
|
|
10537
10544
|
},
|
|
10538
10545
|
canaryUrl: "https://www.fedexfreight.com/en-us/tracking",
|
|
10539
10546
|
trackingUrlTemplate: "https://www.fedexfreight.com/fedextrack/?trknbr={trackingNumber}",
|
|
@@ -23633,6 +23640,7 @@ var Je = {
|
|
|
23633
23640
|
"bpost-jjbe"
|
|
23634
23641
|
],
|
|
23635
23642
|
"austrian-post": [
|
|
23643
|
+
"austrian-post-4",
|
|
23636
23644
|
"austrian-post-1",
|
|
23637
23645
|
"austrian-post-3",
|
|
23638
23646
|
"austrian-post-2"
|
|
@@ -29790,6 +29798,7 @@ var Al = {
|
|
|
29790
29798
|
chunil: 621,
|
|
29791
29799
|
ciblex: 640,
|
|
29792
29800
|
"city-express-rs": 817,
|
|
29801
|
+
"citylink-express": 533,
|
|
29793
29802
|
citymail: 868,
|
|
29794
29803
|
"cj-logistics": 977,
|
|
29795
29804
|
"cj-packet": 956,
|
|
@@ -13,9 +13,11 @@ const EVENTS = {
|
|
|
13
13
|
// Customs released the parcel.
|
|
14
14
|
ico_okaduana: 'in_transit',
|
|
15
15
|
};
|
|
16
|
-
// Incidents CACESA names by icon: customs holding the parcel for inspection
|
|
16
|
+
// Incidents CACESA names by icon: customs holding the parcel for inspection,
|
|
17
|
+
// or for the documents it checks ("ko" beside the customs icon).
|
|
17
18
|
const INCIDENTS = {
|
|
18
19
|
ico_aduana: 'exception',
|
|
20
|
+
ico_aduanako: 'exception',
|
|
19
21
|
};
|
|
20
22
|
/** The provider code of a milestone: its type, a slash and its icon ("EVENTO/ico_aduana"). */
|
|
21
23
|
export function cacesaCode(type, icon) {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
{ "code": "EVENTO/ico_aduana", "wording": "Start Custom clearance Process", "language": "en", "stage": "customs", "confirmedBy": "live" },
|
|
8
8
|
{ "code": "EVENTO/ico_okaduana", "wording": "Customs cleared-Green channel", "language": "en", "stage": "in_transit", "confirmedBy": "live", "note": "Customs released the parcel." },
|
|
9
9
|
{ "code": "EVENTO/ico_subirdocu", "wording": "Customs document required uploaded", "language": "en", "stage": "customs", "confirmedBy": "live", "note": "The recipient uploaded the document customs asked for." },
|
|
10
|
-
{ "code": "INCIDENCIA/ico_aduana", "wording": "Shipment held in warehouse for inspection", "language": "en", "stage": "exception", "confirmedBy": "live", "note": "An incident: customs holds the parcel for inspection." }
|
|
10
|
+
{ "code": "INCIDENCIA/ico_aduana", "wording": "Shipment held in warehouse for inspection", "language": "en", "stage": "exception", "confirmedBy": "live", "note": "An incident: customs holds the parcel for inspection." },
|
|
11
|
+
{ "code": "INCIDENCIA/ico_aduanako", "wording": "Held by customs-documentary verification", "language": "en", "stage": "exception", "confirmedBy": "live", "firstSeen": "2026-10-19", "note": "An incident: customs holds the parcel until it has checked the shipment's documents." }
|
|
11
12
|
]
|
|
12
13
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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 lookup the tracking-result page on citylinkexpress.com makes for the number in its address. */
|
|
5
|
+
export declare const CITYLINK_API = "https://www.citylinkexpress.com/wp-json/wp/v2/getTracking";
|
|
6
|
+
export declare class CitylinkTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
});
|
|
13
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
14
|
+
}
|
|
15
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { CITYLINK_MAX_BYTES, isCitylinkNotFound, normalizeCitylinkNumber, parseCitylink, PROVIDER } from './parser.js';
|
|
8
|
+
/** The lookup the tracking-result page on citylinkexpress.com makes for the number in its address. */
|
|
9
|
+
export const CITYLINK_API = 'https://www.citylinkexpress.com/wp-json/wp/v2/getTracking';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
// Bot checks Cloudflare, in front of the site, could put in place of the reply.
|
|
12
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*(?:Just a moment|Attention Required)|cf-error-details/i;
|
|
13
|
+
function json(body) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(body);
|
|
16
|
+
}
|
|
17
|
+
catch (cause) {
|
|
18
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
19
|
+
throw new ChallengeError(PROVIDER, 'City-Link answered with a browser check', { cause });
|
|
20
|
+
throw new SchemaError(PROVIDER, 'City-Link returned a reply that is not JSON', { cause });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class CitylinkTracker {
|
|
24
|
+
options;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async fetch(raw, context = {}) {
|
|
29
|
+
const number = normalizeCitylinkNumber(raw);
|
|
30
|
+
const read = async (signal, timeoutMs) => {
|
|
31
|
+
const { response, bytes } = await fetchBounded(CITYLINK_API, { method: 'POST', signal, headers: {
|
|
32
|
+
Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
33
|
+
'User-Agent': userAgentOf(this.options.userAgent),
|
|
34
|
+
}, body: new URLSearchParams({ tracking: number, xr_option: 'false' }).toString() }, { provider: PROVIDER,
|
|
35
|
+
timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: CITYLINK_MAX_BYTES, redirect: 'manual',
|
|
36
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
37
|
+
if (REDIRECTS.includes(response.status))
|
|
38
|
+
throw new TransportError(PROVIDER, 'City-Link redirected its tracking lookup');
|
|
39
|
+
if (response.status === 404) {
|
|
40
|
+
// The tracker's own answer for a number it has no record of; any other
|
|
41
|
+
// 404, such as WordPress's for a route it no longer has, is a moved route.
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = JSON.parse(decodeText(bytes));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
payload = undefined;
|
|
48
|
+
}
|
|
49
|
+
if (isCitylinkNotFound(payload))
|
|
50
|
+
throw new NotFoundError(PROVIDER);
|
|
51
|
+
throw new TransportError(PROVIDER, 'City-Link tracking lookup is unavailable');
|
|
52
|
+
}
|
|
53
|
+
if (response.status === 410)
|
|
54
|
+
throw new TransportError(PROVIDER, 'City-Link tracking lookup is unavailable');
|
|
55
|
+
return parseCitylink(json(decodeText(bytes)), number);
|
|
56
|
+
};
|
|
57
|
+
// A request that fails to reach the site, or hangs, gets one more try: the
|
|
58
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
59
|
+
return runSteps({ carrier: 'citylink-express', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
60
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
61
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
62
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export const adapter = (environment) => {
|
|
67
|
+
const tracker = new CitylinkTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
68
|
+
return {
|
|
69
|
+
id: 'citylink-express', recordsSteps: true, steps: ['direct', 'retry'],
|
|
70
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
71
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeCitylinkNumber(number))),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -19,18 +19,69 @@
|
|
|
19
19
|
"timezone": "Asia/Kuala_Lumpur",
|
|
20
20
|
"tracking": {
|
|
21
21
|
"mode": "automatic",
|
|
22
|
-
"adapter": "
|
|
23
|
-
"steps": [
|
|
22
|
+
"adapter": "citylink-express",
|
|
23
|
+
"steps": [
|
|
24
|
+
"direct",
|
|
25
|
+
"retry"
|
|
26
|
+
],
|
|
27
|
+
"recognition": {
|
|
28
|
+
"rank": 533
|
|
29
|
+
},
|
|
30
|
+
"localClocks": true
|
|
24
31
|
},
|
|
25
|
-
"capabilities": [
|
|
32
|
+
"capabilities": [
|
|
33
|
+
"history",
|
|
34
|
+
"provider_code",
|
|
35
|
+
"location",
|
|
36
|
+
"delivered_at",
|
|
37
|
+
"weight"
|
|
38
|
+
],
|
|
26
39
|
"portal": {
|
|
27
40
|
"url": "https://www.citylinkexpress.com/tracking-result/?track0={trackingNumber}",
|
|
28
41
|
"searchUrl": "https://www.citylinkexpress.com/track-your-shipment/",
|
|
29
42
|
"canaryUrl": "https://www.citylinkexpress.com/track-your-shipment/",
|
|
30
|
-
"shows": [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
"shows": [
|
|
44
|
+
"status",
|
|
45
|
+
"progress_rail",
|
|
46
|
+
"history",
|
|
47
|
+
"location",
|
|
48
|
+
"origin",
|
|
49
|
+
"destination",
|
|
50
|
+
"customer_reference",
|
|
51
|
+
"item_category",
|
|
52
|
+
"parcel_count",
|
|
53
|
+
"related_tracking_numbers",
|
|
54
|
+
"driver_name",
|
|
55
|
+
"signatory",
|
|
56
|
+
"proof_of_delivery"
|
|
57
|
+
],
|
|
58
|
+
"scraped": [
|
|
59
|
+
"status",
|
|
60
|
+
"history",
|
|
61
|
+
"location",
|
|
62
|
+
"provider_code",
|
|
63
|
+
"delivered_at",
|
|
64
|
+
"weight"
|
|
65
|
+
],
|
|
66
|
+
"discarded": [
|
|
67
|
+
"progress_rail",
|
|
68
|
+
"origin",
|
|
69
|
+
"destination",
|
|
70
|
+
"customer_reference",
|
|
71
|
+
"item_category",
|
|
72
|
+
"parcel_count",
|
|
73
|
+
"related_tracking_numbers",
|
|
74
|
+
"driver_name",
|
|
75
|
+
"signatory",
|
|
76
|
+
"proof_of_delivery"
|
|
77
|
+
],
|
|
78
|
+
"unavailable": [
|
|
79
|
+
"eta",
|
|
80
|
+
"service_name",
|
|
81
|
+
"offset_timestamps",
|
|
82
|
+
"sender_name",
|
|
83
|
+
"recipient_name"
|
|
84
|
+
]
|
|
34
85
|
},
|
|
35
86
|
"links": [
|
|
36
87
|
{
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "City-Link Express";
|
|
3
|
+
export declare const CITYLINK_MAX_BYTES: number;
|
|
4
|
+
/** A City-Link consignment number; anything else is not one City-Link issues. */
|
|
5
|
+
export declare function normalizeCitylinkNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* A scan's wording as the page shows it, without the `SR - ` the page drops,
|
|
8
|
+
* the courier or the person who signed, or phone numbers.
|
|
9
|
+
*/
|
|
10
|
+
export declare function citylinkWording(raw: string): string;
|
|
11
|
+
/** A scan's code: `CP_Code`, with its `DisposeCode` after a colon when it has one, as `SP:60`. */
|
|
12
|
+
export declare function citylinkCode(row: Record<string, unknown>): string;
|
|
13
|
+
/** Whether a reply is the tracker's own answer for a number it has no record of. */
|
|
14
|
+
export declare function isCitylinkNotFound(payload: unknown): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* The reply of the tracking page's `/wp-json/wp/v2/getTracking` for one
|
|
17
|
+
* number: City-Link's answer in `req.data`, its scans oldest first. A number it
|
|
18
|
+
* has no record of is an HTTP 404 instead (see `isCitylinkNotFound`). The
|
|
19
|
+
* shipment must name the number asked. Only its number, weight and scans are
|
|
20
|
+
* read.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseCitylink(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { countryTimeZone, firstOccurrence, sharedClockZone } from '../../core/time/index.js';
|
|
6
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
import { citylinkStage } from './status.js';
|
|
9
|
+
export const PROVIDER = 'City-Link Express';
|
|
10
|
+
// A long history is a few kilobytes.
|
|
11
|
+
export const CITYLINK_MAX_BYTES = 256 * 1024;
|
|
12
|
+
// The tracker asks for the fifteen digits of the consignment note.
|
|
13
|
+
const NUMBER = /^\d{15}$/;
|
|
14
|
+
// City-Link's own clock: its system and its Malaysian branches keep Malaysia's.
|
|
15
|
+
const ZONE = 'Asia/Kuala_Lumpur';
|
|
16
|
+
const DATE = /^(\d{2})\/(\d{2})\/(\d{4})$/;
|
|
17
|
+
const CLOCK = /^(\d{2})(\d{2})(\d{2})$/;
|
|
18
|
+
const MAX_ROWS = 500;
|
|
19
|
+
const MAX_EVENTS = 100;
|
|
20
|
+
/** A City-Link consignment number; anything else is not one City-Link issues. */
|
|
21
|
+
export function normalizeCitylinkNumber(raw) {
|
|
22
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
23
|
+
if (!NUMBER.test(number))
|
|
24
|
+
throw new InvalidInputError(PROVIDER, 'City-Link consignment numbers are fifteen digits');
|
|
25
|
+
return number;
|
|
26
|
+
}
|
|
27
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
28
|
+
function text(row, key, maxLength = 300) {
|
|
29
|
+
const value = row[key];
|
|
30
|
+
if (value === null || value === undefined)
|
|
31
|
+
return '';
|
|
32
|
+
if (typeof value !== 'string')
|
|
33
|
+
throw new SchemaError(PROVIDER, `City-Link returned an invalid ${key}`);
|
|
34
|
+
return clean(value, maxLength);
|
|
35
|
+
}
|
|
36
|
+
// The courier a scan names after its label: "With City-Link delivery courier: …".
|
|
37
|
+
const COURIER = /\b(courier|rider|driver)\s*:[\s\S]*$/i;
|
|
38
|
+
// Who took the parcel, after the scan's own words: "Package Delivered - Signed for by: …".
|
|
39
|
+
const RECEIVER = /\s*[,;:\-–(]?\s*\b(signed for by|received by|collected by|delivered to)\b\s*:?\s*\S[\s\S]*$/i;
|
|
40
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens
|
|
41
|
+
// between them, as phone numbers are written.
|
|
42
|
+
const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
43
|
+
/**
|
|
44
|
+
* A scan's wording as the page shows it, without the `SR - ` the page drops,
|
|
45
|
+
* the courier or the person who signed, or phone numbers.
|
|
46
|
+
*/
|
|
47
|
+
export function citylinkWording(raw) {
|
|
48
|
+
let own = clean(raw).replace(/\bSR - /g, '').replace(COURIER, '$1').replace(PHONE, '');
|
|
49
|
+
const receiver = RECEIVER.exec(own);
|
|
50
|
+
// A wording that is only the label keeps it.
|
|
51
|
+
if (receiver)
|
|
52
|
+
own = own.slice(0, receiver.index).trim() || receiver[1];
|
|
53
|
+
return clean(own).replace(/^[\s,;:.\-–]+|[\s,;:\-–]+$/g, '');
|
|
54
|
+
}
|
|
55
|
+
/** A scan's `detDate` (`DD/MM/YYYY`) and `detTime` (`HHmmss`) as an offset-less wall clock. */
|
|
56
|
+
function wallClock(row) {
|
|
57
|
+
const date = DATE.exec(text(row, 'detDate', 16));
|
|
58
|
+
const clock = CLOCK.exec(text(row, 'detTime', 16));
|
|
59
|
+
const wall = date && clock ? `${date[3]}-${date[2]}-${date[1]}T${clock[1]}:${clock[2]}:${clock[3]}` : '';
|
|
60
|
+
// Parsing in UTC checks the calendar digits only. Luxon rolls 24:00:00 over to the next day.
|
|
61
|
+
const checked = DateTime.fromISO(wall, { zone: 'UTC' });
|
|
62
|
+
if (!wall || !checked.isValid || checked.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== wall) {
|
|
63
|
+
throw new SchemaError(PROVIDER, 'City-Link returned an invalid scan time');
|
|
64
|
+
}
|
|
65
|
+
return wall;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A scan's instant, when its place settles the clock: the country after the
|
|
69
|
+
* location's last comma keeps Malaysia's clock at that moment, so the reading
|
|
70
|
+
* is the same whether the branch or City-Link's system stamped it. Malaysia,
|
|
71
|
+
* Singapore and Brunei do; elsewhere, or without a location, the wall clock
|
|
72
|
+
* stays local.
|
|
73
|
+
*/
|
|
74
|
+
function scanInstant(wall, location) {
|
|
75
|
+
const comma = location.lastIndexOf(',');
|
|
76
|
+
const zone = comma < 0 ? null : countryTimeZone(location.slice(comma + 1).trim());
|
|
77
|
+
const shared = zone ? sharedClockZone([ZONE, zone], wall) : null;
|
|
78
|
+
return shared ? firstOccurrence(DateTime.fromISO(wall, { zone: shared })).toISO({ suppressMilliseconds: true }) : null;
|
|
79
|
+
}
|
|
80
|
+
/** A scan's code: `CP_Code`, with its `DisposeCode` after a colon when it has one, as `SP:60`. */
|
|
81
|
+
export function citylinkCode(row) {
|
|
82
|
+
const code = cleanScalar(row.CP_Code, 16).toUpperCase();
|
|
83
|
+
const disposal = cleanScalar(row.DisposeCode, 16).toUpperCase();
|
|
84
|
+
return code && disposal ? `${code}:${disposal}` : code;
|
|
85
|
+
}
|
|
86
|
+
function projectRow(row) {
|
|
87
|
+
const description = citylinkWording(text(row, 'status', 500));
|
|
88
|
+
if (!description)
|
|
89
|
+
throw new SchemaError(PROVIDER, 'City-Link returned a scan without wording');
|
|
90
|
+
const wall = wallClock(row);
|
|
91
|
+
const location = text(row, 'location', 120);
|
|
92
|
+
const time = scanInstant(wall, location);
|
|
93
|
+
const code = citylinkCode(row);
|
|
94
|
+
const staged = citylinkStage(code, description);
|
|
95
|
+
const event = {
|
|
96
|
+
...(time ? { time } : { local_time: wall }),
|
|
97
|
+
...(location ? { location } : {}),
|
|
98
|
+
description,
|
|
99
|
+
...(code ? { provider_code: code } : {}),
|
|
100
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
101
|
+
};
|
|
102
|
+
return { key: JSON.stringify([wall, code, description, location]), wall, event };
|
|
103
|
+
}
|
|
104
|
+
/** The weight in kilograms the reply gives as a decimal string; none when it is missing or zero. */
|
|
105
|
+
function weight(data) {
|
|
106
|
+
const value = cleanScalar(data.weight, 16);
|
|
107
|
+
const kilograms = /^\d{1,5}(?:\.\d{1,3})?$/.test(value) ? Number(value) : 0;
|
|
108
|
+
return kilograms > 0 ? kilograms : null;
|
|
109
|
+
}
|
|
110
|
+
/** Whether a reply is the tracker's own answer for a number it has no record of. */
|
|
111
|
+
export function isCitylinkNotFound(payload) {
|
|
112
|
+
return isRecord(payload) && typeof payload.error === 'string' && /^no tracking data found\.?$/i.test(payload.error.trim());
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The reply of the tracking page's `/wp-json/wp/v2/getTracking` for one
|
|
116
|
+
* number: City-Link's answer in `req.data`, its scans oldest first. A number it
|
|
117
|
+
* has no record of is an HTTP 404 instead (see `isCitylinkNotFound`). The
|
|
118
|
+
* shipment must name the number asked. Only its number, weight and scans are
|
|
119
|
+
* read.
|
|
120
|
+
*/
|
|
121
|
+
export function parseCitylink(payload, raw) {
|
|
122
|
+
const number = normalizeCitylinkNumber(raw);
|
|
123
|
+
const request = isRecord(payload) ? payload.req : undefined;
|
|
124
|
+
if (!isRecord(request) || !Object.hasOwn(request, 'data'))
|
|
125
|
+
throw new SchemaError(PROVIDER, 'City-Link returned an invalid envelope');
|
|
126
|
+
if (request.status !== undefined && request.status !== 200)
|
|
127
|
+
throw new IndeterminateError(PROVIDER, 'City-Link did not complete the lookup');
|
|
128
|
+
const data = request.data;
|
|
129
|
+
if (!isRecord(data) || typeof data.returncode !== 'string')
|
|
130
|
+
throw new SchemaError(PROVIDER, 'City-Link returned a reply without its result code');
|
|
131
|
+
// The page reports any other code as no details found; the tracker's own
|
|
132
|
+
// answer for an unknown number is the 404, so another code is inconclusive.
|
|
133
|
+
if (data.returncode !== '00')
|
|
134
|
+
throw new IndeterminateError(PROVIDER, 'City-Link did not complete the lookup');
|
|
135
|
+
if (typeof data.trackingNo !== 'string')
|
|
136
|
+
throw new SchemaError(PROVIDER, 'City-Link returned a shipment without its number');
|
|
137
|
+
if (normalizeTrackingNumber(clean(data.trackingNo, 64)) !== number)
|
|
138
|
+
throw new IndeterminateError(PROVIDER, 'City-Link answered for another shipment');
|
|
139
|
+
const rows = data.trackDetails;
|
|
140
|
+
if (!Array.isArray(rows))
|
|
141
|
+
throw new SchemaError(PROVIDER, 'City-Link returned a shipment with invalid scans');
|
|
142
|
+
if (rows.length > MAX_ROWS)
|
|
143
|
+
throw new SchemaError(PROVIDER, 'City-Link returned an oversized history');
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const scans = [];
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
if (!isRecord(row))
|
|
148
|
+
throw new SchemaError(PROVIDER, 'City-Link returned an invalid scan');
|
|
149
|
+
const scan = projectRow(row);
|
|
150
|
+
if (seen.has(scan.key))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(scan.key);
|
|
153
|
+
scans.push(scan);
|
|
154
|
+
}
|
|
155
|
+
if (!scans.length)
|
|
156
|
+
throw new NoHistoryError(PROVIDER);
|
|
157
|
+
// Newest first, as the page lists them; it turns the reply's order round.
|
|
158
|
+
if (scans[0].wall < scans.at(-1).wall)
|
|
159
|
+
scans.reverse();
|
|
160
|
+
const events = scans.map((scan) => scan.event);
|
|
161
|
+
const newest = events[0];
|
|
162
|
+
const current = events.find((event) => event.stage);
|
|
163
|
+
const stage = current?.stage;
|
|
164
|
+
const deliveredAt = stage === 'delivered' ? current.time : undefined;
|
|
165
|
+
const kilograms = weight(data);
|
|
166
|
+
return {
|
|
167
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
168
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
169
|
+
last_status_text: newest.description,
|
|
170
|
+
last_update: newest.time ?? null,
|
|
171
|
+
...(newest.time ? {} : { last_update_local: newest.local_time }),
|
|
172
|
+
expected_delivery: null,
|
|
173
|
+
...(deliveredAt ? { delivered_at: deliveredAt } : {}),
|
|
174
|
+
...(kilograms ? { weight_kg: kilograms } : {}),
|
|
175
|
+
events: events.slice(0, MAX_EVENTS),
|
|
176
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage City-Link's own vocabulary gives a scan: its code, else its wording. */
|
|
4
|
+
export declare function citylinkMappedStage(code: string | null, wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: its code or wording, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function citylinkStage(code: string, wording: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|