universal-parcel-scraper 0.413.0-main.2132 → 0.415.0-main.2136
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 +6 -3
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +8 -3
- package/dist/carriers/labaih/adapter.d.ts +15 -0
- package/dist/carriers/labaih/adapter.js +59 -0
- package/dist/carriers/labaih/carrier.json +48 -8
- package/dist/carriers/labaih/parser.d.ts +23 -0
- package/dist/carriers/labaih/parser.js +127 -0
- package/dist/carriers/labaih/status.d.ts +8 -0
- package/dist/carriers/labaih/status.js +54 -0
- package/dist/carriers/labaih/statuses.json +14 -2
- package/dist/carriers/shipblu/adapter.d.ts +15 -0
- package/dist/carriers/shipblu/adapter.js +76 -0
- package/dist/carriers/shipblu/carrier.json +58 -7
- package/dist/carriers/shipblu/parser.d.ts +19 -0
- package/dist/carriers/shipblu/parser.js +174 -0
- package/dist/carriers/shipblu/status.d.ts +9 -0
- package/dist/carriers/shipblu/status.js +52 -0
- package/dist/carriers/shipblu/statuses.json +26 -21
- package/dist/core/catalog/statusMaps.js +4 -2
- package/dist/data/catalog.json +6 -3
- package/dist/generated/catalog.d.ts +6 -3
- package/dist/generated/catalog.js +6 -3
- 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 **450 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>760 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,300 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
|
@@ -18174,7 +18174,9 @@
|
|
|
18174
18174
|
"timezone": "Asia/Riyadh",
|
|
18175
18175
|
"tracking": {
|
|
18176
18176
|
"mode": "automatic",
|
|
18177
|
-
"adapter": "
|
|
18177
|
+
"adapter": "labaih",
|
|
18178
|
+
"recognitionRank": 788,
|
|
18179
|
+
"localClocks": true
|
|
18178
18180
|
},
|
|
18179
18181
|
"canaryUrl": "https://mylabaih.com/",
|
|
18180
18182
|
"trackingUrlTemplate": "https://track.mylabaih.com/track/?cn={trackingNumber}",
|
|
@@ -18184,7 +18186,7 @@
|
|
|
18184
18186
|
"domains": [
|
|
18185
18187
|
"track.mylabaih.com"
|
|
18186
18188
|
],
|
|
18187
|
-
"pathPattern": "^/track
|
|
18189
|
+
"pathPattern": "^/track(?:/en)?/?$",
|
|
18188
18190
|
"params": [
|
|
18189
18191
|
"cn"
|
|
18190
18192
|
]
|
|
@@ -25350,7 +25352,8 @@
|
|
|
25350
25352
|
"timezone": "Africa/Cairo",
|
|
25351
25353
|
"tracking": {
|
|
25352
25354
|
"mode": "automatic",
|
|
25353
|
-
"adapter": "
|
|
25355
|
+
"adapter": "shipblu",
|
|
25356
|
+
"recognitionRank": 789
|
|
25354
25357
|
},
|
|
25355
25358
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25356
25359
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -13879,14 +13879,16 @@ var Re = {
|
|
|
13879
13879
|
timezone: "Asia/Riyadh",
|
|
13880
13880
|
tracking: {
|
|
13881
13881
|
mode: "automatic",
|
|
13882
|
-
adapter: "
|
|
13882
|
+
adapter: "labaih",
|
|
13883
|
+
recognitionRank: 788,
|
|
13884
|
+
localClocks: !0
|
|
13883
13885
|
},
|
|
13884
13886
|
canaryUrl: "https://mylabaih.com/",
|
|
13885
13887
|
trackingUrlTemplate: "https://track.mylabaih.com/track/?cn={trackingNumber}",
|
|
13886
13888
|
trackingSearchUrl: "https://mylabaih.com/",
|
|
13887
13889
|
linkRules: [{
|
|
13888
13890
|
domains: ["track.mylabaih.com"],
|
|
13889
|
-
pathPattern: "^/track
|
|
13891
|
+
pathPattern: "^/track(?:/en)?/?$",
|
|
13890
13892
|
params: ["cn"]
|
|
13891
13893
|
}],
|
|
13892
13894
|
detectionRules: []
|
|
@@ -19162,7 +19164,8 @@ var Re = {
|
|
|
19162
19164
|
timezone: "Africa/Cairo",
|
|
19163
19165
|
tracking: {
|
|
19164
19166
|
mode: "automatic",
|
|
19165
|
-
adapter: "
|
|
19167
|
+
adapter: "shipblu",
|
|
19168
|
+
recognitionRank: 789
|
|
19166
19169
|
},
|
|
19167
19170
|
canaryUrl: "https://go.shipblu.com/en/",
|
|
19168
19171
|
trackingUrlTemplate: "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -29141,6 +29144,7 @@ var _l = {
|
|
|
29141
29144
|
"kuehne-nagel": 435,
|
|
29142
29145
|
"l-post": 808,
|
|
29143
29146
|
"la-poste": 1e3,
|
|
29147
|
+
labaih: 788,
|
|
29144
29148
|
"latvijas-pasts": 100,
|
|
29145
29149
|
"leopards-courier": 965,
|
|
29146
29150
|
"lietuvos-pastas": 895,
|
|
@@ -29226,6 +29230,7 @@ var _l = {
|
|
|
29226
29230
|
seur: 870,
|
|
29227
29231
|
sfc: 955,
|
|
29228
29232
|
shadowfax: 595,
|
|
29233
|
+
shipblu: 789,
|
|
29229
29234
|
shipentegra: 833,
|
|
29230
29235
|
"shree-maruti": 822,
|
|
29231
29236
|
"skroutz-last-mile": 913,
|
|
@@ -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 tracker's English page; the Arabic one that links open leaves some scans out. */
|
|
5
|
+
export declare const LABAIH_TRACKING = "https://track.mylabaih.com/track/en/?cn=";
|
|
6
|
+
export declare class LabaihTracker {
|
|
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,59 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { NotFoundError, 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 { LABAIH_MAX_BYTES, normalizeLabaihNumber, parseLabaih, PROVIDER } from './parser.js';
|
|
8
|
+
/** The tracker's English page; the Arabic one that links open leaves some scans out. */
|
|
9
|
+
export const LABAIH_TRACKING = 'https://track.mylabaih.com/track/en/?cn=';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
/** The tracker's redirect for a number it does not have, to its "Shipment(s) Not Found" page. */
|
|
12
|
+
function missing(response, url) {
|
|
13
|
+
const location = response.headers.get('location');
|
|
14
|
+
if (!location)
|
|
15
|
+
return false;
|
|
16
|
+
try {
|
|
17
|
+
const target = new URL(location, url);
|
|
18
|
+
return target.hostname === 'track.mylabaih.com' && /^\/errors?\/404\/?$/.test(target.pathname);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class LabaihTracker {
|
|
25
|
+
options;
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
async fetch(raw, context = {}) {
|
|
30
|
+
const number = normalizeLabaihNumber(raw);
|
|
31
|
+
const url = `${LABAIH_TRACKING}${number}`;
|
|
32
|
+
const read = async (signal, timeoutMs) => {
|
|
33
|
+
const { response, bytes } = await fetchBounded(url, { signal, headers: {
|
|
34
|
+
Accept: 'text/html', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
35
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: LABAIH_MAX_BYTES, redirect: 'manual',
|
|
36
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
37
|
+
if (REDIRECTS.includes(response.status) && missing(response, url))
|
|
38
|
+
throw new NotFoundError(PROVIDER);
|
|
39
|
+
// Any other redirect, or a 404, is a moved page.
|
|
40
|
+
if (response.status !== 200)
|
|
41
|
+
throw new TransportError(PROVIDER, 'LaBaih tracking page moved', { status: response.status });
|
|
42
|
+
return parseLabaih(decodeText(bytes), number);
|
|
43
|
+
};
|
|
44
|
+
// A read that fails to reach the site, or hangs, gets one more: the first gets half of the budget.
|
|
45
|
+
return runSteps({ carrier: 'labaih', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
46
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
47
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
48
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const adapter = (environment) => {
|
|
53
|
+
const tracker = new LabaihTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
54
|
+
return {
|
|
55
|
+
id: 'labaih', recordsSteps: true, steps: ['direct', 'retry'],
|
|
56
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
57
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeLabaihNumber(number))),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -17,25 +17,65 @@
|
|
|
17
17
|
"timezone": "Asia/Riyadh",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "labaih",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 788
|
|
27
|
+
},
|
|
28
|
+
"localClocks": true
|
|
22
29
|
},
|
|
23
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history",
|
|
32
|
+
"location"
|
|
33
|
+
],
|
|
24
34
|
"portal": {
|
|
25
35
|
"url": "https://track.mylabaih.com/track/?cn={trackingNumber}",
|
|
26
36
|
"searchUrl": "https://mylabaih.com/",
|
|
27
37
|
"canaryUrl": "https://mylabaih.com/",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
"shows": [
|
|
39
|
+
"status",
|
|
40
|
+
"progress_rail",
|
|
41
|
+
"history",
|
|
42
|
+
"location",
|
|
43
|
+
"sender_name",
|
|
44
|
+
"order_contents",
|
|
45
|
+
"customer_reference",
|
|
46
|
+
"recipient_address"
|
|
47
|
+
],
|
|
48
|
+
"scraped": [
|
|
49
|
+
"status",
|
|
50
|
+
"history",
|
|
51
|
+
"location"
|
|
52
|
+
],
|
|
53
|
+
"discarded": [
|
|
54
|
+
"progress_rail",
|
|
55
|
+
"sender_name",
|
|
56
|
+
"order_contents",
|
|
57
|
+
"customer_reference",
|
|
58
|
+
"recipient_address"
|
|
59
|
+
],
|
|
60
|
+
"unavailable": [
|
|
61
|
+
"eta",
|
|
62
|
+
"weight",
|
|
63
|
+
"service_name",
|
|
64
|
+
"provider_code",
|
|
65
|
+
"recipient_name",
|
|
66
|
+
"proof_of_delivery",
|
|
67
|
+
"delivered_at",
|
|
68
|
+
"offset_timestamps",
|
|
69
|
+
"delivery_partner",
|
|
70
|
+
"delivery_tracking_number"
|
|
71
|
+
]
|
|
32
72
|
},
|
|
33
73
|
"links": [
|
|
34
74
|
{
|
|
35
75
|
"domains": [
|
|
36
76
|
"track.mylabaih.com"
|
|
37
77
|
],
|
|
38
|
-
"pathPattern": "^/track
|
|
78
|
+
"pathPattern": "^/track(?:/en)?/?$",
|
|
39
79
|
"params": [
|
|
40
80
|
"cn"
|
|
41
81
|
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "LaBaih";
|
|
3
|
+
export declare const LABAIH_MAX_BYTES = 1000000;
|
|
4
|
+
/** A LaBaih number: fourteen digits, which begin with the date and time it was created. */
|
|
5
|
+
export declare function normalizeLabaihNumber(raw: string): string;
|
|
6
|
+
/** A challenge or block page in place of the tracker. */
|
|
7
|
+
export declare function assertNotChallenged(html: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* A scan's wording. A failed attempt's reason follows a colon and can go on,
|
|
10
|
+
* after another colon, with the recipient's district or city as given:
|
|
11
|
+
* "Unsuccessful Delivery: Wrong Customer District: …". Only the reason is
|
|
12
|
+
* kept, and only when it is words; phone numbers are dropped.
|
|
13
|
+
*/
|
|
14
|
+
export declare function labaihWording(text: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* The tracker's English page for one number, `track.mylabaih.com/track/en/?cn=`,
|
|
17
|
+
* which the site renders on its server: a heading with the number and the shop's
|
|
18
|
+
* reference, the shop, a progress rail, then the history, newest first. Each
|
|
19
|
+
* scan has a wording, a wall clock without a zone and, for an arrival at a
|
|
20
|
+
* distribution centre, its city. An unknown number is a redirect the adapter
|
|
21
|
+
* reads before this. The shop, its reference and the rail are not kept.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseLabaih(html: string, raw: string): CarrierResult;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { calendarDay } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { labaihStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'LaBaih';
|
|
9
|
+
// A page is about 110 kB, most of it inline icons.
|
|
10
|
+
export const LABAIH_MAX_BYTES = 1_000_000;
|
|
11
|
+
const NUMBER = /^\d{14}$/;
|
|
12
|
+
// The heading over the shipment: the number, then the shop's own reference.
|
|
13
|
+
const TITLE = /^Shipment Tracking - (\d{14})(?:\s+-.*)?$/;
|
|
14
|
+
// A scan's wall clock, without a zone: "2026-01-02 13:45:06".
|
|
15
|
+
const CLOCK = /^(\d{4})-(\d{2})-(\d{2}) ([01]\d|2[0-3]):([0-5]\d):([0-5]\d)$/;
|
|
16
|
+
// Bot checks a CDN or firewall could put in place of the page.
|
|
17
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|\/cdn-cgi\/challenge-platform\/|<title>\s*(?:Just a moment|Attention Required!|Access Denied)/i;
|
|
18
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens between them, as phone numbers are written.
|
|
19
|
+
const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
20
|
+
// A failed attempt's reason as the page words it, from the courier's list: letters only.
|
|
21
|
+
const REASON = /^[\p{L}][\p{L}\s'’/&().,-]{0,79}$/u;
|
|
22
|
+
const MAX_ROWS = 300;
|
|
23
|
+
/** A LaBaih number: fourteen digits, which begin with the date and time it was created. */
|
|
24
|
+
export function normalizeLabaihNumber(raw) {
|
|
25
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
26
|
+
if (!NUMBER.test(number))
|
|
27
|
+
throw new InvalidInputError(PROVIDER, 'LaBaih numbers are fourteen digits');
|
|
28
|
+
return number;
|
|
29
|
+
}
|
|
30
|
+
/** A challenge or block page in place of the tracker. */
|
|
31
|
+
export function assertNotChallenged(html) {
|
|
32
|
+
if (CHALLENGE.test(html.slice(0, 64_000)))
|
|
33
|
+
throw new ChallengeError(PROVIDER, 'LaBaih answered with a browser check');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A scan's wording. A failed attempt's reason follows a colon and can go on,
|
|
37
|
+
* after another colon, with the recipient's district or city as given:
|
|
38
|
+
* "Unsuccessful Delivery: Wrong Customer District: …". Only the reason is
|
|
39
|
+
* kept, and only when it is words; phone numbers are dropped.
|
|
40
|
+
*/
|
|
41
|
+
export function labaihWording(text) {
|
|
42
|
+
const [head = '', reason = ''] = clean(text, 500).split(':').map((part) => clean(part, 500));
|
|
43
|
+
const label = clean(head.replace(PHONE, ''), 160).replace(/[\s,;\-–]+$/, '');
|
|
44
|
+
return REASON.test(reason) ? `${label}: ${reason}` : label;
|
|
45
|
+
}
|
|
46
|
+
/** A scan's wall clock in `local_time` form, checked against the calendar. */
|
|
47
|
+
function wallClock(text) {
|
|
48
|
+
const match = CLOCK.exec(text);
|
|
49
|
+
const day = match ? calendarDay(Number(match[1]), Number(match[2]), Number(match[3])) : null;
|
|
50
|
+
if (!match || !day || Number(match[1]) < 2015)
|
|
51
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned an invalid scan time');
|
|
52
|
+
return `${day}T${match[4]}:${match[5]}:${match[6]}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The tracker's English page for one number, `track.mylabaih.com/track/en/?cn=`,
|
|
56
|
+
* which the site renders on its server: a heading with the number and the shop's
|
|
57
|
+
* reference, the shop, a progress rail, then the history, newest first. Each
|
|
58
|
+
* scan has a wording, a wall clock without a zone and, for an arrival at a
|
|
59
|
+
* distribution centre, its city. An unknown number is a redirect the adapter
|
|
60
|
+
* reads before this. The shop, its reference and the rail are not kept.
|
|
61
|
+
*/
|
|
62
|
+
export function parseLabaih(html, raw) {
|
|
63
|
+
const number = normalizeLabaihNumber(raw);
|
|
64
|
+
assertNotChallenged(html);
|
|
65
|
+
const $ = load(html);
|
|
66
|
+
const titles = $('.shipper-details h1');
|
|
67
|
+
if (titles.length === 0)
|
|
68
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned a page without a shipment');
|
|
69
|
+
if (titles.length > 1)
|
|
70
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned several shipments');
|
|
71
|
+
const shown = TITLE.exec(clean(titles.text(), 200))?.[1];
|
|
72
|
+
if (!shown)
|
|
73
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned a shipment without its number');
|
|
74
|
+
if (shown !== number)
|
|
75
|
+
throw new IndeterminateError(PROVIDER, 'LaBaih answered for another shipment');
|
|
76
|
+
const lists = $('.shipment-details .collapsible');
|
|
77
|
+
if (lists.length !== 1)
|
|
78
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned a page without its history');
|
|
79
|
+
const rows = lists.find('.list-update').toArray();
|
|
80
|
+
if (rows.length > MAX_ROWS)
|
|
81
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned an oversized history');
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
const scans = [];
|
|
84
|
+
rows.forEach((row, index) => {
|
|
85
|
+
const headings = $(row).find('h3');
|
|
86
|
+
const metas = $(row).find('.up-meta');
|
|
87
|
+
if (headings.length !== 1 || metas.length !== 1)
|
|
88
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned an invalid scan');
|
|
89
|
+
const description = labaihWording(headings.text());
|
|
90
|
+
if (!description)
|
|
91
|
+
throw new SchemaError(PROVIDER, 'LaBaih returned a scan without its wording');
|
|
92
|
+
const location = clean(metas.find('strong').text(), 80);
|
|
93
|
+
const meta = metas.clone();
|
|
94
|
+
meta.find('strong').remove();
|
|
95
|
+
const clock = wallClock(clean(meta.text(), 60));
|
|
96
|
+
const staged = labaihStage(description);
|
|
97
|
+
const event = {
|
|
98
|
+
local_time: clock,
|
|
99
|
+
...(location ? { location } : {}),
|
|
100
|
+
description,
|
|
101
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
102
|
+
};
|
|
103
|
+
// A scan the page lists twice is kept once.
|
|
104
|
+
const identity = JSON.stringify(event);
|
|
105
|
+
if (seen.has(identity))
|
|
106
|
+
return;
|
|
107
|
+
seen.add(identity);
|
|
108
|
+
scans.push({ event, clock, index });
|
|
109
|
+
});
|
|
110
|
+
if (!scans.length)
|
|
111
|
+
throw new NoHistoryError(PROVIDER);
|
|
112
|
+
// Newest first; scans at the same second keep the page's order.
|
|
113
|
+
const events = scans.sort((left, right) => (left.clock < right.clock ? 1 : left.clock > right.clock ? -1 : left.index - right.index))
|
|
114
|
+
.map(({ event }) => event);
|
|
115
|
+
const newest = events[0];
|
|
116
|
+
const current = events.find((event) => event.stage);
|
|
117
|
+
const stage = current?.stage;
|
|
118
|
+
return {
|
|
119
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
120
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
121
|
+
last_status_text: newest.description,
|
|
122
|
+
last_update: null,
|
|
123
|
+
last_update_local: newest.local_time,
|
|
124
|
+
expected_delivery: null,
|
|
125
|
+
events,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage LaBaih's wordings give a scan, in `normalizeStatusWording` form or as shown. */
|
|
4
|
+
export declare function labaihMappedStage(wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: LaBaih's wordings, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function labaihStage(wording: string): ClassifiedWording | null;
|
|
7
|
+
/** What the map says about one scan, by its wording: the tracker shows no codes. */
|
|
8
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
const key = normalizeStatusWording;
|
|
4
|
+
/**
|
|
5
|
+
* LaBaih's scan wordings, in English as the adapter reads them and in Arabic
|
|
6
|
+
* as the default page shows them. The tracker shows no codes. A parcel is
|
|
7
|
+
* collected from the online shop that sends it, arrives at a city's
|
|
8
|
+
* distribution centre and goes out to the customer from there.
|
|
9
|
+
*/
|
|
10
|
+
const WORDINGS = new Map([
|
|
11
|
+
[key('Shipment created'), 'registered'],
|
|
12
|
+
[key('تم إنشاء الشحنة'), 'registered'],
|
|
13
|
+
[key('Shipment Collected From Partner'), 'accepted'],
|
|
14
|
+
[key('تم استلام الشحنة من الشريك'), 'accepted'],
|
|
15
|
+
// Back at the distribution centre after an attempt; the Arabic page words it as an arrival there.
|
|
16
|
+
[key('Returned To Facility'), 'in_transit'],
|
|
17
|
+
[key('وصلت الشحنة إلى مركز التوزيع'), 'in_transit'],
|
|
18
|
+
[key('In-Transit to Customer'), 'out_for_delivery'],
|
|
19
|
+
[key('الشحنة في طريقها إلى العميل'), 'out_for_delivery'],
|
|
20
|
+
[key('Unsuccessful Delivery'), 'failed_attempt'],
|
|
21
|
+
[key('محاولة توصيل غير ناجحة'), 'failed_attempt'],
|
|
22
|
+
[key('Shipment Delivered'), 'delivered'],
|
|
23
|
+
[key('تم توصيل الشحنة'), 'delivered'],
|
|
24
|
+
]);
|
|
25
|
+
// An arrival at a city's distribution centre: "Arrived at Riyadh Facility", or in Arabic with "بـ" and the city.
|
|
26
|
+
const ARRIVED = /^(?:arrived at \S.* facility|وصلت الشحنة إلى مركز التوزيع بـ\S.*)$/u;
|
|
27
|
+
// A failed attempt keeps the reason the page gives after a colon: "Unsuccessful Delivery: Waiting time exceed".
|
|
28
|
+
const FAILED = /^(?:unsuccessful delivery|محاولة توصيل غير ناجحة):/u;
|
|
29
|
+
// Messages about the delivery location, which move nothing.
|
|
30
|
+
const NOTICES = [
|
|
31
|
+
'SMS sent requesting location to customer',
|
|
32
|
+
'Customer sent location',
|
|
33
|
+
'العميل قام بإرسال إحداثيات التوصيل',
|
|
34
|
+
].map(key);
|
|
35
|
+
/** The stage LaBaih's wordings give a scan, in `normalizeStatusWording` form or as shown. */
|
|
36
|
+
export function labaihMappedStage(wording) {
|
|
37
|
+
const normalized = key(wording);
|
|
38
|
+
return WORDINGS.get(normalized) ?? (ARRIVED.test(normalized) ? 'in_transit' : FAILED.test(normalized) ? 'failed_attempt' : undefined);
|
|
39
|
+
}
|
|
40
|
+
/** A scan's stage: LaBaih's wordings, else the shared wording rules, else nothing. */
|
|
41
|
+
export function labaihStage(wording) {
|
|
42
|
+
const mapped = labaihMappedStage(wording);
|
|
43
|
+
if (mapped)
|
|
44
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
45
|
+
if (NOTICES.includes(key(wording)))
|
|
46
|
+
return null;
|
|
47
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
48
|
+
return worded.source === 'none' ? null : worded;
|
|
49
|
+
}
|
|
50
|
+
/** What the map says about one scan, by its wording: the tracker shows no codes. */
|
|
51
|
+
export const statusMap = {
|
|
52
|
+
stage: (code, wording) => (code ? undefined : labaihMappedStage(wording)),
|
|
53
|
+
gaps: NOTICES.map((wording) => ({ wording, note: 'A message asking for, or giving, the delivery location; the parcel does not move.' })),
|
|
54
|
+
};
|
|
@@ -4,11 +4,23 @@
|
|
|
4
4
|
{"wording": "تم إنشاء الشحنة", "stage": "registered", "language": "ar", "confirmedBy": "portal", "note": "Shipment created; the first event of every captured history."},
|
|
5
5
|
{"wording": "تم استلام الشحنة من الشريك", "stage": "accepted", "language": "ar", "confirmedBy": "portal", "note": "Shipment collected from the partner, the online shop that sends it."},
|
|
6
6
|
{"wording": "العميل قام بإرسال إحداثيات التوصيل", "language": "ar", "confirmedBy": "portal", "note": "The recipient sent the delivery location; not a parcel movement."},
|
|
7
|
-
{"wording": "وصلت الشحنة إلى مركز التوزيع", "stage": "in_transit", "language": "ar", "confirmedBy": "portal", "note": "Shipment arrived at the distribution centre."},
|
|
7
|
+
{"wording": "وصلت الشحنة إلى مركز التوزيع", "stage": "in_transit", "language": "ar", "confirmedBy": "portal", "note": "Shipment arrived at the distribution centre. The Arabic page also words a return to the facility after an attempt this way."},
|
|
8
8
|
{"wording": "وصلت الشحنة إلى مركز التوزيع بـالرياض", "stage": "in_transit", "language": "ar", "confirmedBy": "portal", "note": "The same event with the city appended; Jeddah appears the same way."},
|
|
9
9
|
{"wording": "الشحنة في طريقها إلى العميل", "stage": "out_for_delivery", "language": "ar", "confirmedBy": "portal", "note": "Shipment on its way to the customer."},
|
|
10
10
|
{"wording": "محاولة توصيل غير ناجحة", "stage": "failed_attempt", "language": "ar", "confirmedBy": "portal", "note": "Unsuccessful delivery attempt."},
|
|
11
|
+
{"wording": "تم توصيل الشحنة", "stage": "delivered", "language": "ar", "confirmedBy": "live", "note": "Shipment delivered."},
|
|
11
12
|
{"wording": "تم استلام الشحنة", "stage": "accepted", "language": "ar", "confirmedBy": "portal", "note": "Step label in the tracker's progress bar."},
|
|
12
|
-
{"wording": "تم التوصيل", "stage": "delivered", "language": "ar", "confirmedBy": "portal", "note": "Step label in the tracker's progress bar."}
|
|
13
|
+
{"wording": "تم التوصيل", "stage": "delivered", "language": "ar", "confirmedBy": "portal", "note": "Step label in the tracker's progress bar."},
|
|
14
|
+
{"wording": "Shipment created", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "The first event of every history."},
|
|
15
|
+
{"wording": "Shipment Collected From Partner", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Collected from the online shop that sends the parcel."},
|
|
16
|
+
{"wording": "SMS sent requesting location to customer", "language": "en", "confirmedBy": "live", "note": "A text asking the recipient for the delivery location; only the English page lists it, and the parcel does not move."},
|
|
17
|
+
{"wording": "Customer sent location", "language": "en", "confirmedBy": "live", "note": "The recipient sent the delivery location; the parcel does not move."},
|
|
18
|
+
{"wording": "Arrived at Riyadh Facility", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Arrival at a city's distribution centre, whose city the scan also gives as its place; Jeddah appears the same way."},
|
|
19
|
+
{"wording": "In-Transit to Customer", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "On its way to the customer."},
|
|
20
|
+
{"wording": "Unsuccessful Delivery: Waiting time exceed", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "A failed attempt with the courier's reason after the colon."},
|
|
21
|
+
{"wording": "Unsuccessful Delivery: Wrong Customer District", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "The page adds the district as the recipient gave it after another colon; the adapter drops it."},
|
|
22
|
+
{"wording": "Unsuccessful Delivery: Wrong Customer City", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "The page adds the city as the recipient gave it after another colon; the adapter drops it."},
|
|
23
|
+
{"wording": "Returned To Facility", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Back at the distribution centre after a failed attempt, before going out again; the Arabic page words it as an arrival there."},
|
|
24
|
+
{"wording": "Shipment Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "Delivered."}
|
|
13
25
|
]
|
|
14
26
|
}
|
|
@@ -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 API the tracking page on go.shipblu.com looks the number in its address up in. */
|
|
5
|
+
export declare const SHIPBLU_API = "https://api.shipblu.com/api/v1/";
|
|
6
|
+
export declare class ShipbluTracker {
|
|
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,76 @@
|
|
|
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 { isRecord } from '../../core/types.js';
|
|
8
|
+
import { normalizeShipbluNumber, parseShipblu, PROVIDER, SHIPBLU_MAX_BYTES, shipbluOrderType } from './parser.js';
|
|
9
|
+
/** The API the tracking page on go.shipblu.com looks the number in its address up in. */
|
|
10
|
+
export const SHIPBLU_API = 'https://api.shipblu.com/api/v1/';
|
|
11
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
12
|
+
// Bot checks a CDN or firewall could put in place of the reply.
|
|
13
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required)|AwsWafIntegration|awswaf\.com/i;
|
|
14
|
+
function json(body) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(body);
|
|
17
|
+
}
|
|
18
|
+
catch (cause) {
|
|
19
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
20
|
+
throw new ChallengeError(PROVIDER, 'ShipBlu answered with a browser check', { cause });
|
|
21
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned a reply that is not JSON', { cause });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The service's own answer to a number it does not know: `{"detail":"Not found."}`. */
|
|
25
|
+
function unknownNumber(body) {
|
|
26
|
+
try {
|
|
27
|
+
const reply = JSON.parse(body);
|
|
28
|
+
return isRecord(reply) && typeof reply.detail === 'string' && /^not found\.?$/i.test(reply.detail.trim());
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class ShipbluTracker {
|
|
35
|
+
options;
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
}
|
|
39
|
+
async fetch(raw, context = {}) {
|
|
40
|
+
const number = normalizeShipbluNumber(raw);
|
|
41
|
+
const url = `${SHIPBLU_API}${shipbluOrderType(number)}/${number}/?pin=`;
|
|
42
|
+
const read = async (signal, timeoutMs) => {
|
|
43
|
+
// The service words its errors in the language asked for; English keeps the not-found answer readable.
|
|
44
|
+
const { response, bytes } = await fetchBounded(url, { method: 'GET', signal, headers: {
|
|
45
|
+
Accept: 'application/json', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
46
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: SHIPBLU_MAX_BYTES, redirect: 'manual',
|
|
47
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
48
|
+
if (REDIRECTS.includes(response.status))
|
|
49
|
+
throw new TransportError(PROVIDER, 'ShipBlu redirected its tracking lookup');
|
|
50
|
+
const body = decodeText(bytes);
|
|
51
|
+
if (response.status === 404 && unknownNumber(body))
|
|
52
|
+
throw new NotFoundError(PROVIDER);
|
|
53
|
+
if (response.status === 404 || response.status === 410) {
|
|
54
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
55
|
+
throw new ChallengeError(PROVIDER, 'ShipBlu answered with a browser check', { status: response.status });
|
|
56
|
+
throw new TransportError(PROVIDER, 'ShipBlu tracking lookup is unavailable', { status: response.status });
|
|
57
|
+
}
|
|
58
|
+
return parseShipblu(json(body), number);
|
|
59
|
+
};
|
|
60
|
+
// A request that fails to reach the service, or hangs, gets one more try:
|
|
61
|
+
// the first gets half of the budget, so the retry fits in the rest.
|
|
62
|
+
return runSteps({ carrier: 'shipblu', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
63
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
64
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
65
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const adapter = (environment) => {
|
|
70
|
+
const tracker = new ShipbluTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
71
|
+
return {
|
|
72
|
+
id: 'shipblu', recordsSteps: true, steps: ['direct', 'retry'],
|
|
73
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
74
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeShipbluNumber(number))),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
@@ -17,18 +17,69 @@
|
|
|
17
17
|
"timezone": "Africa/Cairo",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "shipblu",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 789
|
|
27
|
+
}
|
|
22
28
|
},
|
|
23
|
-
"capabilities": [
|
|
29
|
+
"capabilities": [
|
|
30
|
+
"history",
|
|
31
|
+
"provider_code",
|
|
32
|
+
"offset_timestamps",
|
|
33
|
+
"eta",
|
|
34
|
+
"delivered_at"
|
|
35
|
+
],
|
|
24
36
|
"portal": {
|
|
25
37
|
"url": "https://go.shipblu.com/en/{trackingNumber}",
|
|
26
38
|
"searchUrl": "https://go.shipblu.com/en/",
|
|
27
39
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
"shows": [
|
|
41
|
+
"status",
|
|
42
|
+
"progress_rail",
|
|
43
|
+
"eta",
|
|
44
|
+
"delivered_at",
|
|
45
|
+
"failed_delivery_reason",
|
|
46
|
+
"sender_name",
|
|
47
|
+
"recipient_address",
|
|
48
|
+
"parcel_count",
|
|
49
|
+
"qr",
|
|
50
|
+
"payment_details",
|
|
51
|
+
"delivery_options"
|
|
52
|
+
],
|
|
53
|
+
"scraped": [
|
|
54
|
+
"status",
|
|
55
|
+
"history",
|
|
56
|
+
"provider_code",
|
|
57
|
+
"offset_timestamps",
|
|
58
|
+
"eta",
|
|
59
|
+
"delivered_at",
|
|
60
|
+
"failed_delivery_reason"
|
|
61
|
+
],
|
|
62
|
+
"discarded": [
|
|
63
|
+
"sender_name",
|
|
64
|
+
"recipient_name",
|
|
65
|
+
"recipient_address",
|
|
66
|
+
"contact_details",
|
|
67
|
+
"coordinates",
|
|
68
|
+
"parcel_count",
|
|
69
|
+
"qr",
|
|
70
|
+
"payment_details",
|
|
71
|
+
"delivery_options",
|
|
72
|
+
"remarks"
|
|
73
|
+
],
|
|
74
|
+
"unavailable": [
|
|
75
|
+
"location",
|
|
76
|
+
"weight",
|
|
77
|
+
"service_name",
|
|
78
|
+
"proof_of_delivery",
|
|
79
|
+
"destination_country",
|
|
80
|
+
"delivery_partner",
|
|
81
|
+
"delivery_tracking_number"
|
|
82
|
+
]
|
|
32
83
|
},
|
|
33
84
|
"links": [
|
|
34
85
|
{
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "ShipBlu";
|
|
3
|
+
export declare const SHIPBLU_MAX_BYTES: number;
|
|
4
|
+
export type ShipbluOrderType = 'delivery-order' | 'return';
|
|
5
|
+
/**
|
|
6
|
+
* The tracker's list for a number, by its leading digits as the page routes
|
|
7
|
+
* it: 1 and 31 are delivery orders, 2 and 32 returns.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shipbluOrderType(number: string): ShipbluOrderType | undefined;
|
|
10
|
+
/** A ShipBlu number for a parcel, as its digits; anything else is not one ShipBlu issues for a parcel. */
|
|
11
|
+
export declare function normalizeShipbluNumber(raw: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* The reply of the tracking page's `GET /api/v1/{delivery-order|return}/{number}/`
|
|
14
|
+
* for one number. An unknown number is HTTP 404, which the adapter reads before
|
|
15
|
+
* this. The reply must name the number asked. It also carries the merchant, the
|
|
16
|
+
* recipient's name, phones and address and the cash to collect, which are never
|
|
17
|
+
* read.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseShipblu(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,174 @@
|
|
|
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 { explicitOffsetTime } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
import { shipbluMappedStage, shipbluStage, shipbluWording } from './status.js';
|
|
9
|
+
export const PROVIDER = 'ShipBlu';
|
|
10
|
+
// An order with its recipient and a long history is a few kilobytes.
|
|
11
|
+
export const SHIPBLU_MAX_BYTES = 256 * 1024;
|
|
12
|
+
// The site's tracking box takes seven or more digits starting with 1 to 4.
|
|
13
|
+
const NUMBER = /^[1-4]\d{6,39}$/;
|
|
14
|
+
const CODE = /^[a-z][a-z_]{0,39}$/;
|
|
15
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
16
|
+
// An instant as the service writes it: up to microseconds, and an offset.
|
|
17
|
+
const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
18
|
+
// The scans whose comment the page shows: the reason a courier gives for a missed visit.
|
|
19
|
+
const ATTEMPTS = new Set(['delivery_attempted', 'return_attempted']);
|
|
20
|
+
// Before pickup the page promises no day: "To be determined after picking up your order".
|
|
21
|
+
const BEFORE_PICKUP = new Set(['created', 'pickup_requested', 'pickup_rescheduled']);
|
|
22
|
+
// The statuses the page's timeline places on a step of their own; on any
|
|
23
|
+
// other, an order on hold shows as such.
|
|
24
|
+
const OWN_STEP = new Set(['created', 'pickup_requested', 'pickup_rescheduled', 'out_for_pickup', 'return_requested',
|
|
25
|
+
'picked_up', 'cancelled', 'out_for_delivery', 'out_for_return', 'delivered', 'returned']);
|
|
26
|
+
const FINAL = new Set(['delivered', 'returned', 'exception']);
|
|
27
|
+
// A run of eight or more digits, with spaces, brackets, dots or hyphens
|
|
28
|
+
// between them, as phone numbers are written; a date stays.
|
|
29
|
+
const PHONE = /(?<![A-Z\d])(?!\d{4}-\d{2}-\d{2}(?!\d))\+?\d(?:[\s().-]{0,2}\d){7,}(?![A-Z\d])/gi;
|
|
30
|
+
const MAX_ROWS = 500;
|
|
31
|
+
const MAX_EVENTS = 100;
|
|
32
|
+
/**
|
|
33
|
+
* The tracker's list for a number, by its leading digits as the page routes
|
|
34
|
+
* it: 1 and 31 are delivery orders, 2 and 32 returns.
|
|
35
|
+
*/
|
|
36
|
+
export function shipbluOrderType(number) {
|
|
37
|
+
if (/^(?:1|31)/.test(number))
|
|
38
|
+
return 'delivery-order';
|
|
39
|
+
if (/^(?:2|32)/.test(number))
|
|
40
|
+
return 'return';
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** A ShipBlu number for a parcel, as its digits; anything else is not one ShipBlu issues for a parcel. */
|
|
44
|
+
export function normalizeShipbluNumber(raw) {
|
|
45
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
46
|
+
if (!NUMBER.test(number))
|
|
47
|
+
throw new InvalidInputError(PROVIDER, 'ShipBlu numbers are seven or more digits starting with 1 to 4');
|
|
48
|
+
// The page looks numbers starting with 4 up as cash collections, which carry no parcel,
|
|
49
|
+
// and has no list for one starting with 3 then another digit than 1 or 2.
|
|
50
|
+
if (!shipbluOrderType(number))
|
|
51
|
+
throw new InvalidInputError(PROVIDER, 'ShipBlu tracks parcels under numbers starting with 1, 2, 31 or 32');
|
|
52
|
+
return number;
|
|
53
|
+
}
|
|
54
|
+
/** An instant the reply states with its offset. */
|
|
55
|
+
function instant(value) {
|
|
56
|
+
return typeof value === 'string' && INSTANT.test(value.trim()) ? explicitOffsetTime(value) : null;
|
|
57
|
+
}
|
|
58
|
+
/** A calendar day, or nothing. */
|
|
59
|
+
function day(value) {
|
|
60
|
+
if (typeof value !== 'string' || !DAY.test(value.trim()))
|
|
61
|
+
return null;
|
|
62
|
+
return DateTime.fromISO(value.trim(), { zone: 'UTC' }).isValid ? value.trim() : null;
|
|
63
|
+
}
|
|
64
|
+
/** A status code, in the lower case the service writes. */
|
|
65
|
+
function statusCode(row) {
|
|
66
|
+
const code = typeof row.status === 'string' ? row.status.trim().toLowerCase() : '';
|
|
67
|
+
if (!CODE.test(code))
|
|
68
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an invalid status');
|
|
69
|
+
return code;
|
|
70
|
+
}
|
|
71
|
+
/** A missed visit's reason, without phone numbers. */
|
|
72
|
+
function reason(row) {
|
|
73
|
+
const value = row.comment;
|
|
74
|
+
if (value === null || value === undefined)
|
|
75
|
+
return '';
|
|
76
|
+
if (typeof value !== 'string')
|
|
77
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an invalid comment');
|
|
78
|
+
return clean(value.replace(PHONE, ''), 200).replace(/^[\s,;:.\-–]+|[\s,;:\-–]+$/g, '');
|
|
79
|
+
}
|
|
80
|
+
function projectRow(row) {
|
|
81
|
+
const code = statusCode(row);
|
|
82
|
+
const time = instant(row.created);
|
|
83
|
+
if (!time)
|
|
84
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an invalid scan time');
|
|
85
|
+
const why = ATTEMPTS.has(code) ? reason(row) : '';
|
|
86
|
+
const description = why ? `${shipbluWording(code)}: ${why}` : shipbluWording(code);
|
|
87
|
+
const staged = shipbluStage(code);
|
|
88
|
+
const event = {
|
|
89
|
+
time: time.iso,
|
|
90
|
+
description,
|
|
91
|
+
provider_code: code,
|
|
92
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
93
|
+
};
|
|
94
|
+
return { key: JSON.stringify([time.timestamp, code, why]), scan: { event, timestamp: time.timestamp } };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The day the page promises: none before pickup or once the order ended, else
|
|
98
|
+
* the day the customer chose, else for a return the service's estimate. The
|
|
99
|
+
* page asks another endpoint for a delivery order's estimate, which this
|
|
100
|
+
* does not read.
|
|
101
|
+
*/
|
|
102
|
+
function estimate(payload, type, current, stage) {
|
|
103
|
+
if (BEFORE_PICKUP.has(current) || (stage && FINAL.has(stage)))
|
|
104
|
+
return null;
|
|
105
|
+
return day(payload.preferred_date) ?? (type === 'return' ? day(payload.estimated_date) : null);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The reply of the tracking page's `GET /api/v1/{delivery-order|return}/{number}/`
|
|
109
|
+
* for one number. An unknown number is HTTP 404, which the adapter reads before
|
|
110
|
+
* this. The reply must name the number asked. It also carries the merchant, the
|
|
111
|
+
* recipient's name, phones and address and the cash to collect, which are never
|
|
112
|
+
* read.
|
|
113
|
+
*/
|
|
114
|
+
export function parseShipblu(payload, raw) {
|
|
115
|
+
const number = normalizeShipbluNumber(raw);
|
|
116
|
+
const type = shipbluOrderType(number);
|
|
117
|
+
if (!isRecord(payload))
|
|
118
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an invalid reply');
|
|
119
|
+
if (typeof payload.tracking_number !== 'string')
|
|
120
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an order without its number');
|
|
121
|
+
if (payload.tracking_number.trim() !== number)
|
|
122
|
+
throw new IndeterminateError(PROVIDER, 'ShipBlu answered for another order');
|
|
123
|
+
const current = statusCode(payload);
|
|
124
|
+
const rows = payload.tracking_events ?? [];
|
|
125
|
+
if (!Array.isArray(rows))
|
|
126
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an order with invalid scans');
|
|
127
|
+
if (rows.length > MAX_ROWS)
|
|
128
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an oversized history');
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const scans = [];
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
if (!isRecord(row))
|
|
133
|
+
throw new SchemaError(PROVIDER, 'ShipBlu returned an invalid scan');
|
|
134
|
+
const { key, scan } = projectRow(row);
|
|
135
|
+
if (seen.has(key))
|
|
136
|
+
continue;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
scans.push(scan);
|
|
139
|
+
}
|
|
140
|
+
// Newest first; scans at the same instant keep the reverse of the order they were recorded in.
|
|
141
|
+
scans.reverse().sort((a, b) => b.timestamp - a.timestamp);
|
|
142
|
+
const events = scans.map((scan) => scan.event);
|
|
143
|
+
// The page shows an order held for the merchant on the step it waits at.
|
|
144
|
+
const onHold = payload.on_hold !== null && payload.on_hold !== undefined && !OWN_STEP.has(current);
|
|
145
|
+
const summary = onHold ? 'exception' : shipbluMappedStage(current);
|
|
146
|
+
const scanned = events.find((event) => event.stage);
|
|
147
|
+
const stage = summary ?? scanned?.stage;
|
|
148
|
+
const source = summary ? 'carrier_map' : scanned?.stage_source;
|
|
149
|
+
const delivered = stage === 'delivered'
|
|
150
|
+
? instant(payload.delivered_date)?.iso ?? events.find((event) => event.provider_code === 'delivered')?.time : undefined;
|
|
151
|
+
const facts = {
|
|
152
|
+
expected_delivery: estimate(payload, type, current, stage),
|
|
153
|
+
...(delivered ? { delivered_at: delivered } : {}),
|
|
154
|
+
};
|
|
155
|
+
if (!events.length) {
|
|
156
|
+
if (!summary)
|
|
157
|
+
throw new NoHistoryError(PROVIDER);
|
|
158
|
+
return {
|
|
159
|
+
status: languageStageStatus(summary), current_stage: summary, current_stage_source: 'carrier_map',
|
|
160
|
+
last_status_text: onHold ? 'On hold' : shipbluWording(current), provider_code: current,
|
|
161
|
+
last_update: delivered ?? null, ...facts, summary_only: true, events: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const newest = events[0];
|
|
165
|
+
return {
|
|
166
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
167
|
+
...(stage ? { current_stage: stage, current_stage_source: source ?? 'carrier_map' } : {}),
|
|
168
|
+
last_status_text: onHold ? 'On hold' : newest.description,
|
|
169
|
+
last_update: newest.time,
|
|
170
|
+
...facts,
|
|
171
|
+
events: events.slice(0, MAX_EVENTS),
|
|
172
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import type { CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage ShipBlu's map gives a status code. */
|
|
4
|
+
export declare function shipbluMappedStage(code: string): Stage | undefined;
|
|
5
|
+
/** The wording of a status code, as `out_for_delivery` reads "Out for delivery". */
|
|
6
|
+
export declare function shipbluWording(code: string): string;
|
|
7
|
+
/** A scan's stage: the map, else the shared wording rules for a code the map does not know, else nothing. */
|
|
8
|
+
export declare function shipbluStage(code: string): ClassifiedWording | null;
|
|
9
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* ShipBlu's order statuses, as its API documentation lists them. A return
|
|
4
|
+
* order travels from the customer to the merchant, so its arrival there is
|
|
5
|
+
* `returned`, as on an order sent back.
|
|
6
|
+
*/
|
|
7
|
+
const CODES = new Map([
|
|
8
|
+
['created', 'registered'],
|
|
9
|
+
['pickup_requested', 'registered'], // The merchant asked ShipBlu to collect the order.
|
|
10
|
+
['pickup_rescheduled', 'registered'],
|
|
11
|
+
['out_for_pickup', 'registered'], // A courier on the way to the merchant.
|
|
12
|
+
['return_requested', 'registered'], // The merchant asked ShipBlu to collect a return from the customer.
|
|
13
|
+
['picked_up', 'accepted'],
|
|
14
|
+
['in_transit', 'in_transit'], // At a ShipBlu warehouse.
|
|
15
|
+
['en_route', 'in_transit'], // Between two warehouses.
|
|
16
|
+
['out_for_delivery', 'out_for_delivery'],
|
|
17
|
+
['delivery_attempted', 'failed_attempt'],
|
|
18
|
+
['delivered', 'delivered'],
|
|
19
|
+
['return_to_origin', 'returned'], // Refused, badly packed or out of zone: it goes back to the merchant.
|
|
20
|
+
['out_for_return', 'returned'], // A courier taking it to the merchant.
|
|
21
|
+
['return_postponed', 'returned'], // The merchant moved the return to the next day.
|
|
22
|
+
['returned', 'returned'],
|
|
23
|
+
['return_refused', 'exception'], // The merchant refused to take it back.
|
|
24
|
+
['cancelled', 'exception'], // Cancelled by the merchant before pickup.
|
|
25
|
+
]);
|
|
26
|
+
/** Codes the map leaves without a stage on purpose. */
|
|
27
|
+
const GAPS = new Map([
|
|
28
|
+
['return_attempted', 'A courier could not collect a return from the customer; the parcel is not in ShipBlu\'s hands yet.'],
|
|
29
|
+
]);
|
|
30
|
+
/** The stage ShipBlu's map gives a status code. */
|
|
31
|
+
export function shipbluMappedStage(code) {
|
|
32
|
+
return CODES.get(code);
|
|
33
|
+
}
|
|
34
|
+
/** The wording of a status code, as `out_for_delivery` reads "Out for delivery". */
|
|
35
|
+
export function shipbluWording(code) {
|
|
36
|
+
const words = code.replace(/_+/g, ' ').trim();
|
|
37
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
38
|
+
}
|
|
39
|
+
/** A scan's stage: the map, else the shared wording rules for a code the map does not know, else nothing. */
|
|
40
|
+
export function shipbluStage(code) {
|
|
41
|
+
const mapped = shipbluMappedStage(code);
|
|
42
|
+
if (mapped)
|
|
43
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
44
|
+
if (GAPS.has(code))
|
|
45
|
+
return null;
|
|
46
|
+
const classified = classifyWording(shipbluWording(code), 'in_transit');
|
|
47
|
+
return classified.source === 'none' ? null : classified;
|
|
48
|
+
}
|
|
49
|
+
export const statusMap = {
|
|
50
|
+
stage: (code) => (code === null ? undefined : shipbluMappedStage(code)),
|
|
51
|
+
gaps: [...GAPS].map(([code, note]) => ({ code, note })),
|
|
52
|
+
};
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "shipblu",
|
|
3
3
|
"entries": [
|
|
4
|
-
{"code": "created", "wording": "Created", "stage": "registered", "language": "en", "confirmedBy": "
|
|
5
|
-
{"wording": "Pickup
|
|
6
|
-
{"wording": "Out for
|
|
7
|
-
{"
|
|
8
|
-
{"code": "
|
|
9
|
-
{"code": "
|
|
10
|
-
{"code": "
|
|
11
|
-
{"code": "
|
|
12
|
-
{"wording": "
|
|
13
|
-
{"code": "
|
|
14
|
-
{"code": "
|
|
15
|
-
{"code": "delivery_attempted", "wording": "Delivery
|
|
16
|
-
{"code": "
|
|
17
|
-
{"code": "
|
|
18
|
-
{"wording": "
|
|
19
|
-
{"wording": "Return
|
|
20
|
-
{"code": "
|
|
21
|
-
{"code": "
|
|
22
|
-
{"code": "
|
|
23
|
-
{"code": "
|
|
24
|
-
{"wording": "
|
|
4
|
+
{"code": "created", "wording": "Created", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "The merchant created the order."},
|
|
5
|
+
{"code": "pickup_requested", "wording": "Pickup requested", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "The merchant asked ShipBlu to collect the order."},
|
|
6
|
+
{"code": "out_for_pickup", "wording": "Out for pickup", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "A courier is on the way to collect the order from the merchant."},
|
|
7
|
+
{"code": "picked_up", "wording": "Picked up", "stage": "accepted", "language": "en", "confirmedBy": "live"},
|
|
8
|
+
{"code": "in_transit", "wording": "In transit", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "At a ShipBlu warehouse, as the API documentation defines the status; it alternates with en_route along the way."},
|
|
9
|
+
{"code": "en_route", "wording": "En route", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Moving from one warehouse to another, as the API documentation defines the status."},
|
|
10
|
+
{"code": "out_for_delivery", "wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live"},
|
|
11
|
+
{"code": "delivered", "wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live"},
|
|
12
|
+
{"code": "pickup_rescheduled", "wording": "Pickup rescheduled", "stage": "registered", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the merchant moved the pickup."},
|
|
13
|
+
{"code": "return_requested", "wording": "Return requested", "stage": "registered", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the merchant asked ShipBlu to collect a return from the customer."},
|
|
14
|
+
{"code": "return_attempted", "wording": "Return attempted", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "Status in ShipBlu's API documentation: a courier could not collect a return from the customer, so the parcel is not in ShipBlu's hands yet."},
|
|
15
|
+
{"code": "delivery_attempted", "wording": "Delivery attempted", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation; the courier gives the reason in the scan's comment."},
|
|
16
|
+
{"code": "return_to_origin", "wording": "Return to origin", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the customer refused the order, its packaging broke ShipBlu's rules or the address is out of zone."},
|
|
17
|
+
{"code": "out_for_return", "wording": "Out for return", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: a courier is taking the order back to the merchant."},
|
|
18
|
+
{"code": "return_postponed", "wording": "Return postponed", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the merchant moved the return to the next day."},
|
|
19
|
+
{"code": "return_refused", "wording": "Return refused", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the merchant refused to take the order back."},
|
|
20
|
+
{"code": "returned", "wording": "Returned", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the order reached the merchant."},
|
|
21
|
+
{"code": "cancelled", "wording": "Cancelled", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "Status in ShipBlu's API documentation: the merchant cancelled the order before pickup."},
|
|
22
|
+
{"code": "picked_up", "wording": "تم استلام الطلب", "stage": "accepted", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline."},
|
|
23
|
+
{"code": "in_transit", "wording": "En Route", "stage": "in_transit", "language": "en", "confirmedBy": "bundle", "note": "Label of the step in the go.shipblu.com tracker's timeline."},
|
|
24
|
+
{"code": "in_transit", "wording": "في مركز التوزيع", "stage": "in_transit", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline: at the distribution centre."},
|
|
25
|
+
{"code": "out_for_delivery", "wording": "طلبك في طريقه إليك", "stage": "out_for_delivery", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline."},
|
|
26
|
+
{"code": "delivered", "wording": "تم تسليم الطلب", "stage": "delivered", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline."},
|
|
27
|
+
{"code": "out_for_return", "wording": "طلبك فى طريقه للتاجر", "stage": "returned", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline: the order is on its way back to the merchant."},
|
|
28
|
+
{"code": "returned", "wording": "تم ارجاع الطلب", "stage": "returned", "language": "ar", "confirmedBy": "bundle", "note": "Arabic label of the step in the go.shipblu.com tracker's timeline."},
|
|
29
|
+
{"wording": "On Hold", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline: the merchant's balance does not cover the shipping fee. The tracker shows it from the order's hold flag, not as a status."}
|
|
25
30
|
]
|
|
26
31
|
}
|
|
@@ -202,6 +202,7 @@ import { statusMap as kyoungdong } from '../../carriers/kyoungdong/status.js';
|
|
|
202
202
|
import { statusMap as lPost } from '../../carriers/l-post/status.js';
|
|
203
203
|
import { statusMap as laPoste } from '../../carriers/la-poste/status.js';
|
|
204
204
|
import { statusMap as laPosteSenegal } from '../../carriers/la-poste-senegal/status.js';
|
|
205
|
+
import { statusMap as labaih } from '../../carriers/labaih/status.js';
|
|
205
206
|
import { statusMap as latvijasPasts } from '../../carriers/latvijas-pasts/status.js';
|
|
206
207
|
import { statusMap as leopardsCourier } from '../../carriers/leopards-courier/status.js';
|
|
207
208
|
import { statusMap as liccardi } from '../../carriers/liccardi/status.js';
|
|
@@ -295,6 +296,7 @@ import { statusMap as servientrega } from '../../carriers/servientrega/status.js
|
|
|
295
296
|
import { statusMap as sfc } from '../../carriers/sfc/status.js';
|
|
296
297
|
import { statusMap as shadowfax } from '../../carriers/shadowfax/status.js';
|
|
297
298
|
import { statusMap as shipa } from '../../carriers/shipa/status.js';
|
|
299
|
+
import { statusMap as shipblu } from '../../carriers/shipblu/status.js';
|
|
298
300
|
import { statusMap as shipentegra } from '../../carriers/shipentegra/status.js';
|
|
299
301
|
import { statusMap as shreeMaruti } from '../../carriers/shree-maruti/status.js';
|
|
300
302
|
import { statusMap as skroutzLastMile } from '../../carriers/skroutz-last-mile/status.js';
|
|
@@ -403,7 +405,7 @@ export const STATUS_MAPS = {
|
|
|
403
405
|
'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-cargo': jntCargo,
|
|
404
406
|
'j-and-t-middle-east': jAndTMiddleEast, janio, jeebly, 'jersey-post': jerseyPost, 'jet-logistic': jetLogistic, jhgj, jitsu, jne, 'jordan-post': jordanPost,
|
|
405
407
|
jrt, 'js-express': jsExpress, kazpost, kex, 'kuehne-nagel': kuehneNagel, kyoungdong, 'l-post': lPost, 'la-poste': laPoste,
|
|
406
|
-
'la-poste-senegal': laPosteSenegal, 'latvijas-pasts': latvijasPasts,
|
|
408
|
+
'la-poste-senegal': laPosteSenegal, labaih, 'latvijas-pasts': latvijasPasts,
|
|
407
409
|
'leopards-courier': leopardsCourier, liccardi, 'lietuvos-pastas': lietuvosPastas, linhan, 'lion-parcel': lionParcel, logen, loggi, logoix, 'loomis-express': loomisExpress,
|
|
408
410
|
'lotte-global': lotteGlobal, 'm-one': mOne, 'magyar-posta': magyarPosta, mailamericas, mainfreight, maltapost, matkahuolto, mayiyinfu, meest, 'messenger-cz': messengerCz,
|
|
409
411
|
'metropolitan-warehouse': metropolitanWarehouse, 'mng-kargo': mngKargo, mtscn, mylerz, naqel, nationex, 'next-level-delivery': nextLevelDelivery, nipost, 'nippon-express': nipponExpress,
|
|
@@ -418,7 +420,7 @@ export const STATUS_MAPS = {
|
|
|
418
420
|
'pts-worldwide-express': ptsWorldwideExpress, ptt, 'quantium-solutions': quantiumSolutions, 'quicksilver-sa': quicksilverSa, raben, redpack, redur, redx, relaytech,
|
|
419
421
|
'rl-carriers': rlCarriers,
|
|
420
422
|
'rpx-indonesia': rpxIndonesia, sagawa, saia, sailpost, sameday, 'saudi-post': saudiPost, seino, seko, sending, serpost, servientrega, sfc,
|
|
421
|
-
shadowfax, shipa, shipentegra, 'shree-maruti': shreeMaruti, 'skroutz-last-mile': skroutzLastMile, 'skynet-worldwide': skynetWorldwide,
|
|
423
|
+
shadowfax, shipa, shipblu, shipentegra, 'shree-maruti': shreeMaruti, 'skroutz-last-mile': skroutzLastMile, 'skynet-worldwide': skynetWorldwide,
|
|
422
424
|
'slovak-parcel-service': slovakParcelService,
|
|
423
425
|
'slovenska-posta': slovenskaPosta, 'smsa-express': smsaExpress, 'south-african-post-office': southAfricanPost,
|
|
424
426
|
'southeastern-freight-lines': southeasternFreightLines, 'sp-express': spExpress, speedaf, speedex, speedx, speedy, 'spx-br': spxBr, 'spx-id': spxId, 'spx-my': spxMy,
|
package/dist/data/catalog.json
CHANGED
|
@@ -18174,7 +18174,9 @@
|
|
|
18174
18174
|
"timezone": "Asia/Riyadh",
|
|
18175
18175
|
"tracking": {
|
|
18176
18176
|
"mode": "automatic",
|
|
18177
|
-
"adapter": "
|
|
18177
|
+
"adapter": "labaih",
|
|
18178
|
+
"recognitionRank": 788,
|
|
18179
|
+
"localClocks": true
|
|
18178
18180
|
},
|
|
18179
18181
|
"canaryUrl": "https://mylabaih.com/",
|
|
18180
18182
|
"trackingUrlTemplate": "https://track.mylabaih.com/track/?cn={trackingNumber}",
|
|
@@ -18184,7 +18186,7 @@
|
|
|
18184
18186
|
"domains": [
|
|
18185
18187
|
"track.mylabaih.com"
|
|
18186
18188
|
],
|
|
18187
|
-
"pathPattern": "^/track
|
|
18189
|
+
"pathPattern": "^/track(?:/en)?/?$",
|
|
18188
18190
|
"params": [
|
|
18189
18191
|
"cn"
|
|
18190
18192
|
]
|
|
@@ -25350,7 +25352,8 @@
|
|
|
25350
25352
|
"timezone": "Africa/Cairo",
|
|
25351
25353
|
"tracking": {
|
|
25352
25354
|
"mode": "automatic",
|
|
25353
|
-
"adapter": "
|
|
25355
|
+
"adapter": "shipblu",
|
|
25356
|
+
"recognitionRank": 789
|
|
25354
25357
|
},
|
|
25355
25358
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25356
25359
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -11687,14 +11687,16 @@ export declare const CARRIER_CATALOG: {
|
|
|
11687
11687
|
readonly timezone: "Asia/Riyadh";
|
|
11688
11688
|
readonly tracking: {
|
|
11689
11689
|
readonly mode: "automatic";
|
|
11690
|
-
readonly adapter: "
|
|
11690
|
+
readonly adapter: "labaih";
|
|
11691
|
+
readonly recognitionRank: 788;
|
|
11692
|
+
readonly localClocks: true;
|
|
11691
11693
|
};
|
|
11692
11694
|
readonly canaryUrl: "https://mylabaih.com/";
|
|
11693
11695
|
readonly trackingUrlTemplate: "https://track.mylabaih.com/track/?cn={trackingNumber}";
|
|
11694
11696
|
readonly trackingSearchUrl: "https://mylabaih.com/";
|
|
11695
11697
|
readonly linkRules: readonly [{
|
|
11696
11698
|
readonly domains: readonly ["track.mylabaih.com"];
|
|
11697
|
-
readonly pathPattern: "^/track
|
|
11699
|
+
readonly pathPattern: "^/track(?:/en)?/?$";
|
|
11698
11700
|
readonly params: readonly ["cn"];
|
|
11699
11701
|
}];
|
|
11700
11702
|
readonly detectionRules: readonly [];
|
|
@@ -16185,7 +16187,8 @@ export declare const CARRIER_CATALOG: {
|
|
|
16185
16187
|
readonly timezone: "Africa/Cairo";
|
|
16186
16188
|
readonly tracking: {
|
|
16187
16189
|
readonly mode: "automatic";
|
|
16188
|
-
readonly adapter: "
|
|
16190
|
+
readonly adapter: "shipblu";
|
|
16191
|
+
readonly recognitionRank: 789;
|
|
16189
16192
|
};
|
|
16190
16193
|
readonly canaryUrl: "https://go.shipblu.com/en/";
|
|
16191
16194
|
readonly trackingUrlTemplate: "https://go.shipblu.com/en/{trackingNumber}";
|
|
@@ -18175,7 +18175,9 @@ export const CARRIER_CATALOG = {
|
|
|
18175
18175
|
"timezone": "Asia/Riyadh",
|
|
18176
18176
|
"tracking": {
|
|
18177
18177
|
"mode": "automatic",
|
|
18178
|
-
"adapter": "
|
|
18178
|
+
"adapter": "labaih",
|
|
18179
|
+
"recognitionRank": 788,
|
|
18180
|
+
"localClocks": true
|
|
18179
18181
|
},
|
|
18180
18182
|
"canaryUrl": "https://mylabaih.com/",
|
|
18181
18183
|
"trackingUrlTemplate": "https://track.mylabaih.com/track/?cn={trackingNumber}",
|
|
@@ -18185,7 +18187,7 @@ export const CARRIER_CATALOG = {
|
|
|
18185
18187
|
"domains": [
|
|
18186
18188
|
"track.mylabaih.com"
|
|
18187
18189
|
],
|
|
18188
|
-
"pathPattern": "^/track
|
|
18190
|
+
"pathPattern": "^/track(?:/en)?/?$",
|
|
18189
18191
|
"params": [
|
|
18190
18192
|
"cn"
|
|
18191
18193
|
]
|
|
@@ -25351,7 +25353,8 @@ export const CARRIER_CATALOG = {
|
|
|
25351
25353
|
"timezone": "Africa/Cairo",
|
|
25352
25354
|
"tracking": {
|
|
25353
25355
|
"mode": "automatic",
|
|
25354
|
-
"adapter": "
|
|
25356
|
+
"adapter": "shipblu",
|
|
25357
|
+
"recognitionRank": 789
|
|
25355
25358
|
},
|
|
25356
25359
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25357
25360
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -182,6 +182,7 @@ export const CARRIER_RECOGNITION_RANKS = {
|
|
|
182
182
|
"kuehne-nagel": 435,
|
|
183
183
|
"l-post": 808,
|
|
184
184
|
"la-poste": 1000,
|
|
185
|
+
"labaih": 788,
|
|
185
186
|
"latvijas-pasts": 100,
|
|
186
187
|
"leopards-courier": 965,
|
|
187
188
|
"lietuvos-pastas": 895,
|
|
@@ -267,6 +268,7 @@ export const CARRIER_RECOGNITION_RANKS = {
|
|
|
267
268
|
"seur": 870,
|
|
268
269
|
"sfc": 955,
|
|
269
270
|
"shadowfax": 595,
|
|
271
|
+
"shipblu": 789,
|
|
270
272
|
"shipentegra": 833,
|
|
271
273
|
"shree-maruti": 822,
|
|
272
274
|
"skroutz-last-mile": 913,
|
|
@@ -241,6 +241,7 @@ import { adapter as kyoungdong } from '../carriers/kyoungdong/adapter.js';
|
|
|
241
241
|
import { adapter as lPost } from '../carriers/l-post/adapter.js';
|
|
242
242
|
import { adapter as laPoste } from '../carriers/la-poste/adapter.js';
|
|
243
243
|
import { adapter as laPosteSenegal } from '../carriers/la-poste-senegal/adapter.js';
|
|
244
|
+
import { adapter as labaih } from '../carriers/labaih/adapter.js';
|
|
244
245
|
import { adapter as landmarkGlobal } from '../carriers/landmark-global/adapter.js';
|
|
245
246
|
import { adapter as latvijasPasts } from '../carriers/latvijas-pasts/adapter.js';
|
|
246
247
|
import { adapter as lbcExpress } from '../carriers/lbc-express/adapter.js';
|
|
@@ -356,6 +357,7 @@ import { adapter as sfExpress } from '../carriers/sf-express/adapter.js';
|
|
|
356
357
|
import { adapter as sfc } from '../carriers/sfc/adapter.js';
|
|
357
358
|
import { adapter as shadowfax } from '../carriers/shadowfax/adapter.js';
|
|
358
359
|
import { adapter as shipa } from '../carriers/shipa/adapter.js';
|
|
360
|
+
import { adapter as shipblu } from '../carriers/shipblu/adapter.js';
|
|
359
361
|
import { adapter as shipentegra } from '../carriers/shipentegra/adapter.js';
|
|
360
362
|
import { adapter as shreeMaruti } from '../carriers/shree-maruti/adapter.js';
|
|
361
363
|
import { adapter as singaporePost } from '../carriers/singapore-post/adapter.js';
|
|
@@ -691,6 +693,7 @@ export const REGISTRY = {
|
|
|
691
693
|
"l-post": lPost,
|
|
692
694
|
"la-poste": laPoste,
|
|
693
695
|
"la-poste-senegal": laPosteSenegal,
|
|
696
|
+
"labaih": labaih,
|
|
694
697
|
"landmark-global": landmarkGlobal,
|
|
695
698
|
"latvijas-pasts": latvijasPasts,
|
|
696
699
|
"lbc-express": lbcExpress,
|
|
@@ -806,6 +809,7 @@ export const REGISTRY = {
|
|
|
806
809
|
"sfc": sfc,
|
|
807
810
|
"shadowfax": shadowfax,
|
|
808
811
|
"shipa": shipa,
|
|
812
|
+
"shipblu": shipblu,
|
|
809
813
|
"shipentegra": shipentegra,
|
|
810
814
|
"shree-maruti": shreeMaruti,
|
|
811
815
|
"singapore-post": singaporePost,
|
|
@@ -1296,7 +1300,7 @@ export const REGISTRY = {
|
|
|
1296
1300
|
"la-poste-senegal": "la-poste-senegal",
|
|
1297
1301
|
"la-poste-tunisienne": "universal",
|
|
1298
1302
|
"laar-courier": "universal",
|
|
1299
|
-
"labaih": "
|
|
1303
|
+
"labaih": "labaih",
|
|
1300
1304
|
"landmark-global": "landmark-global",
|
|
1301
1305
|
"lao-post": "universal",
|
|
1302
1306
|
"last-one-mile-coop": "universal",
|
|
@@ -1507,7 +1511,7 @@ export const REGISTRY = {
|
|
|
1507
1511
|
"sfc": "sfc",
|
|
1508
1512
|
"shadowfax": "shadowfax",
|
|
1509
1513
|
"shipa": "shipa",
|
|
1510
|
-
"shipblu": "
|
|
1514
|
+
"shipblu": "shipblu",
|
|
1511
1515
|
"shipentegra": "shipentegra",
|
|
1512
1516
|
"shipup": "universal",
|
|
1513
1517
|
"shree-anjani": "universal",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.415.0-main.2136",
|
|
4
4
|
"description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|