universal-parcel-scraper 0.427.0-main.2176 → 0.428.0-main.2178

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 **462 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **463 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>767 catalog entries · 134 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 14,493 such statuses, each filed under one stage.
158
+ The carrier folders record 14,524 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
@@ -28429,7 +28429,8 @@
28429
28429
  "timezone": "Asia/Singapore",
28430
28430
  "tracking": {
28431
28431
  "mode": "automatic",
28432
- "adapter": "universal"
28432
+ "adapter": "tracx-logis",
28433
+ "recognitionRank": 777
28433
28434
  },
28434
28435
  "canaryUrl": "https://www.tracxlogis.com/tracking",
28435
28436
  "trackingUrlTemplate": "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 767,
3
- "activeAdapters": 462,
3
+ "activeAdapters": 463,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -82237,6 +82237,78 @@
82237
82237
  "tracx-logis"
82238
82238
  ]
82239
82239
  },
82240
+ {
82241
+ "input": "QSP184265966",
82242
+ "carrier": "tracx-logis",
82243
+ "confidence": "high",
82244
+ "candidates": [
82245
+ "tracx-logis"
82246
+ ]
82247
+ },
82248
+ {
82249
+ "input": "QSP198482732",
82250
+ "carrier": "tracx-logis",
82251
+ "confidence": "high",
82252
+ "candidates": [
82253
+ "tracx-logis"
82254
+ ]
82255
+ },
82256
+ {
82257
+ "input": "QSP202668109",
82258
+ "carrier": "tracx-logis",
82259
+ "confidence": "high",
82260
+ "candidates": [
82261
+ "tracx-logis"
82262
+ ]
82263
+ },
82264
+ {
82265
+ "input": "QSP205361135",
82266
+ "carrier": "tracx-logis",
82267
+ "confidence": "high",
82268
+ "candidates": [
82269
+ "tracx-logis"
82270
+ ]
82271
+ },
82272
+ {
82273
+ "input": "QSP210201535",
82274
+ "carrier": "tracx-logis",
82275
+ "confidence": "high",
82276
+ "candidates": [
82277
+ "tracx-logis"
82278
+ ]
82279
+ },
82280
+ {
82281
+ "input": "QSP212901660",
82282
+ "carrier": "tracx-logis",
82283
+ "confidence": "high",
82284
+ "candidates": [
82285
+ "tracx-logis"
82286
+ ]
82287
+ },
82288
+ {
82289
+ "input": "QSP216440445",
82290
+ "carrier": "tracx-logis",
82291
+ "confidence": "high",
82292
+ "candidates": [
82293
+ "tracx-logis"
82294
+ ]
82295
+ },
82296
+ {
82297
+ "input": "QSP220318030",
82298
+ "carrier": "tracx-logis",
82299
+ "confidence": "high",
82300
+ "candidates": [
82301
+ "tracx-logis"
82302
+ ]
82303
+ },
82304
+ {
82305
+ "input": "QSP222954533",
82306
+ "carrier": "tracx-logis",
82307
+ "confidence": "high",
82308
+ "candidates": [
82309
+ "tracx-logis"
82310
+ ]
82311
+ },
82240
82312
  {
82241
82313
  "input": "QUL-DL-9194640",
82242
82314
  "carrier": "unknown",
@@ -21388,7 +21388,8 @@ var Re = {
21388
21388
  timezone: "Asia/Singapore",
21389
21389
  tracking: {
21390
21390
  mode: "automatic",
21391
- adapter: "universal"
21391
+ adapter: "tracx-logis",
21392
+ recognitionRank: 777
21392
21393
  },
21393
21394
  canaryUrl: "https://www.tracxlogis.com/tracking",
21394
21395
  trackingUrlTemplate: "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}",
@@ -29478,6 +29479,7 @@ var _l = {
29478
29479
  torrestir: 866,
29479
29480
  "total-express": 975,
29480
29481
  trackon: 821,
29482
+ "tracx-logis": 777,
29481
29483
  transaher: 852,
29482
29484
  transcorp: 787,
29483
29485
  tsjy: 992,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The id of the server action TracX Logis's tracking page calls for a parcel's
3
+ * history, read from the page's client chunks. Next builds the page's
4
+ * reference with `createServerReference("<id>", …, "getTrackingHistory")`:
5
+ * only an id bound to that name counts, since the same module registers the
6
+ * page's other actions next to it.
7
+ */
8
+ /** Chunks read per discovery, the last ones the tracking view loads first. */
9
+ export declare const MAX_CHUNKS = 8;
10
+ /** Fetches a chunk's text within the bytes it may have. */
11
+ export type ChunkReader = (url: string) => Promise<string>;
12
+ /**
13
+ * The chunks the tracking view loads, in the page's order; when the page no
14
+ * longer names the view, every chunk its scripts load, except the
15
+ * polyfills older browsers take instead.
16
+ */
17
+ export declare function viewChunks(html: string, page: string): string[];
18
+ /** The id a chunk binds to the history lookup, if it builds that reference. */
19
+ export declare function lookupActionId(source: string): string | undefined;
20
+ /**
21
+ * The action id the page's chunks bind to the history lookup. The view's own
22
+ * code comes after the shared chunks it loads, so the chunks are read from the
23
+ * last one back, one at a time, and never more than `MAX_CHUNKS`.
24
+ */
25
+ export declare function discoverActionId(html: string, page: string, read: ChunkReader): Promise<string | undefined>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The id of the server action TracX Logis's tracking page calls for a parcel's
3
+ * history, read from the page's client chunks. Next builds the page's
4
+ * reference with `createServerReference("<id>", …, "getTrackingHistory")`:
5
+ * only an id bound to that name counts, since the same module registers the
6
+ * page's other actions next to it.
7
+ */
8
+ /** Chunks read per discovery, the last ones the tracking view loads first. */
9
+ export const MAX_CHUNKS = 8;
10
+ const SCRIPT = /<script\b([^>]*)>/gi;
11
+ const SOURCE = /\bsrc\s*=\s*["']([^"'<>]+)["']/i;
12
+ const CHUNK_PATH = /\/_next\/static\/chunks\/[\w.-]+\.js/g;
13
+ // The tracking view's client module, with the chunks it loads, in the page's own server components.
14
+ const VIEW = /I\[\d+,\[([^\]]{0,8000})\],\\*"TrackingContent\\*"\]/;
15
+ // The page streams its server components in pieces, which can split a row.
16
+ const PIECE_BREAK = /"\]\)<\/script><script\b[^>]*>self\.__next_f\.push\(\[1,"/g;
17
+ const LOOKUP = /createServerReference\)\(\s*"([0-9a-f]{40,42})"\s*,[\w$.\s,]{0,200}?,\s*"getTrackingHistory"\s*\)/;
18
+ /**
19
+ * The chunks the tracking view loads, in the page's order; when the page no
20
+ * longer names the view, every chunk its scripts load, except the
21
+ * polyfills older browsers take instead.
22
+ */
23
+ export function viewChunks(html, page) {
24
+ const origin = new URL(page).origin;
25
+ const listed = VIEW.exec(html.replace(PIECE_BREAK, ''))?.[1];
26
+ const paths = listed !== undefined ? [...listed.matchAll(CHUNK_PATH)].map(([path]) => path)
27
+ : [...html.matchAll(SCRIPT)].map(([, attributes = '']) => attributes).filter(attributes => !/\bnomodule\b/i.test(attributes))
28
+ .flatMap(attributes => SOURCE.exec(attributes)?.[1] ?? []);
29
+ const chunks = [];
30
+ for (const path of paths) {
31
+ let url;
32
+ try {
33
+ url = new URL(path.replace(/&amp;/g, '&'), page);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (url.origin !== origin || !/^\/_next\/static\/chunks\/[\w.-]+\.js$/.test(url.pathname))
39
+ continue;
40
+ if (!chunks.includes(url.href))
41
+ chunks.push(url.href);
42
+ }
43
+ return chunks;
44
+ }
45
+ /** The id a chunk binds to the history lookup, if it builds that reference. */
46
+ export function lookupActionId(source) {
47
+ return LOOKUP.exec(source)?.[1];
48
+ }
49
+ /**
50
+ * The action id the page's chunks bind to the history lookup. The view's own
51
+ * code comes after the shared chunks it loads, so the chunks are read from the
52
+ * last one back, one at a time, and never more than `MAX_CHUNKS`.
53
+ */
54
+ export async function discoverActionId(html, page, read) {
55
+ for (const chunk of viewChunks(html, page).reverse().slice(0, MAX_CHUNKS)) {
56
+ const found = lookupActionId(await read(chunk));
57
+ if (found !== undefined)
58
+ return found;
59
+ }
60
+ return undefined;
61
+ }
@@ -0,0 +1,28 @@
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, whose server actions look a parcel up. */
5
+ export declare const TRACX_PAGE = "https://www.tracxlogis.com/tracking";
6
+ /**
7
+ * The id of the page's `getTrackingHistory` action, tried first. Every
8
+ * visitor's page carries the same one until the site is built again; when the
9
+ * tracker no longer knows it, the lookup reads the current one from the page.
10
+ */
11
+ export declare const TRACX_ACTION = "406dae20d6d6ebbae5417f417519605f84915781b6";
12
+ export declare class TracxTracker {
13
+ private readonly options;
14
+ private discovered;
15
+ constructor(options?: {
16
+ fetcher?: typeof fetch;
17
+ recorder?: StepRecorder;
18
+ userAgent?: string;
19
+ /** The id tried first when none read from the page is current; the built-in one by default. */
20
+ action?: string;
21
+ });
22
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
23
+ private remember;
24
+ private post;
25
+ /** The page or one of its chunks, as text. */
26
+ private get;
27
+ }
28
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,127 @@
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 { discoverActionId } from './action.js';
8
+ import { isChallengePage, normalizeTracxNumber, parseTracx, PROVIDER, readActionReply, TRACX_MAX_BYTES, UnknownActionError } from './parser.js';
9
+ /** The tracking page, whose server actions look a parcel up. */
10
+ export const TRACX_PAGE = 'https://www.tracxlogis.com/tracking';
11
+ /**
12
+ * The id of the page's `getTrackingHistory` action, tried first. Every
13
+ * visitor's page carries the same one until the site is built again; when the
14
+ * tracker no longer knows it, the lookup reads the current one from the page.
15
+ */
16
+ export const TRACX_ACTION = '406dae20d6d6ebbae5417f417519605f84915781b6';
17
+ // The page is about a hundred kilobytes, and each of its chunks under a hundred.
18
+ const PAGE_MAX_BYTES = 600_000;
19
+ const CHUNK_MAX_BYTES = 600_000;
20
+ /** How long an id read from the page stays the first try after it last answered. */
21
+ const ACTION_TTL_MS = 6 * 3_600_000;
22
+ const REDIRECTS = [301, 302, 303, 307, 308];
23
+ const UNAVAILABLE = [405, 410];
24
+ const NOT_FOUND = 404;
25
+ export class TracxTracker {
26
+ options;
27
+ // An id the page named and the tracker then answered, for the lookups after it.
28
+ discovered;
29
+ constructor(options = {}) {
30
+ this.options = options;
31
+ }
32
+ async fetch(raw, context = {}) {
33
+ const number = normalizeTracxNumber(raw);
34
+ const cached = this.discovered && this.discovered.until > Date.now() ? this.discovered.id : undefined;
35
+ let action = cached ?? this.options.action ?? TRACX_ACTION;
36
+ let stale = false;
37
+ const refresh = async (signal, deadline) => {
38
+ const html = await this.get(TRACX_PAGE, signal, deadline, PAGE_MAX_BYTES);
39
+ const found = await discoverActionId(html, TRACX_PAGE, url => this.get(url, signal, deadline, CHUNK_MAX_BYTES));
40
+ if (found === undefined)
41
+ throw new SchemaError(PROVIDER, 'TracX Logis tracking page no longer names its lookup: the tracking lookup changed');
42
+ // The page naming the id that just failed means the id is not what changed.
43
+ if (found === action)
44
+ throw new SchemaError(PROVIDER, 'TracX Logis did not answer its own lookup: the tracking lookup changed');
45
+ action = found;
46
+ stale = false;
47
+ };
48
+ const attempt = async (signal, timeoutMs) => {
49
+ const deadline = performance.now() + timeoutMs;
50
+ if (stale)
51
+ await refresh(signal, deadline);
52
+ const id = action;
53
+ try {
54
+ const result = await this.post(number, id, signal, deadline);
55
+ this.remember(id);
56
+ return result;
57
+ }
58
+ catch (error) {
59
+ if (error instanceof NotFoundError)
60
+ this.remember(id);
61
+ if (error instanceof UnknownActionError) {
62
+ stale = true;
63
+ if (id === cached)
64
+ this.discovered = undefined;
65
+ }
66
+ throw error;
67
+ }
68
+ };
69
+ // The first try gets half of the budget, so a retry fits in the rest. An
70
+ // action the tracker no longer has is read again from the page and tried
71
+ // once more; a lookup that fails to reach TracX Logis, or hangs, gets one retry.
72
+ return runSteps({ carrier: 'tracx-logis', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
73
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
74
+ { id: 'direct', run: ({ signal, remainingMs }) => attempt(signal, firstAttemptMs(remainingMs)) },
75
+ { id: 'action', recovers: error => error instanceof UnknownActionError,
76
+ run: ({ signal, remainingMs }) => attempt(signal, firstAttemptMs(remainingMs)) },
77
+ networkRetryStep(({ signal, remainingMs }) => attempt(signal, remainingMs)),
78
+ ]);
79
+ }
80
+ remember(id) {
81
+ if (id !== TRACX_ACTION)
82
+ this.discovered = { id, until: Date.now() + ACTION_TTL_MS };
83
+ }
84
+ async post(number, action, signal, deadline) {
85
+ // The page calls its action with the number typed in, as a one-item argument list.
86
+ const { response, bytes } = await fetchBounded(TRACX_PAGE, { method: 'POST', signal, headers: {
87
+ Accept: 'text/x-component', 'Content-Type': 'text/plain;charset=UTF-8', 'Next-Action': action,
88
+ 'User-Agent': userAgentOf(this.options.userAgent),
89
+ }, body: JSON.stringify([number]) }, { provider: PROVIDER, timeoutMs: timeLeft(deadline), maxBytes: TRACX_MAX_BYTES,
90
+ redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 403, NOT_FOUND, ...UNAVAILABLE] });
91
+ // Next answers an action it does not have with 404 and this header.
92
+ if (response.headers.has('x-nextjs-action-not-found'))
93
+ throw new UnknownActionError();
94
+ if (response.status === 403) {
95
+ if (isChallengePage(decodeText(bytes)))
96
+ throw new ChallengeError(PROVIDER, 'TracX Logis answered with a browser check');
97
+ throw new ChallengeError(PROVIDER, 'TracX Logis refused the lookup', { status: 403 });
98
+ }
99
+ if (REDIRECTS.includes(response.status) || response.status === NOT_FOUND || UNAVAILABLE.includes(response.status)) {
100
+ throw new TransportError(PROVIDER, 'TracX Logis tracking lookup is unavailable', { status: response.status });
101
+ }
102
+ return parseTracx(readActionReply(bytes, response.headers.get('content-type')), number);
103
+ }
104
+ /** The page or one of its chunks, as text. */
105
+ async get(url, signal, deadline, maxBytes) {
106
+ const { response, bytes } = await fetchBounded(url, { method: 'GET', signal, headers: {
107
+ Accept: url.endsWith('.js') ? '*/*' : 'text/html', 'User-Agent': userAgentOf(this.options.userAgent),
108
+ } }, { provider: PROVIDER, timeoutMs: timeLeft(deadline), maxBytes, redirect: 'manual', fetcher: this.options.fetcher,
109
+ allowHttpStatuses: [...REDIRECTS, 403, NOT_FOUND, ...UNAVAILABLE] });
110
+ const text = decodeText(bytes);
111
+ if (response.status === 403 || (/^\s*</.test(text) && isChallengePage(text))) {
112
+ throw new ChallengeError(PROVIDER, 'TracX Logis answered its tracking page with a browser check');
113
+ }
114
+ if (response.status !== 200)
115
+ throw new TransportError(PROVIDER, 'TracX Logis tracking page is unavailable', { status: response.status });
116
+ return text;
117
+ }
118
+ }
119
+ const timeLeft = (deadline) => Math.max(1, Math.floor(deadline - performance.now()));
120
+ export const adapter = (environment) => {
121
+ const tracker = new TracxTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
122
+ return {
123
+ id: 'tracx-logis', recordsSteps: true, steps: ['direct', 'action', 'retry'],
124
+ track: (input, context) => tracker.fetch(input.number, context),
125
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeTracxNumber(number))),
126
+ };
127
+ };
@@ -23,18 +23,72 @@
23
23
  "timezone": "Asia/Singapore",
24
24
  "tracking": {
25
25
  "mode": "automatic",
26
- "adapter": "universal",
27
- "steps": []
26
+ "adapter": "tracx-logis",
27
+ "steps": [
28
+ "direct",
29
+ "action",
30
+ "retry"
31
+ ],
32
+ "recognition": {
33
+ "rank": 777
34
+ }
28
35
  },
29
- "capabilities": [],
36
+ "capabilities": [
37
+ "history",
38
+ "location",
39
+ "provider_code",
40
+ "offset_timestamps",
41
+ "delivered_at",
42
+ "destination_country",
43
+ "delivery_partner",
44
+ "delivery_tracking_number"
45
+ ],
30
46
  "portal": {
31
47
  "url": "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}",
32
48
  "searchUrl": "https://www.tracxlogis.com/tracking",
33
49
  "canaryUrl": "https://www.tracxlogis.com/tracking",
34
- "shows": [],
35
- "scraped": [],
36
- "discarded": [],
37
- "unavailable": []
50
+ "shows": [
51
+ "status",
52
+ "history",
53
+ "location",
54
+ "provider_code",
55
+ "offset_timestamps",
56
+ "delivered_at",
57
+ "progress_rail",
58
+ "origin_country",
59
+ "destination_country",
60
+ "delivery_partner",
61
+ "delivery_tracking_number",
62
+ "customer_reference",
63
+ "recipient_name",
64
+ "remarks"
65
+ ],
66
+ "scraped": [
67
+ "status",
68
+ "history",
69
+ "location",
70
+ "provider_code",
71
+ "offset_timestamps",
72
+ "delivered_at",
73
+ "destination_country",
74
+ "delivery_partner",
75
+ "delivery_tracking_number"
76
+ ],
77
+ "discarded": [
78
+ "progress_rail",
79
+ "origin_country",
80
+ "customer_reference",
81
+ "recipient_name",
82
+ "remarks"
83
+ ],
84
+ "unavailable": [
85
+ "eta",
86
+ "weight",
87
+ "service_name",
88
+ "sender_name",
89
+ "recipient_address",
90
+ "proof_of_delivery"
91
+ ]
38
92
  },
39
93
  "links": [
40
94
  {
@@ -96,6 +96,177 @@
96
96
  },
97
97
  "note": "An archived lookup on Qxpress's own tracker address; the capture was not opened, and the page loads the history by script, so the history was not visible."
98
98
  },
99
+ {
100
+ "number": "QSP222954533",
101
+ "role": "shipment",
102
+ "evidence": "public_shipment_report",
103
+ "source": {
104
+ "url": "https://www.parcelpanel.com/couriers/qxpress/",
105
+ "date": "2026-10-19",
106
+ "dateKind": "capture",
107
+ "access": "full_page"
108
+ },
109
+ "context": {
110
+ "assessment": "confirmed"
111
+ },
112
+ "expect": {
113
+ "carrier": "tracx-logis",
114
+ "confidence": "high"
115
+ },
116
+ "note": "ParcelPanel's Qxpress page offered it to try. TracX Logis's tracker answers it as a parcel within Singapore, delivered by a local courier."
117
+ },
118
+ {
119
+ "number": "QSP220318030",
120
+ "role": "shipment",
121
+ "evidence": "public_shipment_report",
122
+ "source": {
123
+ "url": "https://www.parcelpanel.com/couriers/qxpress/",
124
+ "date": "2026-10-19",
125
+ "dateKind": "capture",
126
+ "access": "indexed_excerpt"
127
+ },
128
+ "context": {
129
+ "assessment": "confirmed"
130
+ },
131
+ "expect": {
132
+ "carrier": "tracx-logis",
133
+ "confidence": "high"
134
+ },
135
+ "note": "A search engine's copy of ParcelPanel's Qxpress page offered it to try; the page now offers another. TracX Logis's tracker answers it as a parcel from Korea that Sagawa delivered in Japan, naming Sagawa's number."
136
+ },
137
+ {
138
+ "number": "QSP216440445",
139
+ "role": "shipment",
140
+ "evidence": "public_shipment_report",
141
+ "source": {
142
+ "url": "https://web.archive.org/web/20260416141813/https://www.parcelpanel.com/couriers/qxpress/",
143
+ "date": "2026-04-16",
144
+ "dateKind": "capture",
145
+ "access": "full_page"
146
+ },
147
+ "context": {
148
+ "assessment": "confirmed"
149
+ },
150
+ "expect": {
151
+ "carrier": "tracx-logis",
152
+ "confidence": "high"
153
+ },
154
+ "note": "ParcelPanel's Qxpress page offered it to try. TracX Logis's tracker answers it as a parcel within Singapore, delivered."
155
+ },
156
+ {
157
+ "number": "QSP212901660",
158
+ "role": "shipment",
159
+ "evidence": "public_shipment_report",
160
+ "source": {
161
+ "url": "https://web.archive.org/web/20260120181926/https://www.parcelpanel.com/couriers/qxpress/",
162
+ "date": "2026-01-20",
163
+ "dateKind": "capture",
164
+ "access": "full_page"
165
+ },
166
+ "context": {
167
+ "assessment": "confirmed"
168
+ },
169
+ "expect": {
170
+ "carrier": "tracx-logis",
171
+ "confidence": "high"
172
+ },
173
+ "note": "ParcelPanel's Qxpress page offered it to try. TracX Logis's tracker answers it as a parcel from Korea that Ecohai delivered in Japan, with Ecohai's linehaul scans."
174
+ },
175
+ {
176
+ "number": "QSP210201535",
177
+ "role": "shipment",
178
+ "evidence": "public_shipment_report",
179
+ "source": {
180
+ "url": "https://web.archive.org/web/20251013085950/https://www.parcelpanel.com/couriers/qxpress/",
181
+ "date": "2025-10-13",
182
+ "dateKind": "capture",
183
+ "access": "full_page"
184
+ },
185
+ "context": {
186
+ "assessment": "confirmed"
187
+ },
188
+ "expect": {
189
+ "carrier": "tracx-logis",
190
+ "confidence": "high"
191
+ },
192
+ "note": "ParcelPanel's Qxpress page offered it to try. TracX Logis's tracker answers it as a parcel within Singapore, delivered."
193
+ },
194
+ {
195
+ "number": "QSP202668109",
196
+ "role": "shipment",
197
+ "evidence": "public_shipment_report",
198
+ "source": {
199
+ "url": "https://www.ordertracker.com/carriers/qxpress",
200
+ "date": "2026-10-19",
201
+ "dateKind": "capture",
202
+ "access": "full_page"
203
+ },
204
+ "context": {
205
+ "assessment": "confirmed"
206
+ },
207
+ "expect": {
208
+ "carrier": "tracx-logis",
209
+ "confidence": "high"
210
+ },
211
+ "note": "OrderTracker's Qxpress page gives it as an example. TracX Logis's tracker answers it as a parcel within Singapore, delivered the day after a failed delivery."
212
+ },
213
+ {
214
+ "number": "QSP205361135",
215
+ "role": "shipment",
216
+ "evidence": "public_shipment_report",
217
+ "source": {
218
+ "url": "https://www.qxpress.net/Customer/PopupTraceParcels?TrackingNo=QSP205361135",
219
+ "date": "2026-10-19",
220
+ "dateKind": "capture",
221
+ "access": "indexed_excerpt"
222
+ },
223
+ "context": {
224
+ "assessment": "confirmed"
225
+ },
226
+ "expect": {
227
+ "carrier": "tracx-logis",
228
+ "confidence": "high"
229
+ },
230
+ "note": "A lookup on Qxpress's own tracker address in a search engine's index. TracX Logis's tracker answers it as a parcel from Korea that Japan Post delivered, naming Japan Post's number."
231
+ },
232
+ {
233
+ "number": "QSP198482732",
234
+ "role": "shipment",
235
+ "evidence": "public_shipment_report",
236
+ "source": {
237
+ "url": "https://web.archive.org/web/20250426094317/https://www.qxpress.net/Customer/PopupTraceParcels?TrackingNo=QSP198482732Qxpress%E3%81%AB%E3%81%AA%E3%82%8A%E3%81%BE%E3%81%97%E3%81%9F%E3%82%89%E3%80%81%E6%AD%A3%E3%81%97%E3%81%84%E9%80%81%E3%82%8A%E5%85%88%E3%81%94%E4%BD%8F%E6%89%80%E3%81%AE%E3%81%94%E6%95%99%E7%A4%BA%E3%82%92%E3%81%8A%E9%A1%98%E3%81%84%E3%81%84%E3%81%9F%E3%81%97%E3%81%BE%E3%81%99%EF%BC%BE%EF%BC%BE",
238
+ "date": "2025-04-26",
239
+ "dateKind": "capture",
240
+ "access": "indexed_excerpt"
241
+ },
242
+ "context": {
243
+ "assessment": "confirmed"
244
+ },
245
+ "expect": {
246
+ "carrier": "tracx-logis",
247
+ "confidence": "high"
248
+ },
249
+ "note": "An archived lookup on Qxpress's own tracker address, with a seller's note asking for the right address after the number. TracX Logis's tracker answers it as a parcel from Korea for Sagawa that failed delivery, went to long-term storage for a wrong address and was returned."
250
+ },
251
+ {
252
+ "number": "QSP184265966",
253
+ "role": "shipment",
254
+ "evidence": "public_shipment_report",
255
+ "source": {
256
+ "url": "https://web.archive.org/web/20250506001341/https://smartship.qxpress.net/Customer/PopupTraceParcels?TrackingNo=QSP184265966",
257
+ "date": "2025-05-06",
258
+ "dateKind": "capture",
259
+ "access": "indexed_excerpt"
260
+ },
261
+ "context": {
262
+ "assessment": "confirmed"
263
+ },
264
+ "expect": {
265
+ "carrier": "tracx-logis",
266
+ "confidence": "high"
267
+ },
268
+ "note": "An archived lookup on Qxpress's seller tracker address. TracX Logis's tracker answers it as a parcel from Korea for JD Sagawa, with only its order record from the older system."
269
+ },
99
270
  {
100
271
  "number": "JP3564644",
101
272
  "role": "shipment",
@@ -0,0 +1,38 @@
1
+ import { SchemaError } from '../../core/errors/index.js';
2
+ import type { CarrierResult } from '../../core/result/index.js';
3
+ export declare const PROVIDER = "TracX Logis";
4
+ export declare const TRACX_MAX_BYTES = 1000000;
5
+ /**
6
+ * The tracker no longer has the action the request named: it answered with
7
+ * Next's reply for an unknown action, or with its page.
8
+ */
9
+ export declare class UnknownActionError extends SchemaError {
10
+ constructor();
11
+ }
12
+ /** Whether a page is a CDN's or firewall's browser check. */
13
+ export declare function isChallengePage(html: string): boolean;
14
+ /** TracX Logis's own numbers, QSP and nine digits, as its tracker prints them; anything else is not one it issues. */
15
+ export declare function normalizeTracxNumber(raw: string): string;
16
+ /**
17
+ * What the page's server action returned. Row 0 names the row that holds it,
18
+ * as `{"a":"$@1",…}`; a thrown action leaves an error row there instead. The
19
+ * result is plain JSON whose strings Flight escapes: `$$` for a leading
20
+ * dollar, `$undefined` for an absent value, and a text row for a long string.
21
+ */
22
+ export declare function actionResult(bytes: Uint8Array): unknown;
23
+ /**
24
+ * The action's reply as the transport read it: a Flight stream, or a page in
25
+ * its place. A page is a browser check, or Next's answer to an action it no
26
+ * longer has.
27
+ */
28
+ export declare function readActionReply(bytes: Uint8Array, contentType: string | null): unknown;
29
+ /**
30
+ * The result of the tracking page's `getTrackingHistory` action for one
31
+ * number: `dataInfo`, the parcel's summary, and `history`, its scans. A
32
+ * number the tracker does not know comes back with every summary field null
33
+ * and no scans. The tracker also finds a parcel by its last-mile or delivery
34
+ * number, so the parcel counts only when its own number is the one asked.
35
+ * The recipient's name, the order's reference, the courier in charge of each
36
+ * scan and a scan's details, flight times in several local clocks, are left out.
37
+ */
38
+ export declare function parseTracx(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,285 @@
1
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
2
+ import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
3
+ import { languageStageStatus } from '../../core/status/index.js';
4
+ import { explicitOffsetTime } from '../../core/time/index.js';
5
+ import { clean } from '../../core/transport/index.js';
6
+ import { isRecord } from '../../core/types.js';
7
+ import { tracxHandoff } from './partners.js';
8
+ import { tracxStage } from './status.js';
9
+ export const PROVIDER = 'TracX Logis';
10
+ // The action's reply re-renders the tracking page with its result: under a hundred kilobytes.
11
+ export const TRACX_MAX_BYTES = 1_000_000;
12
+ const NUMBER = /^QSP\d{9}$/;
13
+ const MAX_SCANS = 500;
14
+ const MAX_EVENTS = 100;
15
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?Z$/;
16
+ // Bot checks a CDN or firewall could put in place of the reply.
17
+ const CHALLENGE = /<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected)|_cf_chl_opt|cf-turnstile|challenge-platform|awswaf|captcha/i;
18
+ /** Next's notFound() and redirect() travel as error rows, and mean the route changed. */
19
+ const NEXT_CONTROL = /^(?:NEXT_NOT_FOUND$|NEXT_HTTP_ERROR_FALLBACK;|NEXT_REDIRECT;)/;
20
+ // The fields of the parcel's summary; every one is null for a number the tracker does not know.
21
+ const SUMMARY_FIELDS = ['ShippingNo', 'DeliveryNo', 'TrackingNo', 'InputNo', 'LastmileShippingCompany', 'StartNationCd', 'DeliveryNationCd'];
22
+ /**
23
+ * The tracker no longer has the action the request named: it answered with
24
+ * Next's reply for an unknown action, or with its page.
25
+ */
26
+ export class UnknownActionError extends SchemaError {
27
+ constructor() {
28
+ super(PROVIDER, 'TracX Logis no longer knows the action asked: the tracking lookup changed');
29
+ this.name = 'UnknownActionError';
30
+ }
31
+ }
32
+ /** Whether a page is a CDN's or firewall's browser check. */
33
+ export function isChallengePage(html) {
34
+ return CHALLENGE.test(html.slice(0, 64_000));
35
+ }
36
+ /** TracX Logis's own numbers, QSP and nine digits, as its tracker prints them; anything else is not one it issues. */
37
+ export function normalizeTracxNumber(raw) {
38
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
39
+ if (!NUMBER.test(number))
40
+ throw new InvalidInputError(PROVIDER, 'TracX Logis numbers are QSP and nine digits');
41
+ return number;
42
+ }
43
+ // Row tags whose payload gives its length in bytes, as hex before a comma, and
44
+ // ends without a line feed: text, and typed arrays.
45
+ const LENGTH_TAGS = new Set([...'TAOoUSsLlGgMmV'].map(tag => tag.charCodeAt(0)));
46
+ const COLON = 0x3a;
47
+ const COMMA = 0x2c;
48
+ const LINE_FEED = 0x0a;
49
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
50
+ const ascii = (bytes) => String.fromCharCode(...bytes);
51
+ function decode(bytes) {
52
+ try {
53
+ return utf8.decode(bytes);
54
+ }
55
+ catch (cause) {
56
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unreadable reply', { cause });
57
+ }
58
+ }
59
+ /** The digest of an error row; an unreadable one is an empty digest, never a Next signal. */
60
+ function errorDigest(json) {
61
+ try {
62
+ const error = JSON.parse(json);
63
+ return isRecord(error) && typeof error.digest === 'string' ? error.digest : '';
64
+ }
65
+ catch {
66
+ return '';
67
+ }
68
+ }
69
+ /**
70
+ * The rows of a React Server Components reply by id. A text row ends where its
71
+ * byte length does, so rows are read by length, not split at line feeds. Hint
72
+ * and module rows are skipped.
73
+ */
74
+ function flightRows(bytes) {
75
+ const rows = new Map();
76
+ let at = 0;
77
+ while (at < bytes.length) {
78
+ if (bytes[at] === LINE_FEED) {
79
+ at += 1;
80
+ continue;
81
+ }
82
+ const colon = bytes.indexOf(COLON, at);
83
+ const id = colon < 0 ? '' : ascii(bytes.subarray(at, Math.min(colon, at + 9)));
84
+ // Hint rows (`:HL[…]`) have no id.
85
+ if (id !== '' && !/^[0-9a-f]{1,8}$/.test(id))
86
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unreadable reply');
87
+ if (colon < 0)
88
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unreadable reply');
89
+ at = colon + 1;
90
+ if (id !== '' && LENGTH_TAGS.has(bytes[at])) {
91
+ const comma = bytes.indexOf(COMMA, at);
92
+ const length = comma < 0 ? '' : ascii(bytes.subarray(at + 1, Math.min(comma, at + 10)));
93
+ if (!/^[0-9a-f]{1,8}$/.test(length))
94
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unreadable reply');
95
+ const end = comma + 1 + parseInt(length, 16);
96
+ if (end > bytes.length)
97
+ throw new SchemaError(PROVIDER, 'TracX Logis returned a truncated reply');
98
+ if (bytes[at] === 0x54)
99
+ rows.set(id, { kind: 'text', text: decode(bytes.subarray(comma + 1, end)) });
100
+ at = end;
101
+ continue;
102
+ }
103
+ const end = bytes.indexOf(LINE_FEED, at);
104
+ const stop = end < 0 ? bytes.length : end;
105
+ const row = decode(bytes.subarray(at, stop));
106
+ at = stop + 1;
107
+ if (id === '')
108
+ continue;
109
+ if (row.startsWith('E{'))
110
+ rows.set(id, { kind: 'error', digest: errorDigest(row.slice(1)) });
111
+ else if (/^(?:[[{"\d-]|null$|true$|false$)/.test(row))
112
+ rows.set(id, { kind: 'json', json: row });
113
+ }
114
+ return rows;
115
+ }
116
+ function parsedRow(row) {
117
+ if (row?.kind !== 'json')
118
+ return undefined;
119
+ try {
120
+ return JSON.parse(row.json);
121
+ }
122
+ catch (cause) {
123
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unreadable reply', { cause });
124
+ }
125
+ }
126
+ /**
127
+ * What the page's server action returned. Row 0 names the row that holds it,
128
+ * as `{"a":"$@1",…}`; a thrown action leaves an error row there instead. The
129
+ * result is plain JSON whose strings Flight escapes: `$$` for a leading
130
+ * dollar, `$undefined` for an absent value, and a text row for a long string.
131
+ */
132
+ export function actionResult(bytes) {
133
+ const rows = flightRows(bytes);
134
+ const head = parsedRow(rows.get('0'));
135
+ const pointer = isRecord(head) ? head.a : Array.isArray(head) ? head[0] : undefined;
136
+ const target = typeof pointer === 'string' ? /^\$@([0-9a-f]{1,8})$/.exec(pointer)?.[1] : undefined;
137
+ if (target === undefined)
138
+ throw new SchemaError(PROVIDER, 'TracX Logis returned a reply without its lookup result');
139
+ const found = rows.get(target);
140
+ if (found?.kind === 'error') {
141
+ if (NEXT_CONTROL.test(found.digest))
142
+ throw new SchemaError(PROVIDER, 'TracX Logis redirected or dropped its tracking page');
143
+ throw new IndeterminateError(PROVIDER, 'TracX Logis could not complete the lookup');
144
+ }
145
+ if (found?.kind !== 'json')
146
+ throw new SchemaError(PROVIDER, 'TracX Logis returned a reply without its lookup result');
147
+ let nodes = 0;
148
+ const resolve = (node, depth) => {
149
+ nodes += 1;
150
+ if (nodes > 50_000 || depth > 16)
151
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an oversized reply');
152
+ if (typeof node === 'string') {
153
+ if (!node.startsWith('$'))
154
+ return node;
155
+ if (node.startsWith('$$'))
156
+ return node.slice(1);
157
+ if (node === '$undefined')
158
+ return undefined;
159
+ const text = rows.get(/^\$([0-9a-f]{1,8})$/.exec(node)?.[1] ?? '');
160
+ if (text?.kind === 'text')
161
+ return text.text;
162
+ // A Date, a reference to an object or any other Flight value is not what this lookup returns.
163
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unexpected value');
164
+ }
165
+ if (Array.isArray(node))
166
+ return node.map((child) => resolve(child, depth + 1));
167
+ if (isRecord(node))
168
+ return Object.fromEntries(Object.entries(node).map(([key, child]) => [key, resolve(child, depth + 1)]));
169
+ return node;
170
+ };
171
+ return resolve(parsedRow(found), 0);
172
+ }
173
+ /**
174
+ * The action's reply as the transport read it: a Flight stream, or a page in
175
+ * its place. A page is a browser check, or Next's answer to an action it no
176
+ * longer has.
177
+ */
178
+ export function readActionReply(bytes, contentType) {
179
+ if (!/^text\/x-component\b/i.test(contentType ?? '')) {
180
+ const head = decode(bytes.subarray(0, 64_000));
181
+ if (isChallengePage(head))
182
+ throw new ChallengeError(PROVIDER, 'TracX Logis answered with a browser check');
183
+ if (/^\s*</.test(head))
184
+ throw new UnknownActionError();
185
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an unexpected reply');
186
+ }
187
+ return actionResult(bytes);
188
+ }
189
+ /** An optional text field; anything but a string is a changed reply. */
190
+ function text(record, key, maxLength = 300) {
191
+ const value = record[key];
192
+ if (value === null || value === undefined)
193
+ return '';
194
+ if (typeof value !== 'string')
195
+ throw new SchemaError(PROVIDER, `TracX Logis returned an invalid ${key}`);
196
+ return clean(value, maxLength);
197
+ }
198
+ /** Markup the page renders in a scan's note, as plain text. */
199
+ function plain(html) {
200
+ return clean(html.replace(/<br\s*\/?>/gi, '. ').replace(/<[^>]*>/g, ' ')
201
+ .replace(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;/g, "'"))
202
+ .replace(/(?:\s*\.)+\s*$/, '').replace(/\s*\.\s*\.\s*/g, '. ');
203
+ }
204
+ function projectScan(row) {
205
+ const code = text(row, 'Status', 16).toUpperCase();
206
+ const reason = text(row, 'Reason', 16).toUpperCase();
207
+ const wording = text(row, 'StatusDesc');
208
+ if (!wording)
209
+ throw new SchemaError(PROVIDER, 'TracX Logis returned a scan without wording');
210
+ const why = plain(text(row, 'ReasonDesc', 500));
211
+ const description = why && why.toLowerCase() !== wording.toLowerCase() ? `${wording}: ${why}` : wording;
212
+ const stamp = text(row, 'UTCDate', 40);
213
+ const instant = INSTANT.test(stamp) ? explicitOffsetTime(stamp) : null;
214
+ if (!instant)
215
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an invalid scan time');
216
+ const location = text(row, 'Location', 120);
217
+ const providerCode = code && reason ? `${code}:${reason}` : code;
218
+ const staged = tracxStage(providerCode, wording);
219
+ const event = {
220
+ time: instant.iso,
221
+ description,
222
+ ...(location ? { location } : {}),
223
+ ...(providerCode ? { provider_code: providerCode } : {}),
224
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
225
+ };
226
+ return { key: JSON.stringify([instant.timestamp, providerCode, description, location]), timestamp: instant.timestamp, event };
227
+ }
228
+ /**
229
+ * The result of the tracking page's `getTrackingHistory` action for one
230
+ * number: `dataInfo`, the parcel's summary, and `history`, its scans. A
231
+ * number the tracker does not know comes back with every summary field null
232
+ * and no scans. The tracker also finds a parcel by its last-mile or delivery
233
+ * number, so the parcel counts only when its own number is the one asked.
234
+ * The recipient's name, the order's reference, the courier in charge of each
235
+ * scan and a scan's details, flight times in several local clocks, are left out.
236
+ */
237
+ export function parseTracx(payload, raw) {
238
+ const number = normalizeTracxNumber(raw);
239
+ if (!isRecord(payload) || !isRecord(payload.dataInfo) || !Array.isArray(payload.history)) {
240
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an invalid envelope');
241
+ }
242
+ const summary = payload.dataInfo;
243
+ const rows = payload.history;
244
+ if (rows.length === 0 && SUMMARY_FIELDS.every(key => summary[key] === null))
245
+ throw new NotFoundError(PROVIDER);
246
+ const shipping = text(summary, 'ShippingNo', 64);
247
+ if (!shipping)
248
+ throw new SchemaError(PROVIDER, 'TracX Logis returned a parcel without its number');
249
+ if (normalizeTrackingNumber(shipping) !== number)
250
+ throw new IndeterminateError(PROVIDER, 'TracX Logis answered for another parcel');
251
+ if (rows.length > MAX_SCANS)
252
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an oversized history');
253
+ const seen = new Set();
254
+ const scans = [];
255
+ for (const row of rows) {
256
+ if (!isRecord(row))
257
+ throw new SchemaError(PROVIDER, 'TracX Logis returned an invalid scan');
258
+ const scan = projectScan(row);
259
+ if (seen.has(scan.key))
260
+ continue;
261
+ seen.add(scan.key);
262
+ scans.push(scan);
263
+ }
264
+ if (!scans.length)
265
+ throw new NoHistoryError(PROVIDER);
266
+ // Newest first, as the page lists them; scans at one instant keep the reply's order.
267
+ const events = scans.map((scan, index) => ({ ...scan, index }))
268
+ .sort((left, right) => right.timestamp - left.timestamp || left.index - right.index).map(scan => scan.event);
269
+ const newest = events[0];
270
+ const staged = events.find(event => event.stage);
271
+ const stage = staged?.stage;
272
+ const destination = text(summary, 'DeliveryNationCd', 8).toUpperCase();
273
+ return {
274
+ status: stage ? languageStageStatus(stage) : 'unknown',
275
+ ...(stage ? { current_stage: stage, current_stage_source: staged.stage_source ?? 'carrier_map' } : {}),
276
+ last_status_text: newest.description,
277
+ last_update: newest.time,
278
+ expected_delivery: null,
279
+ ...(stage === 'delivered' ? { delivered_at: staged.time } : {}),
280
+ ...(/^[A-Z]{2}$/.test(destination) ? { destination_country: destination } : {}),
281
+ ...tracxHandoff(text(summary, 'LastmileShippingCompany', 80), text(summary, 'TrackingNo', 64), number),
282
+ events: events.slice(0, MAX_EVENTS),
283
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
284
+ };
285
+ }
@@ -0,0 +1,6 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ /**
3
+ * The delivering company's number from the summary's `TrackingNo`, and that
4
+ * company when its name is one above and its rules fit the number.
5
+ */
6
+ export declare function tracxHandoff(company: string, reference: string, number: string): Pick<CarrierResult, 'delivery_carrier' | 'delivery_tracking_number'>;
@@ -0,0 +1,36 @@
1
+ import { detectCarrierMatch, normalizeTrackingNumber } from '../../core/detection/index.js';
2
+ /**
3
+ * The last-mile companies whose catalog carrier is certain, by the names
4
+ * TracX Logis's replies and site code give them. Other names, such as Ecohai,
5
+ * the Singapore couriers it hires, or K-Packet, which Korea Post carries and
6
+ * the destination's post delivers, name no carrier.
7
+ */
8
+ const PARTNERS = {
9
+ SAGAWA: 'sagawa',
10
+ YAMATO: 'yamato',
11
+ JPPOST: 'japan-post',
12
+ 'JP POST': 'japan-post',
13
+ EPOST: 'korea-post',
14
+ CJGLS: 'cj-logistics',
15
+ HANJIN: 'hanjin',
16
+ LOTTE: 'lotte-global',
17
+ LOGEN: 'logen',
18
+ ILYANG: 'ilyang-logis',
19
+ };
20
+ /** A company's name as the site reads it: without a two- or four-letter code before it, such as JD in JD Sagawa. */
21
+ function partnerOf(company) {
22
+ const name = company.trim().toUpperCase().replace(/\s+/g, ' ');
23
+ return PARTNERS[name] ?? PARTNERS[/^(?:[A-Z]{4}|[A-Z]{2}) (.+)$/.exec(name)?.[1] ?? ''];
24
+ }
25
+ /**
26
+ * The delivering company's number from the summary's `TrackingNo`, and that
27
+ * company when its name is one above and its rules fit the number.
28
+ */
29
+ export function tracxHandoff(company, reference, number) {
30
+ const delivery = normalizeTrackingNumber(reference);
31
+ if (!/^[A-Z0-9]{4,40}$/.test(delivery) || delivery === number)
32
+ return {};
33
+ const partner = partnerOf(company);
34
+ const fits = partner !== undefined && detectCarrierMatch(delivery).candidates.includes(partner);
35
+ return { ...(fits ? { delivery_carrier: partner } : {}), delivery_tracking_number: delivery };
36
+ }
@@ -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 TracX Logis's codes give a scan code, `code` or `code:reason`. */
4
+ export declare function tracxMappedStage(code: string): Stage | undefined;
5
+ /** A scan's stage: its code, else the shared wording rules, else nothing. */
6
+ export declare function tracxStage(code: string, wording: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,61 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ /**
3
+ * TracX Logis's scan codes: the older two-letter ones, which its own code
4
+ * labels, and the newer ones its tracker sends with their wording. A scan
5
+ * with a reason has the code `code:reason`.
6
+ */
7
+ const CODES = new Map([
8
+ ['N1', 'registered'], // Received Shipping Order.
9
+ ['P0', 'registered'], // Generating the waybill.
10
+ ['P1', 'registered'], // Pickup requested.
11
+ ['P2', 'registered'], // Manifested for pickup.
12
+ ['PA', 'registered'], // Pickup assigned to a driver.
13
+ ['PS', 'registered'], // Pickup started.
14
+ ['RE', 'registered'], // Pickup reassigned.
15
+ ['P3', 'accepted'], // Pickup Done.
16
+ ['D2', 'in_transit'], // Received at Collection Center.
17
+ ['SI', 'in_transit'], // Released from Collection Center, towards the destination country.
18
+ ['M50', 'in_transit'], // Arrival in Destination Country.
19
+ ['SA', 'in_transit'],
20
+ ['M70', 'customs'], // Customs Clearance Initiated in Destination Country.
21
+ ['IC', 'customs'], // In customs.
22
+ ['CH', 'customs'], // Held in customs clearance.
23
+ ['M80', 'in_transit'], // Customs Clearance Completed in Destination Country.
24
+ ['AO', 'in_transit'],
25
+ ['AI', 'in_transit'],
26
+ ['L10', 'in_transit'], // Handover/Receipt Completed by Delivery Company.
27
+ ['L40', 'in_transit'], // Linehaul Loading (Transfer to Delivery Point).
28
+ ['L50', 'in_transit'], // Linehaul Unloading.
29
+ ['L70', 'in_transit'], // In Transit, between the delivery company's sites.
30
+ ['L20', 'out_for_delivery'], // Delivery Started.
31
+ ['L60', 'out_for_delivery'], // Departure for Delivery to Customer.
32
+ ['D3', 'out_for_delivery'], // Starting Delivery to Recipient.
33
+ ['D4', 'delivered'], // Delivery Completed.
34
+ ['DX', 'failed_attempt'], // Delivery Failed, sometimes with the reason.
35
+ ['LK', 'exception'], // Kept in the warehouse for long-term storage, with the reason.
36
+ ['RD', 'returned'], // On its way back to the sender.
37
+ ['RT', 'returned'], // Back with the sender.
38
+ ['D9', 'returned'],
39
+ ]);
40
+ /** The reason that turns a delivery round, or a delivery, into the return to the sender. */
41
+ const RETURN_TO_ORIGIN = 'RO';
42
+ /** The stage TracX Logis's codes give a scan code, `code` or `code:reason`. */
43
+ export function tracxMappedStage(code) {
44
+ const [base = '', reason] = code.trim().toUpperCase().split(':');
45
+ const stage = CODES.get(base);
46
+ if (reason === RETURN_TO_ORIGIN && (stage === 'out_for_delivery' || stage === 'delivered'))
47
+ return 'returned';
48
+ return stage;
49
+ }
50
+ /** A scan's stage: its code, else the shared wording rules, else nothing. */
51
+ export function tracxStage(code, wording) {
52
+ const mapped = tracxMappedStage(code);
53
+ if (mapped)
54
+ return { stage: mapped, source: 'carrier_map' };
55
+ const worded = classifyWording(wording, 'in_transit');
56
+ return worded.source === 'none' ? null : worded;
57
+ }
58
+ export const statusMap = {
59
+ stage: (code) => (code ? tracxMappedStage(code) : undefined),
60
+ gaps: [],
61
+ };
@@ -1,9 +1,40 @@
1
1
  {
2
2
  "carrier": "tracx-logis",
3
3
  "entries": [
4
+ {"code": "N1", "wording": "Received Shipping Order", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "The seller's order; a parcel moved from the older system may have only this scan."},
5
+ {"code": "P0", "wording": "generating waybill", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
6
+ {"code": "P1", "wording": "pickup requested", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
7
+ {"code": "P2", "wording": "manifested", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
8
+ {"code": "PA", "wording": "pickup assigned", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
9
+ {"code": "PS", "wording": "pickup start", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
10
+ {"code": "RE", "wording": "pickup reassigned", "stage": "registered", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
11
+ {"code": "P3", "wording": "Pickup Done", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Collected from the seller."},
12
+ {"code": "D2", "wording": "Received at Collection Center", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "At TracX Logis's hub in the origin country."},
4
13
  {"code": "D2", "wording": "Arrived at the Processing Facility", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Activity in an archived reply of Qxpress's tracking API; seen at the origin hub in Korea and in Hong Kong."},
14
+ {"code": "SI", "wording": "Released from Collection Center", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Leaving the origin hub for the destination country; its details list the flight."},
5
15
  {"code": "SI", "wording": "Parcel Departed from Sender Nation", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Activity in an archived reply of Qxpress's tracking API; the parcel left the origin country."},
16
+ {"code": "M50", "wording": "Arrival in Destination Country", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The older code is SA."},
6
17
  {"code": "SA", "wording": "Arrived at the Distribution Center", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Activity in an archived reply of Qxpress's tracking API; the arrival at the destination country's hub."},
7
- {"code": "D3", "wording": "Starting Delivery to Recipient", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "Activity in an archived reply of Qxpress's tracking API."}
18
+ {"code": "M70", "wording": "Customs Clearance Initiated in Destination Country", "stage": "customs", "language": "en", "confirmedBy": "live", "note": "The older code is IC."},
19
+ {"code": "IC", "wording": "in customs", "stage": "customs", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
20
+ {"code": "CH", "wording": "In Customs Clearance", "stage": "customs", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code, whose Korean label says held in clearance; the tracker has not shown it."},
21
+ {"code": "M80", "wording": "Customs Clearance Completed in Destination Country", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The older code is AO."},
22
+ {"code": "L10", "wording": "Handover/Receipt Completed by Delivery Company", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "The last-mile company took the parcel; the older code is AI."},
23
+ {"code": "L40", "wording": "Linehaul Loading (Transfer to Delivery Point)", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen with Ecohai and Japan Post, at the delivery company's sites."},
24
+ {"code": "L50", "wording": "Linehaul Unloading", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
25
+ {"code": "L70", "wording": "In Transit", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Seen with Japan Post, between its sites."},
26
+ {"code": "L20", "wording": "Delivery Started", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Seen with a Singapore courier; the older code is D3."},
27
+ {"code": "L60", "wording": "Departure for Delivery to Customer", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Seen with Sagawa; the older code is D3."},
28
+ {"code": "D3", "wording": "Starting Delivery to Recipient", "stage": "out_for_delivery", "language": "en", "confirmedBy": "portal", "note": "Activity in an archived reply of Qxpress's tracking API."},
29
+ {"code": "D4", "wording": "Delivery Completed", "stage": "delivered", "language": "en", "confirmedBy": "live"},
30
+ {"code": "DX:OT", "wording": "Delivery Failed", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "The reason's wording, over time, follows; another round came the next day."},
31
+ {"code": "DX", "wording": "Delivery Failed", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "Without a reason; the parcel went to long-term storage after it."},
32
+ {"code": "LK:IA", "wording": "Registered for long-term storage in the TraxLogis warehouse", "stage": "exception", "language": "en", "confirmedBy": "live", "note": "The reason's wording, Wrong Address, follows; the return came the next day."},
33
+ {"code": "D3:RO", "wording": "rto returning", "stage": "returned", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code for a delivery round with the reason RO, return to origin; the tracker has not shown it."},
34
+ {"code": "D4:RO", "wording": "rto returned", "stage": "returned", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code for a delivery with the reason RO; the tracker has not shown it."},
35
+ {"code": "RD", "wording": "retun order on delivery", "stage": "returned", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
36
+ {"code": "RT", "wording": "Return completed", "stage": "returned", "language": "en", "confirmedBy": "live", "note": "Back with the sender after long-term storage."},
37
+ {"code": "RT", "wording": "retun order deliverd", "stage": "returned", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."},
38
+ {"code": "D9", "wording": "returned", "stage": "returned", "language": "en", "confirmedBy": "source", "note": "A label in TracX Logis's own site code; the tracker has not shown it."}
8
39
  ]
9
40
  }
@@ -351,6 +351,7 @@ import { statusMap as toptrans } from '../../carriers/toptrans/status.js';
351
351
  import { statusMap as torrestir } from '../../carriers/torrestir/status.js';
352
352
  import { statusMap as totalExpress } from '../../carriers/total-express/status.js';
353
353
  import { statusMap as trackon } from '../../carriers/trackon/status.js';
354
+ import { statusMap as tracxLogis } from '../../carriers/tracx-logis/status.js';
354
355
  import { statusMap as transaher } from '../../carriers/transaher/status.js';
355
356
  import { statusMap as transcorp } from '../../carriers/transcorp/status.js';
356
357
  import { statusMap as transmission } from '../../carriers/transmission/status.js';
@@ -442,7 +443,7 @@ export const STATUS_MAPS = {
442
443
  't-cat': tCat, 't-group': tGroup, 'tanzania-posts': tanzaniaPosts, taxydema, 'tci-express': tciExpress, 'tcs-pakistan': tcsPakistan, tdn, tealca,
443
444
  'team-express-ae': teamExpressAe, 'team-global-express': teamGlobalExpress, teleship, 'tforce-freight': tforceFreight,
444
445
  'thailand-post': thailandPost, thhy, tiki, 'tmm-express': tmmExpress, tnt, 'tnt-it': tntIt, toptrans, torrestir, 'total-express': totalExpress,
445
- trackon, transaher, transcorp, transmission, tsjy, 'tws-express': twsExpress, 'ubi-smart-parcel': ubiSmartParcel,
446
+ trackon, 'tracx-logis': tracxLogis, transaher, transcorp, transmission, tsjy, 'tws-express': twsExpress, 'ubi-smart-parcel': ubiSmartParcel,
446
447
  'united-delivery-service': unitedDeliveryService, ups, urbano, urvaam,
447
448
  'uzbekistan-post': uzbekistanPost, valmo, 'vasp-expresso': vaspExpresso, veloces, venipak, 'via-cargo': viaCargo, 'viettel-post': viettelPost,
448
449
  'vinted-go': vintedGo, wanbexpress, whistl, winit, 'xde-logistics': xdeLogistics, xdp, xlcourier, 'xpo-ltl': xpoLtl,
@@ -28429,7 +28429,8 @@
28429
28429
  "timezone": "Asia/Singapore",
28430
28430
  "tracking": {
28431
28431
  "mode": "automatic",
28432
- "adapter": "universal"
28432
+ "adapter": "tracx-logis",
28433
+ "recognitionRank": 777
28433
28434
  },
28434
28435
  "canaryUrl": "https://www.tracxlogis.com/tracking",
28435
28436
  "trackingUrlTemplate": "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}",
@@ -18106,7 +18106,8 @@ export declare const CARRIER_CATALOG: {
18106
18106
  readonly timezone: "Asia/Singapore";
18107
18107
  readonly tracking: {
18108
18108
  readonly mode: "automatic";
18109
- readonly adapter: "universal";
18109
+ readonly adapter: "tracx-logis";
18110
+ readonly recognitionRank: 777;
18110
18111
  };
18111
18112
  readonly canaryUrl: "https://www.tracxlogis.com/tracking";
18112
18113
  readonly trackingUrlTemplate: "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}";
@@ -28430,7 +28430,8 @@ export const CARRIER_CATALOG = {
28430
28430
  "timezone": "Asia/Singapore",
28431
28431
  "tracking": {
28432
28432
  "mode": "automatic",
28433
- "adapter": "universal"
28433
+ "adapter": "tracx-logis",
28434
+ "recognitionRank": 777
28434
28435
  },
28435
28436
  "canaryUrl": "https://www.tracxlogis.com/tracking",
28436
28437
  "trackingUrlTemplate": "https://www.tracxlogis.com/tracking?trackingNo={trackingNumber}",
@@ -307,6 +307,7 @@ export const CARRIER_RECOGNITION_RANKS = {
307
307
  "torrestir": 866,
308
308
  "total-express": 975,
309
309
  "trackon": 821,
310
+ "tracx-logis": 777,
310
311
  "transaher": 852,
311
312
  "transcorp": 787,
312
313
  "tsjy": 992,
@@ -421,6 +421,7 @@ import { adapter as toptrans } from '../carriers/toptrans/adapter.js';
421
421
  import { adapter as torrestir } from '../carriers/torrestir/adapter.js';
422
422
  import { adapter as totalExpress } from '../carriers/total-express/adapter.js';
423
423
  import { adapter as trackon } from '../carriers/trackon/adapter.js';
424
+ import { adapter as tracxLogis } from '../carriers/tracx-logis/adapter.js';
424
425
  import { adapter as transaher } from '../carriers/transaher/adapter.js';
425
426
  import { adapter as transcorp } from '../carriers/transcorp/adapter.js';
426
427
  import { adapter as transmission } from '../carriers/transmission/adapter.js';
@@ -885,6 +886,7 @@ export const REGISTRY = {
885
886
  "torrestir": torrestir,
886
887
  "total-express": totalExpress,
887
888
  "trackon": trackon,
889
+ "tracx-logis": tracxLogis,
888
890
  "transaher": transaher,
889
891
  "transcorp": transcorp,
890
892
  "transmission": transmission,
@@ -1627,7 +1629,7 @@ export const REGISTRY = {
1627
1629
  "total-express": "total-express",
1628
1630
  "tp-logistics-th": "universal",
1629
1631
  "trackon": "trackon",
1630
- "tracx-logis": "universal",
1632
+ "tracx-logis": "tracx-logis",
1631
1633
  "trans-o-flex": "universal",
1632
1634
  "transaher": "transaher",
1633
1635
  "transcorp": "transcorp",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.427.0"
5
+ "version": "0.428.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.427.0-main.2176",
3
+ "version": "0.428.0-main.2178",
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",