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 +2 -2
- package/data/catalog.json +2 -1
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +33 -0
- package/dist/browser/scraper.js +4 -2
- package/dist/carriers/j-and-t-au/adapter.d.ts +30 -0
- package/dist/carriers/j-and-t-au/adapter.js +89 -0
- package/dist/carriers/j-and-t-au/carrier.json +31 -7
- package/dist/carriers/j-and-t-au/parser.d.ts +18 -0
- package/dist/carriers/j-and-t-au/parser.js +169 -0
- package/dist/carriers/j-and-t-au/status.d.ts +7 -0
- package/dist/carriers/j-and-t-au/status.js +36 -0
- package/dist/carriers/j-and-t-au/statuses.json +10 -1
- package/dist/carriers/japan-post/numbers.json +19 -0
- package/dist/carriers/mrw/parser.js +15 -8
- package/dist/carriers/seur/numbers.json +34 -0
- package/dist/carriers/sf-express/identity.d.ts +2 -0
- package/dist/carriers/sf-express/identity.js +6 -0
- package/dist/carriers/sf-express/parser.js +1 -1
- package/dist/carriers/sf-express/status.d.ts +2 -2
- package/dist/carriers/sf-express/status.js +11 -4
- package/dist/carriers/sf-express/statuses.json +18 -0
- package/dist/core/catalog/eventIdentity.js +2 -1
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/core/detection/valid.js +4 -3
- package/dist/data/catalog.json +2 -1
- package/dist/generated/catalog.d.ts +2 -1
- package/dist/generated/catalog.js +2 -1
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
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,
|
|
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": "
|
|
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}",
|
|
@@ -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",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -13165,7 +13165,8 @@ var Je = {
|
|
|
13165
13165
|
timezone: "Australia/Sydney",
|
|
13166
13166
|
tracking: {
|
|
13167
13167
|
mode: "automatic",
|
|
13168
|
-
adapter: "
|
|
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]
|
|
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": "
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
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[
|
|
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[
|
|
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,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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
package/dist/data/catalog.json
CHANGED
|
@@ -17144,7 +17144,8 @@
|
|
|
17144
17144
|
"timezone": "Australia/Sydney",
|
|
17145
17145
|
"tracking": {
|
|
17146
17146
|
"mode": "automatic",
|
|
17147
|
-
"adapter": "
|
|
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: "
|
|
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": "
|
|
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}",
|
|
@@ -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": "
|
|
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",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
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",
|