universal-parcel-scraper 0.181.0 → 0.183.0-main.1321
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 +23 -5
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +50 -0
- package/dist/browser/scraper.js +21 -5
- package/dist/carriers/couriersplease/adapter.d.ts +17 -0
- package/dist/carriers/couriersplease/adapter.js +111 -0
- package/dist/carriers/couriersplease/carrier.json +51 -7
- package/dist/carriers/couriersplease/parser.d.ts +22 -0
- package/dist/carriers/couriersplease/parser.js +222 -0
- package/dist/carriers/couriersplease/status.d.ts +7 -0
- package/dist/carriers/couriersplease/status.js +96 -0
- package/dist/carriers/couriersplease/statuses.json +21 -18
- package/dist/carriers/gdex/adapter.d.ts +17 -0
- package/dist/carriers/gdex/adapter.js +58 -0
- package/dist/carriers/gdex/carrier.json +47 -7
- package/dist/carriers/gdex/numbers.json +38 -0
- package/dist/carriers/gdex/parser.d.ts +15 -0
- package/dist/carriers/gdex/parser.js +149 -0
- package/dist/carriers/gdex/status.d.ts +7 -0
- package/dist/carriers/gdex/status.js +41 -0
- package/dist/carriers/gdex/statuses.json +12 -3
- package/dist/carriers/leopards-courier/adapter.d.ts +13 -0
- package/dist/carriers/leopards-courier/adapter.js +73 -0
- package/dist/carriers/leopards-courier/carrier.json +52 -7
- package/dist/carriers/leopards-courier/numbers.json +6 -8
- package/dist/carriers/leopards-courier/parser.d.ts +26 -0
- package/dist/carriers/leopards-courier/parser.js +190 -0
- package/dist/carriers/leopards-courier/status.d.ts +7 -0
- package/dist/carriers/leopards-courier/status.js +34 -0
- package/dist/carriers/leopards-courier/statuses.json +4 -1
- package/dist/carriers/pos-indonesia/adapter.d.ts +14 -0
- package/dist/carriers/pos-indonesia/adapter.js +74 -0
- package/dist/carriers/pos-indonesia/carrier.json +53 -7
- package/dist/carriers/pos-indonesia/numbers.json +52 -14
- package/dist/carriers/pos-indonesia/parser.d.ts +25 -0
- package/dist/carriers/pos-indonesia/parser.js +201 -0
- package/dist/carriers/pos-indonesia/status.d.ts +12 -0
- package/dist/carriers/pos-indonesia/status.js +41 -0
- package/dist/carriers/pos-indonesia/statuses.json +7 -0
- package/dist/core/catalog/statusMaps.js +12 -5
- package/dist/data/catalog.json +23 -5
- package/dist/generated/catalog.d.ts +17 -5
- package/dist/generated/catalog.js +23 -5
- package/dist/generated/recognition.js +4 -0
- package/dist/generated/registry.js +12 -4
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { clean, textFromHtml } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { couriersPleaseStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'CouriersPlease';
|
|
9
|
+
// A consignment of several items is a few kilobytes per item.
|
|
10
|
+
export const COURIERSPLEASE_MAX_BYTES = 512 * 1024;
|
|
11
|
+
// Consignments, item labels, prepaid coupons and the SSCCs some senders print:
|
|
12
|
+
// letters and digits, with at least one digit.
|
|
13
|
+
const NUMBER = /^(?=[A-Z]*\d)[A-Z0-9]{8,30}$/;
|
|
14
|
+
// The tracking page's session token: "salt==:encrypted".
|
|
15
|
+
const TOKEN = /<meta\s+name="csrf-token"\s+content="([A-Za-z0-9+/=_:-]{16,512})"/;
|
|
16
|
+
const MAX_ITEMS = 100;
|
|
17
|
+
const MAX_ROWS = 500;
|
|
18
|
+
const MAX_EVENTS = 100;
|
|
19
|
+
/** A number for CouriersPlease's tracker, in upper case; anything else is not one it issues. */
|
|
20
|
+
export function normalizeCouriersPleaseNumber(raw) {
|
|
21
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
|
|
22
|
+
if (!NUMBER.test(number))
|
|
23
|
+
throw new InvalidInputError(PROVIDER, 'CouriersPlease numbers are eight to thirty letters and digits');
|
|
24
|
+
return number;
|
|
25
|
+
}
|
|
26
|
+
/** The session token the tracking page puts in its `csrf-token` meta tag, or null. */
|
|
27
|
+
export function couriersPleaseToken(html) {
|
|
28
|
+
return TOKEN.exec(html.slice(0, 200_000))?.[1] ?? null;
|
|
29
|
+
}
|
|
30
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
31
|
+
function text(row, key, maxLength = 500) {
|
|
32
|
+
const value = row[key];
|
|
33
|
+
if (value === null || value === undefined)
|
|
34
|
+
return '';
|
|
35
|
+
if (typeof value !== 'string')
|
|
36
|
+
throw new SchemaError(PROVIDER, `CouriersPlease returned an invalid ${key}`);
|
|
37
|
+
return clean(value, maxLength);
|
|
38
|
+
}
|
|
39
|
+
const LINK = /<a\b[^>]*>([\s\S]*?)<\/a\s*>/gi;
|
|
40
|
+
const BREAK = /\s*<br\s*\/?>\s*/gi;
|
|
41
|
+
const MARK = '\u0000';
|
|
42
|
+
/**
|
|
43
|
+
* A scan's wording without its markup. A line break becomes the end of a
|
|
44
|
+
* sentence, as on the page, and a sentence that points to a link, such as
|
|
45
|
+
* "Please contact our Customer Support Team here", is left out.
|
|
46
|
+
*/
|
|
47
|
+
export function couriersPleaseWording(raw) {
|
|
48
|
+
// A link's text keeps a mark, so that its sentence can be told apart.
|
|
49
|
+
const marked = textFromHtml(raw.slice(0, 2_000).replace(LINK, ` ${MARK}$1 `).replace(BREAK, '. '), 1_000);
|
|
50
|
+
const kept = marked.split(/(?<=\.)\s+/).filter((sentence) => !sentence.includes(MARK)).join(' ');
|
|
51
|
+
const wording = kept || marked.replaceAll(MARK, '');
|
|
52
|
+
return clean(wording.replace(/\.(?:\s*\.)+/g, '.').replace(/\s+([,.])/g, '$1')).replace(/^[\s,;:.\-–]+|[\s,;:\-–.]+$/g, '');
|
|
53
|
+
}
|
|
54
|
+
const DATE = /^\d{1,2} [A-Z][a-z]{2} \d{4}$/;
|
|
55
|
+
const CLOCK = /^(?:1[0-2]|0?[1-9]):[0-5]\d [ap]m$/i;
|
|
56
|
+
/** A scan's `date` ("23 Mar 2026") and `time` ("5:00 pm"), a wall clock to the minute. */
|
|
57
|
+
function wallClock(date, time) {
|
|
58
|
+
const parsed = DATE.test(date) && CLOCK.test(time)
|
|
59
|
+
// Parsing in UTC checks the calendar only; the wall clock keeps no offset.
|
|
60
|
+
? DateTime.fromFormat(`${date} ${time}`, 'd MMM yyyy h:mm a', { zone: 'UTC', locale: 'en-US' }) : null;
|
|
61
|
+
if (!parsed?.isValid)
|
|
62
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid scan time');
|
|
63
|
+
return parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss");
|
|
64
|
+
}
|
|
65
|
+
/** The consignment's labelled facts: `time` names the field and `action` holds its value. */
|
|
66
|
+
function facts(rows) {
|
|
67
|
+
if (!Array.isArray(rows) || rows.length > MAX_ROWS)
|
|
68
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid consignment details');
|
|
69
|
+
const found = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!isRecord(row))
|
|
72
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid consignment details');
|
|
73
|
+
const label = text(row, 'time', 60);
|
|
74
|
+
if (label && !found.has(label))
|
|
75
|
+
found.set(label, text(row, 'action', 300));
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
/** Each item's rows, or null for an item the tracker has no rows for. */
|
|
80
|
+
function items(entry) {
|
|
81
|
+
const list = entry.trakingInfo;
|
|
82
|
+
if (!Array.isArray(list) || list.length > MAX_ITEMS)
|
|
83
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned invalid items');
|
|
84
|
+
return list.map((item) => {
|
|
85
|
+
if (!isRecord(item) || !isRecord(item.tracker))
|
|
86
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid item');
|
|
87
|
+
const track = item.tracker.track;
|
|
88
|
+
if (track === null || track === undefined)
|
|
89
|
+
return null;
|
|
90
|
+
if (!Array.isArray(track) || track.length > MAX_ROWS)
|
|
91
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid item history');
|
|
92
|
+
if (track.some((row) => !isRecord(row)))
|
|
93
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid scan');
|
|
94
|
+
return track;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** The item number an item's first row names ("CPPL TRACKER", "SSCC TRACKER" or a coupon's product), or ''. */
|
|
98
|
+
function itemNumber(rows) {
|
|
99
|
+
const [first] = rows;
|
|
100
|
+
if (!first || first.action !== null || first.nodeType !== null)
|
|
101
|
+
return '';
|
|
102
|
+
return text(first, 'date', 64).toUpperCase();
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* An item's scans, as the page lists them: rows with wording, without the
|
|
106
|
+
* first row naming the item, the page's own "Overdue" marker, a sender's
|
|
107
|
+
* notice that the parcel was despatched to CouriersPlease, or the shipment
|
|
108
|
+
* details a sender sends after the pickup.
|
|
109
|
+
*/
|
|
110
|
+
function itemScans(rows, order) {
|
|
111
|
+
const scans = [];
|
|
112
|
+
let pickedUp = false;
|
|
113
|
+
for (const row of itemNumber(rows) ? rows.slice(1) : rows) {
|
|
114
|
+
const code = text(row, 'nodeType', 40).toLowerCase();
|
|
115
|
+
if (code === 'pickup')
|
|
116
|
+
pickedUp = true;
|
|
117
|
+
const date = text(row, 'date', 32);
|
|
118
|
+
const action = row.action;
|
|
119
|
+
if (action !== null && action !== undefined && typeof action !== 'string')
|
|
120
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid action');
|
|
121
|
+
const description = typeof action === 'string' ? couriersPleaseWording(action) : '';
|
|
122
|
+
if (!date || !description || /^overdue$/i.test(description))
|
|
123
|
+
continue;
|
|
124
|
+
if (code === 'manifest' && pickedUp)
|
|
125
|
+
continue;
|
|
126
|
+
if (code === 'status' && /despatched to couriersplease/i.test(description))
|
|
127
|
+
continue;
|
|
128
|
+
const wall = wallClock(date, text(row, 'time', 16));
|
|
129
|
+
const location = text(row, 'stateName', 60);
|
|
130
|
+
const providerCode = /^[a-z][a-z-]{0,39}$/.test(code) ? code : '';
|
|
131
|
+
const staged = couriersPleaseStage(providerCode, description);
|
|
132
|
+
const event = {
|
|
133
|
+
local_time: wall,
|
|
134
|
+
...(location ? { location } : {}),
|
|
135
|
+
description,
|
|
136
|
+
...(providerCode ? { provider_code: providerCode } : {}),
|
|
137
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
138
|
+
};
|
|
139
|
+
scans.push({ event, key: JSON.stringify([wall, providerCode, description, location]), wall, order: order.next++ });
|
|
140
|
+
}
|
|
141
|
+
return scans;
|
|
142
|
+
}
|
|
143
|
+
/** The estimate the page shows, "26 Mar 2026", as a calendar day. */
|
|
144
|
+
function estimate(value) {
|
|
145
|
+
if (!value || !DATE.test(value))
|
|
146
|
+
return null;
|
|
147
|
+
const parsed = DateTime.fromFormat(value, 'd MMM yyyy', { zone: 'UTC', locale: 'en-US' });
|
|
148
|
+
return parsed.isValid ? parsed.toISODate() : null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The reply of the tracking page's `/api/track/locate` for one number. Its one
|
|
152
|
+
* entry holds the consignment's details, when the number is a consignment, and
|
|
153
|
+
* each item's rows, the first naming the item. A consignment must be the
|
|
154
|
+
* number asked, or an item must: a label or coupon has no consignment details.
|
|
155
|
+
* An unknown number gets no rows ("No scan events found", or an empty list),
|
|
156
|
+
* which the page reports as not a valid tracking number.
|
|
157
|
+
*/
|
|
158
|
+
export function parseCouriersPlease(payload, raw) {
|
|
159
|
+
const number = normalizeCouriersPleaseNumber(raw);
|
|
160
|
+
if (!isRecord(payload) || !Object.hasOwn(payload, 'data'))
|
|
161
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid envelope');
|
|
162
|
+
if (payload.data === null) {
|
|
163
|
+
// The service's refusal of the request itself, worded in `msg`.
|
|
164
|
+
if (typeof payload.msg === 'string' && payload.msg)
|
|
165
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease did not take the lookup');
|
|
166
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned a reply without data');
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(payload.data) || !Array.isArray(payload.data.root))
|
|
169
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned an invalid result list');
|
|
170
|
+
if (payload.data.root.length !== 1 || !isRecord(payload.data.root[0])) {
|
|
171
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease answered for another number of shipments');
|
|
172
|
+
}
|
|
173
|
+
const entry = payload.data.root[0];
|
|
174
|
+
if (!isRecord(entry.statusInfo))
|
|
175
|
+
throw new SchemaError(PROVIDER, 'CouriersPlease returned a result without its status');
|
|
176
|
+
const details = facts(entry.consignmentInfo ?? []);
|
|
177
|
+
const tracks = items(entry);
|
|
178
|
+
const consignment = (details.get('Consignment #') ?? '').toUpperCase();
|
|
179
|
+
const listed = tracks.flatMap((rows) => rows?.length ? [rows] : []);
|
|
180
|
+
if (!listed.length) {
|
|
181
|
+
if (consignment === number)
|
|
182
|
+
throw new NoHistoryError(PROVIDER);
|
|
183
|
+
throw new NotFoundError(PROVIDER);
|
|
184
|
+
}
|
|
185
|
+
if (consignment !== number && !listed.some((rows) => itemNumber(rows) === number)) {
|
|
186
|
+
throw new IndeterminateError(PROVIDER, 'CouriersPlease answered for another shipment');
|
|
187
|
+
}
|
|
188
|
+
const order = { next: 0 };
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
const scans = [];
|
|
191
|
+
for (const rows of listed) {
|
|
192
|
+
for (const scan of itemScans(rows, order)) {
|
|
193
|
+
// An item of several repeats the consignment's own scans.
|
|
194
|
+
if (seen.has(scan.key))
|
|
195
|
+
continue;
|
|
196
|
+
seen.add(scan.key);
|
|
197
|
+
scans.push(scan);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!scans.length)
|
|
201
|
+
throw new NoHistoryError(PROVIDER);
|
|
202
|
+
// Newest first; each item lists its scans oldest first.
|
|
203
|
+
scans.sort((a, b) => (a.wall === b.wall ? b.order - a.order : a.wall < b.wall ? 1 : -1));
|
|
204
|
+
const events = scans.map((scan) => scan.event);
|
|
205
|
+
const newest = events[0];
|
|
206
|
+
const current = events.find((event) => event.stage);
|
|
207
|
+
const stage = current?.stage;
|
|
208
|
+
// The page shows the estimate as still to be confirmed while the sender's
|
|
209
|
+
// details are all there is.
|
|
210
|
+
const announced = events.every((event) => event.provider_code === 'manifest');
|
|
211
|
+
const expected = stage === 'delivered' || stage === 'returned' || announced ? null : estimate(details.get('Delivery ETA'));
|
|
212
|
+
return {
|
|
213
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
214
|
+
...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
|
|
215
|
+
last_status_text: newest.description,
|
|
216
|
+
last_update: null,
|
|
217
|
+
last_update_local: newest.local_time,
|
|
218
|
+
expected_delivery: expected,
|
|
219
|
+
events: events.slice(0, MAX_EVENTS),
|
|
220
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage CouriersPlease's vocabulary gives a row's wording, else its kind. */
|
|
4
|
+
export declare function couriersPleaseMappedStage(code: string | null, wording: string): Stage | undefined;
|
|
5
|
+
/** A scan's stage: its wording or kind, else the shared wording rules, else nothing. */
|
|
6
|
+
export declare function couriersPleaseStage(code: string, wording: string): ClassifiedWording | null;
|
|
7
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The kind of scan CouriersPlease's tracker gives each row (`nodeType`). Only
|
|
5
|
+
* kinds seen in replies are mapped. `faildeliver` covers failed attempts as
|
|
6
|
+
* well as parcels held for a problem or refused, so its wording comes first;
|
|
7
|
+
* `status` rows are notes whose wording alone can stage them.
|
|
8
|
+
*/
|
|
9
|
+
const CODES = new Map([
|
|
10
|
+
['manifest', 'registered'], // Shipment information received from the sender.
|
|
11
|
+
['pickup', 'accepted'],
|
|
12
|
+
['futile', 'exception'], // A pickup attempt at the sender failed.
|
|
13
|
+
['reweigh', 'in_transit'], // Arrived at a facility for processing.
|
|
14
|
+
['transit', 'in_transit'], // In transit between delivery centres.
|
|
15
|
+
['deconsolidate', 'in_transit'], // Arrived at the delivery centre.
|
|
16
|
+
['indepot', 'in_transit'], // Processed at the delivery centre.
|
|
17
|
+
['agent', 'in_transit'], // Handed to a regional delivery partner.
|
|
18
|
+
['accept', 'out_for_delivery'], // Onboard for delivery.
|
|
19
|
+
['offboard-depot', 'failed_attempt'], // Back at the depot undelivered; rescheduled for the next business day.
|
|
20
|
+
['faildeliver', 'failed_attempt'],
|
|
21
|
+
['deliver', 'delivered'],
|
|
22
|
+
]);
|
|
23
|
+
/** A wording in `normalizeStatusWording` form, with dashes made plain and no closing full stop. */
|
|
24
|
+
function key(wording) {
|
|
25
|
+
return normalizeStatusWording(wording).replace(/\s*[–—]\s*/g, ' - ').replace(/[\s.]+$/, '');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The help centre's statuses, by their whole wording. They come before the
|
|
29
|
+
* row's kind, which several of them share.
|
|
30
|
+
*/
|
|
31
|
+
const WORDINGS = new Map([
|
|
32
|
+
['Shipment information received', 'registered'],
|
|
33
|
+
['Your parcel has been picked up', 'accepted'],
|
|
34
|
+
['Received at Collection Point and awaiting collection by our driver', 'accepted'],
|
|
35
|
+
['Pickup attempt was unsuccessful', 'exception'],
|
|
36
|
+
['Pickup attempt was unsuccessful. Awaiting rebook confirmation by sender', 'exception'],
|
|
37
|
+
['Arrived at facility for processing', 'in_transit'],
|
|
38
|
+
['Processed at delivery centre', 'in_transit'],
|
|
39
|
+
['In transit to delivery centre', 'in_transit'],
|
|
40
|
+
['Arrived at delivery centre', 'in_transit'],
|
|
41
|
+
['On the way to our delivery partner', 'in_transit'],
|
|
42
|
+
['In transit to regional delivery centre', 'in_transit'],
|
|
43
|
+
['Incomplete Shipment Received', 'in_transit'],
|
|
44
|
+
['In Transit with Couriers Please', 'in_transit'],
|
|
45
|
+
['Processing for collection at a nearby collection point', 'in_transit'],
|
|
46
|
+
['Onboard for delivery', 'out_for_delivery'],
|
|
47
|
+
['Resent for delivery', 'out_for_delivery'],
|
|
48
|
+
['Delivery rescheduled for next business day', 'failed_attempt'],
|
|
49
|
+
['Delivery attempted – Signature required', 'failed_attempt'],
|
|
50
|
+
['Delivery attempted – Signature required – Redirected to a collection point', 'failed_attempt'],
|
|
51
|
+
['Delivery attempted – Unsafe to leave', 'failed_attempt'],
|
|
52
|
+
['Delivery attempted – Unsafe to leave – Sent to a collection point', 'failed_attempt'],
|
|
53
|
+
['Delivery attempted – Business was closed', 'failed_attempt'],
|
|
54
|
+
['Delivery attempted – Additional address information required', 'exception'],
|
|
55
|
+
['Unable to deliver', 'exception'],
|
|
56
|
+
['Delivery attempted – Delivery refused by receiver', 'returned'],
|
|
57
|
+
['Pickup at Collection Point – Ready for collection', 'ready_for_pickup'],
|
|
58
|
+
['Depot collection arranged', 'ready_for_pickup'],
|
|
59
|
+
['Parcel has been collected from the collection point', 'delivered'],
|
|
60
|
+
['Collected from Depot', 'delivered'],
|
|
61
|
+
['Delivered', 'delivered'],
|
|
62
|
+
['Delivered with Authority to Leave (ATL)', 'delivered'],
|
|
63
|
+
['Collection period expired – Return to sender in progress', 'returned'],
|
|
64
|
+
['Return to Sender In Progress', 'returned'],
|
|
65
|
+
].map(([wording, stage]) => [key(wording), stage]));
|
|
66
|
+
/**
|
|
67
|
+
* Notices the help centre lists that move nothing: a new delivery date or the
|
|
68
|
+
* receiver's instructions for the delivery still to come.
|
|
69
|
+
*/
|
|
70
|
+
const NOTICES = [
|
|
71
|
+
['Delivery rescheduled', 'A new delivery date, which tracking shows; the parcel does not move.'],
|
|
72
|
+
['Delivery instructions – Rescheduled', 'The receiver chose a new delivery date; it moves nothing.'],
|
|
73
|
+
['Delivery instructions – Collection point', 'The receiver redirected the parcel to a collection point; the parcel has not moved yet.'],
|
|
74
|
+
['Delivery instructions – Leave with Neighbour', 'An instruction for the delivery still to come; it moves nothing.'],
|
|
75
|
+
['Delivery instructions – Authority to Leave (ATL)', 'An instruction for the delivery still to come; it moves nothing.'],
|
|
76
|
+
['Delivery instructions – New delivery address', 'An instruction for the delivery still to come; it moves nothing.'],
|
|
77
|
+
];
|
|
78
|
+
const NOTES = new Set(NOTICES.map(([wording]) => key(wording)));
|
|
79
|
+
/** The stage CouriersPlease's vocabulary gives a row's wording, else its kind. */
|
|
80
|
+
export function couriersPleaseMappedStage(code, wording) {
|
|
81
|
+
return WORDINGS.get(key(wording)) ?? (code ? CODES.get(code.toLowerCase()) : undefined);
|
|
82
|
+
}
|
|
83
|
+
/** A scan's stage: its wording or kind, else the shared wording rules, else nothing. */
|
|
84
|
+
export function couriersPleaseStage(code, wording) {
|
|
85
|
+
if (NOTES.has(key(wording)))
|
|
86
|
+
return null;
|
|
87
|
+
const mapped = couriersPleaseMappedStage(code || null, wording);
|
|
88
|
+
if (mapped)
|
|
89
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
90
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
91
|
+
return worded.source === 'none' ? null : worded;
|
|
92
|
+
}
|
|
93
|
+
export const statusMap = {
|
|
94
|
+
stage: (code, wording) => (NOTES.has(key(wording)) ? undefined : couriersPleaseMappedStage(code, wording)),
|
|
95
|
+
gaps: NOTICES.map(([wording, note]) => ({ wording: normalizeStatusWording(wording), note })),
|
|
96
|
+
};
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "couriersplease",
|
|
3
3
|
"entries": [
|
|
4
|
-
{"wording": "Shipment information received", "stage": "registered", "language": "en", "confirmedBy": "
|
|
5
|
-
{"wording": "Your parcel has been picked up", "stage": "accepted", "language": "en", "confirmedBy": "
|
|
4
|
+
{"code": "manifest", "wording": "Shipment information received", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "The sender has created the shipment; Couriers Please may not have the parcel yet."},
|
|
5
|
+
{"code": "pickup", "wording": "Your parcel has been picked up", "stage": "accepted", "language": "en", "confirmedBy": "live"},
|
|
6
6
|
{"wording": "Pickup attempt was unsuccessful", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "A collection attempt at the sender failed."},
|
|
7
|
-
{"wording": "
|
|
8
|
-
{"wording": "
|
|
9
|
-
{"wording": "
|
|
10
|
-
{"wording": "
|
|
7
|
+
{"code": "futile", "wording": "Pickup attempt was unsuccessful. Awaiting rebook confirmation by sender", "stage": "exception", "language": "en", "confirmedBy": "live", "note": "The reply breaks the line between the two sentences."},
|
|
8
|
+
{"code": "reweigh", "wording": "Arrived at facility for processing", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The reply ends it with a full stop, which the adapter drops."},
|
|
9
|
+
{"code": "indepot", "wording": "Processed at delivery centre", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
|
|
10
|
+
{"code": "transit", "wording": "In transit to delivery centre", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The reply appends the line haul's two cities in brackets."},
|
|
11
|
+
{"code": "deconsolidate", "wording": "Arrived at delivery centre", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
|
|
11
12
|
{"wording": "On the way to our delivery partner", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "Handed to a partner that completes the delivery."},
|
|
12
|
-
{"wording": "In transit to regional delivery centre", "stage": "in_transit", "language": "en", "confirmedBy": "
|
|
13
|
-
{"wording": "
|
|
14
|
-
{"wording": "
|
|
15
|
-
{"wording": "Delivery rescheduled", "language": "en", "confirmedBy": "
|
|
13
|
+
{"code": "agent", "wording": "In transit to regional delivery centre", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Handed to a regional delivery partner; also seen as a line haul scan."},
|
|
14
|
+
{"code": "status", "wording": "In Transit with Couriers Please", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "A note on the item's history beside its line haul scans."},
|
|
15
|
+
{"code": "accept", "wording": "Onboard for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live"},
|
|
16
|
+
{"code": "offboard-depot", "wording": "Delivery rescheduled for next business day", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "Back at the depot undelivered."},
|
|
17
|
+
{"wording": "Delivery rescheduled", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "A new delivery date, which tracking shows; the parcel does not move."},
|
|
16
18
|
{"wording": "Resent for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
17
19
|
{"wording": "Delivery attempted – Signature required", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
18
20
|
{"wording": "Delivery attempted – Signature required – Redirected to a collection point", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
@@ -20,23 +22,24 @@
|
|
|
20
22
|
{"wording": "Delivery attempted – Unsafe to leave – Sent to a collection point", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
21
23
|
{"wording": "Delivery attempted – Business was closed", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
22
24
|
{"wording": "Delivery attempted – Additional address information required", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "Needs a corrected address before delivery can go on."},
|
|
23
|
-
{"wording": "Unable to deliver", "stage": "exception", "language": "en", "confirmedBy": "
|
|
25
|
+
{"code": "faildeliver", "wording": "Unable to deliver", "stage": "exception", "language": "en", "confirmedBy": "live", "note": "The reply adds a sentence pointing to customer support with a link, which the adapter drops. The help centre says the issue needs further review."},
|
|
24
26
|
{"wording": "Delivery attempted – Delivery refused by receiver", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Refused and being returned to the sender."},
|
|
25
27
|
{"wording": "Processing for collection at a nearby collection point", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "At the collection point but not yet ready to collect."},
|
|
26
28
|
{"wording": "Pickup at Collection Point – Ready for collection", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
27
29
|
{"wording": "Parcel has been collected from the collection point", "stage": "delivered", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
30
|
+
{"code": "deliver", "wording": "Parcel has been collected from", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "Followed by the collection point's name."},
|
|
28
31
|
{"wording": "Collection period expired – Return to sender in progress", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
29
32
|
{"wording": "Received at Collection Point and awaiting collection by our driver", "stage": "accepted", "language": "en", "confirmedBy": "official-doc", "note": "A customer drop-off waiting for a Couriers Please driver."},
|
|
30
33
|
{"wording": "Depot collection arranged", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "official-doc", "note": "The parcel is set aside for collection at a depot; the help centre says to wait for collection details."},
|
|
31
34
|
{"wording": "Collected from Depot", "stage": "delivered", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
32
|
-
{"
|
|
33
|
-
{"wording": "Delivered with Authority to Leave (ATL)", "stage": "delivered", "language": "en", "confirmedBy": "
|
|
35
|
+
{"code": "deliver", "wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live"},
|
|
36
|
+
{"code": "deliver", "wording": "Delivered with Authority to Leave (ATL)", "stage": "delivered", "language": "en", "confirmedBy": "live"},
|
|
34
37
|
{"wording": "Return to Sender In Progress", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
35
38
|
{"wording": "Incomplete Shipment Received", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "Not all parcels of a multi-parcel shipment have arrived yet."},
|
|
36
|
-
{"wording": "Delivery instructions – Rescheduled", "language": "en", "confirmedBy": "official-doc", "note": "The receiver chose a new delivery date."},
|
|
37
|
-
{"wording": "Delivery instructions – Collection point", "language": "en", "confirmedBy": "official-doc", "note": "The receiver redirected the parcel to a collection point."},
|
|
38
|
-
{"wording": "Delivery instructions – Leave with Neighbour", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
39
|
-
{"wording": "Delivery instructions – Authority to Leave (ATL)", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."},
|
|
40
|
-
{"wording": "Delivery instructions – New delivery address", "language": "en", "confirmedBy": "official-doc", "note": "Listed in Couriers Please's help centre article on tracking statuses."}
|
|
39
|
+
{"wording": "Delivery instructions – Rescheduled", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "The receiver chose a new delivery date; it moves nothing."},
|
|
40
|
+
{"wording": "Delivery instructions – Collection point", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "The receiver redirected the parcel to a collection point; it moves nothing."},
|
|
41
|
+
{"wording": "Delivery instructions – Leave with Neighbour", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "Listed in Couriers Please's help centre article on tracking statuses; it moves nothing."},
|
|
42
|
+
{"wording": "Delivery instructions – Authority to Leave (ATL)", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "Listed in Couriers Please's help centre article on tracking statuses; it moves nothing."},
|
|
43
|
+
{"wording": "Delivery instructions – New delivery address", "language": "en", "confirmedBy": "official-doc", "unmapped": true, "note": "Listed in Couriers Please's help centre article on tracking statuses; it moves nothing."}
|
|
41
44
|
]
|
|
42
45
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The tracking search of the GDEX app, which app.gdexpress.com also serves on the web. */
|
|
5
|
+
export declare const GDEX_API = "https://app.gdexpress.com/api/trackings";
|
|
6
|
+
/** The address the app asks for one number. */
|
|
7
|
+
export declare function gdexLookupUrl(number: string): string;
|
|
8
|
+
export declare class GdexTracker {
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(options?: {
|
|
11
|
+
fetcher?: typeof fetch;
|
|
12
|
+
recorder?: StepRecorder;
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
});
|
|
15
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
16
|
+
}
|
|
17
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
5
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
6
|
+
import { GDEX_MAX_BYTES, normalizeGdexNumber, parseGdex, PROVIDER } from './parser.js';
|
|
7
|
+
/** The tracking search of the GDEX app, which app.gdexpress.com also serves on the web. */
|
|
8
|
+
export const GDEX_API = 'https://app.gdexpress.com/api/trackings';
|
|
9
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
10
|
+
// Bot checks a CDN or firewall could put in place of the reply.
|
|
11
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|challenge-platform|<title>\s*Attention Required|AwsWafIntegration/i;
|
|
12
|
+
/** The address the app asks for one number. */
|
|
13
|
+
export function gdexLookupUrl(number) {
|
|
14
|
+
return `${GDEX_API}?search=${encodeURIComponent(number)}`;
|
|
15
|
+
}
|
|
16
|
+
function json(body) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(body);
|
|
19
|
+
}
|
|
20
|
+
catch (cause) {
|
|
21
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
22
|
+
throw new ChallengeError(PROVIDER, 'GDEX answered with a browser check', { cause });
|
|
23
|
+
throw new SchemaError(PROVIDER, 'GDEX returned a reply that is not JSON', { cause });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class GdexTracker {
|
|
27
|
+
options;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
async fetch(raw, context = {}) {
|
|
32
|
+
const number = normalizeGdexNumber(raw);
|
|
33
|
+
const read = async (signal, timeoutMs) => {
|
|
34
|
+
const { response, bytes } = await fetchBounded(gdexLookupUrl(number), { method: 'GET', signal, headers: {
|
|
35
|
+
Accept: 'application/json, text/plain, */*', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
36
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: GDEX_MAX_BYTES,
|
|
37
|
+
redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
38
|
+
if (REDIRECTS.includes(response.status))
|
|
39
|
+
throw new TransportError(PROVIDER, 'GDEX redirected its tracking lookup');
|
|
40
|
+
// The app reads an unknown number from a 200 reply; a 404 is a moved route.
|
|
41
|
+
if (response.status === 404 || response.status === 410)
|
|
42
|
+
throw new TransportError(PROVIDER, 'GDEX tracking lookup is unavailable');
|
|
43
|
+
return parseGdex(json(decodeText(bytes)), number);
|
|
44
|
+
};
|
|
45
|
+
return runSteps({ carrier: 'gdex', budgetMs: context.budgetMs ?? 20_000, signal: context.signal,
|
|
46
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
47
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, remainingMs) },
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const adapter = (environment) => {
|
|
52
|
+
const tracker = new GdexTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
53
|
+
return {
|
|
54
|
+
id: 'gdex', recordsSteps: true, steps: ['direct'],
|
|
55
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
56
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeGdexNumber(number))),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -19,18 +19,58 @@
|
|
|
19
19
|
"timezone": "Asia/Kuala_Lumpur",
|
|
20
20
|
"tracking": {
|
|
21
21
|
"mode": "automatic",
|
|
22
|
-
"adapter": "
|
|
23
|
-
"steps": [
|
|
22
|
+
"adapter": "gdex",
|
|
23
|
+
"steps": [
|
|
24
|
+
"direct"
|
|
25
|
+
],
|
|
26
|
+
"recognition": {
|
|
27
|
+
"rank": 957
|
|
28
|
+
}
|
|
24
29
|
},
|
|
25
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history",
|
|
32
|
+
"location",
|
|
33
|
+
"provider_code",
|
|
34
|
+
"delivered_at"
|
|
35
|
+
],
|
|
26
36
|
"portal": {
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
37
|
+
"url": "https://gdexpress.com/tracking/?consignmentno={trackingNumber}",
|
|
38
|
+
"searchUrl": "https://gdexpress.com/our-business/delivery/tools-references/tracking/",
|
|
39
|
+
"canaryUrl": "https://app.gdexpress.com/",
|
|
40
|
+
"shows": [
|
|
41
|
+
"status",
|
|
42
|
+
"history",
|
|
43
|
+
"location"
|
|
44
|
+
],
|
|
45
|
+
"scraped": [
|
|
46
|
+
"status",
|
|
47
|
+
"history",
|
|
48
|
+
"location",
|
|
49
|
+
"provider_code",
|
|
50
|
+
"delivered_at"
|
|
51
|
+
],
|
|
30
52
|
"discarded": [],
|
|
31
|
-
"unavailable": [
|
|
53
|
+
"unavailable": [
|
|
54
|
+
"eta",
|
|
55
|
+
"weight",
|
|
56
|
+
"service_name",
|
|
57
|
+
"offset_timestamps",
|
|
58
|
+
"sender_name",
|
|
59
|
+
"recipient_name",
|
|
60
|
+
"proof_of_delivery"
|
|
61
|
+
]
|
|
32
62
|
},
|
|
33
63
|
"links": [
|
|
64
|
+
{
|
|
65
|
+
"domains": [
|
|
66
|
+
"gdexpress.com",
|
|
67
|
+
"www.gdexpress.com"
|
|
68
|
+
],
|
|
69
|
+
"pathPattern": "^/tracking/?$",
|
|
70
|
+
"params": [
|
|
71
|
+
"consignmentno"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
34
74
|
{
|
|
35
75
|
"domains": [
|
|
36
76
|
"web2.gdexpress.com"
|
|
@@ -250,6 +250,44 @@
|
|
|
250
250
|
]
|
|
251
251
|
},
|
|
252
252
|
"note": "A Lowyat forum post about the GDex courier service gives it as the tracking number of a parcel sent through a GDex branch."
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"number": "MY37324153968",
|
|
256
|
+
"role": "shipment",
|
|
257
|
+
"evidence": "public_shipment_report",
|
|
258
|
+
"source": {
|
|
259
|
+
"url": "https://web.archive.org/web/20260504075317/https://gdexpress.com/tracking/?consignmentno=MY37324153968",
|
|
260
|
+
"date": "2026-05-04",
|
|
261
|
+
"dateKind": "capture",
|
|
262
|
+
"access": "full_page"
|
|
263
|
+
},
|
|
264
|
+
"context": {
|
|
265
|
+
"assessment": "confirmed"
|
|
266
|
+
},
|
|
267
|
+
"expect": {
|
|
268
|
+
"carrier": "gdex",
|
|
269
|
+
"confidence": "high"
|
|
270
|
+
},
|
|
271
|
+
"note": "An archived lookup on GDEX's current tracking page; the opened capture shows the consignment's history."
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"number": "MY37279142960",
|
|
275
|
+
"role": "shipment",
|
|
276
|
+
"evidence": "public_shipment_report",
|
|
277
|
+
"source": {
|
|
278
|
+
"url": "https://web.archive.org/web/20250805102258/https://gdexpress.com/tracking/?consignmentno=MY37279142960",
|
|
279
|
+
"date": "2025-08-05",
|
|
280
|
+
"dateKind": "capture",
|
|
281
|
+
"access": "full_page"
|
|
282
|
+
},
|
|
283
|
+
"context": {
|
|
284
|
+
"assessment": "candidate"
|
|
285
|
+
},
|
|
286
|
+
"expect": {
|
|
287
|
+
"carrier": "gdex",
|
|
288
|
+
"confidence": "high"
|
|
289
|
+
},
|
|
290
|
+
"note": "An archived lookup on GDEX's current tracking page passes it as the consignment number; the opened capture shows no history."
|
|
253
291
|
}
|
|
254
292
|
]
|
|
255
293
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "GDEX";
|
|
3
|
+
export declare const GDEX_MAX_BYTES: number;
|
|
4
|
+
/** A GDEX consignment number, in upper case; anything else is not one GDEX issues. */
|
|
5
|
+
export declare function normalizeGdexNumber(raw: string): string;
|
|
6
|
+
/** A scan's wording, without the person a delivery names, a receiver or signer, or phone numbers. */
|
|
7
|
+
export declare function gdexWording(raw: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The reply of the GDEX app's `/api/trackings?search=` for one number: one
|
|
10
|
+
* entry per number searched. A number GDEX has no record of comes back with
|
|
11
|
+
* `valid: false` and no data, which the app and the tracking page report as
|
|
12
|
+
* not found. A known consignment's scans are in `data.trackStatuses`, newest
|
|
13
|
+
* first.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseGdex(payload: unknown, raw: string): CarrierResult;
|