universal-parcel-scraper 0.181.0 → 0.182.0-main.1318
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 +8 -3
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +34 -0
- package/dist/browser/scraper.js +11 -3
- package/dist/carriers/couriersplease/adapter.d.ts +17 -0
- package/dist/carriers/couriersplease/adapter.js +111 -0
- package/dist/carriers/couriersplease/carrier.json +51 -7
- package/dist/carriers/couriersplease/parser.d.ts +22 -0
- package/dist/carriers/couriersplease/parser.js +222 -0
- package/dist/carriers/couriersplease/status.d.ts +7 -0
- package/dist/carriers/couriersplease/status.js +96 -0
- package/dist/carriers/couriersplease/statuses.json +21 -18
- package/dist/carriers/leopards-courier/adapter.d.ts +13 -0
- package/dist/carriers/leopards-courier/adapter.js +73 -0
- package/dist/carriers/leopards-courier/carrier.json +52 -7
- package/dist/carriers/leopards-courier/numbers.json +6 -8
- package/dist/carriers/leopards-courier/parser.d.ts +26 -0
- package/dist/carriers/leopards-courier/parser.js +190 -0
- package/dist/carriers/leopards-courier/status.d.ts +7 -0
- package/dist/carriers/leopards-courier/status.js +34 -0
- package/dist/carriers/leopards-courier/statuses.json +4 -1
- package/dist/carriers/pos-indonesia/adapter.d.ts +14 -0
- package/dist/carriers/pos-indonesia/adapter.js +74 -0
- package/dist/carriers/pos-indonesia/carrier.json +53 -7
- package/dist/carriers/pos-indonesia/numbers.json +52 -14
- package/dist/carriers/pos-indonesia/parser.d.ts +25 -0
- package/dist/carriers/pos-indonesia/parser.js +201 -0
- package/dist/carriers/pos-indonesia/status.d.ts +12 -0
- package/dist/carriers/pos-indonesia/status.js +41 -0
- package/dist/carriers/pos-indonesia/statuses.json +7 -0
- package/dist/core/catalog/statusMaps.js +10 -4
- package/dist/data/catalog.json +8 -3
- package/dist/generated/catalog.d.ts +8 -3
- package/dist/generated/catalog.js +8 -3
- package/dist/generated/recognition.js +3 -0
- package/dist/generated/registry.js +9 -3
- 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 **259 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>494 catalog entries · 113 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 9,
|
|
158
|
+
The carrier folders record 9,595 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
|
@@ -7692,7 +7692,9 @@
|
|
|
7692
7692
|
"timezone": "Australia/Sydney",
|
|
7693
7693
|
"tracking": {
|
|
7694
7694
|
"mode": "automatic",
|
|
7695
|
-
"adapter": "
|
|
7695
|
+
"adapter": "couriersplease",
|
|
7696
|
+
"recognitionRank": 961,
|
|
7697
|
+
"localClocks": true
|
|
7696
7698
|
},
|
|
7697
7699
|
"canaryUrl": "https://www.couriersplease.com.au/tools-track",
|
|
7698
7700
|
"trackingUrlTemplate": "https://www.couriersplease.com.au/tools-track?no={trackingNumber}",
|
|
@@ -12685,7 +12687,8 @@
|
|
|
12685
12687
|
"timezone": "Asia/Karachi",
|
|
12686
12688
|
"tracking": {
|
|
12687
12689
|
"mode": "automatic",
|
|
12688
|
-
"adapter": "
|
|
12690
|
+
"adapter": "leopards-courier",
|
|
12691
|
+
"recognitionRank": 965
|
|
12689
12692
|
},
|
|
12690
12693
|
"canaryUrl": "https://pk.leopardscourier.com/",
|
|
12691
12694
|
"trackingSearchUrl": "https://pk.leopardscourier.com/",
|
|
@@ -15151,7 +15154,9 @@
|
|
|
15151
15154
|
"timezone": "Asia/Jakarta",
|
|
15152
15155
|
"tracking": {
|
|
15153
15156
|
"mode": "automatic",
|
|
15154
|
-
"adapter": "
|
|
15157
|
+
"adapter": "pos-indonesia",
|
|
15158
|
+
"recognitionRank": 964,
|
|
15159
|
+
"localClocks": true
|
|
15155
15160
|
},
|
|
15156
15161
|
"canaryUrl": "https://www.posindonesia.co.id/id/tracking",
|
|
15157
15162
|
"trackingUrlTemplate": "https://www.posindonesia.co.id/id/tracking/{trackingNumber}",
|
|
@@ -51340,6 +51340,17 @@
|
|
|
51340
51340
|
"chronopost"
|
|
51341
51341
|
]
|
|
51342
51342
|
},
|
|
51343
|
+
{
|
|
51344
|
+
"input": "EE205187475BN",
|
|
51345
|
+
"carrier": "intl-post",
|
|
51346
|
+
"confidence": "high",
|
|
51347
|
+
"candidates": [
|
|
51348
|
+
"intl-post"
|
|
51349
|
+
],
|
|
51350
|
+
"asked": [
|
|
51351
|
+
"chronopost"
|
|
51352
|
+
]
|
|
51353
|
+
},
|
|
51343
51354
|
{
|
|
51344
51355
|
"input": "EE212938475ID",
|
|
51345
51356
|
"carrier": "intl-post",
|
|
@@ -57016,6 +57027,14 @@
|
|
|
57016
57027
|
"four-px"
|
|
57017
57028
|
]
|
|
57018
57029
|
},
|
|
57030
|
+
{
|
|
57031
|
+
"input": "LP107221675JP",
|
|
57032
|
+
"carrier": "japan-post",
|
|
57033
|
+
"confidence": "high",
|
|
57034
|
+
"candidates": [
|
|
57035
|
+
"japan-post"
|
|
57036
|
+
]
|
|
57037
|
+
},
|
|
57019
57038
|
{
|
|
57020
57039
|
"input": "LP157079155HK",
|
|
57021
57040
|
"carrier": "hongkong-post",
|
|
@@ -59769,6 +59788,9 @@
|
|
|
59769
59788
|
"confidence": "low",
|
|
59770
59789
|
"candidates": [
|
|
59771
59790
|
"pos-indonesia"
|
|
59791
|
+
],
|
|
59792
|
+
"asked": [
|
|
59793
|
+
"pos-indonesia"
|
|
59772
59794
|
]
|
|
59773
59795
|
},
|
|
59774
59796
|
{
|
|
@@ -59794,6 +59816,9 @@
|
|
|
59794
59816
|
"confidence": "low",
|
|
59795
59817
|
"candidates": [
|
|
59796
59818
|
"pos-indonesia"
|
|
59819
|
+
],
|
|
59820
|
+
"asked": [
|
|
59821
|
+
"pos-indonesia"
|
|
59797
59822
|
]
|
|
59798
59823
|
},
|
|
59799
59824
|
{
|
|
@@ -59802,6 +59827,9 @@
|
|
|
59802
59827
|
"confidence": "low",
|
|
59803
59828
|
"candidates": [
|
|
59804
59829
|
"pos-indonesia"
|
|
59830
|
+
],
|
|
59831
|
+
"asked": [
|
|
59832
|
+
"pos-indonesia"
|
|
59805
59833
|
]
|
|
59806
59834
|
},
|
|
59807
59835
|
{
|
|
@@ -59810,6 +59838,9 @@
|
|
|
59810
59838
|
"confidence": "low",
|
|
59811
59839
|
"candidates": [
|
|
59812
59840
|
"pos-indonesia"
|
|
59841
|
+
],
|
|
59842
|
+
"asked": [
|
|
59843
|
+
"pos-indonesia"
|
|
59813
59844
|
]
|
|
59814
59845
|
},
|
|
59815
59846
|
{
|
|
@@ -67982,6 +68013,9 @@
|
|
|
67982
68013
|
"confidence": "low",
|
|
67983
68014
|
"candidates": [
|
|
67984
68015
|
"pos-indonesia"
|
|
68016
|
+
],
|
|
68017
|
+
"asked": [
|
|
68018
|
+
"pos-indonesia"
|
|
67985
68019
|
]
|
|
67986
68020
|
},
|
|
67987
68021
|
{
|
package/dist/browser/scraper.js
CHANGED
|
@@ -6050,7 +6050,9 @@ var Ae = {
|
|
|
6050
6050
|
timezone: "Australia/Sydney",
|
|
6051
6051
|
tracking: {
|
|
6052
6052
|
mode: "automatic",
|
|
6053
|
-
adapter: "
|
|
6053
|
+
adapter: "couriersplease",
|
|
6054
|
+
recognitionRank: 961,
|
|
6055
|
+
localClocks: !0
|
|
6054
6056
|
},
|
|
6055
6057
|
canaryUrl: "https://www.couriersplease.com.au/tools-track",
|
|
6056
6058
|
trackingUrlTemplate: "https://www.couriersplease.com.au/tools-track?no={trackingNumber}",
|
|
@@ -9749,7 +9751,8 @@ var Ae = {
|
|
|
9749
9751
|
timezone: "Asia/Karachi",
|
|
9750
9752
|
tracking: {
|
|
9751
9753
|
mode: "automatic",
|
|
9752
|
-
adapter: "
|
|
9754
|
+
adapter: "leopards-courier",
|
|
9755
|
+
recognitionRank: 965
|
|
9753
9756
|
},
|
|
9754
9757
|
canaryUrl: "https://pk.leopardscourier.com/",
|
|
9755
9758
|
trackingSearchUrl: "https://pk.leopardscourier.com/",
|
|
@@ -11567,7 +11570,9 @@ var Ae = {
|
|
|
11567
11570
|
timezone: "Asia/Jakarta",
|
|
11568
11571
|
tracking: {
|
|
11569
11572
|
mode: "automatic",
|
|
11570
|
-
adapter: "
|
|
11573
|
+
adapter: "pos-indonesia",
|
|
11574
|
+
recognitionRank: 964,
|
|
11575
|
+
localClocks: !0
|
|
11571
11576
|
},
|
|
11572
11577
|
canaryUrl: "https://www.posindonesia.co.id/id/tracking",
|
|
11573
11578
|
trackingUrlTemplate: "https://www.posindonesia.co.id/id/tracking/{trackingNumber}",
|
|
@@ -21041,6 +21046,7 @@ var nl = {
|
|
|
21041
21046
|
"correo-argentino": 978,
|
|
21042
21047
|
"correos-de-mexico": 30,
|
|
21043
21048
|
"correos-express": 800,
|
|
21049
|
+
couriersplease: 961,
|
|
21044
21050
|
"ctt-express": 790,
|
|
21045
21051
|
"cyprus-post": 60,
|
|
21046
21052
|
"dai-post": 62,
|
|
@@ -21100,6 +21106,7 @@ var nl = {
|
|
|
21100
21106
|
"kuehne-nagel": 435,
|
|
21101
21107
|
"la-poste": 1e3,
|
|
21102
21108
|
"latvijas-pasts": 100,
|
|
21109
|
+
"leopards-courier": 965,
|
|
21103
21110
|
"lietuvos-pastas": 895,
|
|
21104
21111
|
logen: 48,
|
|
21105
21112
|
"loomis-express": 285,
|
|
@@ -21124,6 +21131,7 @@ var nl = {
|
|
|
21124
21131
|
paquetexpress: 35,
|
|
21125
21132
|
passport: 55,
|
|
21126
21133
|
"poczta-polska": 750,
|
|
21134
|
+
"pos-indonesia": 964,
|
|
21127
21135
|
"post-luxembourg": 80,
|
|
21128
21136
|
"posta-slovenije": 130,
|
|
21129
21137
|
"posta-srbije": 110,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The tracking page, which opens the session its lookup needs. */
|
|
5
|
+
export declare const COURIERSPLEASE_PAGE = "https://www.couriersplease.com.au/tools-track";
|
|
6
|
+
/** The lookup the tracking page makes for the numbers typed in. */
|
|
7
|
+
export declare const COURIERSPLEASE_API = "https://www.couriersplease.com.au/api/track/locate";
|
|
8
|
+
export declare class CouriersPleaseTracker {
|
|
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,111 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, IndeterminateError, RateLimitedError, 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 { COURIERSPLEASE_MAX_BYTES, couriersPleaseToken, normalizeCouriersPleaseNumber, parseCouriersPlease, PROVIDER } from './parser.js';
|
|
8
|
+
/** The tracking page, which opens the session its lookup needs. */
|
|
9
|
+
export const COURIERSPLEASE_PAGE = 'https://www.couriersplease.com.au/tools-track';
|
|
10
|
+
/** The lookup the tracking page makes for the numbers typed in. */
|
|
11
|
+
export const COURIERSPLEASE_API = 'https://www.couriersplease.com.au/api/track/locate';
|
|
12
|
+
const ORIGIN = 'https://www.couriersplease.com.au';
|
|
13
|
+
const PAGE_MAX_BYTES = 512 * 1024;
|
|
14
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
15
|
+
const GONE = [404, 410];
|
|
16
|
+
// Azure Front Door's own page when its firewall refuses a request: a 429 for
|
|
17
|
+
// too many requests at once, a 403 for a blocked client.
|
|
18
|
+
const BLOCKED = /<title>\s*Service unavailable\s*<\/title>|The request is blocked\./i;
|
|
19
|
+
// Bot checks a CDN could put in place of the page or the reply.
|
|
20
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|challenge-platform/i;
|
|
21
|
+
// The session cookie the page sets beside its token.
|
|
22
|
+
const SESSION = /^__Host-csrf=[A-Za-z0-9._~+/=-]{8,512}$/;
|
|
23
|
+
function retryAfterMs(response) {
|
|
24
|
+
const value = response.headers.get('retry-after')?.trim() ?? '';
|
|
25
|
+
return /^\d{1,5}$/.test(value) ? Number(value) * 1000 : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** A refusal before any reply: a rate limit, a firewall block or a moved route. */
|
|
28
|
+
function refusal(response, body, stage) {
|
|
29
|
+
if (response.status === 429) {
|
|
30
|
+
throw new RateLimitedError(PROVIDER, retryAfterMs(response), `CouriersPlease rate limited its ${stage}`, { status: 429 });
|
|
31
|
+
}
|
|
32
|
+
if (REDIRECTS.includes(response.status) || GONE.includes(response.status)) {
|
|
33
|
+
throw new TransportError(PROVIDER, `CouriersPlease ${stage} is unavailable`, { status: response.status });
|
|
34
|
+
}
|
|
35
|
+
if (BLOCKED.test(body) || CHALLENGE.test(body))
|
|
36
|
+
throw new ChallengeError(PROVIDER, `CouriersPlease blocked its ${stage}`, { status: response.status });
|
|
37
|
+
throw new ChallengeError(PROVIDER, `CouriersPlease refused its ${stage}`, { status: response.status });
|
|
38
|
+
}
|
|
39
|
+
function json(body) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(body);
|
|
42
|
+
}
|
|
43
|
+
catch (cause) {
|
|
44
|
+
if (BLOCKED.test(body.slice(0, 64_000)) || CHALLENGE.test(body.slice(0, 64_000))) {
|
|
45
|
+
throw new ChallengeError(PROVIDER, 'CouriersPlease answered with a block page', { cause });
|
|
46
|
+
}
|
|
47
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned a reply that is not JSON', { cause });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class CouriersPleaseTracker {
|
|
51
|
+
options;
|
|
52
|
+
constructor(options = {}) {
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
async fetch(raw, context = {}) {
|
|
56
|
+
const number = normalizeCouriersPleaseNumber(raw);
|
|
57
|
+
const userAgent = userAgentOf(this.options.userAgent);
|
|
58
|
+
const read = async (signal, timeoutMs) => {
|
|
59
|
+
// The page and the lookup share the attempt's time.
|
|
60
|
+
const deadline = performance.now() + timeoutMs;
|
|
61
|
+
const left = () => Math.max(1, Math.floor(deadline - performance.now()));
|
|
62
|
+
const page = await fetchBounded(COURIERSPLEASE_PAGE, { method: 'GET', signal, headers: {
|
|
63
|
+
Accept: 'text/html,application/xhtml+xml', 'User-Agent': userAgent,
|
|
64
|
+
} }, { provider: PROVIDER, timeoutMs: left(), maxBytes: PAGE_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
|
|
65
|
+
allowHttpStatuses: [...REDIRECTS, ...GONE, 403, 429] });
|
|
66
|
+
const html = decodeText(page.bytes);
|
|
67
|
+
if (page.response.status !== 200)
|
|
68
|
+
refusal(page.response, html.slice(0, 64_000), 'tracking page');
|
|
69
|
+
if (BLOCKED.test(html.slice(0, 64_000)) || CHALLENGE.test(html.slice(0, 64_000))) {
|
|
70
|
+
throw new ChallengeError(PROVIDER, 'CouriersPlease answered the tracking page with a block page');
|
|
71
|
+
}
|
|
72
|
+
// The lookup takes the page's token with the cookie it was minted for.
|
|
73
|
+
const token = couriersPleaseToken(html);
|
|
74
|
+
const cookie = page.response.headers.getSetCookie().map((header) => header.split(';', 1)[0].trim()).find((part) => SESSION.test(part));
|
|
75
|
+
if (!token || !cookie)
|
|
76
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease tracking page opened no session');
|
|
77
|
+
const { response, bytes } = await fetchBounded(COURIERSPLEASE_API, { method: 'POST', signal, headers: {
|
|
78
|
+
Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': userAgent, Origin: ORIGIN,
|
|
79
|
+
// The service answers only requests its own pages make, as browsers mark them.
|
|
80
|
+
'Sec-Fetch-Site': 'same-origin', 'csrf-token': token, Cookie: cookie,
|
|
81
|
+
}, body: JSON.stringify({ trackingCode: number }) }, { provider: PROVIDER, timeoutMs: left(), maxBytes: COURIERSPLEASE_MAX_BYTES,
|
|
82
|
+
redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, ...GONE, 400, 403, 429] });
|
|
83
|
+
const body = decodeText(bytes);
|
|
84
|
+
if (response.status === 400 || response.status === 403) {
|
|
85
|
+
// The session the page just opened was refused: a fresh one is the only remedy.
|
|
86
|
+
if (/CSRF/i.test(body.slice(0, 4_000)))
|
|
87
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease refused the tracking page’s session');
|
|
88
|
+
if (response.status === 400 && !BLOCKED.test(body.slice(0, 4_000)))
|
|
89
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease did not take the lookup');
|
|
90
|
+
}
|
|
91
|
+
if (response.status !== 200)
|
|
92
|
+
refusal(response, body.slice(0, 64_000), 'tracking lookup');
|
|
93
|
+
return parseCouriersPlease(json(body), number);
|
|
94
|
+
};
|
|
95
|
+
// A page or lookup that fails to reach CouriersPlease, or hangs, gets one
|
|
96
|
+
// more try with a new session: the first gets half of the budget.
|
|
97
|
+
return runSteps({ carrier: 'couriersplease', budgetMs: context.budgetMs ?? 20_000, signal: context.signal,
|
|
98
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
99
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
100
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export const adapter = (environment) => {
|
|
105
|
+
const tracker = new CouriersPleaseTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
106
|
+
return {
|
|
107
|
+
id: 'couriersplease', recordsSteps: true, steps: ['direct', 'retry'],
|
|
108
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
109
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeCouriersPleaseNumber(number))),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -17,18 +17,62 @@
|
|
|
17
17
|
"timezone": "Australia/Sydney",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "couriersplease",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 961
|
|
27
|
+
},
|
|
28
|
+
"localClocks": true
|
|
22
29
|
},
|
|
23
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history",
|
|
32
|
+
"location",
|
|
33
|
+
"provider_code",
|
|
34
|
+
"eta"
|
|
35
|
+
],
|
|
24
36
|
"portal": {
|
|
25
37
|
"url": "https://www.couriersplease.com.au/tools-track?no={trackingNumber}",
|
|
26
38
|
"searchUrl": "https://www.couriersplease.com.au/tools-track",
|
|
27
39
|
"canaryUrl": "https://www.couriersplease.com.au/tools-track",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
"shows": [
|
|
41
|
+
"status",
|
|
42
|
+
"history",
|
|
43
|
+
"location",
|
|
44
|
+
"provider_code",
|
|
45
|
+
"eta",
|
|
46
|
+
"delivered_at",
|
|
47
|
+
"parcel_count",
|
|
48
|
+
"weight",
|
|
49
|
+
"volume",
|
|
50
|
+
"customer_reference",
|
|
51
|
+
"recipient_name",
|
|
52
|
+
"driver_name",
|
|
53
|
+
"depot_code"
|
|
54
|
+
],
|
|
55
|
+
"scraped": [
|
|
56
|
+
"status",
|
|
57
|
+
"history",
|
|
58
|
+
"location",
|
|
59
|
+
"provider_code",
|
|
60
|
+
"eta"
|
|
61
|
+
],
|
|
62
|
+
"discarded": [
|
|
63
|
+
"delivered_at",
|
|
64
|
+
"parcel_count",
|
|
65
|
+
"weight",
|
|
66
|
+
"volume",
|
|
67
|
+
"customer_reference",
|
|
68
|
+
"recipient_name",
|
|
69
|
+
"driver_name",
|
|
70
|
+
"depot_code"
|
|
71
|
+
],
|
|
72
|
+
"unavailable": [
|
|
73
|
+
"service_name",
|
|
74
|
+
"offset_timestamps"
|
|
75
|
+
]
|
|
32
76
|
},
|
|
33
77
|
"links": [
|
|
34
78
|
{
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "CouriersPlease";
|
|
3
|
+
export declare const COURIERSPLEASE_MAX_BYTES: number;
|
|
4
|
+
/** A number for CouriersPlease's tracker, in upper case; anything else is not one it issues. */
|
|
5
|
+
export declare function normalizeCouriersPleaseNumber(raw: string): string;
|
|
6
|
+
/** The session token the tracking page puts in its `csrf-token` meta tag, or null. */
|
|
7
|
+
export declare function couriersPleaseToken(html: string): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* A scan's wording without its markup. A line break becomes the end of a
|
|
10
|
+
* sentence, as on the page, and a sentence that points to a link, such as
|
|
11
|
+
* "Please contact our Customer Support Team here", is left out.
|
|
12
|
+
*/
|
|
13
|
+
export declare function couriersPleaseWording(raw: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* The reply of the tracking page's `/api/track/locate` for one number. Its one
|
|
16
|
+
* entry holds the consignment's details, when the number is a consignment, and
|
|
17
|
+
* each item's rows, the first naming the item. A consignment must be the
|
|
18
|
+
* number asked, or an item must: a label or coupon has no consignment details.
|
|
19
|
+
* An unknown number gets no rows ("No scan events found", or an empty list),
|
|
20
|
+
* which the page reports as not a valid tracking number.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseCouriersPlease(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,222 @@
|
|
|
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 { clean, textFromHtml } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { couriersPleaseStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'CouriersPlease';
|
|
9
|
+
// A consignment of several items is a few kilobytes per item.
|
|
10
|
+
export const COURIERSPLEASE_MAX_BYTES = 512 * 1024;
|
|
11
|
+
// Consignments, item labels, prepaid coupons and the SSCCs some senders print:
|
|
12
|
+
// letters and digits, with at least one digit.
|
|
13
|
+
const NUMBER = /^(?=[A-Z]*\d)[A-Z0-9]{8,30}$/;
|
|
14
|
+
// The tracking page's session token: "salt==:encrypted".
|
|
15
|
+
const TOKEN = /<meta\s+name="csrf-token"\s+content="([A-Za-z0-9+/=_:-]{16,512})"/;
|
|
16
|
+
const MAX_ITEMS = 100;
|
|
17
|
+
const MAX_ROWS = 500;
|
|
18
|
+
const MAX_EVENTS = 100;
|
|
19
|
+
/** A number for CouriersPlease's tracker, in upper case; anything else is not one it issues. */
|
|
20
|
+
export function normalizeCouriersPleaseNumber(raw) {
|
|
21
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
|
|
22
|
+
if (!NUMBER.test(number))
|
|
23
|
+
throw new InvalidInputError(PROVIDER, 'CouriersPlease numbers are eight to thirty letters and digits');
|
|
24
|
+
return number;
|
|
25
|
+
}
|
|
26
|
+
/** The session token the tracking page puts in its `csrf-token` meta tag, or null. */
|
|
27
|
+
export function couriersPleaseToken(html) {
|
|
28
|
+
return TOKEN.exec(html.slice(0, 200_000))?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
31
|
+
function text(row, key, maxLength = 500) {
|
|
32
|
+
const value = row[key];
|
|
33
|
+
if (value === null || value === undefined)
|
|
34
|
+
return '';
|
|
35
|
+
if (typeof value !== 'string')
|
|
36
|
+
throw new SchemaError(PROVIDER, `CouriersPlease returned an invalid ${key}`);
|
|
37
|
+
return clean(value, maxLength);
|
|
38
|
+
}
|
|
39
|
+
const LINK = /<a\b[^>]*>([\s\S]*?)<\/a\s*>/gi;
|
|
40
|
+
const BREAK = /\s*<br\s*\/?>\s*/gi;
|
|
41
|
+
const MARK = '\u0000';
|
|
42
|
+
/**
|
|
43
|
+
* A scan's wording without its markup. A line break becomes the end of a
|
|
44
|
+
* sentence, as on the page, and a sentence that points to a link, such as
|
|
45
|
+
* "Please contact our Customer Support Team here", is left out.
|
|
46
|
+
*/
|
|
47
|
+
export function couriersPleaseWording(raw) {
|
|
48
|
+
// A link's text keeps a mark, so that its sentence can be told apart.
|
|
49
|
+
const marked = textFromHtml(raw.slice(0, 2_000).replace(LINK, ` ${MARK}$1 `).replace(BREAK, '. '), 1_000);
|
|
50
|
+
const kept = marked.split(/(?<=\.)\s+/).filter((sentence) => !sentence.includes(MARK)).join(' ');
|
|
51
|
+
const wording = kept || marked.replaceAll(MARK, '');
|
|
52
|
+
return clean(wording.replace(/\.(?:\s*\.)+/g, '.').replace(/\s+([,.])/g, '$1')).replace(/^[\s,;:.\-–]+|[\s,;:\-–.]+$/g, '');
|
|
53
|
+
}
|
|
54
|
+
const DATE = /^\d{1,2} [A-Z][a-z]{2} \d{4}$/;
|
|
55
|
+
const CLOCK = /^(?:1[0-2]|0?[1-9]):[0-5]\d [ap]m$/i;
|
|
56
|
+
/** A scan's `date` ("23 Mar 2026") and `time` ("5:00 pm"), a wall clock to the minute. */
|
|
57
|
+
function wallClock(date, time) {
|
|
58
|
+
const parsed = DATE.test(date) && CLOCK.test(time)
|
|
59
|
+
// Parsing in UTC checks the calendar only; the wall clock keeps no offset.
|
|
60
|
+
? DateTime.fromFormat(`${date} ${time}`, 'd MMM yyyy h:mm a', { zone: 'UTC', locale: 'en-US' }) : null;
|
|
61
|
+
if (!parsed?.isValid)
|
|
62
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid scan time');
|
|
63
|
+
return parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss");
|
|
64
|
+
}
|
|
65
|
+
/** The consignment's labelled facts: `time` names the field and `action` holds its value. */
|
|
66
|
+
function facts(rows) {
|
|
67
|
+
if (!Array.isArray(rows) || rows.length > MAX_ROWS)
|
|
68
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid consignment details');
|
|
69
|
+
const found = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!isRecord(row))
|
|
72
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid consignment details');
|
|
73
|
+
const label = text(row, 'time', 60);
|
|
74
|
+
if (label && !found.has(label))
|
|
75
|
+
found.set(label, text(row, 'action', 300));
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/** Each item's rows, or null for an item the tracker has no rows for. */
|
|
80
|
+
function items(entry) {
|
|
81
|
+
const list = entry.trakingInfo;
|
|
82
|
+
if (!Array.isArray(list) || list.length > MAX_ITEMS)
|
|
83
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid items');
|
|
84
|
+
return list.map((item) => {
|
|
85
|
+
if (!isRecord(item) || !isRecord(item.tracker))
|
|
86
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid item');
|
|
87
|
+
const track = item.tracker.track;
|
|
88
|
+
if (track === null || track === undefined)
|
|
89
|
+
return null;
|
|
90
|
+
if (!Array.isArray(track) || track.length > MAX_ROWS)
|
|
91
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid item history');
|
|
92
|
+
if (track.some((row) => !isRecord(row)))
|
|
93
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid scan');
|
|
94
|
+
return track;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** The item number an item's first row names ("CPPL TRACKER", "SSCC TRACKER" or a coupon's product), or ''. */
|
|
98
|
+
function itemNumber(rows) {
|
|
99
|
+
const [first] = rows;
|
|
100
|
+
if (!first || first.action !== null || first.nodeType !== null)
|
|
101
|
+
return '';
|
|
102
|
+
return text(first, 'date', 64).toUpperCase();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* An item's scans, as the page lists them: rows with wording, without the
|
|
106
|
+
* first row naming the item, the page's own "Overdue" marker, a sender's
|
|
107
|
+
* notice that the parcel was despatched to CouriersPlease, or the shipment
|
|
108
|
+
* details a sender sends after the pickup.
|
|
109
|
+
*/
|
|
110
|
+
function itemScans(rows, order) {
|
|
111
|
+
const scans = [];
|
|
112
|
+
let pickedUp = false;
|
|
113
|
+
for (const row of itemNumber(rows) ? rows.slice(1) : rows) {
|
|
114
|
+
const code = text(row, 'nodeType', 40).toLowerCase();
|
|
115
|
+
if (code === 'pickup')
|
|
116
|
+
pickedUp = true;
|
|
117
|
+
const date = text(row, 'date', 32);
|
|
118
|
+
const action = row.action;
|
|
119
|
+
if (action !== null && action !== undefined && typeof action !== 'string')
|
|
120
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid action');
|
|
121
|
+
const description = typeof action === 'string' ? couriersPleaseWording(action) : '';
|
|
122
|
+
if (!date || !description || /^overdue$/i.test(description))
|
|
123
|
+
continue;
|
|
124
|
+
if (code === 'manifest' && pickedUp)
|
|
125
|
+
continue;
|
|
126
|
+
if (code === 'status' && /despatched to couriersplease/i.test(description))
|
|
127
|
+
continue;
|
|
128
|
+
const wall = wallClock(date, text(row, 'time', 16));
|
|
129
|
+
const location = text(row, 'stateName', 60);
|
|
130
|
+
const providerCode = /^[a-z][a-z-]{0,39}$/.test(code) ? code : '';
|
|
131
|
+
const staged = couriersPleaseStage(providerCode, description);
|
|
132
|
+
const event = {
|
|
133
|
+
local_time: wall,
|
|
134
|
+
...(location ? { location } : {}),
|
|
135
|
+
description,
|
|
136
|
+
...(providerCode ? { provider_code: providerCode } : {}),
|
|
137
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
138
|
+
};
|
|
139
|
+
scans.push({ event, key: JSON.stringify([wall, providerCode, description, location]), wall, order: order.next++ });
|
|
140
|
+
}
|
|
141
|
+
return scans;
|
|
142
|
+
}
|
|
143
|
+
/** The estimate the page shows, "26 Mar 2026", as a calendar day. */
|
|
144
|
+
function estimate(value) {
|
|
145
|
+
if (!value || !DATE.test(value))
|
|
146
|
+
return null;
|
|
147
|
+
const parsed = DateTime.fromFormat(value, 'd MMM yyyy', { zone: 'UTC', locale: 'en-US' });
|
|
148
|
+
return parsed.isValid ? parsed.toISODate() : null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The reply of the tracking page's `/api/track/locate` for one number. Its one
|
|
152
|
+
* entry holds the consignment's details, when the number is a consignment, and
|
|
153
|
+
* each item's rows, the first naming the item. A consignment must be the
|
|
154
|
+
* number asked, or an item must: a label or coupon has no consignment details.
|
|
155
|
+
* An unknown number gets no rows ("No scan events found", or an empty list),
|
|
156
|
+
* which the page reports as not a valid tracking number.
|
|
157
|
+
*/
|
|
158
|
+
export function parseCouriersPlease(payload, raw) {
|
|
159
|
+
const number = normalizeCouriersPleaseNumber(raw);
|
|
160
|
+
if (!isRecord(payload) || !Object.hasOwn(payload, 'data'))
|
|
161
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid envelope');
|
|
162
|
+
if (payload.data === null) {
|
|
163
|
+
// The service's refusal of the request itself, worded in `msg`.
|
|
164
|
+
if (typeof payload.msg === 'string' && payload.msg)
|
|
165
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease did not take the lookup');
|
|
166
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned a reply without data');
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(payload.data) || !Array.isArray(payload.data.root))
|
|
169
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid result list');
|
|
170
|
+
if (payload.data.root.length !== 1 || !isRecord(payload.data.root[0])) {
|
|
171
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease answered for another number of shipments');
|
|
172
|
+
}
|
|
173
|
+
const entry = payload.data.root[0];
|
|
174
|
+
if (!isRecord(entry.statusInfo))
|
|
175
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned a result without its status');
|
|
176
|
+
const details = facts(entry.consignmentInfo ?? []);
|
|
177
|
+
const tracks = items(entry);
|
|
178
|
+
const consignment = (details.get('Consignment #') ?? '').toUpperCase();
|
|
179
|
+
const listed = tracks.flatMap((rows) => rows?.length ? [rows] : []);
|
|
180
|
+
if (!listed.length) {
|
|
181
|
+
if (consignment === number)
|
|
182
|
+
throw new NoHistoryError(PROVIDER);
|
|
183
|
+
throw new NotFoundError(PROVIDER);
|
|
184
|
+
}
|
|
185
|
+
if (consignment !== number && !listed.some((rows) => itemNumber(rows) === number)) {
|
|
186
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease answered for another shipment');
|
|
187
|
+
}
|
|
188
|
+
const order = { next: 0 };
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
const scans = [];
|
|
191
|
+
for (const rows of listed) {
|
|
192
|
+
for (const scan of itemScans(rows, order)) {
|
|
193
|
+
// An item of several repeats the consignment's own scans.
|
|
194
|
+
if (seen.has(scan.key))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(scan.key);
|
|
197
|
+
scans.push(scan);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!scans.length)
|
|
201
|
+
throw new NoHistoryError(PROVIDER);
|
|
202
|
+
// Newest first; each item lists its scans oldest first.
|
|
203
|
+
scans.sort((a, b) => (a.wall === b.wall ? b.order - a.order : a.wall < b.wall ? 1 : -1));
|
|
204
|
+
const events = scans.map((scan) => scan.event);
|
|
205
|
+
const newest = events[0];
|
|
206
|
+
const current = events.find((event) => event.stage);
|
|
207
|
+
const stage = current?.stage;
|
|
208
|
+
// The page shows the estimate as still to be confirmed while the sender's
|
|
209
|
+
// details are all there is.
|
|
210
|
+
const announced = events.every((event) => event.provider_code === 'manifest');
|
|
211
|
+
const expected = stage === 'delivered' || stage === 'returned' || announced ? null : estimate(details.get('Delivery ETA'));
|
|
212
|
+
return {
|
|
213
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
214
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
215
|
+
last_status_text: newest.description,
|
|
216
|
+
last_update: null,
|
|
217
|
+
last_update_local: newest.local_time,
|
|
218
|
+
expected_delivery: expected,
|
|
219
|
+
events: events.slice(0, MAX_EVENTS),
|
|
220
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -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 CouriersPlease's vocabulary gives a row's wording, else its kind. */
|
|
4
|
+
export declare function couriersPleaseMappedStage(code: string | null, wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: its wording or kind, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function couriersPleaseStage(code: string, wording: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|