universal-parcel-scraper 0.652.3-main.3107 → 0.653.0-main.3111

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 **611 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **613 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>789 catalog entries · 135 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 16,475 such statuses, each filed under one stage.
158
+ The carrier folders record 16,485 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
@@ -15150,7 +15150,8 @@
15150
15150
  "timezone": "Africa/Lagos",
15151
15151
  "tracking": {
15152
15152
  "mode": "automatic",
15153
- "adapter": "universal"
15153
+ "adapter": "gig-logistics",
15154
+ "localClocks": true
15154
15155
  },
15155
15156
  "canaryUrl": "https://giglogistics.com/track-shipment/",
15156
15157
  "trackingUrlTemplate": "https://giglogistics.com/track-shipment/?waybill={trackingNumber}",
@@ -26523,7 +26524,9 @@
26523
26524
  "timezone": "Australia/Sydney",
26524
26525
  "tracking": {
26525
26526
  "mode": "automatic",
26526
- "adapter": "universal"
26527
+ "adapter": "sendle",
26528
+ "recognitionRank": 64,
26529
+ "localClocks": true
26527
26530
  },
26528
26531
  "canaryUrl": "https://www.sendle.com/",
26529
26532
  "trackingUrlTemplate": "https://www.sendle.com/tracking?ref={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 789,
3
- "activeAdapters": 611,
3
+ "activeAdapters": 613,
4
4
  "countries": 135,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -84468,6 +84468,7 @@
84468
84468
  "sendle"
84469
84469
  ],
84470
84470
  "asked": [
84471
+ "sendle",
84471
84472
  "colis-prive"
84472
84473
  ]
84473
84474
  },
@@ -11727,7 +11727,8 @@ var Ye = {
11727
11727
  timezone: "Africa/Lagos",
11728
11728
  tracking: {
11729
11729
  mode: "automatic",
11730
- adapter: "universal"
11730
+ adapter: "gig-logistics",
11731
+ localClocks: !0
11731
11732
  },
11732
11733
  canaryUrl: "https://giglogistics.com/track-shipment/",
11733
11734
  trackingUrlTemplate: "https://giglogistics.com/track-shipment/?waybill={trackingNumber}",
@@ -20124,7 +20125,9 @@ var Ye = {
20124
20125
  timezone: "Australia/Sydney",
20125
20126
  tracking: {
20126
20127
  mode: "automatic",
20127
- adapter: "universal"
20128
+ adapter: "sendle",
20129
+ recognitionRank: 64,
20130
+ localClocks: !0
20128
20131
  },
20129
20132
  canaryUrl: "https://www.sendle.com/",
20130
20133
  trackingUrlTemplate: "https://www.sendle.com/tracking?ref={trackingNumber}",
@@ -30902,6 +30905,7 @@ var zl = {
30902
30905
  seko: 854,
30903
30906
  sendex: 794,
30904
30907
  sending: 891,
30908
+ sendle: 64,
30905
30909
  serpost: 893,
30906
30910
  servientrega: 979,
30907
30911
  seur: 870,
@@ -89,6 +89,8 @@ const PATTERNS = [
89
89
  [/已出口直封$/, 'in_transit'],
90
90
  [/^出口海关\/放行$/, 'in_transit'],
91
91
  [/^到达寄达地(?:处理中心)?$/, 'in_transit'],
92
+ // Collected at the destination port after import clearance, though its English says a delivery was made.
93
+ [/^目的港已提货/, 'in_transit'],
92
94
  [/^离开【[^】]*】处理中心$/, 'in_transit'],
93
95
  [/^送交出口海关$/, 'customs'],
94
96
  [/安排投递$/, 'out_for_delivery'],
@@ -53,6 +53,7 @@
53
53
  {"wording": "送交出口海关", "stage": "customs", "language": "zh", "confirmedBy": "live", "note": "Presented to export customs."},
54
54
  {"wording": "出口海关/放行", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Released by export customs; transit resumes."},
55
55
  {"wording": "到达寄达地", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Arrived in the destination country."},
56
+ {"wording": "目的港已提货 Delivery has been made at the destination port", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Collected at the destination port after import clearance, before the destination's own scans; the English says a delivery, which it is not."},
56
57
  {"wording": "到达寄达地处理中心", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Arrived at the destination's processing centre."},
57
58
  {"wording": "离开【GB】处理中心", "stage": "in_transit", "language": "zh", "confirmedBy": "live", "note": "Left the destination's processing centre."},
58
59
  {"wording": "【GB】安排投递", "stage": "out_for_delivery", "language": "zh", "confirmedBy": "live", "note": "Out for delivery abroad; any country."},
@@ -0,0 +1,17 @@
1
+ import type { AdapterFactory, 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 page, which asks the lookup for the waybill in its query. */
5
+ export declare const GIG_LOGISTICS_PAGE = "https://giglogistics.com/track-shipment/";
6
+ /** The lookup the tracking page asks, followed by the waybill. */
7
+ export declare const GIG_LOGISTICS_API = "https://agility.giglogistics.com/api/portal/trackshipment/";
8
+ export declare class GigLogisticsTracker {
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,53 @@
1
+ import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
2
+ import { runSteps } from '../../core/runner/index.js';
3
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
4
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
5
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
6
+ import { GIG_LOGISTICS_MAX_BYTES, normalizeGigLogisticsNumber, parseGigLogistics, PROVIDER } from './parser.js';
7
+ /** The tracking page, which asks the lookup for the waybill in its query. */
8
+ export const GIG_LOGISTICS_PAGE = 'https://giglogistics.com/track-shipment/';
9
+ /** The lookup the tracking page asks, followed by the waybill. */
10
+ export const GIG_LOGISTICS_API = 'https://agility.giglogistics.com/api/portal/trackshipment/';
11
+ const REDIRECTS = [301, 302, 303, 307, 308];
12
+ // Bot checks a CDN or a firewall could put in place of the reply.
13
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|<title>\s*Attention Required|cf-error-details|captcha/i;
14
+ /** The reply's JSON. */
15
+ function json(body) {
16
+ try {
17
+ return JSON.parse(body);
18
+ }
19
+ catch (cause) {
20
+ if (CHALLENGE.test(body.slice(0, 64_000)))
21
+ throw new ChallengeError(PROVIDER, 'GIG Logistics answered with a browser check', { cause });
22
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned a reply that is not JSON', { cause });
23
+ }
24
+ }
25
+ export class GigLogisticsTracker {
26
+ options;
27
+ constructor(options = {}) {
28
+ this.options = options;
29
+ }
30
+ async fetch(raw, context = {}) {
31
+ const number = normalizeGigLogisticsNumber(raw);
32
+ const read = async (signal, timeoutMs) => {
33
+ const { response, bytes } = await fetchBounded(`${GIG_LOGISTICS_API}${number}`, { method: 'GET', signal, headers: {
34
+ Accept: 'application/json', Referer: GIG_LOGISTICS_PAGE, 'User-Agent': userAgentOf(this.options.userAgent),
35
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: GIG_LOGISTICS_MAX_BYTES,
36
+ redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
37
+ // The lookup answers every waybill, known or not, with HTTP 200; a redirect or a 404 is a moved route.
38
+ if (response.status !== 200)
39
+ throw new TransportError(PROVIDER, 'GIG Logistics tracking lookup moved', { status: response.status });
40
+ return parseGigLogistics(json(decodeText(bytes)), number);
41
+ };
42
+ // A read that fails to reach the site, or hangs, gets one more: the first gets half of the budget.
43
+ return runSteps({ carrier: 'gig-logistics', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
44
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
45
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
46
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
47
+ ]);
48
+ }
49
+ }
50
+ export const adapter = (environment) => {
51
+ const tracker = new GigLogisticsTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
52
+ return { id: 'gig-logistics', recordsSteps: true, steps: ['direct', 'retry'], track: (input, context) => tracker.fetch(input.number, context) };
53
+ };
@@ -18,18 +18,49 @@
18
18
  "timezone": "Africa/Lagos",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "gig-logistics",
22
+ "steps": [
23
+ "direct",
24
+ "retry"
25
+ ],
26
+ "localClocks": true
23
27
  },
24
- "capabilities": [],
28
+ "capabilities": [
29
+ "history",
30
+ "location",
31
+ "provider_code"
32
+ ],
25
33
  "portal": {
26
34
  "url": "https://giglogistics.com/track-shipment/?waybill={trackingNumber}",
27
35
  "searchUrl": "https://giglogistics.com/track-shipment/",
28
36
  "canaryUrl": "https://giglogistics.com/track-shipment/",
29
- "shows": [],
30
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
37
+ "shows": [
38
+ "status",
39
+ "history",
40
+ "origin",
41
+ "destination",
42
+ "event_details"
43
+ ],
44
+ "scraped": [
45
+ "status",
46
+ "history",
47
+ "location",
48
+ "provider_code"
49
+ ],
50
+ "discarded": [
51
+ "origin",
52
+ "destination",
53
+ "event_details",
54
+ "recipient_name",
55
+ "staff_details"
56
+ ],
57
+ "unavailable": [
58
+ "eta",
59
+ "weight",
60
+ "service_name",
61
+ "offset_timestamps",
62
+ "delivery_tracking_number"
63
+ ]
33
64
  },
34
65
  "links": [
35
66
  {
@@ -0,0 +1,21 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "GIG Logistics";
3
+ export declare const GIG_LOGISTICS_MAX_BYTES: number;
4
+ /**
5
+ * A GIG waybill, ten digits. Another reference the tracker may take as an
6
+ * Alphacode is left to the universal providers, without a request: its
7
+ * layout is undocumented and the reply could not be bound to it.
8
+ */
9
+ export declare function normalizeGigLogisticsNumber(raw: string): string;
10
+ /**
11
+ * The reply of the tracking page's `api/portal/trackshipment/{waybill}`
12
+ * lookup. A waybill it has gets `Code` "200" and its scans in
13
+ * `Object.MobileShipmentTrackings`, each naming its waybill, with a code and
14
+ * the code's fixed wording in `ScanStatus`. A waybill it does not have gets
15
+ * `Code` "400" and "Invalid waybill number!!". Only scans naming the whole
16
+ * waybill asked are read, and of them only the code, its wording, the clock
17
+ * and the service centre: the origin and destination addresses, the incident
18
+ * text the page shows, which can name the receiver, and the scanning staff
19
+ * are never read.
20
+ */
21
+ export declare function parseGigLogistics(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,158 @@
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 { explicitOffsetTime } from '../../core/time/index.js';
6
+ import { clean, cleanScalar } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { gigLogisticsCodeWording, gigLogisticsStage } from './status.js';
9
+ export const PROVIDER = 'GIG Logistics';
10
+ // One waybill's reply is a few kilobytes.
11
+ export const GIG_LOGISTICS_MAX_BYTES = 512 * 1024;
12
+ const WAYBILL = /^\d{10}$/;
13
+ // What the tracker could take as its undocumented Alphacode.
14
+ const OTHER_REFERENCE = /^[A-Z0-9]{1,40}$/;
15
+ // A .NET clock: date, time, up to seven decimals, and an offset when one is stated.
16
+ const CLOCK = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(\.\d{1,7})?(Z|[+-]\d{2}:\d{2})?$/;
17
+ const CODE = /^[A-Z0-9]{1,12}$/;
18
+ // A service centre's name. Digits or commas, which an address or a phone number has, are never read.
19
+ const PLACE = /^\p{L}[\p{L} .'&()/-]{0,59}$/u;
20
+ const UNKNOWN_WAYBILL = /^Invalid waybill number\b/i;
21
+ const MAX_ROWS = 500;
22
+ const MAX_EVENTS = 100;
23
+ /**
24
+ * A GIG waybill, ten digits. Another reference the tracker may take as an
25
+ * Alphacode is left to the universal providers, without a request: its
26
+ * layout is undocumented and the reply could not be bound to it.
27
+ */
28
+ export function normalizeGigLogisticsNumber(raw) {
29
+ const number = typeof raw === 'string' && raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
30
+ if (WAYBILL.test(number))
31
+ return number;
32
+ if (OTHER_REFERENCE.test(number))
33
+ throw new IndeterminateError(PROVIDER, 'The adapter reads GIG Logistics waybills, which are ten digits');
34
+ throw new InvalidInputError(PROVIDER, 'GIG Logistics waybills are ten digits');
35
+ }
36
+ /** An optional text field; anything but a string is a changed reply. */
37
+ function text(row, key, maxLength = 300) {
38
+ const value = row[key];
39
+ if (value === null || value === undefined)
40
+ return '';
41
+ if (typeof value !== 'string')
42
+ throw new SchemaError(PROVIDER, `GIG Logistics returned an invalid ${key}`);
43
+ return clean(value, maxLength);
44
+ }
45
+ /** A scan's wall clock to the second, and its instant when the clock states an offset. */
46
+ function scanClock(row) {
47
+ const raw = text(row, 'DateTime', 40);
48
+ const match = CLOCK.exec(raw);
49
+ const local = match ? `${match[1]}T${match[2]}` : '';
50
+ // Parsing in UTC checks the calendar digits only; Luxon rolls 24:00:00 over, so only a clock
51
+ // that reads back unchanged is kept. A year before 2000 is .NET's empty date.
52
+ const parsed = DateTime.fromISO(local, { zone: 'UTC' });
53
+ if (!match || !parsed.isValid || parsed.year < 2000 || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local) {
54
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an invalid scan time');
55
+ }
56
+ const instant = match[4] ? explicitOffsetTime(`${local}${match[4]}`) : null;
57
+ // The decimals keep scans logged within one second in order.
58
+ const order = `${local}${(match[3] ?? '.').padEnd(8, '0')}`;
59
+ return instant ? { local, order, instant } : { local, order };
60
+ }
61
+ /** The service centre a scan names, at most; anything that could be an address is dropped. */
62
+ function place(row) {
63
+ const name = clean(text(row, 'Location', 120).replace(/_/g, ' '), 60);
64
+ return PLACE.test(name) ? name : '';
65
+ }
66
+ /**
67
+ * The reply of the tracking page's `api/portal/trackshipment/{waybill}`
68
+ * lookup. A waybill it has gets `Code` "200" and its scans in
69
+ * `Object.MobileShipmentTrackings`, each naming its waybill, with a code and
70
+ * the code's fixed wording in `ScanStatus`. A waybill it does not have gets
71
+ * `Code` "400" and "Invalid waybill number!!". Only scans naming the whole
72
+ * waybill asked are read, and of them only the code, its wording, the clock
73
+ * and the service centre: the origin and destination addresses, the incident
74
+ * text the page shows, which can name the receiver, and the scanning staff
75
+ * are never read.
76
+ */
77
+ export function parseGigLogistics(payload, raw) {
78
+ const number = normalizeGigLogisticsNumber(raw);
79
+ if (!isRecord(payload))
80
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an invalid reply');
81
+ const code = cleanScalar(payload.Code, 8);
82
+ if (code !== '200') {
83
+ if (!code)
84
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an invalid reply');
85
+ if (code === '400' && UNKNOWN_WAYBILL.test(text(payload, 'ShortDescription', 200))) {
86
+ throw new NotFoundError(PROVIDER, 'GIG Logistics has no shipment under this waybill');
87
+ }
88
+ throw new IndeterminateError(PROVIDER, 'GIG Logistics did not complete the lookup');
89
+ }
90
+ const shipment = payload.Object;
91
+ if (!isRecord(shipment))
92
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an invalid reply');
93
+ const rows = shipment.MobileShipmentTrackings ?? [];
94
+ if (!Array.isArray(rows))
95
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned a shipment with invalid scans');
96
+ if (rows.length > MAX_ROWS)
97
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an oversized history');
98
+ const seen = new Set();
99
+ const scans = [];
100
+ let others = 0;
101
+ for (const row of rows) {
102
+ if (!isRecord(row) || typeof row.Waybill !== 'string')
103
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned an invalid scan');
104
+ if (normalizeTrackingNumber(row.Waybill) !== number) {
105
+ others += 1;
106
+ continue;
107
+ }
108
+ const status = isRecord(row.ScanStatus) ? row.ScanStatus : {};
109
+ const scanCode = (text(row, 'Status', 16) || text(status, 'Code', 16)).toUpperCase();
110
+ if (!CODE.test(scanCode))
111
+ throw new SchemaError(PROVIDER, 'GIG Logistics returned a scan without a code');
112
+ const clock = scanClock(row);
113
+ const description = text(status, 'Reason') || gigLogisticsCodeWording(scanCode) || '';
114
+ // A scan whose code has no wording says nothing readable.
115
+ if (!description)
116
+ continue;
117
+ const location = place(row);
118
+ const key = JSON.stringify([clock.order, scanCode, location]);
119
+ if (seen.has(key))
120
+ continue;
121
+ seen.add(key);
122
+ const staged = gigLogisticsStage(scanCode, description);
123
+ scans.push({ clock, index: scans.length, event: {
124
+ // A scan without an offset keeps its wall clock.
125
+ ...(clock.instant ? { time: clock.instant.iso } : { local_time: clock.local }),
126
+ ...(location ? { location } : {}),
127
+ description,
128
+ provider_code: scanCode,
129
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
130
+ } });
131
+ }
132
+ if (!scans.length) {
133
+ if (others)
134
+ throw new IndeterminateError(PROVIDER, 'GIG Logistics answered for another waybill');
135
+ throw new NoHistoryError(PROVIDER);
136
+ }
137
+ // Newest first, by instant when every scan states one, else by wall clock; equal ones in the reply's order.
138
+ const timed = scans.every((scan) => scan.clock.instant);
139
+ scans.sort((left, right) => (timed
140
+ ? right.clock.instant.timestamp - left.clock.instant.timestamp
141
+ : right.clock.order.localeCompare(left.clock.order)) || left.index - right.index);
142
+ const events = scans.map((scan) => scan.event);
143
+ const newest = events[0];
144
+ // A record of the booking logged after the parcel moved is no step back.
145
+ const scanned = events.find((event) => event.stage && event.stage !== 'registered') ?? events.find((event) => event.stage);
146
+ const stage = scanned?.stage;
147
+ return {
148
+ status: stage ? languageStageStatus(stage) : 'unknown',
149
+ ...(stage ? { current_stage: stage, current_stage_source: scanned.stage_source } : {}),
150
+ last_status_text: newest.description,
151
+ last_update: newest.time ?? null,
152
+ ...(newest.local_time ? { last_update_local: newest.local_time } : {}),
153
+ expected_delivery: null,
154
+ ...(stage === 'delivered' && scanned?.time ? { delivered_at: scanned.time } : {}),
155
+ events: events.slice(0, MAX_EVENTS),
156
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
157
+ };
158
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClassifiedWording } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The wording recorded for a scan code. */
4
+ export declare function gigLogisticsCodeWording(code: string): string | undefined;
5
+ /** A scan's stage: its code, else its wording, else the shared wording rules, else nothing. */
6
+ export declare function gigLogisticsStage(code: string, wording: string): ClassifiedWording | null;
7
+ /** What the map says about a scan, by its code or its wording. */
8
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,32 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ import statuses from './statuses.json' with { type: 'json' };
4
+ const ENTRIES = statuses.entries;
5
+ /**
6
+ * GIG's scans each have a code, such as `OKT`, and the code's fixed wording.
7
+ * The code is the key; a code not recorded in `statuses.json` falls to its
8
+ * recorded wording, then to the shared wording rules.
9
+ */
10
+ const CODES = new Map(ENTRIES.filter((entry) => entry.code && entry.stage)
11
+ .map((entry) => [entry.code, entry.stage]));
12
+ const WORDINGS = new Map(ENTRIES.filter((entry) => entry.wording && entry.stage)
13
+ .map((entry) => [normalizeStatusWording(entry.wording), entry.stage]));
14
+ const CODE_WORDINGS = new Map(ENTRIES.filter((entry) => entry.code && entry.wording)
15
+ .map((entry) => [entry.code, entry.wording]));
16
+ /** The wording recorded for a scan code. */
17
+ export function gigLogisticsCodeWording(code) {
18
+ return CODE_WORDINGS.get(code);
19
+ }
20
+ /** A scan's stage: its code, else its wording, else the shared wording rules, else nothing. */
21
+ export function gigLogisticsStage(code, wording) {
22
+ const mapped = CODES.get(code) ?? WORDINGS.get(normalizeStatusWording(wording));
23
+ if (mapped)
24
+ return { stage: mapped, source: 'carrier_map' };
25
+ const worded = classifyWording(wording, 'in_transit');
26
+ return worded.source === 'none' ? null : worded;
27
+ }
28
+ /** What the map says about a scan, by its code or its wording. */
29
+ export const statusMap = {
30
+ stage: (code, wording) => (code ? CODES.get(code) : undefined) ?? WORDINGS.get(wording),
31
+ gaps: [],
32
+ };
@@ -47,9 +47,10 @@ const CODES = new Map([
47
47
  export function mailamericasMappedStage(code) {
48
48
  return CODES.get(code.toUpperCase());
49
49
  }
50
- // Tax ID checks (SM), notices (AV) and the record of a prepaid duty (AD-4,
51
- // seen while the parcel was still at the origin warehouse) move nothing.
52
- const NOTICE = /^(?:SM-|AV-|AD-4$)/i;
50
+ // Tax ID checks (SM), notices (AV), the record of a prepaid duty (AD-4,
51
+ // seen while the parcel was still at the origin warehouse) and a delivery
52
+ // moved to a later round (LM-11) move nothing.
53
+ const NOTICE = /^(?:SM-|AV-|AD-4$|LM-11$)/i;
53
54
  /** A scan's stage: its code, else the shared wording rules, else nothing. */
54
55
  export function mailamericasStage(code, wording) {
55
56
  const mapped = mailamericasMappedStage(code);
@@ -69,5 +70,6 @@ export const statusMap = {
69
70
  { code: 'SM-9', note: 'The buyer\'s details form received, logged again every half hour while the parcel moves on; it moves nothing.' },
70
71
  { code: 'AD-4', note: 'The import duty recorded as paid, which can come while the parcel is still at the origin warehouse; it moves nothing.' },
71
72
  { code: 'AV-1', note: 'A notice that adds information to the history; it moves nothing.' },
73
+ { code: 'LM-11', note: 'The delivery moved to a later round, logged after a round or beside the next one, which can deliver; it names no attempt and moves nothing.' },
72
74
  ],
73
75
  };
@@ -40,6 +40,7 @@
40
40
  {"code": "LM-5", "wording": "Delivery office: Returned for failed delivery", "language": "en", "stage": "returned", "confirmedBy": "live"},
41
41
  {"code": "LM-7", "wording": "Delivery office: Recipient Absent", "language": "en", "stage": "failed_attempt", "confirmedBy": "live"},
42
42
  {"code": "LM-9", "wording": "Delivery office: Wrong delivery address", "language": "en", "stage": "failed_attempt", "confirmedBy": "live", "note": "The parcel went out again and was delivered in the history seen."},
43
+ {"code": "LM-11", "wording": "Delivery office: Rescheduling", "language": "en", "confirmedBy": "live", "unmapped": true, "note": "After a round with the postman, and again beside the next round, which delivered the parcel; it names no attempt."},
43
44
  {"code": "PF-1", "wording": "Finished: Delivered", "language": "en", "stage": "delivered", "confirmedBy": "live"},
44
45
  {"code": "PF-2", "wording": "Finished: Delivered to return address", "language": "en", "stage": "returned", "confirmedBy": "live", "note": "The summary's status reads returned."},
45
46
  {"code": "RT-1", "wording": "Return-Destruction: Received at return Warehouse", "language": "en", "stage": "returned", "confirmedBy": "live", "note": "The summary's status reads returned."},
@@ -0,0 +1,31 @@
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 page. */
5
+ export declare const SENDLE_PAGE = "https://www.sendle.com/tracking";
6
+ /** The Convex deployment the tracking page's bundle calls. */
7
+ export declare const SENDLE_API = "https://keen-orca-155.convex.cloud";
8
+ /** The mutation that hands out a proof-of-work challenge, and the action that looks a number up with its answer. */
9
+ export declare const SENDLE_CHALLENGE = "tracking:createChallenge";
10
+ export declare const SENDLE_LOOKUP = "tracking:lookupTracking";
11
+ /** Whether a digest starts with `bits` zero bits, as the page checks it. */
12
+ export declare function sendleProofHolds(digest: Uint8Array, bits: number): boolean;
13
+ /**
14
+ * The page's answer to a challenge: the first counter, written in base 36,
15
+ * whose SHA-256 of `salt:counter` starts with `difficulty` zero bits. The
16
+ * search yields between batches and stops when the lookup's signal ends or the
17
+ * attempt's deadline passes.
18
+ */
19
+ export declare function solveSendleChallenge(salt: string, difficulty: number, signal: AbortSignal, deadline: number): Promise<string>;
20
+ export declare class SendleTracker {
21
+ private readonly options;
22
+ constructor(options?: {
23
+ fetcher?: typeof fetch;
24
+ recorder?: StepRecorder;
25
+ userAgent?: string;
26
+ });
27
+ /** One call of a public Convex function over its HTTP API, as JSON. */
28
+ private call;
29
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
30
+ }
31
+ export declare const adapter: AdapterFactory;