universal-parcel-scraper 0.366.0-main.1999 → 0.368.0-main.2003

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 **396 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **398 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>727 catalog entries · 133 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 13,496 such statuses, each filed under one stage.
158
+ The carrier folders record 13,540 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
@@ -17174,7 +17174,7 @@
17174
17174
  "timezone": "Asia/Seoul",
17175
17175
  "tracking": {
17176
17176
  "mode": "automatic",
17177
- "adapter": "universal"
17177
+ "adapter": "kyoungdong"
17178
17178
  },
17179
17179
  "canaryUrl": "https://kdexp.com/index.do",
17180
17180
  "trackingUrlTemplate": "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -19610,7 +19610,9 @@
19610
19610
  "timezone": "Europe/Kyiv",
19611
19611
  "tracking": {
19612
19612
  "mode": "automatic",
19613
- "adapter": "universal"
19613
+ "adapter": "nova-global",
19614
+ "recognitionRank": 834,
19615
+ "localClocks": true
19614
19616
  },
19615
19617
  "canaryUrl": "https://nova.global/track/",
19616
19618
  "trackingUrlTemplate": "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 727,
3
- "activeAdapters": 396,
3
+ "activeAdapters": 398,
4
4
  "countries": 133,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -13154,7 +13154,7 @@ var Pe = {
13154
13154
  timezone: "Asia/Seoul",
13155
13155
  tracking: {
13156
13156
  mode: "automatic",
13157
- adapter: "universal"
13157
+ adapter: "kyoungdong"
13158
13158
  },
13159
13159
  canaryUrl: "https://kdexp.com/index.do",
13160
13160
  trackingUrlTemplate: "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -14957,7 +14957,9 @@ var Pe = {
14957
14957
  timezone: "Europe/Kyiv",
14958
14958
  tracking: {
14959
14959
  mode: "automatic",
14960
- adapter: "universal"
14960
+ adapter: "nova-global",
14961
+ recognitionRank: 834,
14962
+ localClocks: !0
14961
14963
  },
14962
14964
  canaryUrl: "https://nova.global/track/",
14963
14965
  trackingUrlTemplate: "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -28116,6 +28118,7 @@ var pl = {
28116
28118
  nipost: 908,
28117
28119
  "nippon-express": 941,
28118
28120
  "norsk-global": 845,
28121
+ "nova-global": 834,
28119
28122
  "nova-poshta": 685,
28120
28123
  nox: 874,
28121
28124
  "nz-post": 760,
@@ -0,0 +1,15 @@
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 lookup the tracking page on kdexp.com makes for a waybill, followed by the number. */
5
+ export declare const KYOUNGDONG_API = "https://kdexp.com/service/delivery/new/ajax_basic.do?barcode=";
6
+ export declare class KyoungdongTracker {
7
+ private readonly options;
8
+ constructor(options?: {
9
+ fetcher?: typeof fetch;
10
+ recorder?: StepRecorder;
11
+ userAgent?: string;
12
+ });
13
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
14
+ }
15
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,56 @@
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 { CARRIER, KYOUNGDONG_MAX_BYTES, normalizeKyoungdongNumber, parseKyoungdong, PROVIDER } from './parser.js';
7
+ /** The lookup the tracking page on kdexp.com makes for a waybill, followed by the number. */
8
+ export const KYOUNGDONG_API = 'https://kdexp.com/service/delivery/new/ajax_basic.do?barcode=';
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|challenge-platform|<title>\s*(?:Just a moment|Attention Required|Access Denied)|g-recaptcha|h-captcha|AwsWafIntegration|awswaf\.com/i;
12
+ function json(body) {
13
+ try {
14
+ return JSON.parse(body);
15
+ }
16
+ catch (cause) {
17
+ if (CHALLENGE.test(body.slice(0, 64_000)))
18
+ throw new ChallengeError(PROVIDER, 'Kyungdong Express answered with a browser check', { cause });
19
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned a reply that is not JSON', { cause });
20
+ }
21
+ }
22
+ export class KyoungdongTracker {
23
+ options;
24
+ constructor(options = {}) {
25
+ this.options = options;
26
+ }
27
+ async fetch(raw, context = {}) {
28
+ const number = normalizeKyoungdongNumber(raw);
29
+ const read = async (signal, timeoutMs) => {
30
+ const { response, bytes } = await fetchBounded(`${KYOUNGDONG_API}${number}`, { method: 'GET', signal, headers: {
31
+ Accept: 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
32
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: KYOUNGDONG_MAX_BYTES, redirect: 'manual',
33
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
34
+ if (REDIRECTS.includes(response.status))
35
+ throw new TransportError(PROVIDER, 'Kyungdong Express redirected its tracking lookup');
36
+ // The lookup answers an unknown number with HTTP 200; a 404 is a moved route.
37
+ if (response.status === 404 || response.status === 410)
38
+ throw new TransportError(PROVIDER, 'Kyungdong Express tracking lookup is unavailable');
39
+ return parseKyoungdong(json(decodeText(bytes)), number);
40
+ };
41
+ // A request that fails to reach the site, or hangs, gets one more try: the
42
+ // first gets half of the budget, so the retry fits in the rest.
43
+ return runSteps({ carrier: CARRIER, 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 KyoungdongTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
52
+ return {
53
+ id: CARRIER, recordsSteps: true, steps: ['direct', 'retry'],
54
+ track: (input, context) => tracker.fetch(input.number, context),
55
+ };
56
+ };
@@ -22,18 +22,68 @@
22
22
  "timezone": "Asia/Seoul",
23
23
  "tracking": {
24
24
  "mode": "automatic",
25
- "adapter": "universal",
26
- "steps": []
25
+ "adapter": "kyoungdong",
26
+ "steps": [
27
+ "direct",
28
+ "retry"
29
+ ]
27
30
  },
28
- "capabilities": [],
31
+ "capabilities": [
32
+ "history",
33
+ "location",
34
+ "provider_code",
35
+ "delivered_at"
36
+ ],
29
37
  "portal": {
30
38
  "url": "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
31
39
  "searchUrl": "https://kdexp.com/index.do",
32
40
  "canaryUrl": "https://kdexp.com/index.do",
33
- "shows": [],
34
- "scraped": [],
35
- "discarded": [],
36
- "unavailable": []
41
+ "shows": [
42
+ "status",
43
+ "history",
44
+ "location",
45
+ "provider_code",
46
+ "delivered_at",
47
+ "sender_name",
48
+ "sender_address",
49
+ "recipient_name",
50
+ "recipient_address",
51
+ "contact_details",
52
+ "order_contents",
53
+ "parcel_count",
54
+ "origin",
55
+ "destination",
56
+ "driver_name",
57
+ "office_contact_details",
58
+ "proof_of_delivery"
59
+ ],
60
+ "scraped": [
61
+ "status",
62
+ "history",
63
+ "location",
64
+ "provider_code",
65
+ "delivered_at"
66
+ ],
67
+ "discarded": [
68
+ "sender_name",
69
+ "sender_address",
70
+ "recipient_name",
71
+ "recipient_address",
72
+ "contact_details",
73
+ "order_contents",
74
+ "parcel_count",
75
+ "origin",
76
+ "destination",
77
+ "driver_name",
78
+ "office_contact_details",
79
+ "proof_of_delivery"
80
+ ],
81
+ "unavailable": [
82
+ "eta",
83
+ "weight",
84
+ "service_name",
85
+ "pickup_point"
86
+ ]
37
87
  },
38
88
  "links": [
39
89
  {
@@ -218,7 +218,7 @@
218
218
  "access": "full_page"
219
219
  },
220
220
  "context": {
221
- "assessment": "candidate"
221
+ "assessment": "confirmed"
222
222
  },
223
223
  "expect": {
224
224
  "carrier": "unknown",
@@ -230,7 +230,7 @@
230
230
  "yunda"
231
231
  ]
232
232
  },
233
- "note": "A Naver KnowledgeiN question asks when this Kyungdong Express parcel will arrive."
233
+ "note": "A Naver KnowledgeiN question asks when this Kyungdong Express parcel will arrive. Kyungdong's tracker answers it with its scans."
234
234
  },
235
235
  {
236
236
  "number": "8026090773281",
@@ -243,7 +243,7 @@
243
243
  "access": "full_page"
244
244
  },
245
245
  "context": {
246
- "assessment": "candidate"
246
+ "assessment": "confirmed"
247
247
  },
248
248
  "expect": {
249
249
  "carrier": "unknown",
@@ -256,7 +256,7 @@
256
256
  "yunda"
257
257
  ]
258
258
  },
259
- "note": "A Naver KnowledgeiN question about a Kyungdong Express lookup gives it."
259
+ "note": "A Naver KnowledgeiN question about a Kyungdong Express lookup gives it. Kyungdong's tracker answers it with its scans."
260
260
  },
261
261
  {
262
262
  "number": "8026091635480",
@@ -269,7 +269,7 @@
269
269
  "access": "full_page"
270
270
  },
271
271
  "context": {
272
- "assessment": "candidate"
272
+ "assessment": "confirmed"
273
273
  },
274
274
  "expect": {
275
275
  "carrier": "unknown",
@@ -281,7 +281,7 @@
281
281
  "yunda"
282
282
  ]
283
283
  },
284
- "note": "A Naver KnowledgeiN question about a stalled Kyungdong Express parcel gives it as the waybill number."
284
+ "note": "A Naver KnowledgeiN question about a stalled Kyungdong Express parcel gives it as the waybill number. Kyungdong's tracker answers it with its scans."
285
285
  },
286
286
  {
287
287
  "number": "8026100380015",
@@ -294,7 +294,7 @@
294
294
  "access": "full_page"
295
295
  },
296
296
  "context": {
297
- "assessment": "candidate"
297
+ "assessment": "confirmed"
298
298
  },
299
299
  "expect": {
300
300
  "carrier": "unknown",
@@ -306,7 +306,7 @@
306
306
  "yunda"
307
307
  ]
308
308
  },
309
- "note": "A Naver KnowledgeiN question tagged Kyungdong Express gives it as the waybill being tracked."
309
+ "note": "A Naver KnowledgeiN question tagged Kyungdong Express gives it as the waybill being tracked. Kyungdong's tracker answers it with its scans."
310
310
  }
311
311
  ]
312
312
  }
@@ -0,0 +1,23 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ import { type Stage } from '../../core/status/index.js';
3
+ export declare const PROVIDER = "Kyungdong Express";
4
+ export declare const CARRIER = "kyoungdong";
5
+ export declare const KYOUNGDONG_MAX_BYTES: number;
6
+ /** A Kyungdong Express waybill as its tracker looks it up: thirteen digits, without the hyphens a label prints. */
7
+ export declare function normalizeKyoungdongNumber(raw: string): string;
8
+ /**
9
+ * A scan's site, when it is a branch, terminal or hub. A delivery-leg scan
10
+ * keeps only a branch the summary names, since it can name the driver, and a
11
+ * line-haul load can name its driver too.
12
+ */
13
+ export declare function kyoungdongSite(value: string, branches: ReadonlySet<string>, stage?: Stage): string;
14
+ /**
15
+ * The reply of `/service/delivery/new/ajax_basic.do?barcode=`, the lookup
16
+ * Kyungdong's tracking page makes. `result` is `suc` with the parcel in `data`
17
+ * and its scans oldest first, or `fail` without data, which the page words as a
18
+ * wrong waybill or a failed lookup: an unknown number, a parcel older than four
19
+ * months or a lookup that failed, so it proves no absence. The parcel must name
20
+ * the number asked. Senders, recipients, addresses, phone numbers, goods and
21
+ * the delivery's signature files are never read.
22
+ */
23
+ export declare function parseKyoungdong(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,139 @@
1
+ import { DateTime } from 'luxon';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { IndeterminateError, InvalidInputError, NoHistoryError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { firstOccurrence } from '../../core/time/index.js';
6
+ import { clean, cleanScalar } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { kyoungdongStage } from './status.js';
9
+ export const PROVIDER = 'Kyungdong Express';
10
+ export const CARRIER = 'kyoungdong';
11
+ // A reply is a few kilobytes.
12
+ export const KYOUNGDONG_MAX_BYTES = 256 * 1024;
13
+ const ZONE = 'Asia/Seoul';
14
+ const NUMBER = /^\d{13}$/;
15
+ // A scan's wall clock to the second, some with a fraction of a second after it.
16
+ const CLOCK = /^(\d{4}-\d{2}-\d{2} (?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(?:\.\d{1,9})?$/;
17
+ const CODE = /^\d{1,6}$/;
18
+ // A site that names a person: a masked name (최**) or a role such as (배송기사), the driver.
19
+ const PERSON = /[**((]|기사/;
20
+ // A branch carries its number after its area's name; terminals and hubs say what they are.
21
+ const BRANCH = /\d|(?:터미널|허브|센터)$/;
22
+ // Scans whose site can be the driver who delivers.
23
+ const DELIVERY_LEG = ['out_for_delivery', 'failed_attempt', 'delivered'];
24
+ const MAX_ROWS = 500;
25
+ const MAX_EVENTS = 100;
26
+ /** A Kyungdong Express waybill as its tracker looks it up: thirteen digits, without the hyphens a label prints. */
27
+ export function normalizeKyoungdongNumber(raw) {
28
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
29
+ if (!NUMBER.test(number))
30
+ throw new InvalidInputError(PROVIDER, 'Kyungdong Express waybills are thirteen digits');
31
+ return number;
32
+ }
33
+ /** An optional text field; anything but a string is a changed reply. */
34
+ function text(row, key, maxLength = 120) {
35
+ const value = row[key];
36
+ if (value === null || value === undefined)
37
+ return '';
38
+ if (typeof value !== 'string')
39
+ throw new SchemaError(PROVIDER, `Kyungdong Express returned an invalid ${key}`);
40
+ return clean(value, maxLength);
41
+ }
42
+ /** A scan's clock: every scan is at a branch or terminal in Korea, which keeps UTC+9 all year. */
43
+ function scanClock(row) {
44
+ const clock = CLOCK.exec(text(row, 'scanDt', 40))?.[1] ?? '';
45
+ const wall = firstOccurrence(DateTime.fromFormat(clock, 'yyyy-MM-dd HH:mm:ss', { zone: ZONE }));
46
+ // Luxon rolls 24:00 over to the next day; only a clock that reads back unchanged is kept.
47
+ if (!clock || !wall.isValid || wall.toFormat('yyyy-MM-dd HH:mm:ss') !== clock)
48
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an invalid scan time');
49
+ return wall.toUTC().toISO({ suppressMilliseconds: true });
50
+ }
51
+ /**
52
+ * A scan's site, when it is a branch, terminal or hub. A delivery-leg scan
53
+ * keeps only a branch the summary names, since it can name the driver, and a
54
+ * line-haul load can name its driver too.
55
+ */
56
+ export function kyoungdongSite(value, branches, stage) {
57
+ const name = clean(value, 120);
58
+ if (!name || PERSON.test(name))
59
+ return '';
60
+ if (branches.has(name))
61
+ return name;
62
+ return stage && DELIVERY_LEG.includes(stage) ? '' : BRANCH.test(name) ? name : '';
63
+ }
64
+ /**
65
+ * The reply of `/service/delivery/new/ajax_basic.do?barcode=`, the lookup
66
+ * Kyungdong's tracking page makes. `result` is `suc` with the parcel in `data`
67
+ * and its scans oldest first, or `fail` without data, which the page words as a
68
+ * wrong waybill or a failed lookup: an unknown number, a parcel older than four
69
+ * months or a lookup that failed, so it proves no absence. The parcel must name
70
+ * the number asked. Senders, recipients, addresses, phone numbers, goods and
71
+ * the delivery's signature files are never read.
72
+ */
73
+ export function parseKyoungdong(payload, raw) {
74
+ const number = normalizeKyoungdongNumber(raw);
75
+ if (!isRecord(payload) || typeof payload.result !== 'string')
76
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an invalid envelope');
77
+ if (payload.result === 'fail') {
78
+ if (payload.data !== undefined && payload.data !== null)
79
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an unexpected failure');
80
+ throw new IndeterminateError(PROVIDER, 'Kyungdong Express has no parcel for this number or failed to look it up', { reason: 'portal_not_found' });
81
+ }
82
+ const data = payload.data;
83
+ if (payload.result !== 'suc' || !isRecord(data))
84
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an unexpected answer');
85
+ const echoed = cleanScalar(data.prodCode, 64);
86
+ if (!echoed)
87
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned a parcel without its waybill');
88
+ if (normalizeTrackingNumber(echoed) !== number)
89
+ throw new IndeterminateError(PROVIDER, 'Kyungdong Express answered for another parcel');
90
+ const rows = data.scanList ?? [];
91
+ if (!Array.isArray(rows))
92
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned a parcel with invalid scans');
93
+ if (rows.length > MAX_ROWS)
94
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an oversized history');
95
+ const branches = new Set([text(data, 'branSndnNm'), text(data, 'branArvlNm')].filter((name) => name && !PERSON.test(name)));
96
+ const seen = new Set();
97
+ const events = [];
98
+ for (const row of rows) {
99
+ if (!isRecord(row))
100
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an invalid scan');
101
+ const label = text(row, 'scanTypeNm', 40);
102
+ if (!label)
103
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned a scan without its type');
104
+ const code = cleanScalar(row.scanType, 16);
105
+ if (code && !CODE.test(code))
106
+ throw new SchemaError(PROVIDER, 'Kyungdong Express returned an invalid scan type code');
107
+ const time = scanClock(row);
108
+ const staged = kyoungdongStage(code || null, label);
109
+ const location = kyoungdongSite(text(row, 'strtPointNm'), branches, staged?.stage);
110
+ const key = JSON.stringify([time, code, label, location]);
111
+ if (seen.has(key))
112
+ continue;
113
+ seen.add(key);
114
+ events.push({
115
+ time,
116
+ ...(location ? { location } : {}),
117
+ description: label,
118
+ ...(code ? { provider_code: code } : {}),
119
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
120
+ });
121
+ }
122
+ if (!events.length)
123
+ throw new NoHistoryError(PROVIDER);
124
+ // Newest first; the reply lists the scans oldest first.
125
+ const newestFirst = events[0].time <= events.at(-1).time ? events.reverse() : events;
126
+ const newest = newestFirst[0];
127
+ const scanned = newestFirst.find((event) => event.stage);
128
+ const stage = scanned?.stage;
129
+ return {
130
+ status: stage ? languageStageStatus(stage) : 'unknown',
131
+ ...(stage ? { current_stage: stage, current_stage_source: scanned.stage_source } : {}),
132
+ last_status_text: newest.description,
133
+ last_update: newest.time,
134
+ expected_delivery: null,
135
+ ...(stage === 'delivered' ? { delivered_at: scanned.time } : {}),
136
+ events: newestFirst.slice(0, MAX_EVENTS),
137
+ ...(newestFirst.length > MAX_EVENTS ? { history_truncated: true } : {}),
138
+ };
139
+ }
@@ -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 Kyungdong's scan types give a label, else a code. */
4
+ export declare function kyoungdongMappedStage(code: string | null, label: string): Stage | undefined;
5
+ /** A scan's stage: its label or code, else the shared wording rules, else nothing. */
6
+ export declare function kyoungdongStage(code: string | null, label: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,42 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ /**
3
+ * The scan types the tracker page's script draws, by their Korean label. The
4
+ * page gives `도착` the icon of a finished parcel, but a delivery scan follows
5
+ * it, so it is the delivering branch's arrival.
6
+ */
7
+ const LABELS = new Map([
8
+ ['예약접수', 'registered'], // Booked, not handed over yet.
9
+ ['접수', 'accepted'], // Accepted by the sending branch.
10
+ ['발송', 'in_transit'], // Sent off by the sending branch; recent histories start with it.
11
+ ['노선상차', 'in_transit'], // Loaded on a line-haul truck.
12
+ ['터미널', 'in_transit'], // Scanned at a hub terminal.
13
+ ['지연도착', 'in_transit'], // A late arrival.
14
+ ['도착', 'in_transit'], // Arrived at the delivering branch.
15
+ ['배송중', 'out_for_delivery'],
16
+ ['인수확인', 'delivered'], // Receipt confirmed.
17
+ ['배송완료', 'delivered'], // Recorded by the delivering branch.
18
+ ]);
19
+ /** The scan type codes seen with their labels. */
20
+ const CODES = new Map([
21
+ ['0002', 'in_transit'], // 발송
22
+ ['0004', 'in_transit'], // 노선상차
23
+ ['0006', 'in_transit'], // 도착
24
+ ['0007', 'delivered'], // 배송완료
25
+ ['0008', 'out_for_delivery'], // 배송중
26
+ ]);
27
+ /** The stage Kyungdong's scan types give a label, else a code. */
28
+ export function kyoungdongMappedStage(code, label) {
29
+ return LABELS.get(label.trim()) ?? (code ? CODES.get(code) : undefined);
30
+ }
31
+ /** A scan's stage: its label or code, else the shared wording rules, else nothing. */
32
+ export function kyoungdongStage(code, label) {
33
+ const mapped = kyoungdongMappedStage(code, label);
34
+ if (mapped)
35
+ return { stage: mapped, source: 'carrier_map' };
36
+ const worded = classifyWording(label, 'in_transit');
37
+ return worded.source === 'none' ? null : worded;
38
+ }
39
+ export const statusMap = {
40
+ stage: (code, wording) => kyoungdongMappedStage(code, wording),
41
+ gaps: [],
42
+ };
@@ -3,13 +3,13 @@
3
3
  "entries": [
4
4
  {"wording": "예약접수", "stage": "registered", "language": "ko", "confirmedBy": "official-doc", "note": "A booked shipment not yet handed over. Named among the scan types the tracker page's script draws."},
5
5
  {"wording": "접수", "stage": "accepted", "language": "ko", "confirmedBy": "official-doc", "note": "The sending branch accepted the shipment. Named among the scan types the tracker page's script draws."},
6
- {"wording": "발송", "stage": "in_transit", "language": "ko", "confirmedBy": "official-doc", "note": "Dispatched from the sending branch. Named among the scan types the tracker page's script draws. A Naver KnowledgeiN answer quotes it from a lookup too."},
7
- {"wording": "노선상차", "stage": "in_transit", "language": "ko", "confirmedBy": "official-doc", "note": "Loaded on a line-haul truck. Named among the scan types the tracker page's script draws."},
6
+ {"code": "0002", "wording": "발송", "stage": "in_transit", "language": "ko", "confirmedBy": "live", "note": "Sent off by the sending branch, at the moment the waybill is registered; recent histories start with it."},
7
+ {"code": "0004", "wording": "노선상차", "stage": "in_transit", "language": "ko", "confirmedBy": "live", "note": "Loaded on a line-haul truck. Its site can be the driver instead of a branch."},
8
8
  {"wording": "터미널", "stage": "in_transit", "language": "ko", "confirmedBy": "official-doc", "note": "Scanned at a hub terminal. Named among the scan types the tracker page's script draws."},
9
9
  {"wording": "지연도착", "stage": "in_transit", "language": "ko", "confirmedBy": "official-doc", "note": "A late arrival scan. Named among the scan types the tracker page's script draws."},
10
- {"wording": "도착", "stage": "in_transit", "language": "ko", "confirmedBy": "official-doc", "note": "Arrived at the delivering branch; the tracker draws it with the completed icon, but a Naver KnowledgeiN answer shows it before delivery. Named among the scan types the tracker page's script draws."},
11
- {"wording": "배송중", "stage": "out_for_delivery", "language": "ko", "confirmedBy": "official-doc", "note": "Named among the scan types the tracker page's script draws."},
10
+ {"code": "0006", "wording": "도착", "stage": "in_transit", "language": "ko", "confirmedBy": "live", "note": "Arrived at the delivering branch; the tracker draws it with the completed icon, but delivery scans follow it."},
11
+ {"code": "0008", "wording": "배송중", "stage": "out_for_delivery", "language": "ko", "confirmedBy": "live", "note": "Out for delivery from the delivering branch."},
12
12
  {"wording": "인수확인", "stage": "delivered", "language": "ko", "confirmedBy": "official-doc", "note": "Receipt confirmed. Named among the scan types the tracker page's script draws."},
13
- {"wording": "배송완료", "stage": "delivered", "language": "ko", "confirmedBy": "official-doc", "note": "Kyungdong's FAQ says the destination branch records it, so a parcel can show it before the recipient has it."}
13
+ {"code": "0007", "wording": "배송완료", "stage": "delivered", "language": "ko", "confirmedBy": "live", "note": "Kyungdong's FAQ says the destination branch records it, so a parcel can show it before the recipient has it. Its site names the driver."}
14
14
  ]
15
15
  }
@@ -0,0 +1,15 @@
1
+ import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ import { type StepRecorder } from '../../core/telemetry/index.js';
4
+ /** The lookup the tracking page on nova.global makes for the number typed in. */
5
+ export declare const NOVA_GLOBAL_API = "https://personal.nova.global/tracking.php";
6
+ export declare class NovaGlobalTracker {
7
+ private readonly options;
8
+ constructor(options?: {
9
+ fetcher?: typeof fetch;
10
+ recorder?: StepRecorder;
11
+ userAgent?: string;
12
+ });
13
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
14
+ }
15
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,62 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
7
+ import { NOVA_GLOBAL_MAX_BYTES, normalizeNovaGlobalNumber, parseNovaGlobal, PROVIDER } from './parser.js';
8
+ /** The lookup the tracking page on nova.global makes for the number typed in. */
9
+ export const NOVA_GLOBAL_API = 'https://personal.nova.global/tracking.php';
10
+ const REDIRECTS = [301, 302, 303, 307, 308];
11
+ // Bot checks Cloudflare, in front of the tracker, could put in place of the reply.
12
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*Just a moment|<title>\s*Attention Required/i;
13
+ function json(body) {
14
+ try {
15
+ return JSON.parse(body);
16
+ }
17
+ catch (cause) {
18
+ if (CHALLENGE.test(body.slice(0, 64_000)))
19
+ throw new ChallengeError(PROVIDER, 'Nova Global answered with a browser check', { cause });
20
+ throw new SchemaError(PROVIDER, 'Nova Global returned a reply that is not JSON', { cause });
21
+ }
22
+ }
23
+ export class NovaGlobalTracker {
24
+ options;
25
+ constructor(options = {}) {
26
+ this.options = options;
27
+ }
28
+ async fetch(raw, context = {}) {
29
+ const number = normalizeNovaGlobalNumber(raw);
30
+ const read = async (signal, timeoutMs) => {
31
+ const { response, bytes } = await fetchBounded(NOVA_GLOBAL_API, { method: 'POST', signal, headers: {
32
+ Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': userAgentOf(this.options.userAgent),
33
+ }, body: new URLSearchParams({ num: number, lang: 'en' }).toString() }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)),
34
+ maxBytes: NOVA_GLOBAL_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
35
+ if (REDIRECTS.includes(response.status))
36
+ throw new TransportError(PROVIDER, 'Nova Global redirected its tracking lookup');
37
+ // The tracker answers every number with HTTP 200; a 404 is a moved route, not an unknown parcel.
38
+ if (response.status === 404 || response.status === 410)
39
+ throw new TransportError(PROVIDER, 'Nova Global tracking lookup is unavailable');
40
+ const body = decodeText(bytes);
41
+ // A number the tracker does not know, or no longer keeps, gets an empty body.
42
+ if (!body.trim())
43
+ throw new NotFoundError(PROVIDER);
44
+ return parseNovaGlobal(json(body), number);
45
+ };
46
+ // A request that fails to reach the tracker, or hangs, gets one more try: the
47
+ // first gets half of the budget, so the retry fits in the rest.
48
+ return runSteps({ carrier: 'nova-global', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
49
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
50
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
51
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
52
+ ]);
53
+ }
54
+ }
55
+ export const adapter = (environment) => {
56
+ const tracker = new NovaGlobalTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
57
+ return {
58
+ id: 'nova-global', recordsSteps: true, steps: ['direct', 'retry'],
59
+ track: (input, context) => tracker.fetch(input.number, context),
60
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeNovaGlobalNumber(number))),
61
+ };
62
+ };
@@ -21,18 +21,65 @@
21
21
  "timezone": "Europe/Kyiv",
22
22
  "tracking": {
23
23
  "mode": "automatic",
24
- "adapter": "universal",
25
- "steps": []
24
+ "adapter": "nova-global",
25
+ "steps": [
26
+ "direct",
27
+ "retry"
28
+ ],
29
+ "recognition": {
30
+ "rank": 834
31
+ },
32
+ "localClocks": true
26
33
  },
27
- "capabilities": [],
34
+ "capabilities": [
35
+ "history",
36
+ "provider_code",
37
+ "location",
38
+ "offset_timestamps",
39
+ "delivered_at",
40
+ "destination_country",
41
+ "delivery_partner",
42
+ "delivery_tracking_number"
43
+ ],
28
44
  "portal": {
29
45
  "url": "https://nova.global/track/?Tracking_ID={trackingNumber}",
30
46
  "searchUrl": "https://nova.global/track/",
31
47
  "canaryUrl": "https://nova.global/track/",
32
- "shows": [],
33
- "scraped": [],
34
- "discarded": [],
35
- "unavailable": []
48
+ "shows": [
49
+ "status",
50
+ "history",
51
+ "location",
52
+ "offset_timestamps",
53
+ "origin_country",
54
+ "destination_country",
55
+ "delivery_tracking_number",
56
+ "tracking_alias"
57
+ ],
58
+ "scraped": [
59
+ "status",
60
+ "history",
61
+ "provider_code",
62
+ "location",
63
+ "offset_timestamps",
64
+ "delivered_at",
65
+ "destination_country",
66
+ "delivery_partner",
67
+ "delivery_tracking_number"
68
+ ],
69
+ "discarded": [
70
+ "origin_country",
71
+ "tracking_alias"
72
+ ],
73
+ "unavailable": [
74
+ "eta",
75
+ "weight",
76
+ "service_name",
77
+ "pickup_point",
78
+ "sender_name",
79
+ "recipient_name",
80
+ "recipient_address",
81
+ "proof_of_delivery"
82
+ ]
36
83
  },
37
84
  "links": [
38
85
  {
@@ -0,0 +1,14 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Nova Global";
3
+ export declare const NOVA_GLOBAL_MAX_BYTES: number;
4
+ /** A Nova Global waybill, in capitals; anything else is not a number it issues. */
5
+ export declare function normalizeNovaGlobalNumber(raw: string): string;
6
+ /**
7
+ * The reply of `tracking.php` for one waybill: the summary of its newest scan,
8
+ * the route's two country codes, the parcel's other numbers, its delivery
9
+ * partner and `historyStatus`, its scans newest first. They keep that order:
10
+ * their clocks are not all instants. The tracker echoes the number asked as
11
+ * `billNumber`. An empty body, the reply to a number it does not know, is
12
+ * read before this.
13
+ */
14
+ export declare function parseNovaGlobal(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,166 @@
1
+ import { DateTime } from 'luxon';
2
+ import { carrierIdFromPartner } from '../../core/catalog/hints.js';
3
+ import { detectCarrierMatch, normalizeTrackingNumber } from '../../core/detection/index.js';
4
+ import { returnedTrackingNumber } from '../../core/detection/returned.js';
5
+ import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
6
+ import { languageStageStatus } from '../../core/status/index.js';
7
+ import { countryCode, countryTimeZone } from '../../core/time/index.js';
8
+ import { clean, cleanScalar } from '../../core/transport/index.js';
9
+ import { isRecord } from '../../core/types.js';
10
+ import { novaGlobalStage } from './status.js';
11
+ export const PROVIDER = 'Nova Global';
12
+ // A long history is a few kilobytes.
13
+ export const NOVA_GLOBAL_MAX_BYTES = 512 * 1024;
14
+ // Two letters, nine to sixteen digits and NPG, as rule nova-global-1 claims.
15
+ const NUMBER = /^[A-Z]{2}\d{9,16}NPG$/;
16
+ const WALL_CLOCK = /^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
17
+ const MAX_ROWS = 500;
18
+ const MAX_EVENTS = 100;
19
+ // The page's script reads this reply as a number the tracker does not know.
20
+ const NOT_FOUND = 203;
21
+ /** A Nova Global waybill, in capitals; anything else is not a number it issues. */
22
+ export function normalizeNovaGlobalNumber(raw) {
23
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
24
+ if (!NUMBER.test(number))
25
+ throw new InvalidInputError(PROVIDER, 'Nova Global waybills are two letters, nine to sixteen digits and NPG');
26
+ return number;
27
+ }
28
+ /** An optional text field; an object or array is a changed reply. */
29
+ function text(row, key, maxLength = 300) {
30
+ const value = row[key];
31
+ if (value === null || value === undefined)
32
+ return '';
33
+ if (typeof value !== 'string' && typeof value !== 'number')
34
+ throw new SchemaError(PROVIDER, `Nova Global returned an invalid ${key}`);
35
+ return cleanScalar(value, maxLength);
36
+ }
37
+ /** A two-letter country code the reply gives, or nothing. */
38
+ function country(row, key) {
39
+ const code = text(row, key, 8).toUpperCase();
40
+ return /^[A-Z]{2}$/.test(code) && countryCode(code) === code ? code : '';
41
+ }
42
+ /**
43
+ * A scan's clock. `date` is a wall clock with no zone, and `timestamp` the
44
+ * instant the tracker makes of it by reading every wall clock on Kyiv time.
45
+ * Scans abroad keep the scanning site's own clock, which that reading
46
+ * misplaces, so the instant counts only where it reads back to the wall
47
+ * clock in the zone of the country the scan names: in practice Ukraine.
48
+ * Every other scan keeps its wall clock.
49
+ */
50
+ function clockOf(row, place) {
51
+ const wall = WALL_CLOCK.exec(text(row, 'date', 32));
52
+ const local = wall ? `${wall[1]}-${wall[2]}-${wall[3]}T${wall[4]}:${wall[5]}:${wall[6]}` : '';
53
+ // Parsing in UTC checks the calendar digits only; Luxon rolls 24:00 over, so the clock must read back unchanged.
54
+ const parsed = DateTime.fromISO(local, { zone: 'UTC' });
55
+ if (!wall || !parsed.isValid || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local) {
56
+ throw new SchemaError(PROVIDER, 'Nova Global returned an invalid scan time');
57
+ }
58
+ const zone = place ? countryTimeZone(place) : null;
59
+ const stamp = row.timestamp;
60
+ if (zone && typeof stamp === 'number' && Number.isSafeInteger(stamp) && stamp > 0) {
61
+ const instant = DateTime.fromSeconds(stamp, { zone });
62
+ const time = instant.isValid && instant.toFormat("yyyy-MM-dd'T'HH:mm:ss") === local ? instant.toISO({ suppressMilliseconds: true }) : null;
63
+ if (time)
64
+ return { time };
65
+ }
66
+ return { local_time: local };
67
+ }
68
+ /**
69
+ * The scan's line, which starts with the name of the country the scan names
70
+ * and a full stop ("Ukraine.Delivered"); the name becomes its location.
71
+ */
72
+ function wordingOf(status, place) {
73
+ const stop = status.indexOf('.');
74
+ const name = stop > 0 ? status.slice(0, stop).trim() : '';
75
+ const rest = stop > 0 ? clean(status.slice(stop + 1)) : '';
76
+ if (place && name && rest && countryCode(name) === place)
77
+ return { description: rest, location: name };
78
+ return { description: status, location: place };
79
+ }
80
+ function projectRow(row) {
81
+ if (!isRecord(row))
82
+ throw new SchemaError(PROVIDER, 'Nova Global returned an invalid scan');
83
+ const status = text(row, 'status');
84
+ if (!status)
85
+ throw new SchemaError(PROVIDER, 'Nova Global returned a scan without wording');
86
+ const place = country(row, 'location');
87
+ const { description, location } = wordingOf(status, place);
88
+ const code = text(row, 'code', 16);
89
+ const staged = novaGlobalStage(code, description);
90
+ const event = {
91
+ ...clockOf(row, place),
92
+ ...(location ? { location } : {}),
93
+ description,
94
+ ...(code ? { provider_code: code } : {}),
95
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
96
+ };
97
+ return { key: JSON.stringify([text(row, 'date', 32), code, status]), event };
98
+ }
99
+ /**
100
+ * The partner the reply says delivers the parcel, with its number. `bills`
101
+ * lists the parcel's other numbers without saying whose each is: the sending
102
+ * carrier's, Nova Poshta's waybill and the partner's. The partner's number is
103
+ * the only one of the partner's own shape; with none or several, there is no
104
+ * hand-off.
105
+ */
106
+ function handoff(payload, number) {
107
+ const name = text(payload, 'partner', 80);
108
+ const partner = name ? carrierIdFromPartner(name) : undefined;
109
+ const bills = payload.bills;
110
+ if (!partner || partner === 'nova-global' || typeof bills !== 'string' || bills.length > 4096)
111
+ return {};
112
+ const references = new Set(bills.split(',').map((bill) => returnedTrackingNumber(bill))
113
+ .filter((bill) => /^[A-Z0-9]{4,40}$/.test(bill) && bill !== number));
114
+ const theirs = [...references].filter((bill) => detectCarrierMatch(bill).candidates.includes(partner));
115
+ return theirs.length === 1 ? { delivery_carrier: partner, delivery_tracking_number: theirs[0] } : {};
116
+ }
117
+ /**
118
+ * The reply of `tracking.php` for one waybill: the summary of its newest scan,
119
+ * the route's two country codes, the parcel's other numbers, its delivery
120
+ * partner and `historyStatus`, its scans newest first. They keep that order:
121
+ * their clocks are not all instants. The tracker echoes the number asked as
122
+ * `billNumber`. An empty body, the reply to a number it does not know, is
123
+ * read before this.
124
+ */
125
+ export function parseNovaGlobal(payload, raw) {
126
+ const number = normalizeNovaGlobalNumber(raw);
127
+ if (payload === NOT_FOUND)
128
+ throw new NotFoundError(PROVIDER);
129
+ if (!isRecord(payload) || typeof payload.billNumber !== 'string')
130
+ throw new SchemaError(PROVIDER, 'Nova Global returned a reply without its waybill');
131
+ if (returnedTrackingNumber(payload.billNumber) !== number)
132
+ throw new IndeterminateError(PROVIDER, 'Nova Global answered for another waybill');
133
+ const rows = payload.historyStatus ?? [];
134
+ if (!Array.isArray(rows))
135
+ throw new SchemaError(PROVIDER, 'Nova Global returned a waybill with invalid scans');
136
+ if (rows.length > MAX_ROWS)
137
+ throw new SchemaError(PROVIDER, 'Nova Global returned an oversized history');
138
+ const seen = new Set();
139
+ const events = [];
140
+ for (const row of rows) {
141
+ const scan = projectRow(row);
142
+ if (seen.has(scan.key))
143
+ continue;
144
+ seen.add(scan.key);
145
+ events.push(scan.event);
146
+ }
147
+ if (!events.length)
148
+ throw new NoHistoryError(PROVIDER);
149
+ const newest = events[0];
150
+ const current = events.find((event) => event.stage);
151
+ const stage = current?.stage;
152
+ const destination = country(payload, 'to');
153
+ return {
154
+ status: stage ? languageStageStatus(stage) : 'unknown',
155
+ ...(stage ? { current_stage: stage, current_stage_source: current.stage_source } : {}),
156
+ last_status_text: newest.description,
157
+ last_update: newest.time ?? null,
158
+ ...(newest.local_time ? { last_update_local: newest.local_time } : {}),
159
+ expected_delivery: null,
160
+ ...(stage === 'delivered' && current.time ? { delivered_at: current.time } : {}),
161
+ ...handoff(payload, number),
162
+ ...(destination ? { destination_country: destination } : {}),
163
+ events: events.slice(0, MAX_EVENTS),
164
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
165
+ };
166
+ }
@@ -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 Nova Global's codes give a scan code. */
4
+ export declare function novaGlobalMappedStage(code: string): Stage | undefined;
5
+ /** A scan's stage: its code, else the shared wording rules, else nothing. */
6
+ export declare function novaGlobalStage(code: string, wording: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,51 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ /**
3
+ * Nova Global's scan codes, the same whatever the reply's language. Only codes
4
+ * seen in replies are mapped; another code falls to the wording rules.
5
+ */
6
+ const CODES = new Map([
7
+ ['1-0', 'registered'], // The order for delivery to the destination country.
8
+ ['1-2', 'accepted'], // A parcel that reached the hub without an order is processed there.
9
+ ['14', 'accepted'],
10
+ ['20', 'customs'], // Delivered to the customs terminal.
11
+ ['22', 'in_transit'], // Left the customs terminal.
12
+ ['23', 'in_transit'], // Arrived at a transit sorting centre.
13
+ ['24', 'in_transit'], // Arrived in a country under customs control, on its way through.
14
+ ['24-1', 'in_transit'], // In transit through Ukraine.
15
+ ['25', 'in_transit'], // Left a country under customs control.
16
+ ['26', 'in_transit'], // Left a transit sorting centre.
17
+ ['30', 'in_transit'], // Sent to the destination city.
18
+ ['301', 'in_transit'], // At the terminal of the destination city.
19
+ ['303', 'ready_for_pickup'], // In a parcel locker, for the recipient to collect.
20
+ ['37', 'out_for_delivery'],
21
+ ['38', 'ready_for_pickup'], // At the branch, for the recipient to collect.
22
+ ['3-1', 'delivered'],
23
+ ['400', 'customs'], // Customs declaration under way.
24
+ ['401', 'in_transit'], // Customs declaration finished: the parcel is cleared.
25
+ ['402', 'customs'], // The parcel needs customs clearance.
26
+ ['407', 'customs'], // Documents submitted for customs clearance.
27
+ ['407-1', 'customs'],
28
+ ['410', 'customs'], // The documents customs asked for arrived.
29
+ ['54', 'in_transit'], // A truck left for its destination.
30
+ ['55', 'in_transit'], // A truck reached its destination.
31
+ ['553', 'exception'], // Lost.
32
+ ['63-4', 'in_transit'], // Released from the hub's area for parcels without an order.
33
+ ['76', 'in_transit'], // The consignment reached the airport.
34
+ ['247', 'in_transit'], // Processed and ready for the flight.
35
+ ]);
36
+ /** The stage Nova Global's codes give a scan code. */
37
+ export function novaGlobalMappedStage(code) {
38
+ return CODES.get(code.trim());
39
+ }
40
+ /** A scan's stage: its code, else the shared wording rules, else nothing. */
41
+ export function novaGlobalStage(code, wording) {
42
+ const mapped = novaGlobalMappedStage(code);
43
+ if (mapped)
44
+ return { stage: mapped, source: 'carrier_map' };
45
+ const worded = classifyWording(wording, 'in_transit');
46
+ return worded.source === 'none' ? null : worded;
47
+ }
48
+ export const statusMap = {
49
+ stage: (code) => (code ? novaGlobalMappedStage(code) : undefined),
50
+ gaps: [],
51
+ };
@@ -12,7 +12,7 @@
12
12
  {"wording": "Відправлення потребує митного оформлення", "stage": "customs", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; the parcel needs customs clearance."},
13
13
  {"wording": "Нестача /невідповідність супровідних документів", "stage": "customs", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; documents are missing or do not match, which holds the parcel in customs. Spacing as the tracker prints it."},
14
14
  {"wording": "Супровідні документи отримані", "stage": "customs", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; the missing documents arrived."},
15
- {"wording": "Митне декларування завершене", "stage": "customs", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; customs clearance finished."},
15
+ {"wording": "Митне декларування завершене", "stage": "in_transit", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; customs clearance finished, which puts the parcel back in transit."},
16
16
  {"wording": "Відправлено в місто призначення", "stage": "in_transit", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; the parcel was sent on to the destination city."},
17
17
  {"wording": "Доставлено в транзитний сортувальний центр", "stage": "in_transit", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker."},
18
18
  {"wording": "Відправлення одержано", "stage": "delivered", "language": "uk", "confirmedBy": "portal", "note": "An event in an archived capture of Nova Poshta Global's tracker; the recipient collected the parcel."},
@@ -23,7 +23,51 @@
23
23
  {"wording": "Shipment is in transit", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker, seen at the arrival airport in Ukraine."},
24
24
  {"wording": "Delivered to the customs terminalr", "stage": "customs", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker, misspelt as the tracker prints it."},
25
25
  {"wording": "In the process of customs declaration", "stage": "customs", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker."},
26
- {"wording": "Customs declaration finished", "stage": "customs", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker."},
27
- {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker."}
26
+ {"wording": "Customs declaration finished", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker; customs clearance finished, which puts the parcel back in transit."},
27
+ {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "An event in an archived capture of the English tracker."},
28
+ {"code": "1-0", "wording": "Order is processed for delivery to the country of destination", "stage": "registered", "language": "en", "confirmedBy": "live"},
29
+ {"code": "1-0", "wording": "Оформлено замовлення на доставку в країну призначення", "stage": "registered", "language": "uk", "confirmedBy": "live"},
30
+ {"code": "1-2", "wording": "Processing an unregistered shipment", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "At the hub, for a parcel that arrived without an order; written beside the order itself."},
31
+ {"code": "14", "wording": "Accepted for delivery", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "The first scan at the sending site, in the United States or Ukraine."},
32
+ {"code": "14", "wording": "Прийнято до перевезення", "stage": "accepted", "language": "uk", "confirmedBy": "live"},
33
+ {"code": "20", "wording": "Delivered to the customs terminal", "stage": "customs", "language": "en", "confirmedBy": "live"},
34
+ {"code": "20", "wording": "Доставлено до митного терміналу", "stage": "customs", "language": "uk", "confirmedBy": "live"},
35
+ {"code": "22", "wording": "Left the customs terminal", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
36
+ {"code": "22", "wording": "Вибуло із митного терміналу", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
37
+ {"code": "23", "wording": "Shipment arrived at the transit sorting center", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
38
+ {"code": "23", "wording": "Вантаж прибув у транзитний сортувальний центр", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
39
+ {"code": "24", "wording": "Shipment arrived in the transit country under customs control", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Also written on arrival in the destination country before the partner delivers."},
40
+ {"code": "24-1", "wording": "The shipment is in transit through Ukraine", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
41
+ {"code": "25", "wording": "Shipment left the transit country under customs control", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
42
+ {"code": "25", "wording": "Вантаж вибув з транзитної країни під митним контролем", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
43
+ {"code": "26", "wording": "Shipment left the transit sorting center", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
44
+ {"code": "30", "wording": "Sent to the destination city", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
45
+ {"code": "30", "wording": "Відправлено в місто призначення", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
46
+ {"code": "301", "wording": "At the terminal of the destination city", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
47
+ {"code": "301", "wording": "На терміналі міста призначення", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
48
+ {"code": "303", "wording": "Delivered to the locker", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "live", "note": "In a Nova Poshta parcel locker, waiting for the recipient."},
49
+ {"code": "303", "wording": "Доставлено в поштомат", "stage": "ready_for_pickup", "language": "uk", "confirmedBy": "live"},
50
+ {"code": "37", "wording": "On courier delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "With the partner's courier in the destination country."},
51
+ {"code": "38", "wording": "At the post office", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "live", "note": "At the Nova Poshta branch, waiting for the recipient."},
52
+ {"code": "3-1", "wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "The page marks this code as the delivery."},
53
+ {"code": "3-1", "wording": "Відправлення одержано", "stage": "delivered", "language": "uk", "confirmedBy": "live"},
54
+ {"code": "400", "wording": "In the process of customs declaration", "stage": "customs", "language": "en", "confirmedBy": "live"},
55
+ {"code": "400", "wording": "В процесі митного декларування", "stage": "customs", "language": "uk", "confirmedBy": "live"},
56
+ {"code": "401", "wording": "Customs declaration finished", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Customs clearance finished, which puts the parcel back in transit."},
57
+ {"code": "401", "wording": "Митне декларування завершене", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
58
+ {"code": "402", "wording": "Parcel require customs clearance", "stage": "customs", "language": "en", "confirmedBy": "live", "note": "Spelt as the tracker prints it."},
59
+ {"code": "407", "wording": "The documents were submitted for customs clearance", "stage": "customs", "language": "en", "confirmedBy": "live"},
60
+ {"code": "407-1", "wording": "The documents were submitted for customs clearance", "stage": "customs", "language": "en", "confirmedBy": "live"},
61
+ {"code": "410", "wording": "Accompanying documents received", "stage": "customs", "language": "en", "confirmedBy": "live"},
62
+ {"code": "54", "wording": "The truck departed for its destination", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
63
+ {"code": "54", "wording": "Рейс вибув до пункту призначення", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
64
+ {"code": "55", "wording": "The truck arrived at the destination point", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
65
+ {"code": "55", "wording": "Рейс прибув в пункт призначення", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
66
+ {"code": "553", "wording": "Shipment lost", "stage": "exception", "language": "en", "confirmedBy": "live"},
67
+ {"code": "63-4", "wording": "IEW released from the \"Unregistered\" area", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Released from the hub's area for parcels that arrived without an order."},
68
+ {"code": "76", "wording": "The batch of cargo arrived at the airport", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
69
+ {"code": "76", "wording": "Партія вантажу прибула в аеропорт", "stage": "in_transit", "language": "uk", "confirmedBy": "live"},
70
+ {"code": "247", "wording": "Shipment processed - ready for further shipment on the flight", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
71
+ {"code": "247", "wording": "Відправлення оброблено - готово до подальшого відвантаження на рейс", "stage": "in_transit", "language": "uk", "confirmedBy": "live"}
28
72
  ]
29
73
  }
@@ -170,6 +170,7 @@ import { statusMap as jsExpress } from '../../carriers/js-express/status.js';
170
170
  import { statusMap as kazpost } from '../../carriers/kazpost/status.js';
171
171
  import { statusMap as kex } from '../../carriers/kex/status.js';
172
172
  import { statusMap as kuehneNagel } from '../../carriers/kuehne-nagel/status.js';
173
+ import { statusMap as kyoungdong } from '../../carriers/kyoungdong/status.js';
173
174
  import { statusMap as laPoste } from '../../carriers/la-poste/status.js';
174
175
  import { statusMap as latvijasPasts } from '../../carriers/latvijas-pasts/status.js';
175
176
  import { statusMap as leopardsCourier } from '../../carriers/leopards-courier/status.js';
@@ -200,6 +201,7 @@ import { statusMap as nextLevelDelivery } from '../../carriers/next-level-delive
200
201
  import { statusMap as nipost } from '../../carriers/nipost/status.js';
201
202
  import { statusMap as nipponExpress } from '../../carriers/nippon-express/status.js';
202
203
  import { statusMap as norskGlobal } from '../../carriers/norsk-global/status.js';
204
+ import { statusMap as novaGlobal } from '../../carriers/nova-global/status.js';
203
205
  import { statusMap as nox } from '../../carriers/nox/status.js';
204
206
  import { statusMap as oca } from '../../carriers/oca/status.js';
205
207
  import { statusMap as oldDominion } from '../../carriers/old-dominion/status.js';
@@ -343,11 +345,11 @@ export const STATUS_MAPS = {
343
345
  'go-express': goExpress, globalpost, go4, gofo, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, 'gofo-nl': gofoNl, gzyl, hanjin, 'happy-post': happyPost, haypost, 'hct-logistics': hctLogistics,
344
346
  helthjem, hepsijet, 'hermes-at': hermesAt, hispapost, 'hr-parcel': hrParcel,
345
347
  'hrvatska-posta': hrvatskaPosta, hywl, 'ids-logistik': idsLogistik, imile, instabox, interrapidisimo, 'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-cargo': jntCargo,
346
- 'j-and-t-middle-east': jAndTMiddleEast, jhgj, jitsu, jne, 'jordan-post': jordanPost, jrt, 'js-express': jsExpress, kazpost, kex, 'kuehne-nagel': kuehneNagel, 'la-poste': laPoste, 'latvijas-pasts': latvijasPasts,
348
+ 'j-and-t-middle-east': jAndTMiddleEast, jhgj, jitsu, jne, 'jordan-post': jordanPost, jrt, 'js-express': jsExpress, kazpost, kex, 'kuehne-nagel': kuehneNagel, kyoungdong, 'la-poste': laPoste, 'latvijas-pasts': latvijasPasts,
347
349
  'leopards-courier': leopardsCourier, liccardi, 'lietuvos-pastas': lietuvosPastas, linhan, 'lion-parcel': lionParcel, logen, loggi, logoix, 'loomis-express': loomisExpress,
348
350
  'lotte-global': lotteGlobal, 'magyar-posta': magyarPosta, mailamericas, mainfreight, maltapost, matkahuolto, mayiyinfu, meest, 'messenger-cz': messengerCz,
349
351
  'metropolitan-warehouse': metropolitanWarehouse, 'mng-kargo': mngKargo, mtscn, mylerz, naqel, nationex, 'next-level-delivery': nextLevelDelivery, nipost, 'nippon-express': nipponExpress,
350
- 'norsk-global': norskGlobal, nox, oca, 'old-dominion': oldDominion, 'olva-courier': olvaCourier, omgo, omniva,
352
+ 'norsk-global': norskGlobal, 'nova-global': novaGlobal, nox, oca, 'old-dominion': oldDominion, 'olva-courier': olvaCourier, omgo, omniva,
351
353
  'one-world-express': oneWorldExpress, 'orlen-paczka': orlenPaczka, 'osm-worldwide': osmWorldwide, 'pack-man': packMan, packlink, packs,
352
354
  'pakistan-post': pakistanPost, palletways, paquetexpress, passport, 'pathao-courier': pathaoCourier, 'pdn-express': pdnExpress, pecom,
353
355
  'pfc-express': pfcExpress, phlpost,
@@ -17174,7 +17174,7 @@
17174
17174
  "timezone": "Asia/Seoul",
17175
17175
  "tracking": {
17176
17176
  "mode": "automatic",
17177
- "adapter": "universal"
17177
+ "adapter": "kyoungdong"
17178
17178
  },
17179
17179
  "canaryUrl": "https://kdexp.com/index.do",
17180
17180
  "trackingUrlTemplate": "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -19610,7 +19610,9 @@
19610
19610
  "timezone": "Europe/Kyiv",
19611
19611
  "tracking": {
19612
19612
  "mode": "automatic",
19613
- "adapter": "universal"
19613
+ "adapter": "nova-global",
19614
+ "recognitionRank": 834,
19615
+ "localClocks": true
19614
19616
  },
19615
19617
  "canaryUrl": "https://nova.global/track/",
19616
19618
  "trackingUrlTemplate": "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -11076,7 +11076,7 @@ export declare const CARRIER_CATALOG: {
11076
11076
  readonly timezone: "Asia/Seoul";
11077
11077
  readonly tracking: {
11078
11078
  readonly mode: "automatic";
11079
- readonly adapter: "universal";
11079
+ readonly adapter: "kyoungdong";
11080
11080
  };
11081
11081
  readonly canaryUrl: "https://kdexp.com/index.do";
11082
11082
  readonly trackingUrlTemplate: "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}";
@@ -12611,7 +12611,9 @@ export declare const CARRIER_CATALOG: {
12611
12611
  readonly timezone: "Europe/Kyiv";
12612
12612
  readonly tracking: {
12613
12613
  readonly mode: "automatic";
12614
- readonly adapter: "universal";
12614
+ readonly adapter: "nova-global";
12615
+ readonly recognitionRank: 834;
12616
+ readonly localClocks: true;
12615
12617
  };
12616
12618
  readonly canaryUrl: "https://nova.global/track/";
12617
12619
  readonly trackingUrlTemplate: "https://nova.global/track/?Tracking_ID={trackingNumber}";
@@ -17175,7 +17175,7 @@ export const CARRIER_CATALOG = {
17175
17175
  "timezone": "Asia/Seoul",
17176
17176
  "tracking": {
17177
17177
  "mode": "automatic",
17178
- "adapter": "universal"
17178
+ "adapter": "kyoungdong"
17179
17179
  },
17180
17180
  "canaryUrl": "https://kdexp.com/index.do",
17181
17181
  "trackingUrlTemplate": "https://kdexp.com/service/delivery/etc/delivery.do?barcode={trackingNumber}",
@@ -19611,7 +19611,9 @@ export const CARRIER_CATALOG = {
19611
19611
  "timezone": "Europe/Kyiv",
19612
19612
  "tracking": {
19613
19613
  "mode": "automatic",
19614
- "adapter": "universal"
19614
+ "adapter": "nova-global",
19615
+ "recognitionRank": 834,
19616
+ "localClocks": true
19615
19617
  },
19616
19618
  "canaryUrl": "https://nova.global/track/",
19617
19619
  "trackingUrlTemplate": "https://nova.global/track/?Tracking_ID={trackingNumber}",
@@ -182,6 +182,7 @@ export const CARRIER_RECOGNITION_RANKS = {
182
182
  "nipost": 908,
183
183
  "nippon-express": 941,
184
184
  "norsk-global": 845,
185
+ "nova-global": 834,
185
186
  "nova-poshta": 685,
186
187
  "nox": 874,
187
188
  "nz-post": 760,
@@ -209,6 +209,7 @@ import { adapter as kazpost } from '../carriers/kazpost/adapter.js';
209
209
  import { adapter as kex } from '../carriers/kex/adapter.js';
210
210
  import { adapter as koreaPost } from '../carriers/korea-post/adapter.js';
211
211
  import { adapter as kuehneNagel } from '../carriers/kuehne-nagel/adapter.js';
212
+ import { adapter as kyoungdong } from '../carriers/kyoungdong/adapter.js';
212
213
  import { adapter as laPoste } from '../carriers/la-poste/adapter.js';
213
214
  import { adapter as landmarkGlobal } from '../carriers/landmark-global/adapter.js';
214
215
  import { adapter as latvijasPasts } from '../carriers/latvijas-pasts/adapter.js';
@@ -245,6 +246,7 @@ import { adapter as ninjaVan } from '../carriers/ninja-van/adapter.js';
245
246
  import { adapter as nipost } from '../carriers/nipost/adapter.js';
246
247
  import { adapter as nipponExpress } from '../carriers/nippon-express/adapter.js';
247
248
  import { adapter as norskGlobal } from '../carriers/norsk-global/adapter.js';
249
+ import { adapter as novaGlobal } from '../carriers/nova-global/adapter.js';
248
250
  import { adapter as novaPoshta } from '../carriers/nova-poshta/adapter.js';
249
251
  import { adapter as nox } from '../carriers/nox/adapter.js';
250
252
  import { adapter as nzPost } from '../carriers/nz-post/adapter.js';
@@ -607,6 +609,7 @@ export const REGISTRY = {
607
609
  "kex": kex,
608
610
  "korea-post": koreaPost,
609
611
  "kuehne-nagel": kuehneNagel,
612
+ "kyoungdong": kyoungdong,
610
613
  "la-poste": laPoste,
611
614
  "landmark-global": landmarkGlobal,
612
615
  "latvijas-pasts": latvijasPasts,
@@ -643,6 +646,7 @@ export const REGISTRY = {
643
646
  "nipost": nipost,
644
647
  "nippon-express": nipponExpress,
645
648
  "norsk-global": norskGlobal,
649
+ "nova-global": novaGlobal,
646
650
  "nova-poshta": novaPoshta,
647
651
  "nox": nox,
648
652
  "nz-post": nzPost,
@@ -1167,7 +1171,7 @@ export const REGISTRY = {
1167
1171
  "kuehne-nagel": "kuehne-nagel",
1168
1172
  "kunyoung": "universal",
1169
1173
  "kwick-box": "universal",
1170
- "kyoungdong": "universal",
1174
+ "kyoungdong": "kyoungdong",
1171
1175
  "kyrgyz-post": "universal",
1172
1176
  "l-post": "universal",
1173
1177
  "la-poste": "la-poste",
@@ -1240,7 +1244,7 @@ export const REGISTRY = {
1240
1244
  "nord-wheel": "universal",
1241
1245
  "norsk-global": "norsk-global",
1242
1246
  "northline": "universal",
1243
- "nova-global": "universal",
1247
+ "nova-global": "nova-global",
1244
1248
  "nova-poshta": "nova-poshta",
1245
1249
  "nox": "nox",
1246
1250
  "nz-couriers": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.366.0"
5
+ "version": "0.368.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.366.0-main.1999",
3
+ "version": "0.368.0-main.2003",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",