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 CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **448 dedicated adapters** and **5 universal fallbacks**
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,283 such statuses, each filed under one stage.
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": "universal"
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}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 760,
3
- "activeAdapters": 448,
3
+ "activeAdapters": 449,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -19162,7 +19162,8 @@ var Re = {
19162
19162
  timezone: "Africa/Cairo",
19163
19163
  tracking: {
19164
19164
  mode: "automatic",
19165
- adapter: "universal"
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": "universal",
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
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
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": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
5
- {"wording": "Pickup Requested", "stage": "registered", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline."},
6
- {"wording": "Out for Pickup", "stage": "registered", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline. A courier is on the way to collect the order from the merchant."},
7
- {"wording": "Pickup Rescheduled", "stage": "registered", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline. The pickup moved to the next business day."},
8
- {"code": "picked_up", "wording": "Picked up", "stage": "accepted", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
9
- {"code": "picked_up", "wording": "تم استلام الطلب", "stage": "accepted", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
10
- {"code": "in_transit", "wording": "En Route", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
11
- {"code": "in_transit", "wording": "في مركز التوزيع", "stage": "in_transit", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
12
- {"wording": "In Transit", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline. The article groups it with En Route: the order is on its way to the hub serving the customer."},
13
- {"code": "out_for_delivery", "wording": "Out for Delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
14
- {"code": "out_for_delivery", "wording": "طلبك في طريقه إليك", "stage": "out_for_delivery", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
15
- {"code": "delivery_attempted", "wording": "Delivery Attempted", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline."},
16
- {"code": "delivered", "wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
17
- {"code": "delivered", "wording": "تم تسليم الطلب", "stage": "delivered", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
18
- {"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."},
19
- {"wording": "Return to Origin", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline."},
20
- {"code": "out_for_return", "wording": "Out for Return", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
21
- {"code": "out_for_return", "wording": "طلبك فى طريقه للتاجر", "stage": "returned", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history. The order is on its way back to the merchant."},
22
- {"code": "returned", "wording": "Returned", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
23
- {"code": "returned", "wording": "تم ارجاع الطلب", "stage": "returned", "language": "ar", "confirmedBy": "official-doc", "note": "Label in the go.shipblu.com tracker's script; not seen in a captured history."},
24
- {"wording": "Canceled", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "From ShipBlu's help article on the order timeline. The order was canceled before pickup."}
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,
@@ -25350,7 +25350,8 @@
25350
25350
  "timezone": "Africa/Cairo",
25351
25351
  "tracking": {
25352
25352
  "mode": "automatic",
25353
- "adapter": "universal"
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: "universal";
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": "universal"
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}",
@@ -267,6 +267,7 @@ export const CARRIER_RECOGNITION_RANKS = {
267
267
  "seur": 870,
268
268
  "sfc": 955,
269
269
  "shadowfax": 595,
270
+ "shipblu": 789,
270
271
  "shipentegra": 833,
271
272
  "shree-maruti": 822,
272
273
  "skroutz-last-mile": 913,
@@ -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": "universal",
1512
+ "shipblu": "shipblu",
1511
1513
  "shipentegra": "shipentegra",
1512
1514
  "shipup": "universal",
1513
1515
  "shree-anjani": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.413.0"
5
+ "version": "0.414.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.413.0-main.2132",
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",