universal-parcel-scraper 0.556.0-main.2575 → 0.558.0-main.2587

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 **600 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **601 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>774 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 16,079 such statuses, each filed under one stage.
158
+ The carrier folders record 16,089 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
@@ -17144,7 +17144,8 @@
17144
17144
  "timezone": "Australia/Sydney",
17145
17145
  "tracking": {
17146
17146
  "mode": "automatic",
17147
- "adapter": "universal"
17147
+ "adapter": "j-and-t-au",
17148
+ "recognitionRank": 698
17148
17149
  },
17149
17150
  "canaryUrl": "https://www.jtexpress.au/tracking",
17150
17151
  "trackingUrlTemplate": "https://www.jtexpress.au/tracking?q={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 774,
3
- "activeAdapters": 600,
3
+ "activeAdapters": 601,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -39780,6 +39780,31 @@
39780
39780
  "confidence": "none",
39781
39781
  "candidates": []
39782
39782
  },
39783
+ {
39784
+ "input": "48285234834793",
39785
+ "carrier": "unknown",
39786
+ "confidence": "low",
39787
+ "candidates": [
39788
+ "dpd",
39789
+ "ciblex",
39790
+ "hermes-de",
39791
+ "seur",
39792
+ "brt",
39793
+ "delhivery",
39794
+ "dhl-ecommerce-uk",
39795
+ "dpd-be",
39796
+ "dpd-de",
39797
+ "dpd-nl",
39798
+ "dpd-uk"
39799
+ ],
39800
+ "asked": [
39801
+ "hermes-de",
39802
+ "dpd-be",
39803
+ "dpd",
39804
+ "dpd-nl",
39805
+ "dpd-de"
39806
+ ]
39807
+ },
39783
39808
  {
39784
39809
  "input": "483151203868516",
39785
39810
  "carrier": "unknown",
@@ -77396,6 +77421,14 @@
77396
77421
  "japan-post"
77397
77422
  ]
77398
77423
  },
77424
+ {
77425
+ "input": "LP107777790JP",
77426
+ "carrier": "japan-post",
77427
+ "confidence": "high",
77428
+ "candidates": [
77429
+ "japan-post"
77430
+ ]
77431
+ },
77399
77432
  {
77400
77433
  "input": "LP157079155HK",
77401
77434
  "carrier": "hongkong-post",
@@ -13165,7 +13165,8 @@ var Je = {
13165
13165
  timezone: "Australia/Sydney",
13166
13166
  tracking: {
13167
13167
  mode: "automatic",
13168
- adapter: "universal"
13168
+ adapter: "j-and-t-au",
13169
+ recognitionRank: 698
13169
13170
  },
13170
13171
  canaryUrl: "https://www.jtexpress.au/tracking",
13171
13172
  trackingUrlTemplate: "https://www.jtexpress.au/tracking?q={trackingNumber}",
@@ -25019,7 +25020,7 @@ function on(e) {
25019
25020
  //#endregion
25020
25021
  //#region core/detection/valid.ts
25021
25022
  function sn(e) {
25022
- return e.length >= 4 && e.length <= 40 && (/^[A-Z0-9]+$/.test(e) || /^\d{4}\/\d{8}$/.test(e));
25023
+ return e.length >= 4 && e.length <= 40 && (/^[A-Z0-9]+(?:_[A-Z0-9]+)?$/.test(e) || /^\d{4}\/\d{8}$/.test(e));
25023
25024
  }
25024
25025
  function cn(e) {
25025
25026
  let t = n(e);
@@ -29943,6 +29944,7 @@ var Al = {
29943
29944
  interrapidisimo: 969,
29944
29945
  "israel-post": 81,
29945
29946
  "j-and-t": 530,
29947
+ "j-and-t-au": 698,
29946
29948
  "j-and-t-cargo": 910,
29947
29949
  janio: 811,
29948
29950
  jeebly: 805,
@@ -0,0 +1,30 @@
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 www.jtexpress.au makes for the waybills typed in. */
5
+ export declare const J_AND_T_AU_API = "https://official.jtaunz.com/official/logisticsTracking/getDetailByWaybillNo?countryCode=AU";
6
+ /** The app id and signing key the site's script gives every visitor; the page sends the key along as a header too. */
7
+ export declare const J_AND_T_AU_APP_ID = "2BZWAROTK578H7MG9YPWT057NN6IN8T1";
8
+ export declare const J_AND_T_AU_SIGNING_KEY = "cteezp48358yyn6t6l9w8wfz2wctuqs7";
9
+ /**
10
+ * The page's signature of a request: MD5, in capitals, of the app id, the
11
+ * timestamp, the nonce, the JSON body with its keys sorted and the key.
12
+ */
13
+ export declare function jAndTAuSignature(timestamp: string, nonce: string, sortedBody: string): string;
14
+ /** The zone the request names, which the reply writes every scan time in: UTC, so each is an instant. */
15
+ export declare const J_AND_T_AU_ZONE = "GMT+0000";
16
+ /** The page's request for one waybill: its body, and its headers signed over the same fields with their keys sorted. */
17
+ export declare function jAndTAuRequest(number: string): {
18
+ body: string;
19
+ headers: Record<string, string>;
20
+ };
21
+ export declare class JAndTAuTracker {
22
+ private readonly options;
23
+ constructor(options?: {
24
+ fetcher?: typeof fetch;
25
+ recorder?: StepRecorder;
26
+ userAgent?: string;
27
+ });
28
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
29
+ }
30
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,89 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
3
+ import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
4
+ import { runSteps } from '../../core/runner/index.js';
5
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
6
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
7
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
8
+ import { J_AND_T_AU_MAX_BYTES, normalizeJAndTAuNumber, parseJAndTAu, PROVIDER } from './parser.js';
9
+ /** The lookup the tracking page on www.jtexpress.au makes for the waybills typed in. */
10
+ export const J_AND_T_AU_API = 'https://official.jtaunz.com/official/logisticsTracking/getDetailByWaybillNo?countryCode=AU';
11
+ /** The app id and signing key the site's script gives every visitor; the page sends the key along as a header too. */
12
+ export const J_AND_T_AU_APP_ID = '2BZWAROTK578H7MG9YPWT057NN6IN8T1'; // gitleaks:allow
13
+ export const J_AND_T_AU_SIGNING_KEY = 'cteezp48358yyn6t6l9w8wfz2wctuqs7'; // gitleaks:allow
14
+ const REDIRECTS = [301, 302, 303, 307, 308];
15
+ // Bot checks a CDN or firewall could put in place of the reply.
16
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|acw_sc__v2|aliyun_waf|_____tmd_____|AwsWafIntegration|awswaf\.com/i;
17
+ /**
18
+ * The page's signature of a request: MD5, in capitals, of the app id, the
19
+ * timestamp, the nonce, the JSON body with its keys sorted and the key.
20
+ */
21
+ export function jAndTAuSignature(timestamp, nonce, sortedBody) {
22
+ return createHash('md5').update(`${J_AND_T_AU_APP_ID}${timestamp}${nonce}${sortedBody}${J_AND_T_AU_SIGNING_KEY}`).digest('hex').toUpperCase();
23
+ }
24
+ /** The zone the request names, which the reply writes every scan time in: UTC, so each is an instant. */
25
+ export const J_AND_T_AU_ZONE = 'GMT+0000';
26
+ /** The page's request for one waybill: its body, and its headers signed over the same fields with their keys sorted. */
27
+ export function jAndTAuRequest(number) {
28
+ const timestamp = String(Date.now());
29
+ const nonce = String(Math.random());
30
+ return {
31
+ body: JSON.stringify({ waybillNos: [number], langType: 'EN' }),
32
+ headers: {
33
+ langType: 'EN', appId: J_AND_T_AU_APP_ID, timestamp, nonce, countryCode: 'AU', key: J_AND_T_AU_SIGNING_KEY,
34
+ sign: jAndTAuSignature(timestamp, nonce, JSON.stringify({ langType: 'EN', waybillNos: [number] })),
35
+ timezone: J_AND_T_AU_ZONE,
36
+ },
37
+ };
38
+ }
39
+ function json(body) {
40
+ try {
41
+ return JSON.parse(body);
42
+ }
43
+ catch (cause) {
44
+ // A JSON lookup answering with a page has been turned away.
45
+ if (CHALLENGE.test(body.slice(0, 64_000)) || /^\s*</.test(body)) {
46
+ throw new ChallengeError(PROVIDER, 'J&T Express Australia answered with a page instead of its lookup', { cause });
47
+ }
48
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned a reply that is not JSON', { cause });
49
+ }
50
+ }
51
+ export class JAndTAuTracker {
52
+ options;
53
+ constructor(options = {}) {
54
+ this.options = options;
55
+ }
56
+ async fetch(raw, context = {}) {
57
+ const number = normalizeJAndTAuNumber(raw);
58
+ const read = async (signal, timeoutMs) => {
59
+ // Signed afresh for each try, as the page signs each request.
60
+ const { body, headers } = jAndTAuRequest(number);
61
+ const { response, bytes } = await fetchBounded(J_AND_T_AU_API, { method: 'POST', signal, headers: {
62
+ Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
63
+ ...headers,
64
+ }, body }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: J_AND_T_AU_MAX_BYTES,
65
+ redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
66
+ if (REDIRECTS.includes(response.status))
67
+ throw new TransportError(PROVIDER, 'J&T Express Australia redirected its tracking lookup');
68
+ // The lookup answers every waybill with HTTP 200; a 404 is a moved route, not an unknown parcel.
69
+ if (response.status === 404 || response.status === 410)
70
+ throw new TransportError(PROVIDER, 'J&T Express Australia tracking lookup is unavailable');
71
+ return parseJAndTAu(json(decodeText(bytes)), number);
72
+ };
73
+ // A request that fails to reach the site, or hangs, gets one more try: the
74
+ // first gets half of the budget, so the retry fits in the rest.
75
+ return runSteps({ carrier: 'j-and-t-au', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
76
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
77
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
78
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
79
+ ]);
80
+ }
81
+ }
82
+ export const adapter = (environment) => {
83
+ const tracker = new JAndTAuTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
84
+ return {
85
+ id: 'j-and-t-au', recordsSteps: true, steps: ['direct', 'retry'],
86
+ track: (input, context) => tracker.fetch(input.number, context),
87
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeJAndTAuNumber(number))),
88
+ };
89
+ };
@@ -19,18 +19,42 @@
19
19
  "timezone": "Australia/Sydney",
20
20
  "tracking": {
21
21
  "mode": "automatic",
22
- "adapter": "universal",
23
- "steps": []
22
+ "adapter": "j-and-t-au",
23
+ "steps": [
24
+ "direct",
25
+ "retry"
26
+ ],
27
+ "recognition": {
28
+ "rank": 698
29
+ }
24
30
  },
25
- "capabilities": [],
31
+ "capabilities": [
32
+ "history"
33
+ ],
26
34
  "portal": {
27
35
  "url": "https://www.jtexpress.au/tracking?q={trackingNumber}",
28
36
  "searchUrl": "https://www.jtexpress.au/tracking",
29
37
  "canaryUrl": "https://www.jtexpress.au/tracking",
30
- "shows": [],
31
- "scraped": [],
32
- "discarded": [],
33
- "unavailable": []
38
+ "shows": [
39
+ "status",
40
+ "history",
41
+ "office_contact_details"
42
+ ],
43
+ "scraped": [
44
+ "status",
45
+ "history",
46
+ "offset_timestamps",
47
+ "delivered_at"
48
+ ],
49
+ "discarded": [
50
+ "office_contact_details"
51
+ ],
52
+ "unavailable": [
53
+ "location",
54
+ "provider_code",
55
+ "eta",
56
+ "weight"
57
+ ]
34
58
  },
35
59
  "links": [
36
60
  {
@@ -0,0 +1,18 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "J&T Express Australia";
3
+ export declare const J_AND_T_AU_MAX_BYTES: number;
4
+ /** A waybill for J&T Express Australia's tracker, in upper case as it prints them; anything else is not one it issues. */
5
+ export declare function normalizeJAndTAuNumber(raw: string): string;
6
+ /**
7
+ * A scan's sentence without the people and phones it can name: anything in
8
+ * brackets, the customer service line, who signed for the parcel, a person
9
+ * after their role, and phone numbers.
10
+ */
11
+ export declare function jAndTAuWording(sentence: string): string;
12
+ /**
13
+ * The reply of `getDetailByWaybillNo` for one waybill: code 1 with one answer
14
+ * in `data` per waybill it knows, which names the waybill in `keyword` and
15
+ * lists its scans in `details`. A waybill it does not know is left out, and
16
+ * the page then says it has no tracking records: not found.
17
+ */
18
+ export declare function parseJAndTAu(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,169 @@
1
+ import { DateTime } from 'luxon';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, RateLimitedError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { clean } from '../../core/transport/index.js';
6
+ import { isRecord } from '../../core/types.js';
7
+ import { jAndTAuStage } from './status.js';
8
+ export const PROVIDER = 'J&T Express Australia';
9
+ // A long history is a few kilobytes.
10
+ export const J_AND_T_AU_MAX_BYTES = 512 * 1024;
11
+ // The waybill J&T Express Australia prints: JTAU and twelve digits.
12
+ const NUMBER = /^JTAU\d{12}$/;
13
+ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/;
14
+ // The page reads a reply as an answer only with this code.
15
+ const ANSWERED = 1;
16
+ // A signature that does not check out, and missing or invalid parameters: the lookup no longer takes the request as sent.
17
+ const REFUSED_REQUEST = new Set([999001010, 999001030, 999001031]);
18
+ const THROTTLED = /too many|too frequent|frequently|rate limit|请求(?:过于)?频繁|访问(?:过于)?频繁/i;
19
+ // Fields that could name the waybill a scan belongs to.
20
+ const IDENTITY = ['waybillNo', 'billCode', 'keyword'];
21
+ const MAX_ROWS = 500;
22
+ const MAX_EVENTS = 100;
23
+ /** A waybill for J&T Express Australia's tracker, in upper case as it prints them; anything else is not one it issues. */
24
+ export function normalizeJAndTAuNumber(raw) {
25
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
26
+ if (!NUMBER.test(number))
27
+ throw new InvalidInputError(PROVIDER, 'J&T Express Australia waybills are JTAU and twelve digits');
28
+ return number;
29
+ }
30
+ // The page sets text in 【】 apart; like square brackets, it can hold a person, a phone or a branch.
31
+ const BRACKETED = /【[^】]{0,200}】|\[[^\]]{0,200}\]/gu;
32
+ // The customer service line some scans end with: "Customer service: 13 00 00", "客服热线:130000".
33
+ const HOTLINE = /\s*(?:customer\s+service(?:\s+(?:hotline|number))?|(?:官网)?客服(?:热线|电话)?)\s*[::]?\s*\+?\d[\d\s-]*\d/giu;
34
+ // Who signed for, received or collected the parcel, to the end of the sentence: "signed for by Sample Person".
35
+ const RECEIVER = /\b((?:signed(?:\s+for)?|received|collected)\s+)by\b[^.。;;]*/giu;
36
+ const ROLES = 'courier|driver|rider|recipient|receiver|consignee|signer|signatory|sender|contact';
37
+ // A role followed by its person, after a colon or in brackets: "Courier: Sample Person", "driver (Sample Person)".
38
+ const PERSON = String.raw `(?:\s*[::][^.。;;,,]*|(?:\s*(?:\([^)]{0,200}\)|([^)]{0,200})))+)`;
39
+ const NAMED = new RegExp(String.raw `\b(${ROLES})${PERSON}`, 'giu');
40
+ const NAMED_ZH = new RegExp(String.raw `(签收人|收件人|寄件人|派件员|快递员|业务员|派送员|收派员|司机|联系人)${PERSON}`, 'gu');
41
+ // A role left alone as the last sentence once its person is gone.
42
+ const BARE_ROLE = new RegExp(String.raw `(?<=[.。])\s*(?:${ROLES})\s*[.。]?$`, 'iu');
43
+ // A run of eight or more digits, with spaces, brackets, dots or hyphens between them, as phone numbers are written; a date stays.
44
+ const PHONE = /(?<![A-Z\d])(?!\d{4}-\d{2}-\d{2}(?!\d))\+?\d(?:[\s().-]{0,2}\d){7,}(?![A-Z\d])/giu;
45
+ /**
46
+ * A scan's sentence without the people and phones it can name: anything in
47
+ * brackets, the customer service line, who signed for the parcel, a person
48
+ * after their role, and phone numbers.
49
+ */
50
+ export function jAndTAuWording(sentence) {
51
+ return clean(sentence.replace(BRACKETED, ' ').replace(HOTLINE, '').replace(RECEIVER, '$1').replace(NAMED, '$1')
52
+ .replace(NAMED_ZH, '$1').replace(PHONE, '').replace(/\(\s*\)|(\s*)/gu, '').replace(/\s+([,.;:!?,。;:])/gu, '$1'))
53
+ .replace(/([,;:,;:])(?=[.。])/gu, '').replace(BARE_ROLE, '').replace(/^[\s,;:,;:\-–]+|[\s,;:,;:\-–]+$/gu, '');
54
+ }
55
+ /** Any waybill a record names must be the one asked. */
56
+ function checkIdentity(record, number) {
57
+ for (const key of IDENTITY) {
58
+ const value = record[key];
59
+ if (value === null || value === undefined || value === '')
60
+ continue;
61
+ if (typeof value !== 'string')
62
+ throw new SchemaError(PROVIDER, `J&T Express Australia returned an invalid ${key}`);
63
+ // The whole value, normalized: a different waybill's suffix must not be cut off before comparing.
64
+ if (normalizeTrackingNumber(value).toUpperCase() !== number) {
65
+ throw new IndeterminateError(PROVIDER, 'J&T Express Australia answered for another waybill');
66
+ }
67
+ }
68
+ }
69
+ /** A scan's instant: `scanTime`, which the reply writes in the zone the request named, UTC. */
70
+ function scanTime(row) {
71
+ const match = WALL_CLOCK.exec(typeof row.scanTime === 'string' ? row.scanTime.trim() : '');
72
+ const clock = match ? `${match[1]}T${match[2]}` : '';
73
+ const parsed = DateTime.fromISO(clock, { zone: 'UTC' });
74
+ // Luxon rolls 24:00 over to the next day; only a clock that reads back unchanged is kept.
75
+ if (!match || !parsed.isValid || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== clock) {
76
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned an invalid scan time');
77
+ }
78
+ return parsed.toISO({ suppressMilliseconds: true });
79
+ }
80
+ /** One scan: its instant and its sentence. A sentence that named only people or phones is no scan. */
81
+ function projectRow(row, number) {
82
+ checkIdentity(row, number);
83
+ if (typeof row.customerTracking !== 'string' || !clean(row.customerTracking)) {
84
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned a scan without wording');
85
+ }
86
+ const time = scanTime(row);
87
+ const description = jAndTAuWording(clean(row.customerTracking, 1000)).slice(0, 300);
88
+ if (!description)
89
+ return null;
90
+ const staged = jAndTAuStage(description);
91
+ const event = {
92
+ time,
93
+ description,
94
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
95
+ };
96
+ return { key: JSON.stringify([time, description]), event };
97
+ }
98
+ /** The refusal or failure a reply that is not an answer stands for. */
99
+ function failure(payload) {
100
+ const message = clean(payload.msg, 200);
101
+ if (THROTTLED.test(message))
102
+ return new RateLimitedError(PROVIDER, undefined, 'J&T Express Australia refused more lookups for now');
103
+ if (typeof payload.code === 'number' && REFUSED_REQUEST.has(payload.code)) {
104
+ return new SchemaError(PROVIDER, 'J&T Express Australia refused the lookup’s signature or parameters');
105
+ }
106
+ return new IndeterminateError(PROVIDER, 'J&T Express Australia did not complete the lookup');
107
+ }
108
+ /**
109
+ * The reply of `getDetailByWaybillNo` for one waybill: code 1 with one answer
110
+ * in `data` per waybill it knows, which names the waybill in `keyword` and
111
+ * lists its scans in `details`. A waybill it does not know is left out, and
112
+ * the page then says it has no tracking records: not found.
113
+ */
114
+ export function parseJAndTAu(payload, raw) {
115
+ const number = normalizeJAndTAuNumber(raw);
116
+ if (!isRecord(payload) || typeof payload.code !== 'number')
117
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned an invalid envelope');
118
+ if (payload.code !== ANSWERED)
119
+ throw failure(payload);
120
+ const answers = payload.data;
121
+ if (!Array.isArray(answers))
122
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned invalid answers');
123
+ for (const answer of answers) {
124
+ if (!isRecord(answer) || typeof answer.keyword !== 'string' || !answer.keyword) {
125
+ throw new SchemaError(PROVIDER, 'J&T Express Australia did not name the waybill it answered for');
126
+ }
127
+ checkIdentity(answer, number);
128
+ }
129
+ if (answers.length > 1)
130
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned more than one answer for the waybill');
131
+ const answer = answers[0];
132
+ if (!answer)
133
+ throw new NotFoundError(PROVIDER);
134
+ const rows = answer.details ?? [];
135
+ if (!Array.isArray(rows))
136
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned invalid scans');
137
+ if (rows.length > MAX_ROWS)
138
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned an oversized history');
139
+ const seen = new Set();
140
+ const scans = [];
141
+ for (const row of rows) {
142
+ if (!isRecord(row))
143
+ throw new SchemaError(PROVIDER, 'J&T Express Australia returned an invalid scan');
144
+ const scan = projectRow(row, number);
145
+ if (!scan || seen.has(scan.key))
146
+ continue;
147
+ seen.add(scan.key);
148
+ scans.push({ index: scans.length, event: scan.event });
149
+ }
150
+ // A waybill the tracker knows without a scan has no history.
151
+ if (!scans.length)
152
+ throw new NoHistoryError(PROVIDER);
153
+ // Newest first, as the page sorts them by time; scans at one time keep the reply's order.
154
+ const clock = (event) => event.time;
155
+ const newestFirst = scans.sort((a, b) => clock(b.event).localeCompare(clock(a.event)) || a.index - b.index).map(({ event }) => event);
156
+ const newest = newestFirst[0];
157
+ const staged = newestFirst.find((event) => event.stage);
158
+ const stage = staged?.stage;
159
+ return {
160
+ status: stage ? languageStageStatus(stage) : 'unknown',
161
+ ...(stage ? { current_stage: stage, current_stage_source: staged.stage_source } : {}),
162
+ last_status_text: newest.description,
163
+ last_update: newest.time,
164
+ expected_delivery: null,
165
+ ...(stage === 'delivered' ? { delivered_at: staged.time } : {}),
166
+ events: newestFirst.slice(0, MAX_EVENTS),
167
+ ...(newestFirst.length > MAX_EVENTS ? { history_truncated: true } : {}),
168
+ };
169
+ }
@@ -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 the map gives a scan's wording. */
4
+ export declare function jAndTAuMappedStage(wording: string): Stage | undefined;
5
+ /** A scan's stage: its wording in the map, else the shared wording rules, else nothing. */
6
+ export declare function jAndTAuStage(wording: string): ClassifiedWording | null;
7
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,36 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /** A wording as the map keys it: normalized, without a closing full stop. */
4
+ const key = (wording) => normalizeStatusWording(wording).replace(/[\s.。]+$/u, '');
5
+ /**
6
+ * The tracker's scans carry no code, only a sentence. Those seen in replies are
7
+ * mapped, in English and in the Chinese the tracker answers other languages
8
+ * with; other wording falls to the shared rules.
9
+ */
10
+ const WORDINGS = new Map([
11
+ // J&T collected an imported parcel from the customs broker; the shared rules would read it as held by customs.
12
+ ['The parcel has been picked up from customs clearance.', 'accepted'],
13
+ ['包裹已从清关行取件。', 'accepted'],
14
+ ['The parcel has been successfully picked up and is being prepared for transportation.', 'accepted'],
15
+ ['快件已揽收成功,运输准备中。', 'accepted'],
16
+ ['The parcel arrives at the distribution center and is prepared for transport to the next destination.', 'in_transit'],
17
+ ['快件到达转运中心,做发往下一站的运输准备', 'in_transit'],
18
+ ['The parcel is dispatched from the transit center to the next destination', 'in_transit'],
19
+ ['快件从转运中心发往下一站', 'in_transit'],
20
+ ].map(([wording, stage]) => [key(wording), stage]));
21
+ /** The stage the map gives a scan's wording. */
22
+ export function jAndTAuMappedStage(wording) {
23
+ return WORDINGS.get(key(wording));
24
+ }
25
+ /** A scan's stage: its wording in the map, else the shared wording rules, else nothing. */
26
+ export function jAndTAuStage(wording) {
27
+ const mapped = jAndTAuMappedStage(wording);
28
+ if (mapped)
29
+ return { stage: mapped, source: 'carrier_map' };
30
+ const worded = classifyWording(wording, 'in_transit');
31
+ return worded.source === 'none' ? null : worded;
32
+ }
33
+ export const statusMap = {
34
+ stage: (_code, wording) => jAndTAuMappedStage(wording),
35
+ gaps: [],
36
+ };
@@ -1,4 +1,13 @@
1
1
  {
2
2
  "carrier": "j-and-t-au",
3
- "entries": []
3
+ "entries": [
4
+ {"wording": "The parcel has been picked up from customs clearance.", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "J&T's first scan of an imported parcel, collected from the customs broker after clearance. The tracker ends it with its customer service number, which the adapter drops."},
5
+ {"wording": "包裹已从清关行取件。", "stage": "accepted", "language": "zh", "confirmedBy": "live", "note": "The Chinese text of the same scan, which the tracker also gives when asked in German."},
6
+ {"wording": "The parcel has been successfully picked up and is being prepared for transportation.", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "The tracker ends it with its customer service number, which the adapter drops."},
7
+ {"wording": "快件已揽收成功,运输准备中。", "stage": "accepted", "language": "zh", "confirmedBy": "live"},
8
+ {"wording": "The parcel arrives at the distribution center and is prepared for transport to the next destination.", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
9
+ {"wording": "快件到达转运中心,做发往下一站的运输准备", "stage": "in_transit", "language": "zh", "confirmedBy": "live"},
10
+ {"wording": "The parcel is dispatched from the transit center to the next destination", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
11
+ {"wording": "快件从转运中心发往下一站", "stage": "in_transit", "language": "zh", "confirmedBy": "live"}
12
+ ]
4
13
  }
@@ -958,6 +958,25 @@
958
958
  "confidence": "high"
959
959
  },
960
960
  "note": "A Japan Post tracking link archived by the Wayback Machine. Japan Post's tracker returns its international history."
961
+ },
962
+ {
963
+ "number": "LP107777790JP",
964
+ "role": "shipment",
965
+ "evidence": "public_shipment_report",
966
+ "source": {
967
+ "url": "https://web.archive.org/web/20261005062200/https://trackings.post.japanpost.jp/services/srv/search/direct?locale=en&reqCodeNo1=LP107777790JP",
968
+ "date": "2026-10-05",
969
+ "dateKind": "capture",
970
+ "access": "full_page"
971
+ },
972
+ "context": {
973
+ "assessment": "confirmed"
974
+ },
975
+ "expect": {
976
+ "carrier": "japan-post",
977
+ "confidence": "high"
978
+ },
979
+ "note": "A Japan Post tracking link archived by the Wayback Machine. Japan Post's tracker returns its international history."
961
980
  }
962
981
  ]
963
982
  }
@@ -96,15 +96,15 @@ function officeLabel(value) {
96
96
  * Ceuta's and Melilla's offices among them, 052 for Gibraltar and 060 for
97
97
  * Andorra, all on Madrid's clock. The Canary Islands' 049 and 050 and
98
98
  * Portugal's 070 to 089 keep clocks of their own, and nothing shows whether
99
- * their rows are written on the office's clock or on Madrid's; MRW Points'
99
+ * their rows are written on the office's clock or on Madrid's; pickup points'
100
100
  * codes name no province. Those, and an office named for a town of the Canary
101
101
  * Islands, have none.
102
102
  */
103
- function officeZone(location) {
104
- const office = /^(\d{3})\d{2}\s+(.+?)(?: \([\p{L} ]+\))?$/u.exec(location);
105
- if (!office || regionHasTown('ES-CN', office[2]))
103
+ function officeZone(location, points) {
104
+ const office = /^((\d{3})\d{2})\s+(.+?)(?: \([\p{L} ]+\))?$/u.exec(location);
105
+ if (!office || points.has(office[1]) || regionHasTown('ES-CN', office[3]))
106
106
  return null;
107
- const province = Number(office[1]);
107
+ const province = Number(office[2]);
108
108
  if (province >= 1 && province <= 48)
109
109
  return 'Europe/Madrid';
110
110
  return province === 52 ? 'Europe/Gibraltar' : province === 60 ? 'Europe/Andorra' : null;
@@ -115,9 +115,9 @@ function instant(wall, zone) {
115
115
  return placed.isValid && placed.toFormat("yyyy-MM-dd'T'HH:mm:ss") === wall ? placed.toISO({ suppressMilliseconds: true }) : null;
116
116
  }
117
117
  /** A row's clock: an instant when its office settles the zone, else the wall clock or the text as shown. */
118
- function rowClock(rawDate, rawHour, location) {
118
+ function rowClock(rawDate, rawHour, location, points) {
119
119
  const clock = buildLocalDate(rawDate, rawHour);
120
- const zone = location ? officeZone(location) : null;
120
+ const zone = location ? officeZone(location, points) : null;
121
121
  const time = zone && clock.local_time ? instant(clock.local_time, zone) : null;
122
122
  return time ? { time } : clock;
123
123
  }
@@ -155,6 +155,13 @@ export function parseMrwHistory(html, raw, summary) {
155
155
  throw new IndeterminateError('MRW', 'Shipment history is empty');
156
156
  if (rows.length > 500)
157
157
  throw new SchemaError('MRW', 'Shipment history is unexpectedly large');
158
+ // A pickup point's place links to its map; a later row there, such as the
159
+ // collection, shows its code without the link.
160
+ const points = new Set(rows.toArray().flatMap((row) => {
161
+ const place = $(row).children('td').eq(3);
162
+ const code = /^\d{5}(?=\s)/.exec(officeLabel(place.text()))?.[0];
163
+ return code && place.find('a[onclick*="openMap"]').length ? [code] : [];
164
+ }));
158
165
  const events = [];
159
166
  const seen = new Set();
160
167
  // MRW renders the current row first. Do not promote an older dated row when
@@ -171,7 +178,7 @@ export function parseMrwHistory(html, raw, summary) {
171
178
  // Read visible text only. The PointCorner link embeds a full store address
172
179
  // and map arguments in onclick; they must never enter the result.
173
180
  const location = officeLabel(cells.eq(3).text());
174
- const clock = rowClock(date, hour, location);
181
+ const clock = rowClock(date, hour, location, points);
175
182
  const key = JSON.stringify([date, hour, rawStatus, location]);
176
183
  if (seen.has(key))
177
184
  continue;
@@ -613,6 +613,40 @@
613
613
  ]
614
614
  },
615
615
  "note": "Made-up number in the layout of a sender's reference, dated day before month, that SEUR's lookup found for its shipments."
616
+ },
617
+ {
618
+ "number": "48285234834793",
619
+ "role": "shipment",
620
+ "evidence": "public_shipment_report",
621
+ "source": {
622
+ "url": "https://www.ocu.org/reclamar/lista-reclamaciones-publicas/reclamaci-C3-B3n-formal-contra-seur/3d4034ae564773a03b",
623
+ "date": "2026-10-14",
624
+ "dateKind": "publication",
625
+ "access": "full_page"
626
+ },
627
+ "context": {
628
+ "assessment": "confirmed",
629
+ "reportedRole": "shipment_tracking",
630
+ "service": "SEUR"
631
+ },
632
+ "expect": {
633
+ "carrier": "unknown",
634
+ "confidence": "low",
635
+ "candidates": [
636
+ "dpd",
637
+ "ciblex",
638
+ "hermes-de",
639
+ "seur",
640
+ "brt",
641
+ "delhivery",
642
+ "dhl-ecommerce-uk",
643
+ "dpd-be",
644
+ "dpd-de",
645
+ "dpd-nl",
646
+ "dpd-uk"
647
+ ]
648
+ },
649
+ "note": "Tracking number in a complaint about a locker drop-off; SEUR's tracker returns its history."
616
650
  }
617
651
  ]
618
652
  }
@@ -0,0 +1,2 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,6 @@
1
+ // Universal providers copy SF's scans to the second, on the same UTC+08:00
2
+ // clock, in their own wording. The scan SF itself returns at that instant
3
+ // takes over the copy, and over its own row stored under older wording.
4
+ export const sameInstantIdentityPolicy = {
5
+ sourceCarrierId: 'sf-express', storedSources: ['sf-express', 'unknown'], requireProviderCode: false,
6
+ };
@@ -83,7 +83,7 @@ export function parse(payload, trackingNumber) {
83
83
  const reason = clean(raw.stayWhyCode, 64);
84
84
  if (!/^[A-Z0-9_-]{1,64}$/.test(code))
85
85
  throw new SchemaError(PROVIDER, 'SF Express returned an invalid operation code');
86
- const status = sfExpressEventStatus(code, reason);
86
+ const status = sfExpressEventStatus(code, reason, text);
87
87
  const event = { time, provider_code: code,
88
88
  description: status?.status === 'delivered' ? 'Delivered' : text,
89
89
  ...(status ? { stage: status.stage } : {}),
@@ -1,4 +1,4 @@
1
1
  import type { ClassifiedStatus } from '../../core/status/index.js';
2
- /** Codes 8000 and 70 are ambiguous without their reason; do not default them to signed. */
3
- export declare function sfExpressEventStatus(code: string, reason: string): ClassifiedStatus | undefined;
2
+ /** Codes 8000 and 70 are ambiguous without their reason, and 99 without its remark; do not default them to signed. */
3
+ export declare function sfExpressEventStatus(code: string, reason: string, remark?: string): ClassifiedStatus | undefined;
4
4
  export declare function sfExpressSummaryStatus(state: string, signed: boolean): ClassifiedStatus | undefined;
@@ -15,8 +15,7 @@ const UNDELIVERED = new Map([
15
15
  const SUMMARY = new Map([['2', EXCEPTION], ['3', TRANSIT], ['4', RETURNED], ['5', EXCEPTION], ['6', EXCEPTION], ['99', TRANSIT], ['100', DELIVERED]]);
16
16
  // The official Taiwan frontend's operation-code groups, plus codes observed in
17
17
  // its public route response whose remarks settle them. The warehouse
18
- // "delivered" group means dispatched. Code 99 reads both "being returned" and
19
- // "being forwarded", so it stays unclassified.
18
+ // "delivered" group means dispatched. Code 99 is read by its remark.
20
19
  const CODES = new Map([
21
20
  ...['80', '980'].map((code) => [code, DELIVERED]),
22
21
  ...['50', '54', '950'].map((code) => [code, { stage: 'accepted', status: 'in_transit' }]),
@@ -28,13 +27,21 @@ const CODES = new Map([
28
27
  ...['98', '208', '649', '310', '115', '116', '302', '305'].map((code) => [code, TRANSIT]),
29
28
  ['605', { stage: 'customs', status: 'in_transit' }],
30
29
  ]);
31
- /** Codes 8000 and 70 are ambiguous without their reason; do not default them to signed. */
32
- export function sfExpressEventStatus(code, reason) {
30
+ // Code 99 carries several notices: one on its way back to the sender, or one
31
+ // sent on to another address. Other remarks stay unclassified.
32
+ const NOTICES = [
33
+ [/^The shipment is being returned\.?$/i, RETURNED],
34
+ [/^The shipment is being forwarded\.?$/i, TRANSIT],
35
+ ];
36
+ /** Codes 8000 and 70 are ambiguous without their reason, and 99 without its remark; do not default them to signed. */
37
+ export function sfExpressEventStatus(code, reason, remark = '') {
33
38
  if (code === '8000') {
34
39
  return REASONS.get(reason);
35
40
  }
36
41
  if (code === '70')
37
42
  return UNDELIVERED.get(reason);
43
+ if (code === '99')
44
+ return NOTICES.find(([wording]) => wording.test(remark.trim()))?.[1];
38
45
  return CODES.get(code);
39
46
  }
40
47
  export function sfExpressSummaryStatus(state, signed) {
@@ -406,6 +406,24 @@
406
406
  "confirmedBy": "bundle",
407
407
  "firstSeen": "2026-09-26",
408
408
  "note": "Shipment summary vocabulary from official abroad/billDetailItem/mixins. State 99 yields to a more specific latest scan."
409
+ },
410
+ {
411
+ "code": "99",
412
+ "wording": "The shipment is being returned",
413
+ "stage": "returned",
414
+ "language": "en",
415
+ "confirmedBy": "live",
416
+ "firstSeen": "2026-10-20",
417
+ "note": "Official Taiwan routes opCode 99, which carries several notices; this remark says the parcel is on its way back to the sender."
418
+ },
419
+ {
420
+ "code": "99",
421
+ "wording": "The shipment is being forwarded",
422
+ "stage": "in_transit",
423
+ "language": "en",
424
+ "confirmedBy": "live",
425
+ "firstSeen": "2026-10-20",
426
+ "note": "Official Taiwan routes opCode 99 with this remark: the parcel is sent on to another address."
409
427
  }
410
428
  ]
411
429
  }
@@ -27,13 +27,14 @@ import { sameInstantIdentityPolicy as postaSlovenije } from '../../carriers/post
27
27
  import { sameInstantIdentityPolicy as posteItaliane } from '../../carriers/poste-italiane/identity.js';
28
28
  import { sameInstantIdentityPolicy as posti } from '../../carriers/posti/identity.js';
29
29
  import { sameInstantIdentityPolicy as royalMail } from '../../carriers/royal-mail/identity.js';
30
+ import { sameInstantIdentityPolicy as sfExpress } from '../../carriers/sf-express/identity.js';
30
31
  import { sameInstantIdentityPolicy as speedpak } from '../../carriers/speedpak/identity.js';
31
32
  import { sameInstantIdentityPolicy as sunYou } from '../../carriers/sunyou/identity.js';
32
33
  import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
33
34
  import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
34
35
  import { sameInstantIdentityPolicy as yanwen } from '../../carriers/yanwen/app.js';
35
36
  import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
36
- const policies = new Map([aliexpress, amazonShipping, australiaPost, austrianPost, canadaPost, chronopost, ciblex, correosExpress, correosSpain, ctt, dhlEcommerce, dpd, dpdFrance, hermesGermany, indiaPost, inpost, jdLogistics, laPoste, lbcExpress, mondialRelay, mrw, nzPost, packeta, posMalaysia, postlogistics, postaSlovenije, posteItaliane, posti, royalMail, speedpak, sunYou, ups, swissPost, yanwen, universal].map((policy) => [policy.sourceCarrierId, policy]));
37
+ const policies = new Map([aliexpress, amazonShipping, australiaPost, austrianPost, canadaPost, chronopost, ciblex, correosExpress, correosSpain, ctt, dhlEcommerce, dpd, dpdFrance, hermesGermany, indiaPost, inpost, jdLogistics, laPoste, lbcExpress, mondialRelay, mrw, nzPost, packeta, posMalaysia, postlogistics, postaSlovenije, posteItaliane, posti, royalMail, sfExpress, speedpak, sunYou, ups, swissPost, yanwen, universal].map((policy) => [policy.sourceCarrierId, policy]));
37
38
  /** Unlisted sources cannot identify a reworded scan by its instant alone. */
38
39
  export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
39
40
  const policy = policies.get(sourceCarrierId);
@@ -250,6 +250,7 @@ import { statusMap as interrapidisimo } from '../../carriers/interrapidisimo/sta
250
250
  import { statusMap as intexPaketdienst } from '../../carriers/intex-paketdienst/status.js';
251
251
  import { statusMap as israelPost } from '../../carriers/israel-post/status.js';
252
252
  import { statusMap as jAndT } from '../../carriers/j-and-t/status.js';
253
+ import { statusMap as jAndTAu } from '../../carriers/j-and-t-au/status.js';
253
254
  import { statusMap as jntCargo } from '../../carriers/j-and-t-cargo/status.js';
254
255
  import { statusMap as jAndTMiddleEast } from '../../carriers/j-and-t-middle-east/status.js';
255
256
  import { statusMap as janio } from '../../carriers/janio/status.js';
@@ -563,7 +564,7 @@ export const STATUS_MAPS = {
563
564
  'houng-ah-loun': houngAhLoun, hovership, 'hr-parcel': hrParcel, 'hrvatska-posta': hrvatskaPosta, huahan, 'hunter-express': hunterExpress, hywl,
564
565
  'icc-worldwide': iccWorldwide, 'ics-courier': icsCourier, 'ids-logistik': idsLogistik, 'ilyang-logis': ilyangLogis, imile,
565
566
  'indah-logistik': indahLogistik, instabox, 'integral-pack': integralPack, 'internet-express': internetExpress, interrapidisimo,
566
- 'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-cargo': jntCargo,
567
+ 'intex-paketdienst': intexPaketdienst, 'israel-post': israelPost, 'j-and-t': jAndT, 'j-and-t-au': jAndTAu, 'j-and-t-cargo': jntCargo,
567
568
  'j-and-t-middle-east': jAndTMiddleEast, janio, jcex, jeebly, 'jersey-post': jerseyPost, 'jet-logistic': jetLogistic, 'jetline-couriers': jetlineCouriers,
568
569
  jhgj, jitsu, jne, 'jordan-post': jordanPost, jrt, 'js-express': jsExpress, jumppoint, kazpost, kex, 'kex-express-hk': kexExpressHk,
569
570
  'kit-tk': kitTk, 'kuehne-nagel': kuehneNagel, kunyoung, kyoungdong, 'kyrgyz-post': kyrgyzPost, 'l-post': lPost, 'la-poste': laPoste,
@@ -11,9 +11,10 @@ import { normalizeTrackingNumber } from './normalize.js';
11
11
  function hasTrackingShape(normalized) {
12
12
  return normalized.length >= 4
13
13
  && normalized.length <= 40
14
- // The NACEX agency/shipment composite is the only tracking shape allowed
15
- // to keep punctuation; everything else stays strict alphanumeric.
16
- && (/^[A-Z0-9]+$/.test(normalized) || /^\d{4}\/\d{8}$/.test(normalized));
14
+ // Letters and digits, with one underscore inside as Better Trucks writes
15
+ // its BTS_ numbers. The NACEX agency/shipment composite is the only other
16
+ // shape allowed to keep punctuation.
17
+ && (/^[A-Z0-9]+(?:_[A-Z0-9]+)?$/.test(normalized) || /^\d{4}\/\d{8}$/.test(normalized));
17
18
  }
18
19
  /**
19
20
  * Shape gate for a number entered whole or carried by a carrier's link. A number
@@ -17144,7 +17144,8 @@
17144
17144
  "timezone": "Australia/Sydney",
17145
17145
  "tracking": {
17146
17146
  "mode": "automatic",
17147
- "adapter": "universal"
17147
+ "adapter": "j-and-t-au",
17148
+ "recognitionRank": 698
17148
17149
  },
17149
17150
  "canaryUrl": "https://www.jtexpress.au/tracking",
17150
17151
  "trackingUrlTemplate": "https://www.jtexpress.au/tracking?q={trackingNumber}",
@@ -11082,7 +11082,8 @@ export declare const CARRIER_CATALOG: {
11082
11082
  readonly timezone: "Australia/Sydney";
11083
11083
  readonly tracking: {
11084
11084
  readonly mode: "automatic";
11085
- readonly adapter: "universal";
11085
+ readonly adapter: "j-and-t-au";
11086
+ readonly recognitionRank: 698;
11086
11087
  };
11087
11088
  readonly canaryUrl: "https://www.jtexpress.au/tracking";
11088
11089
  readonly trackingUrlTemplate: "https://www.jtexpress.au/tracking?q={trackingNumber}";
@@ -17145,7 +17145,8 @@ export const CARRIER_CATALOG = {
17145
17145
  "timezone": "Australia/Sydney",
17146
17146
  "tracking": {
17147
17147
  "mode": "automatic",
17148
- "adapter": "universal"
17148
+ "adapter": "j-and-t-au",
17149
+ "recognitionRank": 698
17149
17150
  },
17150
17151
  "canaryUrl": "https://www.jtexpress.au/tracking",
17151
17152
  "trackingUrlTemplate": "https://www.jtexpress.au/tracking?q={trackingNumber}",
@@ -219,6 +219,7 @@ export const CARRIER_RECOGNITION_RANKS = {
219
219
  "interrapidisimo": 969,
220
220
  "israel-post": 81,
221
221
  "j-and-t": 530,
222
+ "j-and-t-au": 698,
222
223
  "j-and-t-cargo": 910,
223
224
  "janio": 811,
224
225
  "jeebly": 805,
@@ -286,6 +286,7 @@ import { adapter as interrapidisimo } from '../carriers/interrapidisimo/adapter.
286
286
  import { adapter as intexPaketdienst } from '../carriers/intex-paketdienst/adapter.js';
287
287
  import { adapter as israelPost } from '../carriers/israel-post/adapter.js';
288
288
  import { adapter as jAndT } from '../carriers/j-and-t/adapter.js';
289
+ import { adapter as jAndTAu } from '../carriers/j-and-t-au/adapter.js';
289
290
  import { adapter as jAndTCargo } from '../carriers/j-and-t-cargo/adapter.js';
290
291
  import { adapter as jAndTMiddleEast } from '../carriers/j-and-t-middle-east/adapter.js';
291
292
  import { adapter as janio } from '../carriers/janio/adapter.js';
@@ -888,6 +889,7 @@ export const REGISTRY = {
888
889
  "intex-paketdienst": intexPaketdienst,
889
890
  "israel-post": israelPost,
890
891
  "j-and-t": jAndT,
892
+ "j-and-t-au": jAndTAu,
891
893
  "j-and-t-cargo": jAndTCargo,
892
894
  "j-and-t-middle-east": jAndTMiddleEast,
893
895
  "janio": janio,
@@ -1563,7 +1565,7 @@ export const REGISTRY = {
1563
1565
  "iran-post": "universal",
1564
1566
  "israel-post": "israel-post",
1565
1567
  "j-and-t": "j-and-t",
1566
- "j-and-t-au": "universal",
1568
+ "j-and-t-au": "j-and-t-au",
1567
1569
  "j-and-t-br": "universal",
1568
1570
  "j-and-t-cargo": "j-and-t-cargo",
1569
1571
  "j-and-t-cn": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.556.0"
5
+ "version": "0.558.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.556.0-main.2575",
3
+ "version": "0.558.0-main.2587",
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",
@@ -62,6 +62,7 @@
62
62
  "test": "vitest run",
63
63
  "test:scripts": "node --test trawl/*.test.mjs scripts/*.test.mjs",
64
64
  "generate": "node scripts/generate-catalog.mjs && node scripts/generate-registry.mjs --strict && node scripts/generate-region-towns.mjs && node scripts/benchmark.mjs routing && node scripts/coverage-tables.mjs && node scripts/generate-readme.mjs",
65
+ "generate:golden": "node scripts/detection-golden.mjs",
65
66
  "test:generated": "node scripts/generate-catalog.mjs --check && node scripts/generate-registry.mjs --strict --check && node scripts/generate-region-towns.mjs --check && node scripts/benchmark.mjs routing --check && node scripts/coverage-tables.mjs --check && node scripts/generate-readme.mjs --check",
66
67
  "test:package": "node scripts/test-package.mjs",
67
68
  "test:carriers:live": "vitest run --config vitest.carriers-live.config.ts",