universal-parcel-scraper 0.413.0-main.2132 → 0.414.0-main.2134
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 +2 -1
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +3 -1
- 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 +2 -1
- package/dist/data/catalog.json +2 -1
- package/dist/generated/catalog.d.ts +2 -1
- package/dist/generated/catalog.js +2 -1
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +3 -1
- 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 **449 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,288 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
|
@@ -25350,7 +25350,8 @@
|
|
|
25350
25350
|
"timezone": "Africa/Cairo",
|
|
25351
25351
|
"tracking": {
|
|
25352
25352
|
"mode": "automatic",
|
|
25353
|
-
"adapter": "
|
|
25353
|
+
"adapter": "shipblu",
|
|
25354
|
+
"recognitionRank": 789
|
|
25354
25355
|
},
|
|
25355
25356
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25356
25357
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -19162,7 +19162,8 @@ var Re = {
|
|
|
19162
19162
|
timezone: "Africa/Cairo",
|
|
19163
19163
|
tracking: {
|
|
19164
19164
|
mode: "automatic",
|
|
19165
|
-
adapter: "
|
|
19165
|
+
adapter: "shipblu",
|
|
19166
|
+
recognitionRank: 789
|
|
19166
19167
|
},
|
|
19167
19168
|
canaryUrl: "https://go.shipblu.com/en/",
|
|
19168
19169
|
trackingUrlTemplate: "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -29226,6 +29227,7 @@ var _l = {
|
|
|
29226
29227
|
seur: 870,
|
|
29227
29228
|
sfc: 955,
|
|
29228
29229
|
shadowfax: 595,
|
|
29230
|
+
shipblu: 789,
|
|
29229
29231
|
shipentegra: 833,
|
|
29230
29232
|
"shree-maruti": 822,
|
|
29231
29233
|
"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 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
|
}
|
|
@@ -295,6 +295,7 @@ import { statusMap as servientrega } from '../../carriers/servientrega/status.js
|
|
|
295
295
|
import { statusMap as sfc } from '../../carriers/sfc/status.js';
|
|
296
296
|
import { statusMap as shadowfax } from '../../carriers/shadowfax/status.js';
|
|
297
297
|
import { statusMap as shipa } from '../../carriers/shipa/status.js';
|
|
298
|
+
import { statusMap as shipblu } from '../../carriers/shipblu/status.js';
|
|
298
299
|
import { statusMap as shipentegra } from '../../carriers/shipentegra/status.js';
|
|
299
300
|
import { statusMap as shreeMaruti } from '../../carriers/shree-maruti/status.js';
|
|
300
301
|
import { statusMap as skroutzLastMile } from '../../carriers/skroutz-last-mile/status.js';
|
|
@@ -418,7 +419,7 @@ export const STATUS_MAPS = {
|
|
|
418
419
|
'pts-worldwide-express': ptsWorldwideExpress, ptt, 'quantium-solutions': quantiumSolutions, 'quicksilver-sa': quicksilverSa, raben, redpack, redur, redx, relaytech,
|
|
419
420
|
'rl-carriers': rlCarriers,
|
|
420
421
|
'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,
|
|
422
|
+
shadowfax, shipa, shipblu, shipentegra, 'shree-maruti': shreeMaruti, 'skroutz-last-mile': skroutzLastMile, 'skynet-worldwide': skynetWorldwide,
|
|
422
423
|
'slovak-parcel-service': slovakParcelService,
|
|
423
424
|
'slovenska-posta': slovenskaPosta, 'smsa-express': smsaExpress, 'south-african-post-office': southAfricanPost,
|
|
424
425
|
'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
|
@@ -25350,7 +25350,8 @@
|
|
|
25350
25350
|
"timezone": "Africa/Cairo",
|
|
25351
25351
|
"tracking": {
|
|
25352
25352
|
"mode": "automatic",
|
|
25353
|
-
"adapter": "
|
|
25353
|
+
"adapter": "shipblu",
|
|
25354
|
+
"recognitionRank": 789
|
|
25354
25355
|
},
|
|
25355
25356
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25356
25357
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -16185,7 +16185,8 @@ export declare const CARRIER_CATALOG: {
|
|
|
16185
16185
|
readonly timezone: "Africa/Cairo";
|
|
16186
16186
|
readonly tracking: {
|
|
16187
16187
|
readonly mode: "automatic";
|
|
16188
|
-
readonly adapter: "
|
|
16188
|
+
readonly adapter: "shipblu";
|
|
16189
|
+
readonly recognitionRank: 789;
|
|
16189
16190
|
};
|
|
16190
16191
|
readonly canaryUrl: "https://go.shipblu.com/en/";
|
|
16191
16192
|
readonly trackingUrlTemplate: "https://go.shipblu.com/en/{trackingNumber}";
|
|
@@ -25351,7 +25351,8 @@ export const CARRIER_CATALOG = {
|
|
|
25351
25351
|
"timezone": "Africa/Cairo",
|
|
25352
25352
|
"tracking": {
|
|
25353
25353
|
"mode": "automatic",
|
|
25354
|
-
"adapter": "
|
|
25354
|
+
"adapter": "shipblu",
|
|
25355
|
+
"recognitionRank": 789
|
|
25355
25356
|
},
|
|
25356
25357
|
"canaryUrl": "https://go.shipblu.com/en/",
|
|
25357
25358
|
"trackingUrlTemplate": "https://go.shipblu.com/en/{trackingNumber}",
|
|
@@ -356,6 +356,7 @@ import { adapter as sfExpress } from '../carriers/sf-express/adapter.js';
|
|
|
356
356
|
import { adapter as sfc } from '../carriers/sfc/adapter.js';
|
|
357
357
|
import { adapter as shadowfax } from '../carriers/shadowfax/adapter.js';
|
|
358
358
|
import { adapter as shipa } from '../carriers/shipa/adapter.js';
|
|
359
|
+
import { adapter as shipblu } from '../carriers/shipblu/adapter.js';
|
|
359
360
|
import { adapter as shipentegra } from '../carriers/shipentegra/adapter.js';
|
|
360
361
|
import { adapter as shreeMaruti } from '../carriers/shree-maruti/adapter.js';
|
|
361
362
|
import { adapter as singaporePost } from '../carriers/singapore-post/adapter.js';
|
|
@@ -806,6 +807,7 @@ export const REGISTRY = {
|
|
|
806
807
|
"sfc": sfc,
|
|
807
808
|
"shadowfax": shadowfax,
|
|
808
809
|
"shipa": shipa,
|
|
810
|
+
"shipblu": shipblu,
|
|
809
811
|
"shipentegra": shipentegra,
|
|
810
812
|
"shree-maruti": shreeMaruti,
|
|
811
813
|
"singapore-post": singaporePost,
|
|
@@ -1507,7 +1509,7 @@ export const REGISTRY = {
|
|
|
1507
1509
|
"sfc": "sfc",
|
|
1508
1510
|
"shadowfax": "shadowfax",
|
|
1509
1511
|
"shipa": "shipa",
|
|
1510
|
-
"shipblu": "
|
|
1512
|
+
"shipblu": "shipblu",
|
|
1511
1513
|
"shipentegra": "shipentegra",
|
|
1512
1514
|
"shipup": "universal",
|
|
1513
1515
|
"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.414.0-main.2134",
|
|
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",
|