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.
Files changed (48) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +23 -5
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +50 -0
  5. package/dist/browser/scraper.js +21 -5
  6. package/dist/carriers/couriersplease/adapter.d.ts +17 -0
  7. package/dist/carriers/couriersplease/adapter.js +111 -0
  8. package/dist/carriers/couriersplease/carrier.json +51 -7
  9. package/dist/carriers/couriersplease/parser.d.ts +22 -0
  10. package/dist/carriers/couriersplease/parser.js +222 -0
  11. package/dist/carriers/couriersplease/status.d.ts +7 -0
  12. package/dist/carriers/couriersplease/status.js +96 -0
  13. package/dist/carriers/couriersplease/statuses.json +21 -18
  14. package/dist/carriers/gdex/adapter.d.ts +17 -0
  15. package/dist/carriers/gdex/adapter.js +58 -0
  16. package/dist/carriers/gdex/carrier.json +47 -7
  17. package/dist/carriers/gdex/numbers.json +38 -0
  18. package/dist/carriers/gdex/parser.d.ts +15 -0
  19. package/dist/carriers/gdex/parser.js +149 -0
  20. package/dist/carriers/gdex/status.d.ts +7 -0
  21. package/dist/carriers/gdex/status.js +41 -0
  22. package/dist/carriers/gdex/statuses.json +12 -3
  23. package/dist/carriers/leopards-courier/adapter.d.ts +13 -0
  24. package/dist/carriers/leopards-courier/adapter.js +73 -0
  25. package/dist/carriers/leopards-courier/carrier.json +52 -7
  26. package/dist/carriers/leopards-courier/numbers.json +6 -8
  27. package/dist/carriers/leopards-courier/parser.d.ts +26 -0
  28. package/dist/carriers/leopards-courier/parser.js +190 -0
  29. package/dist/carriers/leopards-courier/status.d.ts +7 -0
  30. package/dist/carriers/leopards-courier/status.js +34 -0
  31. package/dist/carriers/leopards-courier/statuses.json +4 -1
  32. package/dist/carriers/pos-indonesia/adapter.d.ts +14 -0
  33. package/dist/carriers/pos-indonesia/adapter.js +74 -0
  34. package/dist/carriers/pos-indonesia/carrier.json +53 -7
  35. package/dist/carriers/pos-indonesia/numbers.json +52 -14
  36. package/dist/carriers/pos-indonesia/parser.d.ts +25 -0
  37. package/dist/carriers/pos-indonesia/parser.js +201 -0
  38. package/dist/carriers/pos-indonesia/status.d.ts +12 -0
  39. package/dist/carriers/pos-indonesia/status.js +41 -0
  40. package/dist/carriers/pos-indonesia/statuses.json +7 -0
  41. package/dist/core/catalog/statusMaps.js +12 -5
  42. package/dist/data/catalog.json +23 -5
  43. package/dist/generated/catalog.d.ts +17 -5
  44. package/dist/generated/catalog.js +23 -5
  45. package/dist/generated/recognition.js +4 -0
  46. package/dist/generated/registry.js +12 -4
  47. package/dist/server/openapi.json +1 -1
  48. package/package.json +1 -1
@@ -0,0 +1,149 @@
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 { firstOccurrence } from '../../core/time/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { gdexStage } from './status.js';
9
+ export const PROVIDER = 'GDEX';
10
+ // A long history is a few kilobytes.
11
+ export const GDEX_MAX_BYTES = 256 * 1024;
12
+ const ZONE = 'Asia/Kuala_Lumpur';
13
+ // Consignment notes print ten digits, or MY and eleven digits.
14
+ const NUMBER = /^(?:\d{10}|MY\d{11})$/;
15
+ // "2026-05-04T17:45:18", optionally with a fraction of a second.
16
+ const WALL = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d{1,6})?$/;
17
+ const MAX_ROWS = 500;
18
+ const MAX_EVENTS = 100;
19
+ /** A GDEX consignment number, in upper case; anything else is not one GDEX issues. */
20
+ export function normalizeGdexNumber(raw) {
21
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
22
+ if (!NUMBER.test(number))
23
+ throw new InvalidInputError(PROVIDER, 'GDEX consignment numbers are ten digits, or MY and eleven digits');
24
+ return number;
25
+ }
26
+ /** An optional text field; anything but a string is a changed reply. */
27
+ function text(row, key, maxLength = 300) {
28
+ const value = row[key];
29
+ if (value === null || value === undefined)
30
+ return '';
31
+ if (typeof value !== 'string')
32
+ throw new SchemaError(PROVIDER, `GDEX returned an invalid ${key}`);
33
+ return clean(value, maxLength);
34
+ }
35
+ // Whoever a delivery scan names after its own word: "Delivered to …", "Delivered (…)".
36
+ const DELIVERED_TO = /^(delivered)\s*(?:[,;:.\-–(]|\b(?:to|by)\b)[\s\S]*$/i;
37
+ // A receiver or signer named after any other scan's words.
38
+ const RECEIVER = /\s*[,;:.\-–(]?\s*\b(?:received|signed) by\b[\s\S]*$/i;
39
+ // A run of nine or more digits, with spaces, brackets, dots or hyphens
40
+ // between them, as phone numbers are written.
41
+ const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
42
+ /** A scan's wording, without the person a delivery names, a receiver or signer, or phone numbers. */
43
+ export function gdexWording(raw) {
44
+ const own = clean(raw).replace(DELIVERED_TO, '$1').replace(RECEIVER, '').replace(PHONE, '');
45
+ return clean(own).replace(/^[\s,;:.\-–]+|[\s,;:\-–]+$/g, '');
46
+ }
47
+ /**
48
+ * A scan's `updatedAt`: the branch's wall clock, with no zone. GDEX scans in
49
+ * Malaysia, whose clock has no daylight saving, so it is read in
50
+ * `Asia/Kuala_Lumpur`.
51
+ */
52
+ function scanTime(row) {
53
+ const wall = WALL.exec(text(row, 'updatedAt', 40))?.[1] ?? '';
54
+ // Parsing in UTC checks the calendar digits only. Luxon rolls 24:00:00 over to the next day.
55
+ const checked = DateTime.fromISO(wall, { zone: 'UTC' });
56
+ if (!wall || !checked.isValid || checked.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== wall) {
57
+ throw new SchemaError(PROVIDER, 'GDEX returned an invalid scan time');
58
+ }
59
+ return firstOccurrence(DateTime.fromISO(wall, { zone: ZONE })).toISO({ suppressMilliseconds: true });
60
+ }
61
+ function projectRow(row) {
62
+ const description = gdexWording(text(row, 'description', 500));
63
+ if (!description)
64
+ throw new SchemaError(PROVIDER, 'GDEX returned a scan without wording');
65
+ const time = scanTime(row);
66
+ const location = text(row, 'location', 120);
67
+ // The app's category for the scan: "Pickup", "In Transit", "Out For Delivery", "Delivered".
68
+ const code = text(row, 'status', 40);
69
+ const staged = gdexStage(code, description);
70
+ const event = {
71
+ time,
72
+ ...(location ? { location } : {}),
73
+ description,
74
+ ...(code ? { provider_code: code } : {}),
75
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
76
+ };
77
+ return { key: JSON.stringify([time, code, description, location]), event };
78
+ }
79
+ /** The entry of the search reply for the number asked. */
80
+ function entryFor(payload, number) {
81
+ if (!isRecord(payload) || !Array.isArray(payload.data))
82
+ throw new SchemaError(PROVIDER, 'GDEX returned an invalid envelope');
83
+ const entries = payload.data;
84
+ if (!entries.length)
85
+ throw new SchemaError(PROVIDER, 'GDEX answered without a consignment');
86
+ if (entries.some((entry) => !isRecord(entry) || typeof entry.trackingNumber !== 'string')) {
87
+ throw new SchemaError(PROVIDER, 'GDEX returned a consignment without its number');
88
+ }
89
+ const matching = entries
90
+ .filter((entry) => clean(entry.trackingNumber, 64).toUpperCase() === number);
91
+ if (matching.length !== 1)
92
+ throw new IndeterminateError(PROVIDER, 'GDEX answered for another consignment');
93
+ return matching[0];
94
+ }
95
+ /**
96
+ * The reply of the GDEX app's `/api/trackings?search=` for one number: one
97
+ * entry per number searched. A number GDEX has no record of comes back with
98
+ * `valid: false` and no data, which the app and the tracking page report as
99
+ * not found. A known consignment's scans are in `data.trackStatuses`, newest
100
+ * first.
101
+ */
102
+ export function parseGdex(payload, raw) {
103
+ const number = normalizeGdexNumber(raw);
104
+ const entry = entryFor(payload, number);
105
+ if (entry.valid === false) {
106
+ if (entry.data === null || entry.data === undefined)
107
+ throw new NotFoundError(PROVIDER);
108
+ throw new SchemaError(PROVIDER, 'GDEX returned data for a consignment it calls invalid');
109
+ }
110
+ if (entry.valid !== true)
111
+ throw new SchemaError(PROVIDER, 'GDEX returned an invalid validity flag');
112
+ const data = entry.data;
113
+ if (!isRecord(data))
114
+ throw new SchemaError(PROVIDER, 'GDEX returned a consignment without its record');
115
+ const rows = data.trackStatuses ?? [];
116
+ if (!Array.isArray(rows))
117
+ throw new SchemaError(PROVIDER, 'GDEX returned a consignment with invalid scans');
118
+ if (rows.length > MAX_ROWS)
119
+ throw new SchemaError(PROVIDER, 'GDEX returned an oversized history');
120
+ const seen = new Set();
121
+ const projected = [];
122
+ for (const row of rows) {
123
+ if (!isRecord(row))
124
+ throw new SchemaError(PROVIDER, 'GDEX returned an invalid scan');
125
+ const scan = projectRow(row);
126
+ if (seen.has(scan.key))
127
+ continue;
128
+ seen.add(scan.key);
129
+ projected.push(scan.event);
130
+ }
131
+ if (!projected.length)
132
+ throw new NoHistoryError(PROVIDER);
133
+ // Newest first, as the app lists them; a reply listed oldest first is turned round.
134
+ const instant = (event) => Date.parse(event.time);
135
+ const events = instant(projected[0]) < instant(projected.at(-1)) ? projected.reverse() : projected;
136
+ const newest = events[0];
137
+ const current = events.find((event) => event.stage);
138
+ const stage = current?.stage;
139
+ return {
140
+ status: stage ? languageStageStatus(stage) : 'unknown',
141
+ ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
142
+ last_status_text: newest.description,
143
+ last_update: newest.time,
144
+ expected_delivery: null,
145
+ ...(stage === 'delivered' ? { delivered_at: current.time } : {}),
146
+ events: events.slice(0, MAX_EVENTS),
147
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
148
+ };
149
+ }
@@ -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 GDEX's own vocabulary gives a scan: its wording, else its category. */
4
+ export declare function gdexMappedStage(code: string | null, wording: string): Stage | undefined;
5
+ /** A scan's stage: its wording or category, else the shared wording rules, else nothing. */
6
+ export declare function gdexStage(code: string, wording: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,41 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * GDEX's scan wordings, in `normalizeStatusWording` form. Hub and branch
5
+ * scans name the site: "Inbound to Kluang", "Outbound by Auto Hub".
6
+ */
7
+ const WORDINGS = [
8
+ [/^picked up by courier$/, 'accepted'],
9
+ [/^(?:inbound|outbound) (?:to|by|from)\b/, 'in_transit'],
10
+ [/^in transit$/, 'in_transit'],
11
+ [/^out for delivery$/, 'out_for_delivery'],
12
+ // Filed under "In Transit": the parcel goes out again.
13
+ [/^(?:unsuccessful delivery|undelivered) due to\b/, 'failed_attempt'],
14
+ [/^delivered$/, 'delivered'],
15
+ ];
16
+ /**
17
+ * The app's category for a scan, its `status`, in lower case. "In Transit"
18
+ * also holds failed deliveries, so it stages nothing on its own.
19
+ */
20
+ const CODES = new Map([
21
+ ['pickup', 'accepted'],
22
+ ['out for delivery', 'out_for_delivery'],
23
+ ['delivered', 'delivered'],
24
+ ]);
25
+ /** The stage GDEX's own vocabulary gives a scan: its wording, else its category. */
26
+ export function gdexMappedStage(code, wording) {
27
+ const normalized = normalizeStatusWording(wording);
28
+ return WORDINGS.find(([pattern]) => pattern.test(normalized))?.[1] ?? (code ? CODES.get(normalizeStatusWording(code)) : undefined);
29
+ }
30
+ /** A scan's stage: its wording or category, else the shared wording rules, else nothing. */
31
+ export function gdexStage(code, wording) {
32
+ const mapped = gdexMappedStage(code || null, wording);
33
+ if (mapped)
34
+ return { stage: mapped, source: 'carrier_map' };
35
+ const worded = classifyWording(wording, 'in_transit');
36
+ return worded.source === 'none' ? null : worded;
37
+ }
38
+ export const statusMap = {
39
+ stage: (code, wording) => gdexMappedStage(code, wording),
40
+ gaps: [],
41
+ };
@@ -1,13 +1,22 @@
1
1
  {
2
2
  "carrier": "gdex",
3
3
  "entries": [
4
- {"wording": "Picked up by courier", "stage": "accepted", "language": "en", "confirmedBy": "portal"},
4
+ {"code": "Pickup", "wording": "Picked up by courier", "stage": "accepted", "language": "en", "confirmedBy": "live"},
5
5
  {"wording": "Outbound from SIN station", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Left the named GDEX station."},
6
+ {"wording": "Outbound to HUB", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Left for the hub."},
7
+ {"code": "In Transit", "wording": "Outbound by Auto Hub", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Left the named hub."},
8
+ {"code": "In Transit", "wording": "Inbound by Auto Hub", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Arrived at the named hub."},
9
+ {"code": "In Transit", "wording": "Inbound by PJY Hub (Petaling Jaya)", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Arrived at the named hub."},
10
+ {"code": "In Transit", "wording": "Outbound by PJY Hub (Petaling Jaya)", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Left the named hub."},
11
+ {"code": "In Transit", "wording": "Outbound from OCU Department", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Left the named department, after a failed delivery."},
12
+ {"code": "In Transit", "wording": "Inbound to Kluang", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Arrived at the named delivery branch."},
13
+ {"code": "In Transit", "wording": "Inbound to Petaling Jaya", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Arrived at the named delivery branch."},
6
14
  {"wording": "In transit", "stage": "in_transit", "language": "en", "confirmedBy": "portal"},
7
15
  {"wording": "Inbound to SDG station", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Arrived at the named delivery station."},
8
16
  {"wording": "Inbound to RWG station", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Arrived at the named delivery station."},
9
- {"wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal"},
17
+ {"code": "Out For Delivery", "wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live"},
18
+ {"code": "In Transit", "wording": "Unsuccessful delivery due to Customer Request for Rescheduled Delivery", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "Not delivered because the customer asked for another day; the category stays In Transit."},
10
19
  {"wording": "Undelivered due to Customer Requests OC", "stage": "failed_attempt", "language": "en", "confirmedBy": "portal", "note": "Not delivered because the customer asked for another arrangement."},
11
- {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal"}
20
+ {"code": "Delivered", "wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live"}
12
21
  ]
13
22
  }
@@ -0,0 +1,13 @@
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
+ export declare class LeopardsTracker {
5
+ private readonly options;
6
+ constructor(options?: {
7
+ fetcher?: typeof fetch;
8
+ recorder?: StepRecorder;
9
+ userAgent?: string;
10
+ });
11
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
12
+ }
13
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,73 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { IndeterminateError, TransportError, UpstreamHttpError, UpstreamNetworkError } 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 { LEOPARDS_LOOKUP, LEOPARDS_MAX_BYTES, LEOPARDS_ORIGIN, LEOPARDS_VIEW, normalizeLeopardsNumber, parseLeopards, PROVIDER, readLeopardsLookup, } from './parser.js';
7
+ const REDIRECTS = [301, 302, 303, 307, 308];
8
+ const GONE = [404, 410];
9
+ // Laravel's session cookie, which holds the consignment the lookup found.
10
+ const SESSION_COOKIE = /^leopards_official_session=([^\s;,]{1,4096})/;
11
+ /**
12
+ * The same failure without the request and response details fetchBounded
13
+ * attaches: they hold the session cookie.
14
+ */
15
+ function withoutSession(error) {
16
+ if (error instanceof UpstreamHttpError)
17
+ return new UpstreamHttpError(error.provider, error.status, error.retryAfterMs);
18
+ if (error instanceof UpstreamNetworkError)
19
+ return new UpstreamNetworkError(error.provider, error.cause);
20
+ return error;
21
+ }
22
+ export class LeopardsTracker {
23
+ options;
24
+ constructor(options = {}) {
25
+ this.options = options;
26
+ }
27
+ async fetch(raw, context = {}) {
28
+ const number = normalizeLeopardsNumber(raw);
29
+ // The tracking box's lookup keeps the consignment in a new session, and the tracking page shows it.
30
+ const read = async (signal, timeoutMs) => {
31
+ const deadline = performance.now() + timeoutMs;
32
+ const left = () => Math.max(1, Math.floor(deadline - performance.now()));
33
+ const get = (url, headers) => fetchBounded(url, { method: 'GET', signal,
34
+ headers: { 'User-Agent': userAgentOf(this.options.userAgent), 'Accept-Language': 'en', ...headers } }, { provider: PROVIDER, timeoutMs: left(), maxBytes: LEOPARDS_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher,
35
+ allowHttpStatuses: [...REDIRECTS, ...GONE] });
36
+ const answered = (response, what) => {
37
+ if (REDIRECTS.includes(response.status))
38
+ throw new TransportError(PROVIDER, `Leopards moved its ${what}`);
39
+ if (GONE.includes(response.status))
40
+ throw new TransportError(PROVIDER, `Leopards ${what} is unavailable`);
41
+ };
42
+ try {
43
+ const lookup = await get(`${LEOPARDS_LOOKUP}?${new URLSearchParams({ cn_number: number })}`, {
44
+ Accept: 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', Referer: `${LEOPARDS_ORIGIN}/`
45
+ });
46
+ answered(lookup.response, 'tracking lookup');
47
+ readLeopardsLookup(decodeText(lookup.bytes), number);
48
+ const session = lookup.response.headers.getSetCookie().map(cookie => SESSION_COOKIE.exec(cookie)?.[0]).find(Boolean);
49
+ if (!session)
50
+ throw new IndeterminateError(PROVIDER, 'Leopards did not open a tracking session', { reason: 'session_rejected' });
51
+ signal.throwIfAborted();
52
+ const page = await get(LEOPARDS_VIEW, { Accept: 'text/html,application/xhtml+xml', Cookie: session, Referer: `${LEOPARDS_ORIGIN}/` });
53
+ answered(page.response, 'tracking page');
54
+ return parseLeopards(decodeText(page.bytes), raw);
55
+ }
56
+ catch (error) {
57
+ throw withoutSession(error);
58
+ }
59
+ };
60
+ return runSteps({ carrier: 'leopards-courier', budgetMs: context.budgetMs ?? 20_000, signal: context.signal,
61
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
62
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, remainingMs) },
63
+ ]);
64
+ }
65
+ }
66
+ export const adapter = (environment) => {
67
+ const tracker = new LeopardsTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
68
+ return {
69
+ id: 'leopards-courier', recordsSteps: true, steps: ['direct'],
70
+ track: (input, context) => tracker.fetch(input.number, context),
71
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeLeopardsNumber(number))),
72
+ };
73
+ };
@@ -18,17 +18,62 @@
18
18
  "timezone": "Asia/Karachi",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "leopards-courier",
22
+ "steps": [
23
+ "direct"
24
+ ],
25
+ "recognition": {
26
+ "rank": 965
27
+ }
23
28
  },
24
- "capabilities": [],
29
+ "capabilities": [
30
+ "history",
31
+ "location",
32
+ "delivered_at",
33
+ "offset_timestamps"
34
+ ],
25
35
  "portal": {
26
36
  "searchUrl": "https://pk.leopardscourier.com/",
27
37
  "canaryUrl": "https://pk.leopardscourier.com/",
28
- "shows": [],
29
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
38
+ "shows": [
39
+ "status",
40
+ "history",
41
+ "location",
42
+ "progress_rail",
43
+ "origin",
44
+ "destination",
45
+ "sender_name",
46
+ "recipient_name",
47
+ "customer_reference",
48
+ "booking_details",
49
+ "piece_count",
50
+ "delivered_at",
51
+ "signatory"
52
+ ],
53
+ "scraped": [
54
+ "status",
55
+ "history",
56
+ "location",
57
+ "delivered_at",
58
+ "offset_timestamps",
59
+ "canonical_tracking_number"
60
+ ],
61
+ "discarded": [
62
+ "progress_rail",
63
+ "origin",
64
+ "destination",
65
+ "sender_name",
66
+ "recipient_name",
67
+ "customer_reference",
68
+ "booking_details",
69
+ "piece_count",
70
+ "signatory"
71
+ ],
72
+ "unavailable": [
73
+ "eta",
74
+ "service_name",
75
+ "weight"
76
+ ]
32
77
  },
33
78
  "links": [
34
79
  {
@@ -25,7 +25,7 @@
25
25
  },
26
26
  {
27
27
  "number": "KI0646202929",
28
- "role": "quarantined",
28
+ "role": "shipment",
29
29
  "evidence": "public_shipment_report",
30
30
  "source": {
31
31
  "url": "https://web.archive.org/web/20260918090914/https://pk.leopardscourier.com/shipment_tracking_view?cn_number=KI0646202929",
@@ -33,9 +33,8 @@
33
33
  "dateKind": "capture",
34
34
  "access": "full_page"
35
35
  },
36
- "quarantine": true,
37
36
  "context": {
38
- "assessment": "review"
37
+ "assessment": "confirmed"
39
38
  },
40
39
  "expect": {
41
40
  "carrier": "unknown",
@@ -46,11 +45,11 @@
46
45
  "aramex-nz"
47
46
  ]
48
47
  },
49
- "note": "Sent to Leopards' own tracker, as an archived address shows; the opened capture says the number is invalid or not found, so it stays quarantined."
48
+ "note": "Sent to Leopards' own tracker, as an archived address shows. The opened capture, taken before its first scan, says the number is invalid or not found; Leopards' tracking page later shows its delivery."
50
49
  },
51
50
  {
52
51
  "number": "KI0646319661",
53
- "role": "quarantined",
52
+ "role": "shipment",
54
53
  "evidence": "public_shipment_report",
55
54
  "source": {
56
55
  "url": "https://web.archive.org/web/20260922134325/https://pk.leopardscourier.com/shipment_tracking_view?cn_number=KI0646319661",
@@ -58,9 +57,8 @@
58
57
  "dateKind": "capture",
59
58
  "access": "full_page"
60
59
  },
61
- "quarantine": true,
62
60
  "context": {
63
- "assessment": "review"
61
+ "assessment": "confirmed"
64
62
  },
65
63
  "expect": {
66
64
  "carrier": "unknown",
@@ -71,7 +69,7 @@
71
69
  "aramex-nz"
72
70
  ]
73
71
  },
74
- "note": "Sent to Leopards' own tracker, as an archived address shows; the opened capture says the number is invalid or not found, so it stays quarantined."
72
+ "note": "Sent to Leopards' own tracker, as an archived address shows. The opened capture, taken before its first scan, says the number is invalid or not found; Leopards' tracking page later shows its delivery."
75
73
  }
76
74
  ]
77
75
  }
@@ -0,0 +1,26 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Leopards";
3
+ export declare const LEOPARDS_MAX_BYTES = 1000000;
4
+ export declare const LEOPARDS_ORIGIN = "https://pk.leopardscourier.com";
5
+ /** The lookup the tracking box sends the number to. */
6
+ export declare const LEOPARDS_LOOKUP = "https://pk.leopardscourier.com/shipment_tracking-new";
7
+ /** The page that shows the consignment the lookup kept in the session. */
8
+ export declare const LEOPARDS_VIEW = "https://pk.leopardscourier.com/shipment_tracking_view";
9
+ /** A Leopards consignment number: two letters and ten digits, nine digits taking a leading zero as the tracker does. */
10
+ export declare function normalizeLeopardsNumber(raw: string): string;
11
+ /** Whether a page is a firewall's or bot check's instead of the site's. */
12
+ export declare function isLeopardsBlockPage(html: string): boolean;
13
+ /**
14
+ * The lookup's reply: `{"success":true}` once the site has kept the
15
+ * consignment in the session, or the tracking page with its notice for a
16
+ * number it has no record of.
17
+ */
18
+ export declare function readLeopardsLookup(body: string, raw: string): void;
19
+ /**
20
+ * The tracking page for the consignment the lookup kept in the session: its
21
+ * number, a delivery box naming who signed, the booking details and its scans
22
+ * newest first, each with a date, a minute and Leopards' wording. The booking
23
+ * details, the signatory and the receiver named in a delivery's wording are
24
+ * not read.
25
+ */
26
+ export declare function parseLeopards(html: string, raw: string): CarrierResult;