universal-parcel-scraper 0.451.1-main.2245 → 0.453.0-main.2249
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 +4 -2
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +119 -0
- package/dist/browser/scraper.js +6 -2
- package/dist/carriers/m-and-p/adapter.d.ts +16 -0
- package/dist/carriers/m-and-p/adapter.js +48 -0
- package/dist/carriers/m-and-p/carrier.json +49 -7
- package/dist/carriers/m-and-p/numbers.json +25 -2
- package/dist/carriers/m-and-p/parser.d.ts +16 -0
- package/dist/carriers/m-and-p/parser.js +150 -0
- package/dist/carriers/m-and-p/status.d.ts +7 -0
- package/dist/carriers/m-and-p/status.js +62 -0
- package/dist/carriers/m-and-p/statuses.json +12 -2
- package/dist/carriers/postex/adapter.d.ts +15 -0
- package/dist/carriers/postex/adapter.js +57 -0
- package/dist/carriers/postex/carrier.json +42 -7
- package/dist/carriers/postex/numbers.json +131 -0
- package/dist/carriers/postex/parser.d.ts +15 -0
- package/dist/carriers/postex/parser.js +154 -0
- package/dist/carriers/postex/status.d.ts +7 -0
- package/dist/carriers/postex/status.js +32 -0
- package/dist/carriers/postex/statuses.json +13 -1
- package/dist/core/catalog/statusMaps.js +4 -2
- package/dist/data/catalog.json +4 -2
- package/dist/generated/catalog.d.ts +4 -2
- package/dist/generated/catalog.js +4 -2
- package/dist/generated/recognition.js +2 -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 **487 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>772 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 14,
|
|
158
|
+
The carrier folders record 14,875 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
|
@@ -19255,7 +19255,8 @@
|
|
|
19255
19255
|
"timezone": "Asia/Karachi",
|
|
19256
19256
|
"tracking": {
|
|
19257
19257
|
"mode": "automatic",
|
|
19258
|
-
"adapter": "
|
|
19258
|
+
"adapter": "m-and-p",
|
|
19259
|
+
"recognitionRank": 746
|
|
19259
19260
|
},
|
|
19260
19261
|
"canaryUrl": "https://www.mulphilog.com/",
|
|
19261
19262
|
"trackingUrlTemplate": "https://www.mulphilog.com/tracking/{trackingNumber}",
|
|
@@ -23673,7 +23674,8 @@
|
|
|
23673
23674
|
"timezone": "Asia/Karachi",
|
|
23674
23675
|
"tracking": {
|
|
23675
23676
|
"mode": "automatic",
|
|
23676
|
-
"adapter": "
|
|
23677
|
+
"adapter": "postex",
|
|
23678
|
+
"recognitionRank": 745
|
|
23677
23679
|
},
|
|
23678
23680
|
"canaryUrl": "https://postex.pk/tracking",
|
|
23679
23681
|
"trackingUrlTemplate": "https://postex.pk/tracking?cn={trackingNumber}",
|
|
@@ -11295,6 +11295,35 @@
|
|
|
11295
11295
|
"delhivery"
|
|
11296
11296
|
]
|
|
11297
11297
|
},
|
|
11298
|
+
{
|
|
11299
|
+
"input": "10388820331824",
|
|
11300
|
+
"carrier": "unknown",
|
|
11301
|
+
"confidence": "low",
|
|
11302
|
+
"candidates": [
|
|
11303
|
+
"dpd-fr",
|
|
11304
|
+
"dpd",
|
|
11305
|
+
"ciblex",
|
|
11306
|
+
"seur",
|
|
11307
|
+
"brt",
|
|
11308
|
+
"delhivery",
|
|
11309
|
+
"dhl-ecommerce-uk",
|
|
11310
|
+
"dpd-be",
|
|
11311
|
+
"dpd-de",
|
|
11312
|
+
"dpd-nl",
|
|
11313
|
+
"dpd-uk",
|
|
11314
|
+
"xpressbees"
|
|
11315
|
+
],
|
|
11316
|
+
"preferred": [
|
|
11317
|
+
"dpd-fr"
|
|
11318
|
+
],
|
|
11319
|
+
"asked": [
|
|
11320
|
+
"seur",
|
|
11321
|
+
"brt",
|
|
11322
|
+
"ciblex",
|
|
11323
|
+
"dhl-ecommerce-uk",
|
|
11324
|
+
"delhivery"
|
|
11325
|
+
]
|
|
11326
|
+
},
|
|
11298
11327
|
{
|
|
11299
11328
|
"input": "1040200044620",
|
|
11300
11329
|
"carrier": "unknown",
|
|
@@ -11937,6 +11966,35 @@
|
|
|
11937
11966
|
"dhl-ecommerce-es"
|
|
11938
11967
|
]
|
|
11939
11968
|
},
|
|
11969
|
+
{
|
|
11970
|
+
"input": "10740270012959",
|
|
11971
|
+
"carrier": "unknown",
|
|
11972
|
+
"confidence": "low",
|
|
11973
|
+
"candidates": [
|
|
11974
|
+
"dpd-fr",
|
|
11975
|
+
"dpd",
|
|
11976
|
+
"ciblex",
|
|
11977
|
+
"seur",
|
|
11978
|
+
"brt",
|
|
11979
|
+
"delhivery",
|
|
11980
|
+
"dhl-ecommerce-uk",
|
|
11981
|
+
"dpd-be",
|
|
11982
|
+
"dpd-de",
|
|
11983
|
+
"dpd-nl",
|
|
11984
|
+
"dpd-uk",
|
|
11985
|
+
"xpressbees"
|
|
11986
|
+
],
|
|
11987
|
+
"preferred": [
|
|
11988
|
+
"dpd-fr"
|
|
11989
|
+
],
|
|
11990
|
+
"asked": [
|
|
11991
|
+
"seur",
|
|
11992
|
+
"brt",
|
|
11993
|
+
"ciblex",
|
|
11994
|
+
"dhl-ecommerce-uk",
|
|
11995
|
+
"delhivery"
|
|
11996
|
+
]
|
|
11997
|
+
},
|
|
11940
11998
|
{
|
|
11941
11999
|
"input": "10764000276716",
|
|
11942
12000
|
"carrier": "unknown",
|
|
@@ -24336,6 +24394,31 @@
|
|
|
24336
24394
|
"colis-prive"
|
|
24337
24395
|
]
|
|
24338
24396
|
},
|
|
24397
|
+
{
|
|
24398
|
+
"input": "21294400207049",
|
|
24399
|
+
"carrier": "unknown",
|
|
24400
|
+
"confidence": "low",
|
|
24401
|
+
"candidates": [
|
|
24402
|
+
"dpd",
|
|
24403
|
+
"ciblex",
|
|
24404
|
+
"seur",
|
|
24405
|
+
"brt",
|
|
24406
|
+
"delhivery",
|
|
24407
|
+
"dhl-ecommerce-uk",
|
|
24408
|
+
"dpd-be",
|
|
24409
|
+
"dpd-de",
|
|
24410
|
+
"dpd-nl",
|
|
24411
|
+
"dpd-uk",
|
|
24412
|
+
"nova-poshta"
|
|
24413
|
+
],
|
|
24414
|
+
"asked": [
|
|
24415
|
+
"dpd-be",
|
|
24416
|
+
"dpd",
|
|
24417
|
+
"dpd-nl",
|
|
24418
|
+
"dpd-de",
|
|
24419
|
+
"seur"
|
|
24420
|
+
]
|
|
24421
|
+
},
|
|
24339
24422
|
{
|
|
24340
24423
|
"input": "2130900025704001IVC001BS",
|
|
24341
24424
|
"carrier": "nz-post",
|
|
@@ -26774,6 +26857,30 @@
|
|
|
26774
26857
|
"heppner"
|
|
26775
26858
|
]
|
|
26776
26859
|
},
|
|
26860
|
+
{
|
|
26861
|
+
"input": "24579690103460",
|
|
26862
|
+
"carrier": "unknown",
|
|
26863
|
+
"confidence": "low",
|
|
26864
|
+
"candidates": [
|
|
26865
|
+
"dpd",
|
|
26866
|
+
"ciblex",
|
|
26867
|
+
"seur",
|
|
26868
|
+
"brt",
|
|
26869
|
+
"delhivery",
|
|
26870
|
+
"dhl-ecommerce-uk",
|
|
26871
|
+
"dpd-be",
|
|
26872
|
+
"dpd-de",
|
|
26873
|
+
"dpd-nl",
|
|
26874
|
+
"dpd-uk"
|
|
26875
|
+
],
|
|
26876
|
+
"asked": [
|
|
26877
|
+
"dpd-be",
|
|
26878
|
+
"dpd",
|
|
26879
|
+
"dpd-nl",
|
|
26880
|
+
"dpd-de",
|
|
26881
|
+
"seur"
|
|
26882
|
+
]
|
|
26883
|
+
},
|
|
26777
26884
|
{
|
|
26778
26885
|
"input": "24622447660SE",
|
|
26779
26886
|
"carrier": "postnord",
|
|
@@ -44061,6 +44168,18 @@
|
|
|
44061
44168
|
"confidence": "none",
|
|
44062
44169
|
"candidates": []
|
|
44063
44170
|
},
|
|
44171
|
+
{
|
|
44172
|
+
"input": "556656610052013",
|
|
44173
|
+
"carrier": "unknown",
|
|
44174
|
+
"confidence": "low",
|
|
44175
|
+
"candidates": [
|
|
44176
|
+
"brt",
|
|
44177
|
+
"yunda"
|
|
44178
|
+
],
|
|
44179
|
+
"asked": [
|
|
44180
|
+
"brt"
|
|
44181
|
+
]
|
|
44182
|
+
},
|
|
44064
44183
|
{
|
|
44065
44184
|
"input": "558 265 735",
|
|
44066
44185
|
"carrier": "unknown",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -14670,7 +14670,8 @@ var ze = {
|
|
|
14670
14670
|
timezone: "Asia/Karachi",
|
|
14671
14671
|
tracking: {
|
|
14672
14672
|
mode: "automatic",
|
|
14673
|
-
adapter: "
|
|
14673
|
+
adapter: "m-and-p",
|
|
14674
|
+
recognitionRank: 746
|
|
14674
14675
|
},
|
|
14675
14676
|
canaryUrl: "https://www.mulphilog.com/",
|
|
14676
14677
|
trackingUrlTemplate: "https://www.mulphilog.com/tracking/{trackingNumber}",
|
|
@@ -17926,7 +17927,8 @@ var ze = {
|
|
|
17926
17927
|
timezone: "Asia/Karachi",
|
|
17927
17928
|
tracking: {
|
|
17928
17929
|
mode: "automatic",
|
|
17929
|
-
adapter: "
|
|
17930
|
+
adapter: "postex",
|
|
17931
|
+
recognitionRank: 745
|
|
17930
17932
|
},
|
|
17931
17933
|
canaryUrl: "https://postex.pk/tracking",
|
|
17932
17934
|
trackingUrlTemplate: "https://postex.pk/tracking?cn={trackingNumber}",
|
|
@@ -29571,6 +29573,7 @@ var xl = {
|
|
|
29571
29573
|
logistiq: 768,
|
|
29572
29574
|
logoix: 869,
|
|
29573
29575
|
"loomis-express": 285,
|
|
29576
|
+
"m-and-p": 746,
|
|
29574
29577
|
"m-one": 825,
|
|
29575
29578
|
mailamericas: 985,
|
|
29576
29579
|
mainfreight: 923,
|
|
@@ -29622,6 +29625,7 @@ var xl = {
|
|
|
29622
29625
|
"posta-slovenije": 130,
|
|
29623
29626
|
"posta-srbije": 110,
|
|
29624
29627
|
"poste-italiane": 900,
|
|
29628
|
+
postex: 745,
|
|
29625
29629
|
posti: 710,
|
|
29626
29630
|
postlogistics: 700,
|
|
29627
29631
|
postnord: 510,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The tracker's page, which the server renders for the consignment number that ends its path. */
|
|
5
|
+
export declare const M_AND_P_TRACKING = "https://www.mulphilog.com/tracking/";
|
|
6
|
+
export declare class MAndPTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
});
|
|
13
|
+
private read;
|
|
14
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
15
|
+
}
|
|
16
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { 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 { M_AND_P_MAX_BYTES, normalizeMAndPNumber, parseMAndP, PROVIDER } from './parser.js';
|
|
8
|
+
/** The tracker's page, which the server renders for the consignment number that ends its path. */
|
|
9
|
+
export const M_AND_P_TRACKING = 'https://www.mulphilog.com/tracking/';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
const GONE = [404, 410];
|
|
12
|
+
const DEFAULT_BUDGET_MS = 20_000;
|
|
13
|
+
export class MAndPTracker {
|
|
14
|
+
options;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async read(number, signal, timeoutMs) {
|
|
19
|
+
const { response, bytes } = await fetchBounded(`${M_AND_P_TRACKING}${number}`, { method: 'GET', signal,
|
|
20
|
+
headers: { Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent) } }, { provider: PROVIDER,
|
|
21
|
+
timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: M_AND_P_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
|
|
22
|
+
allowHttpStatuses: [...REDIRECTS, ...GONE] });
|
|
23
|
+
// The tracker answers a known and an unknown number alike with HTTP 200.
|
|
24
|
+
if (REDIRECTS.includes(response.status))
|
|
25
|
+
throw new TransportError(PROVIDER, 'M&P redirected its tracker', { status: response.status });
|
|
26
|
+
if (GONE.includes(response.status))
|
|
27
|
+
throw new TransportError(PROVIDER, 'M&P tracker is unavailable', { status: response.status });
|
|
28
|
+
return parseMAndP(decodeText(bytes), number);
|
|
29
|
+
}
|
|
30
|
+
async fetch(raw, context = {}) {
|
|
31
|
+
const number = normalizeMAndPNumber(raw);
|
|
32
|
+
// A read that fails to reach M&P, or hangs, is sent once more: the first
|
|
33
|
+
// try gets half of the budget.
|
|
34
|
+
return runSteps({ carrier: 'm-and-p', budgetMs: context.budgetMs ?? DEFAULT_BUDGET_MS, signal: context.signal,
|
|
35
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
36
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => this.read(number, signal, firstAttemptMs(remainingMs)) },
|
|
37
|
+
networkRetryStep(({ signal, remainingMs }) => this.read(number, signal, remainingMs)),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export const adapter = (environment) => {
|
|
42
|
+
const tracker = new MAndPTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
43
|
+
return {
|
|
44
|
+
id: 'm-and-p', recordsSteps: true, steps: ['direct', 'retry'],
|
|
45
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
46
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeMAndPNumber(number))),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
@@ -19,18 +19,60 @@
|
|
|
19
19
|
"timezone": "Asia/Karachi",
|
|
20
20
|
"tracking": {
|
|
21
21
|
"mode": "automatic",
|
|
22
|
-
"adapter": "
|
|
23
|
-
"steps": [
|
|
22
|
+
"adapter": "m-and-p",
|
|
23
|
+
"steps": [
|
|
24
|
+
"direct",
|
|
25
|
+
"retry"
|
|
26
|
+
],
|
|
27
|
+
"recognition": {
|
|
28
|
+
"rank": 746
|
|
29
|
+
}
|
|
24
30
|
},
|
|
25
|
-
"capabilities": [
|
|
31
|
+
"capabilities": [
|
|
32
|
+
"history",
|
|
33
|
+
"location",
|
|
34
|
+
"delivered_at",
|
|
35
|
+
"offset_timestamps"
|
|
36
|
+
],
|
|
26
37
|
"portal": {
|
|
27
38
|
"url": "https://www.mulphilog.com/tracking/{trackingNumber}",
|
|
28
39
|
"searchUrl": "https://www.mulphilog.com/",
|
|
29
40
|
"canaryUrl": "https://www.mulphilog.com/",
|
|
30
|
-
"shows": [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
"shows": [
|
|
42
|
+
"status",
|
|
43
|
+
"history",
|
|
44
|
+
"location",
|
|
45
|
+
"origin",
|
|
46
|
+
"destination",
|
|
47
|
+
"sender_name",
|
|
48
|
+
"recipient_name",
|
|
49
|
+
"customer_reference",
|
|
50
|
+
"booking_details",
|
|
51
|
+
"delivered_at",
|
|
52
|
+
"signatory"
|
|
53
|
+
],
|
|
54
|
+
"scraped": [
|
|
55
|
+
"status",
|
|
56
|
+
"history",
|
|
57
|
+
"location",
|
|
58
|
+
"delivered_at",
|
|
59
|
+
"offset_timestamps"
|
|
60
|
+
],
|
|
61
|
+
"discarded": [
|
|
62
|
+
"origin",
|
|
63
|
+
"destination",
|
|
64
|
+
"sender_name",
|
|
65
|
+
"recipient_name",
|
|
66
|
+
"customer_reference",
|
|
67
|
+
"booking_details",
|
|
68
|
+
"signatory"
|
|
69
|
+
],
|
|
70
|
+
"unavailable": [
|
|
71
|
+
"eta",
|
|
72
|
+
"service_name",
|
|
73
|
+
"weight",
|
|
74
|
+
"proof_of_delivery"
|
|
75
|
+
]
|
|
34
76
|
},
|
|
35
77
|
"links": [
|
|
36
78
|
{
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
"access": "full_page"
|
|
170
170
|
},
|
|
171
171
|
"context": {
|
|
172
|
-
"assessment": "
|
|
172
|
+
"assessment": "confirmed"
|
|
173
173
|
},
|
|
174
174
|
"expect": {
|
|
175
175
|
"carrier": "unknown",
|
|
@@ -189,7 +189,30 @@
|
|
|
189
189
|
"metropolitan-warehouse"
|
|
190
190
|
]
|
|
191
191
|
},
|
|
192
|
-
"note": "A customer's Trustpilot review of M&P (mulphilog.com) gives it as the tracking ID of the parcel the review is about."
|
|
192
|
+
"note": "A customer's Trustpilot review of M&P (mulphilog.com) gives it as the tracking ID of the parcel the review is about; M&P's tracker shows the consignment delivered with its scan history."
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"number": "556656610052013",
|
|
196
|
+
"role": "shipment",
|
|
197
|
+
"evidence": "public_shipment_report",
|
|
198
|
+
"source": {
|
|
199
|
+
"url": "https://www.trustpilot.com/review/mulphilog.com",
|
|
200
|
+
"date": "2026-05-12",
|
|
201
|
+
"dateKind": "publication",
|
|
202
|
+
"access": "full_page"
|
|
203
|
+
},
|
|
204
|
+
"context": {
|
|
205
|
+
"assessment": "confirmed"
|
|
206
|
+
},
|
|
207
|
+
"expect": {
|
|
208
|
+
"carrier": "unknown",
|
|
209
|
+
"confidence": "low",
|
|
210
|
+
"candidates": [
|
|
211
|
+
"brt",
|
|
212
|
+
"yunda"
|
|
213
|
+
]
|
|
214
|
+
},
|
|
215
|
+
"note": "Customers' Trustpilot reviews of M&P (mulphilog.com) give it as the order ID of their complaint; M&P's tracker shows the consignment delivered with its scan history."
|
|
193
216
|
}
|
|
194
217
|
]
|
|
195
218
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "M&P";
|
|
3
|
+
export declare const M_AND_P_MAX_BYTES = 1000000;
|
|
4
|
+
/** An M&P consignment number: twelve to fifteen digits. */
|
|
5
|
+
export declare function normalizeMAndPNumber(raw: string): string;
|
|
6
|
+
/** A scan's sentence without who took the parcel, phone numbers or an amount to collect. */
|
|
7
|
+
export declare function mAndPSentence(text: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The tracker's page for one consignment, which the server renders: a form
|
|
10
|
+
* with the number as asked, the booking date, the shipper and consignee with
|
|
11
|
+
* their cities, then the scans newest first, each with a date, a clock, a
|
|
12
|
+
* status label, the city and a sentence. A consignment the tracker has no
|
|
13
|
+
* record of gets its notice instead of scans. The booking details, names,
|
|
14
|
+
* cities and the order reference are not read, nor who took the parcel.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseMAndP(html: string, rawNumber: string): CarrierResult;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { firstOccurrence } from '../../core/time/index.js';
|
|
7
|
+
import { clean } from '../../core/transport/index.js';
|
|
8
|
+
import { mAndPStage } from './status.js';
|
|
9
|
+
export const PROVIDER = 'M&P';
|
|
10
|
+
// A page is about 250 kB, most of it menus, script and a chat widget.
|
|
11
|
+
export const M_AND_P_MAX_BYTES = 1_000_000;
|
|
12
|
+
// Consignment numbers seen are twelve or fifteen digits.
|
|
13
|
+
const NUMBER = /^\d{12,15}$/;
|
|
14
|
+
const ZONE = 'Asia/Karachi';
|
|
15
|
+
// The day, the month's short name, the year and a twelve-hour clock, as "04 Jun 2026 05:14 pm".
|
|
16
|
+
const CLOCK = /^(\d{1,2}) ([A-Za-z]{3}) (\d{4}) (\d{1,2}):(\d{2}) ([AaPp][Mm])$/;
|
|
17
|
+
const NOT_FOUND = /^No tracking record found with the provided consignment number\.?$/i;
|
|
18
|
+
// A firewall's or bot check's page, read only on a page without the tracker's
|
|
19
|
+
// result area. The site's quote form draws its own captcha, so the word alone
|
|
20
|
+
// is no block.
|
|
21
|
+
const BLOCKED = /<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected|403 Forbidden)|_cf_chl_opt|cf-turnstile|g-recaptcha|hcaptcha|_Incapsula_Resource|AwsWafIntegration|awswaf\.com/i;
|
|
22
|
+
// Who took a delivered parcel, from where the sentence names them to its end.
|
|
23
|
+
const RECEIVER = /[\s,]*(?:\band\s+)?(?:has been\s+)?\b(?:rec(?:ie|ei)ved|signed|collected)\s+by\b.*$/i;
|
|
24
|
+
// A run of ten or more digits with spaces or hyphens, as phone numbers are written.
|
|
25
|
+
const PHONE = /(?<![A-Z\d])\+?\d(?:[\s-]?\d){9,}(?![A-Z\d])/gi;
|
|
26
|
+
// An amount to collect in rupees.
|
|
27
|
+
const AMOUNT = /\b(?:Rs\.?|PKR)\s*\d[\d,]*(?:\.\d+)?/gi;
|
|
28
|
+
// Stands for a line break while a sentence's text is read.
|
|
29
|
+
const BREAK = '\u2029';
|
|
30
|
+
const MAX_ROWS = 200;
|
|
31
|
+
const MAX_EVENTS = 100;
|
|
32
|
+
/** An M&P consignment number: twelve to fifteen digits. */
|
|
33
|
+
export function normalizeMAndPNumber(raw) {
|
|
34
|
+
const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
35
|
+
if (!NUMBER.test(number))
|
|
36
|
+
throw new InvalidInputError(PROVIDER, 'M&P consignment numbers are twelve to fifteen digits');
|
|
37
|
+
return number;
|
|
38
|
+
}
|
|
39
|
+
/** A scan's sentence without who took the parcel, phone numbers or an amount to collect. */
|
|
40
|
+
export function mAndPSentence(text) {
|
|
41
|
+
return clean(text.replace(RECEIVER, '').replace(PHONE, '').replace(AMOUNT, ''), 300)
|
|
42
|
+
.replace(/\s+([,.;:])/g, '$1').replace(/[\s,;:]+$/, '');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The text of a node, a line break read as the end of a sentence: a failed
|
|
46
|
+
* attempt's reason is followed by a line on the next try.
|
|
47
|
+
*/
|
|
48
|
+
function sentences(node) {
|
|
49
|
+
const copy = node.clone();
|
|
50
|
+
copy.find('br').replaceWith(BREAK);
|
|
51
|
+
return copy.text().split(BREAK).map((line) => clean(line, 300)).filter(Boolean)
|
|
52
|
+
.map((line, index, lines) => (index < lines.length - 1 && !/[.!?:]$/.test(line) ? `${line}.` : line)).join(' ');
|
|
53
|
+
}
|
|
54
|
+
function unexpected(html, message) {
|
|
55
|
+
if (BLOCKED.test(html.slice(0, 64_000)))
|
|
56
|
+
throw new ChallengeError(PROVIDER, 'M&P answered with a browser check');
|
|
57
|
+
throw new SchemaError(PROVIDER, message);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A wall clock to the minute without an offset, read in Karachi time: M&P's
|
|
61
|
+
* network is in Pakistan, which keeps one offset without daylight saving.
|
|
62
|
+
*/
|
|
63
|
+
function clock(text) {
|
|
64
|
+
const match = CLOCK.exec(text);
|
|
65
|
+
const written = match ? `${match[1].padStart(2, '0')} ${match[2]} ${match[3]} ${match[4].padStart(2, '0')}:${match[5]} ${match[6].toUpperCase()}` : '';
|
|
66
|
+
// UTC only checks the calendar fields.
|
|
67
|
+
const checked = DateTime.fromFormat(written, 'dd LLL yyyy hh:mm a', { zone: 'UTC', locale: 'en-US' });
|
|
68
|
+
if (!checked.isValid || checked.toFormat('dd LLL yyyy hh:mm a', { locale: 'en-US' }) !== written) {
|
|
69
|
+
throw new SchemaError(PROVIDER, 'M&P returned an invalid scan time');
|
|
70
|
+
}
|
|
71
|
+
const zoned = firstOccurrence(DateTime.fromISO(checked.toFormat("yyyy-MM-dd'T'HH:mm"), { zone: ZONE }));
|
|
72
|
+
return { time: zoned.toISO({ suppressMilliseconds: true }), at: zoned.toMillis() };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The tracker's page for one consignment, which the server renders: a form
|
|
76
|
+
* with the number as asked, the booking date, the shipper and consignee with
|
|
77
|
+
* their cities, then the scans newest first, each with a date, a clock, a
|
|
78
|
+
* status label, the city and a sentence. A consignment the tracker has no
|
|
79
|
+
* record of gets its notice instead of scans. The booking details, names,
|
|
80
|
+
* cities and the order reference are not read, nor who took the parcel.
|
|
81
|
+
*/
|
|
82
|
+
export function parseMAndP(html, rawNumber) {
|
|
83
|
+
const number = normalizeMAndPNumber(rawNumber);
|
|
84
|
+
const $ = load(html);
|
|
85
|
+
const section = $('#orderTrackSection .order-track');
|
|
86
|
+
const asked = $('form#trackingForm input#consignmentInput');
|
|
87
|
+
if (section.length !== 1 || asked.length !== 1)
|
|
88
|
+
unexpected(html, 'M&P returned a page without its tracking result');
|
|
89
|
+
// The form shows the number the page was opened for.
|
|
90
|
+
const shown = clean(asked.attr('value'), 64);
|
|
91
|
+
if (!shown)
|
|
92
|
+
throw new SchemaError(PROVIDER, 'M&P returned a result without its number');
|
|
93
|
+
if (shown !== number)
|
|
94
|
+
throw new IndeterminateError(PROVIDER, 'M&P answered for another consignment');
|
|
95
|
+
const steps = section.children('.order-track-step');
|
|
96
|
+
if (!steps.length) {
|
|
97
|
+
if (section.find('p').toArray().some((node) => NOT_FOUND.test(clean($(node).text(), 200))))
|
|
98
|
+
throw new NotFoundError(PROVIDER);
|
|
99
|
+
if (clean(section.text(), 200))
|
|
100
|
+
throw new SchemaError(PROVIDER, 'M&P returned a result it did not explain');
|
|
101
|
+
throw new NoHistoryError(PROVIDER);
|
|
102
|
+
}
|
|
103
|
+
if (steps.length > MAX_ROWS)
|
|
104
|
+
throw new SchemaError(PROVIDER, 'M&P returned an oversized history');
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
const scans = [];
|
|
107
|
+
steps.each((order, node) => {
|
|
108
|
+
const step = $(node);
|
|
109
|
+
const date = step.find('.order-track-text-sub');
|
|
110
|
+
const label = step.find('.order-track-text-stat.status');
|
|
111
|
+
const place = step.find('.order-track-text-stat.location');
|
|
112
|
+
const message = step.find('.order-track-text-stat.status-message');
|
|
113
|
+
if (date.length !== 1 || label.length !== 1 || place.length > 1 || message.length > 1) {
|
|
114
|
+
throw new SchemaError(PROVIDER, 'M&P returned an invalid scan');
|
|
115
|
+
}
|
|
116
|
+
const when = clock(clean(date.text(), 40));
|
|
117
|
+
const status = clean(label.text(), 80);
|
|
118
|
+
const sentence = message.length ? mAndPSentence(sentences(message)) : '';
|
|
119
|
+
const description = sentence || status;
|
|
120
|
+
if (!description)
|
|
121
|
+
throw new SchemaError(PROVIDER, 'M&P returned a scan without wording');
|
|
122
|
+
const location = clean(place.text(), 120);
|
|
123
|
+
const staged = mAndPStage(status, sentence);
|
|
124
|
+
const event = {
|
|
125
|
+
time: when.time, description, ...(location ? { location } : {}),
|
|
126
|
+
...(status ? { provider_code: status } : {}),
|
|
127
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
128
|
+
};
|
|
129
|
+
const identity = JSON.stringify([event.time, status, description, location]);
|
|
130
|
+
if (seen.has(identity))
|
|
131
|
+
return;
|
|
132
|
+
seen.add(identity);
|
|
133
|
+
scans.push({ event, at: when.at, order });
|
|
134
|
+
});
|
|
135
|
+
// Newest first; scans at the same minute keep the page's order, which is newest first.
|
|
136
|
+
const events = scans.sort((a, b) => b.at - a.at || a.order - b.order).map(({ event }) => event);
|
|
137
|
+
const newest = events[0];
|
|
138
|
+
const current = events.find((event) => event.stage);
|
|
139
|
+
const stage = current?.stage;
|
|
140
|
+
return {
|
|
141
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
142
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
143
|
+
last_status_text: newest.description,
|
|
144
|
+
last_update: newest.time,
|
|
145
|
+
expected_delivery: null,
|
|
146
|
+
...(stage === 'delivered' ? { delivered_at: current.time } : {}),
|
|
147
|
+
events: events.slice(0, MAX_EVENTS),
|
|
148
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -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 the map gives a label or sentence, read case-insensitively. */
|
|
4
|
+
export declare function mAndPMappedStage(wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: its label's, else its sentence's, else the shared wording rules', else nothing. */
|
|
6
|
+
export declare function mAndPStage(label: string, sentence: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tracker's status labels and the fixed sentences under them, current and
|
|
5
|
+
* from the earlier page, whose table had a status and a details column and a
|
|
6
|
+
* CN Status summary. A sentence that goes on with a name or a reason is read
|
|
7
|
+
* by its label.
|
|
8
|
+
*/
|
|
9
|
+
const WORDINGS = new Map([
|
|
10
|
+
['Booked', 'registered'],
|
|
11
|
+
['Your order has been placed. It will be packed and prepared for shipment.', 'registered'],
|
|
12
|
+
['Booking', 'registered'],
|
|
13
|
+
['Shipment is booked.', 'registered'],
|
|
14
|
+
// Taken in at the origin hub.
|
|
15
|
+
['Arrived at OPS Facility', 'accepted'],
|
|
16
|
+
['The shipment has been arrived at M&P operations at the origin hub and will be scheduled for transit', 'accepted'],
|
|
17
|
+
['Arrived at OPS', 'accepted'],
|
|
18
|
+
['Shipment has arrived at origin facility.', 'accepted'],
|
|
19
|
+
['In-transit', 'in_transit'],
|
|
20
|
+
['The shipment has been dispatched and is on its way to the destination city', 'in_transit'],
|
|
21
|
+
['Loading', 'in_transit'],
|
|
22
|
+
['Shipment has departed.', 'in_transit'],
|
|
23
|
+
['LOADED', 'in_transit'],
|
|
24
|
+
['Reached at Destination', 'in_transit'],
|
|
25
|
+
['The shipment has been arrived at the destination city and will be scheduled for delivery', 'in_transit'],
|
|
26
|
+
['Unloading', 'in_transit'],
|
|
27
|
+
['Shipment has arrived at hub.', 'in_transit'],
|
|
28
|
+
['Out-for-Delivery', 'out_for_delivery'],
|
|
29
|
+
['On Delivery', 'out_for_delivery'],
|
|
30
|
+
['Shipment is Out for Delivery.', 'out_for_delivery'],
|
|
31
|
+
['Delivered', 'delivered'],
|
|
32
|
+
['Shipment has been Delivered.', 'delivered'],
|
|
33
|
+
['Unsuccessful Delivery Attempt', 'failed_attempt'],
|
|
34
|
+
['Refused Delivery', 'failed_attempt'],
|
|
35
|
+
['Shipment is undelivered as delivery was refused by consignee.', 'failed_attempt'],
|
|
36
|
+
// Delivery stopped and the parcel is to go back to the shipper.
|
|
37
|
+
['Ready to Return', 'exception'],
|
|
38
|
+
['Shipment is undelivered due to Ready to Return.', 'exception'],
|
|
39
|
+
['Return to Shipper', 'returned'],
|
|
40
|
+
['Shipment has been Returned to Shipper.', 'returned'],
|
|
41
|
+
['RETURNED', 'returned'],
|
|
42
|
+
].map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
|
|
43
|
+
/** The stage the map gives a label or sentence, read case-insensitively. */
|
|
44
|
+
export function mAndPMappedStage(wording) {
|
|
45
|
+
return WORDINGS.get(normalizeStatusWording(wording));
|
|
46
|
+
}
|
|
47
|
+
/** A scan's stage: its label's, else its sentence's, else the shared wording rules', else nothing. */
|
|
48
|
+
export function mAndPStage(label, sentence) {
|
|
49
|
+
const mapped = (label ? mAndPMappedStage(label) : undefined) ?? (sentence ? mAndPMappedStage(sentence) : undefined);
|
|
50
|
+
if (mapped)
|
|
51
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
52
|
+
for (const wording of [label, sentence]) {
|
|
53
|
+
const worded = wording ? classifyWording(wording, 'in_transit') : null;
|
|
54
|
+
if (worded && worded.source !== 'none')
|
|
55
|
+
return worded;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
export const statusMap = {
|
|
60
|
+
stage: (code, wording) => (code ? mAndPMappedStage(code) : undefined) ?? mAndPMappedStage(wording),
|
|
61
|
+
gaps: [],
|
|
62
|
+
};
|