universal-parcel-scraper 0.183.0-main.1321 → 0.185.0-main.1328
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 +12 -4
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +32 -0
- package/dist/browser/scraper.js +15 -5
- package/dist/carriers/cj-packet/adapter.d.ts +15 -0
- package/dist/carriers/cj-packet/adapter.js +58 -0
- package/dist/carriers/cj-packet/carrier.json +60 -7
- package/dist/carriers/cj-packet/numbers.json +4 -4
- package/dist/carriers/cj-packet/parser.d.ts +18 -0
- package/dist/carriers/cj-packet/parser.js +210 -0
- package/dist/carriers/cj-packet/status.d.ts +18 -0
- package/dist/carriers/cj-packet/status.js +69 -0
- package/dist/carriers/cj-packet/statuses.json +72 -1
- package/dist/carriers/kex/adapter.d.ts +22 -0
- package/dist/carriers/kex/adapter.js +104 -0
- package/dist/carriers/kex/carrier.json +58 -7
- package/dist/carriers/kex/numbers.json +76 -0
- package/dist/carriers/kex/parser.d.ts +14 -0
- package/dist/carriers/kex/parser.js +178 -0
- package/dist/carriers/kex/status.d.ts +12 -0
- package/dist/carriers/kex/status.js +49 -0
- package/dist/carriers/kex/statuses.json +12 -1
- package/dist/carriers/sfc/adapter.d.ts +15 -0
- package/dist/carriers/sfc/adapter.js +64 -0
- package/dist/carriers/sfc/carrier.json +62 -7
- package/dist/carriers/sfc/numbers.json +19 -0
- package/dist/carriers/sfc/parser.d.ts +29 -0
- package/dist/carriers/sfc/parser.js +202 -0
- package/dist/carriers/sfc/status.d.ts +10 -0
- package/dist/carriers/sfc/status.js +71 -0
- package/dist/carriers/sfc/statuses.json +28 -7
- package/dist/carriers/steadfast/carrier.json +0 -1
- package/dist/core/catalog/statusMaps.js +6 -2
- package/dist/data/catalog.json +12 -4
- package/dist/generated/catalog.d.ts +11 -4
- package/dist/generated/catalog.js +13 -5
- package/dist/generated/recognition.js +3 -0
- package/dist/generated/registry.js +9 -3
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -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 **263 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>494 catalog entries · 113 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 9,
|
|
158
|
+
The carrier folders record 9,707 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
|
@@ -7368,7 +7368,9 @@
|
|
|
7368
7368
|
"timezone": "Asia/Shanghai",
|
|
7369
7369
|
"tracking": {
|
|
7370
7370
|
"mode": "automatic",
|
|
7371
|
-
"adapter": "
|
|
7371
|
+
"adapter": "cj-packet",
|
|
7372
|
+
"recognitionRank": 956,
|
|
7373
|
+
"localClocks": true
|
|
7372
7374
|
},
|
|
7373
7375
|
"canaryUrl": "https://www.cjpacket.com/",
|
|
7374
7376
|
"trackingUrlTemplate": "https://www.cjpacket.com/?trackingNumber={trackingNumber}",
|
|
@@ -12276,7 +12278,8 @@
|
|
|
12276
12278
|
"timezone": "Asia/Bangkok",
|
|
12277
12279
|
"tracking": {
|
|
12278
12280
|
"mode": "automatic",
|
|
12279
|
-
"adapter": "
|
|
12281
|
+
"adapter": "kex",
|
|
12282
|
+
"recognitionRank": 959
|
|
12280
12283
|
},
|
|
12281
12284
|
"canaryUrl": "https://th.kex-express.com/en/track-parcel",
|
|
12282
12285
|
"trackingUrlTemplate": "https://th.kex-express.com/en/track/?track={trackingNumber}",
|
|
@@ -16849,7 +16852,9 @@
|
|
|
16849
16852
|
"timezone": "Asia/Shanghai",
|
|
16850
16853
|
"tracking": {
|
|
16851
16854
|
"mode": "automatic",
|
|
16852
|
-
"adapter": "
|
|
16855
|
+
"adapter": "sfc",
|
|
16856
|
+
"recognitionRank": 955,
|
|
16857
|
+
"localClocks": true
|
|
16853
16858
|
},
|
|
16854
16859
|
"canaryUrl": "https://www.sendfromchina.com/track",
|
|
16855
16860
|
"trackingSearchUrl": "https://www.sendfromchina.com/track",
|
|
@@ -16858,6 +16863,10 @@
|
|
|
16858
16863
|
{
|
|
16859
16864
|
"pattern": "^SFC\\d[A-Z]{2}\\d{13,14}$",
|
|
16860
16865
|
"confidence": "high"
|
|
16866
|
+
},
|
|
16867
|
+
{
|
|
16868
|
+
"pattern": "^SFC[A-Z]{2}\\d{10}YQ$",
|
|
16869
|
+
"confidence": "high"
|
|
16861
16870
|
}
|
|
16862
16871
|
]
|
|
16863
16872
|
},
|
|
@@ -18070,7 +18079,6 @@
|
|
|
18070
18079
|
"adapter": "universal"
|
|
18071
18080
|
},
|
|
18072
18081
|
"canaryUrl": "https://steadfast.com.bd/tracking",
|
|
18073
|
-
"trackingUrlTemplate": "https://steadfast.com.bd/t/{trackingNumber}",
|
|
18074
18082
|
"trackingSearchUrl": "https://steadfast.com.bd/tracking",
|
|
18075
18083
|
"linkRules": [
|
|
18076
18084
|
{
|
|
@@ -50357,6 +50357,12 @@
|
|
|
50357
50357
|
"pathao-courier"
|
|
50358
50358
|
]
|
|
50359
50359
|
},
|
|
50360
|
+
{
|
|
50361
|
+
"input": "DCAT0000175027",
|
|
50362
|
+
"carrier": "unknown",
|
|
50363
|
+
"confidence": "none",
|
|
50364
|
+
"candidates": []
|
|
50365
|
+
},
|
|
50360
50366
|
{
|
|
50361
50367
|
"input": "DD-KBA6JL",
|
|
50362
50368
|
"carrier": "unknown",
|
|
@@ -60872,6 +60878,18 @@
|
|
|
60872
60878
|
"confidence": "none",
|
|
60873
60879
|
"candidates": []
|
|
60874
60880
|
},
|
|
60881
|
+
{
|
|
60882
|
+
"input": "PROS0518804706",
|
|
60883
|
+
"carrier": "unknown",
|
|
60884
|
+
"confidence": "none",
|
|
60885
|
+
"candidates": []
|
|
60886
|
+
},
|
|
60887
|
+
{
|
|
60888
|
+
"input": "PROS0585164315",
|
|
60889
|
+
"carrier": "unknown",
|
|
60890
|
+
"confidence": "none",
|
|
60891
|
+
"candidates": []
|
|
60892
|
+
},
|
|
60875
60893
|
{
|
|
60876
60894
|
"input": "PRT00000001234000000001",
|
|
60877
60895
|
"carrier": "ecoscooting",
|
|
@@ -63218,6 +63236,12 @@
|
|
|
63218
63236
|
"spring-gds"
|
|
63219
63237
|
]
|
|
63220
63238
|
},
|
|
63239
|
+
{
|
|
63240
|
+
"input": "RUTD000239675UL",
|
|
63241
|
+
"carrier": "unknown",
|
|
63242
|
+
"confidence": "none",
|
|
63243
|
+
"candidates": []
|
|
63244
|
+
},
|
|
63221
63245
|
{
|
|
63222
63246
|
"input": "RW000336615R",
|
|
63223
63247
|
"carrier": "speedaf",
|
|
@@ -63782,6 +63806,14 @@
|
|
|
63782
63806
|
"sfc"
|
|
63783
63807
|
]
|
|
63784
63808
|
},
|
|
63809
|
+
{
|
|
63810
|
+
"input": "SFCAA0121278784YQ",
|
|
63811
|
+
"carrier": "sfc",
|
|
63812
|
+
"confidence": "high",
|
|
63813
|
+
"candidates": [
|
|
63814
|
+
"sfc"
|
|
63815
|
+
]
|
|
63816
|
+
},
|
|
63785
63817
|
{
|
|
63786
63818
|
"input": "SFR250908ST31F5CFEBD",
|
|
63787
63819
|
"carrier": "steadfast",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -5824,7 +5824,9 @@ var Ae = {
|
|
|
5824
5824
|
timezone: "Asia/Shanghai",
|
|
5825
5825
|
tracking: {
|
|
5826
5826
|
mode: "automatic",
|
|
5827
|
-
adapter: "
|
|
5827
|
+
adapter: "cj-packet",
|
|
5828
|
+
recognitionRank: 956,
|
|
5829
|
+
localClocks: !0
|
|
5828
5830
|
},
|
|
5829
5831
|
canaryUrl: "https://www.cjpacket.com/",
|
|
5830
5832
|
trackingUrlTemplate: "https://www.cjpacket.com/?trackingNumber={trackingNumber}",
|
|
@@ -9447,7 +9449,8 @@ var Ae = {
|
|
|
9447
9449
|
timezone: "Asia/Bangkok",
|
|
9448
9450
|
tracking: {
|
|
9449
9451
|
mode: "automatic",
|
|
9450
|
-
adapter: "
|
|
9452
|
+
adapter: "kex",
|
|
9453
|
+
recognitionRank: 959
|
|
9451
9454
|
},
|
|
9452
9455
|
canaryUrl: "https://th.kex-express.com/en/track-parcel",
|
|
9453
9456
|
trackingUrlTemplate: "https://th.kex-express.com/en/track/?track={trackingNumber}",
|
|
@@ -12829,7 +12832,9 @@ var Ae = {
|
|
|
12829
12832
|
timezone: "Asia/Shanghai",
|
|
12830
12833
|
tracking: {
|
|
12831
12834
|
mode: "automatic",
|
|
12832
|
-
adapter: "
|
|
12835
|
+
adapter: "sfc",
|
|
12836
|
+
recognitionRank: 955,
|
|
12837
|
+
localClocks: !0
|
|
12833
12838
|
},
|
|
12834
12839
|
canaryUrl: "https://www.sendfromchina.com/track",
|
|
12835
12840
|
trackingSearchUrl: "https://www.sendfromchina.com/track",
|
|
@@ -12837,6 +12842,9 @@ var Ae = {
|
|
|
12837
12842
|
detectionRules: [{
|
|
12838
12843
|
pattern: "^SFC\\d[A-Z]{2}\\d{13,14}$",
|
|
12839
12844
|
confidence: "high"
|
|
12845
|
+
}, {
|
|
12846
|
+
pattern: "^SFC[A-Z]{2}\\d{10}YQ$",
|
|
12847
|
+
confidence: "high"
|
|
12840
12848
|
}]
|
|
12841
12849
|
},
|
|
12842
12850
|
shadowfax: {
|
|
@@ -13720,7 +13728,6 @@ var Ae = {
|
|
|
13720
13728
|
adapter: "universal"
|
|
13721
13729
|
},
|
|
13722
13730
|
canaryUrl: "https://steadfast.com.bd/tracking",
|
|
13723
|
-
trackingUrlTemplate: "https://steadfast.com.bd/t/{trackingNumber}",
|
|
13724
13731
|
trackingSearchUrl: "https://steadfast.com.bd/tracking",
|
|
13725
13732
|
linkRules: [{
|
|
13726
13733
|
domains: ["steadfast.com.bd", "www.steadfast.com.bd"],
|
|
@@ -15859,7 +15866,7 @@ var Ae = {
|
|
|
15859
15866
|
serpost: [],
|
|
15860
15867
|
servientrega: [],
|
|
15861
15868
|
"seven-eleven-tw": [],
|
|
15862
|
-
sfc: ["sfc-1"],
|
|
15869
|
+
sfc: ["sfc-1", "sfc-ae"],
|
|
15863
15870
|
shadowfax: ["shadowfax-1", "shadowfax-2"],
|
|
15864
15871
|
"shree-maruti": [],
|
|
15865
15872
|
"shree-nandan": [],
|
|
@@ -21048,6 +21055,7 @@ var nl = {
|
|
|
21048
21055
|
chronopost: 890,
|
|
21049
21056
|
ciblex: 640,
|
|
21050
21057
|
"cj-logistics": 977,
|
|
21058
|
+
"cj-packet": 956,
|
|
21051
21059
|
"colis-prive": 740,
|
|
21052
21060
|
coordinadora: 973,
|
|
21053
21061
|
"correo-argentino": 978,
|
|
@@ -21111,6 +21119,7 @@ var nl = {
|
|
|
21111
21119
|
"j-and-t": 530,
|
|
21112
21120
|
"j-and-t-cargo": 910,
|
|
21113
21121
|
jitsu: 85,
|
|
21122
|
+
kex: 959,
|
|
21114
21123
|
"kuehne-nagel": 435,
|
|
21115
21124
|
"la-poste": 1e3,
|
|
21116
21125
|
"latvijas-pasts": 100,
|
|
@@ -21157,6 +21166,7 @@ var nl = {
|
|
|
21157
21166
|
"saudi-post": 963,
|
|
21158
21167
|
servientrega: 979,
|
|
21159
21168
|
seur: 870,
|
|
21169
|
+
sfc: 955,
|
|
21160
21170
|
shadowfax: 595,
|
|
21161
21171
|
"slovak-parcel-service": 180,
|
|
21162
21172
|
"slovenska-posta": 170,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The lookup the tracking page on www.cjpacket.com makes for the numbers in its address. */
|
|
5
|
+
export declare const CJ_PACKET_API = "https://www.cjpacket.com/cj-logistics-api/refactoring/cjpacket/track";
|
|
6
|
+
export declare class CjPacketTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
});
|
|
13
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
14
|
+
}
|
|
15
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { CJ_PACKET_MAX_BYTES, normalizeCjPacketNumber, parseCjPacket, PROVIDER } from './parser.js';
|
|
8
|
+
/** The lookup the tracking page on www.cjpacket.com makes for the numbers in its address. */
|
|
9
|
+
export const CJ_PACKET_API = 'https://www.cjpacket.com/cj-logistics-api/refactoring/cjpacket/track';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
// Bot checks Cloudflare, in front of the site, could put in place of the reply.
|
|
12
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required)/i;
|
|
13
|
+
function json(body) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(body);
|
|
16
|
+
}
|
|
17
|
+
catch (cause) {
|
|
18
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
19
|
+
throw new ChallengeError(PROVIDER, 'CJ Packet answered with a browser check', { cause });
|
|
20
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned a reply that is not JSON', { cause });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class CjPacketTracker {
|
|
24
|
+
options;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async fetch(raw, context = {}) {
|
|
29
|
+
const number = normalizeCjPacketNumber(raw);
|
|
30
|
+
const read = async (signal, timeoutMs) => {
|
|
31
|
+
const { response, bytes } = await fetchBounded(CJ_PACKET_API, { method: 'POST', signal, headers: {
|
|
32
|
+
Accept: 'application/json', 'Content-Type': 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
33
|
+
}, body: JSON.stringify([number]) }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)),
|
|
34
|
+
maxBytes: CJ_PACKET_MAX_BYTES, redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
35
|
+
if (REDIRECTS.includes(response.status))
|
|
36
|
+
throw new TransportError(PROVIDER, 'CJ Packet redirected its tracking lookup');
|
|
37
|
+
// The service answers an unknown number with HTTP 200; a 404 is a moved route.
|
|
38
|
+
if (response.status === 404 || response.status === 410)
|
|
39
|
+
throw new TransportError(PROVIDER, 'CJ Packet tracking lookup is unavailable');
|
|
40
|
+
return parseCjPacket(json(decodeText(bytes)), number);
|
|
41
|
+
};
|
|
42
|
+
// A request that fails to reach the site, or hangs, gets one more try: the
|
|
43
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
44
|
+
return runSteps({ carrier: 'cj-packet', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
45
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
46
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
47
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
48
|
+
]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const adapter = (environment) => {
|
|
52
|
+
const tracker = new CjPacketTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
53
|
+
return {
|
|
54
|
+
id: 'cj-packet', recordsSteps: true, steps: ['direct', 'retry'],
|
|
55
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
56
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeCjPacketNumber(number))),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
@@ -18,18 +18,71 @@
|
|
|
18
18
|
"timezone": "Asia/Shanghai",
|
|
19
19
|
"tracking": {
|
|
20
20
|
"mode": "automatic",
|
|
21
|
-
"adapter": "
|
|
22
|
-
"steps": [
|
|
21
|
+
"adapter": "cj-packet",
|
|
22
|
+
"steps": [
|
|
23
|
+
"direct",
|
|
24
|
+
"retry"
|
|
25
|
+
],
|
|
26
|
+
"recognition": {
|
|
27
|
+
"rank": 956
|
|
28
|
+
},
|
|
29
|
+
"localClocks": true
|
|
23
30
|
},
|
|
24
|
-
"capabilities": [
|
|
31
|
+
"capabilities": [
|
|
32
|
+
"history",
|
|
33
|
+
"location",
|
|
34
|
+
"offset_timestamps",
|
|
35
|
+
"service_name",
|
|
36
|
+
"destination_country",
|
|
37
|
+
"delivery_partner",
|
|
38
|
+
"delivery_tracking_number",
|
|
39
|
+
"delivered_at"
|
|
40
|
+
],
|
|
25
41
|
"portal": {
|
|
26
42
|
"url": "https://www.cjpacket.com/?trackingNumber={trackingNumber}",
|
|
27
43
|
"searchUrl": "https://www.cjpacket.com/",
|
|
28
44
|
"canaryUrl": "https://www.cjpacket.com/",
|
|
29
|
-
"shows": [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
"shows": [
|
|
46
|
+
"status",
|
|
47
|
+
"history",
|
|
48
|
+
"location",
|
|
49
|
+
"service_name",
|
|
50
|
+
"origin_country",
|
|
51
|
+
"destination_country",
|
|
52
|
+
"tracking_alias",
|
|
53
|
+
"delivery_partner",
|
|
54
|
+
"delivery_tracking_number",
|
|
55
|
+
"delivered_at",
|
|
56
|
+
"order_reference",
|
|
57
|
+
"merchant_reference",
|
|
58
|
+
"contact_details"
|
|
59
|
+
],
|
|
60
|
+
"scraped": [
|
|
61
|
+
"status",
|
|
62
|
+
"history",
|
|
63
|
+
"location",
|
|
64
|
+
"offset_timestamps",
|
|
65
|
+
"service_name",
|
|
66
|
+
"destination_country",
|
|
67
|
+
"delivery_partner",
|
|
68
|
+
"delivery_tracking_number",
|
|
69
|
+
"delivered_at"
|
|
70
|
+
],
|
|
71
|
+
"discarded": [
|
|
72
|
+
"origin_country",
|
|
73
|
+
"tracking_alias",
|
|
74
|
+
"order_reference",
|
|
75
|
+
"merchant_reference",
|
|
76
|
+
"contact_details"
|
|
77
|
+
],
|
|
78
|
+
"unavailable": [
|
|
79
|
+
"eta",
|
|
80
|
+
"weight",
|
|
81
|
+
"sender_name",
|
|
82
|
+
"recipient_name",
|
|
83
|
+
"recipient_address",
|
|
84
|
+
"proof_of_delivery"
|
|
85
|
+
]
|
|
33
86
|
},
|
|
34
87
|
"links": [
|
|
35
88
|
{
|
|
@@ -221,13 +221,13 @@
|
|
|
221
221
|
"access": "indexed_excerpt"
|
|
222
222
|
},
|
|
223
223
|
"context": {
|
|
224
|
-
"assessment": "
|
|
224
|
+
"assessment": "confirmed"
|
|
225
225
|
},
|
|
226
226
|
"expect": {
|
|
227
227
|
"carrier": "cj-packet",
|
|
228
228
|
"confidence": "high"
|
|
229
229
|
},
|
|
230
|
-
"note": "Looked up on CJ Packet's own tracker, as an archived address shows;
|
|
230
|
+
"note": "Looked up on CJ Packet's own tracker, as an archived address shows; CJ Packet's tracker knows it."
|
|
231
231
|
},
|
|
232
232
|
{
|
|
233
233
|
"number": "CJPBDC1240600053YQ",
|
|
@@ -240,13 +240,13 @@
|
|
|
240
240
|
"access": "indexed_excerpt"
|
|
241
241
|
},
|
|
242
242
|
"context": {
|
|
243
|
-
"assessment": "
|
|
243
|
+
"assessment": "confirmed"
|
|
244
244
|
},
|
|
245
245
|
"expect": {
|
|
246
246
|
"carrier": "cj-packet",
|
|
247
247
|
"confidence": "high"
|
|
248
248
|
},
|
|
249
|
-
"note": "Looked up on CJ Packet's own tracker, as an archived address shows;
|
|
249
|
+
"note": "Looked up on CJ Packet's own tracker, as an archived address shows; CJ Packet's tracker knows it."
|
|
250
250
|
},
|
|
251
251
|
{
|
|
252
252
|
"number": "CJPMG0390200118YQ",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "CJ Packet";
|
|
3
|
+
export declare const CJ_PACKET_MAX_BYTES: number;
|
|
4
|
+
/** A CJ Packet number, in capitals as the tracker needs it; anything else is not one CJ Packet issues. */
|
|
5
|
+
export declare function normalizeCjPacketNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* A scan's wording without the support contact a courier appends to it, and
|
|
8
|
+
* without phone numbers or e-mail addresses.
|
|
9
|
+
*/
|
|
10
|
+
export declare function cjPacketWording(activity: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The reply of the tracking page's `POST /cj-logistics-api/refactoring/cjpacket/track`
|
|
13
|
+
* for one number. An unknown number is an empty list. The tracker also finds a
|
|
14
|
+
* shipment by its forwarder's or its courier's number, so a shipment counts
|
|
15
|
+
* only when its own CJ Packet number is the one asked. The merchant, the
|
|
16
|
+
* order's references and the courier's phone are never read.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseCjPacket(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { DateTime, FixedOffsetZone } from 'luxon';
|
|
2
|
+
import { carrierIdFromPartner } from '../../core/catalog/hints.js';
|
|
3
|
+
import { detectCarrierMatch, normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { USPS_ROUTING_BARCODE, uspsPackageIdentifier } from '../../core/detection/usps.js';
|
|
5
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
6
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
7
|
+
import { clean, textFromHtml } from '../../core/transport/index.js';
|
|
8
|
+
import { isRecord } from '../../core/types.js';
|
|
9
|
+
import { cjPacketCurrentStage, cjPacketStage, cjPacketSummary } from './status.js';
|
|
10
|
+
export const PROVIDER = 'CJ Packet';
|
|
11
|
+
// A long history is under ten kilobytes.
|
|
12
|
+
export const CJ_PACKET_MAX_BYTES = 512 * 1024;
|
|
13
|
+
// `cj-packet-1`'s layout: CJP, two or three letters, ten digits and YQ.
|
|
14
|
+
const NUMBER = /^CJP[A-Z]{2,3}\d{10}YQ$/;
|
|
15
|
+
const WALL_CLOCK = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
16
|
+
const STAMP = /^\d{1,15}$/;
|
|
17
|
+
// The place a relayed line can open with, such as "[US,SAMPLE CITY,ST]".
|
|
18
|
+
const PLACE = /^\[([^\]]{1,120})\]\s*/;
|
|
19
|
+
// A clause that sends the reader to someone's support, with whatever follows it.
|
|
20
|
+
const CONTACT = /\s*[,,;;]\s*(?=[^,,;;]*\b(?:contact|support|customer service|hotline)\b)[\s\S]*$/i;
|
|
21
|
+
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
|
|
22
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens
|
|
23
|
+
// between them, as phone numbers are written; a date stays.
|
|
24
|
+
const PHONE = /(?<![A-Z\d])(?!\d{4}-\d{2}-\d{2}(?!\d))\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
25
|
+
// Who took a delivered parcel, after the delivery's own words.
|
|
26
|
+
const RECEIVER = /\s*[,;:.\-–]\s*(?:signed(?: for)? by|signatory|received by|recibido por|entregado a)\b[\s\S]*$/i;
|
|
27
|
+
const MAX_SHIPMENTS = 50;
|
|
28
|
+
const MAX_ROWS = 500;
|
|
29
|
+
const MAX_EVENTS = 100;
|
|
30
|
+
/** A CJ Packet number, in capitals as the tracker needs it; anything else is not one CJ Packet issues. */
|
|
31
|
+
export function normalizeCjPacketNumber(raw) {
|
|
32
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
|
|
33
|
+
if (!NUMBER.test(number))
|
|
34
|
+
throw new InvalidInputError(PROVIDER, 'CJ Packet numbers are CJP, two or three letters, ten digits and YQ');
|
|
35
|
+
return number;
|
|
36
|
+
}
|
|
37
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
38
|
+
function text(row, key, maxLength = 300) {
|
|
39
|
+
const value = row[key];
|
|
40
|
+
if (value === null || value === undefined)
|
|
41
|
+
return '';
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
throw new SchemaError(PROVIDER, `CJ Packet returned an invalid ${key}`);
|
|
44
|
+
return clean(value, maxLength);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A scan's wording without the support contact a courier appends to it, and
|
|
48
|
+
* without phone numbers or e-mail addresses.
|
|
49
|
+
*/
|
|
50
|
+
export function cjPacketWording(activity) {
|
|
51
|
+
return clean(activity.replace(CONTACT, '').replace(EMAIL, '').replace(PHONE, '').replace(/\s+([,,;;.。])/g, '$1'))
|
|
52
|
+
.replace(/^[\s,,;;:]+|[\s,,;;:\-–]+$/g, '');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A scan's clock. `eventTime` is the scanning site's wall clock and
|
|
56
|
+
* `eventTimestamp`, when the forwarder sends one, the same moment in epoch
|
|
57
|
+
* milliseconds, which the page shows instead. Their difference is the site's
|
|
58
|
+
* offset, so such a scan is returned at it. A scan without one, or whose two
|
|
59
|
+
* clocks do not differ by an offset a clock can have, keeps its wall clock.
|
|
60
|
+
*/
|
|
61
|
+
function scanClock(row) {
|
|
62
|
+
const wall = text(row, 'eventTime', 32);
|
|
63
|
+
const local = wall.replace(' ', 'T');
|
|
64
|
+
// Parsing in UTC checks the calendar digits only.
|
|
65
|
+
const parsed = DateTime.fromISO(local, { zone: 'UTC' });
|
|
66
|
+
// Luxon rolls 24:00:00 over to the next day; only a clock that reads back unchanged is kept.
|
|
67
|
+
if (!WALL_CLOCK.test(wall) || !parsed.isValid || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== local) {
|
|
68
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid scan time');
|
|
69
|
+
}
|
|
70
|
+
const raw = row.eventTimestamp;
|
|
71
|
+
if (raw === null || raw === undefined || raw === '')
|
|
72
|
+
return { local_time: local };
|
|
73
|
+
const stamp = typeof raw === 'number' ? raw : typeof raw === 'string' && STAMP.test(raw) ? Number(raw) : Number.NaN;
|
|
74
|
+
if (!Number.isSafeInteger(stamp) || stamp <= 0)
|
|
75
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid scan timestamp');
|
|
76
|
+
const minutes = (parsed.toMillis() - stamp) / 60_000;
|
|
77
|
+
if (!Number.isInteger(minutes) || minutes % 15 !== 0 || minutes < -12 * 60 || minutes > 14 * 60)
|
|
78
|
+
return { local_time: local };
|
|
79
|
+
const time = DateTime.fromMillis(stamp, { zone: FixedOffsetZone.instance(minutes) }).toISO({ suppressMilliseconds: true });
|
|
80
|
+
return time ? { time } : { local_time: local };
|
|
81
|
+
}
|
|
82
|
+
function projectRow(row) {
|
|
83
|
+
// The page writes the line out as HTML.
|
|
84
|
+
const activity = textFromHtml(text(row, 'activity', 1000), 1000);
|
|
85
|
+
const place = PLACE.exec(activity);
|
|
86
|
+
let description = cjPacketWording(place ? activity.slice(place[0].length) : activity);
|
|
87
|
+
const staged = cjPacketStage(description);
|
|
88
|
+
if (staged?.stage === 'delivered')
|
|
89
|
+
description = description.replace(RECEIVER, '');
|
|
90
|
+
const clock = scanClock(row);
|
|
91
|
+
// A line left empty says nothing to keep.
|
|
92
|
+
if (!description)
|
|
93
|
+
return null;
|
|
94
|
+
const location = text(row, 'location', 200) || clean(place?.[1], 120);
|
|
95
|
+
const event = {
|
|
96
|
+
...clock,
|
|
97
|
+
description,
|
|
98
|
+
...(location ? { location } : {}),
|
|
99
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
100
|
+
};
|
|
101
|
+
return { key: JSON.stringify([clock, description, location]), event };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The destination courier's number from the reply's last-mile field, and
|
|
105
|
+
* that courier when the reply names one the catalog knows whose rules fit
|
|
106
|
+
* the number. A USPS routing barcode gives its package identifier.
|
|
107
|
+
*/
|
|
108
|
+
function handoff(shipment, number) {
|
|
109
|
+
const compact = normalizeTrackingNumber(text(shipment, 'lastTrackNumber', 64)).toUpperCase();
|
|
110
|
+
const reference = USPS_ROUTING_BARCODE.test(compact) ? uspsPackageIdentifier(compact) ?? '' : compact;
|
|
111
|
+
if (!/^[A-Z0-9]{4,40}$/.test(reference) || reference === number)
|
|
112
|
+
return {};
|
|
113
|
+
const partner = carrierIdFromPartner(text(shipment, 'providerEnName', 80), text(shipment, 'providerUrl', 500));
|
|
114
|
+
const named = partner && partner !== 'cj-packet' && detectCarrierMatch(reference).candidates.includes(partner);
|
|
115
|
+
return { ...(named ? { delivery_carrier: partner } : {}), delivery_tracking_number: reference };
|
|
116
|
+
}
|
|
117
|
+
/** The shipment's summary code: a whole number, or nothing. */
|
|
118
|
+
function summaryCode(shipment) {
|
|
119
|
+
const code = shipment.status;
|
|
120
|
+
if (code === null || code === undefined)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (typeof code !== 'number' || !Number.isSafeInteger(code))
|
|
123
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid status');
|
|
124
|
+
return code;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The reply of the tracking page's `POST /cj-logistics-api/refactoring/cjpacket/track`
|
|
128
|
+
* for one number. An unknown number is an empty list. The tracker also finds a
|
|
129
|
+
* shipment by its forwarder's or its courier's number, so a shipment counts
|
|
130
|
+
* only when its own CJ Packet number is the one asked. The merchant, the
|
|
131
|
+
* order's references and the courier's phone are never read.
|
|
132
|
+
*/
|
|
133
|
+
export function parseCjPacket(payload, raw) {
|
|
134
|
+
const number = normalizeCjPacketNumber(raw);
|
|
135
|
+
if (!isRecord(payload) || typeof payload.success !== 'boolean')
|
|
136
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid envelope');
|
|
137
|
+
if (!payload.success)
|
|
138
|
+
throw new IndeterminateError(PROVIDER, 'CJ Packet did not complete the lookup');
|
|
139
|
+
const shipments = payload.data;
|
|
140
|
+
if (!Array.isArray(shipments) || shipments.length > MAX_SHIPMENTS)
|
|
141
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid shipment list');
|
|
142
|
+
// The page shows "No tracking info found." for an empty list.
|
|
143
|
+
if (!shipments.length)
|
|
144
|
+
throw new NotFoundError(PROVIDER);
|
|
145
|
+
const own = shipments.filter((shipment) => {
|
|
146
|
+
if (!isRecord(shipment) || typeof shipment.cjTrackNumber !== 'string') {
|
|
147
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned a shipment without its number');
|
|
148
|
+
}
|
|
149
|
+
return normalizeTrackingNumber(clean(shipment.cjTrackNumber, 64)).toUpperCase() === number;
|
|
150
|
+
});
|
|
151
|
+
if (!own.length)
|
|
152
|
+
throw new IndeterminateError(PROVIDER, 'CJ Packet answered for another shipment');
|
|
153
|
+
if (own.length > 1)
|
|
154
|
+
throw new IndeterminateError(PROVIDER, 'CJ Packet answered with several shipments for the number');
|
|
155
|
+
const shipment = own[0];
|
|
156
|
+
const rows = shipment.trackEvent ?? [];
|
|
157
|
+
if (!Array.isArray(rows))
|
|
158
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned a shipment with invalid scans');
|
|
159
|
+
if (rows.length > MAX_ROWS)
|
|
160
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an oversized history');
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
const events = [];
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
if (!isRecord(row))
|
|
165
|
+
throw new SchemaError(PROVIDER, 'CJ Packet returned an invalid scan');
|
|
166
|
+
const scan = projectRow(row);
|
|
167
|
+
if (!scan || seen.has(scan.key))
|
|
168
|
+
continue;
|
|
169
|
+
seen.add(scan.key);
|
|
170
|
+
events.push(scan.event);
|
|
171
|
+
}
|
|
172
|
+
const code = summaryCode(shipment);
|
|
173
|
+
const summary = code === undefined ? undefined : cjPacketSummary(code);
|
|
174
|
+
const service = text(shipment, 'logisticsName', 80);
|
|
175
|
+
const country = text(shipment, 'destCountryCode', 8).toUpperCase();
|
|
176
|
+
const facts = {
|
|
177
|
+
...(service ? { service_name: service } : {}),
|
|
178
|
+
...(/^[A-Z]{2}$/.test(country) ? { destination_country: country } : {}),
|
|
179
|
+
...handoff(shipment, number),
|
|
180
|
+
};
|
|
181
|
+
if (!events.length) {
|
|
182
|
+
// A shipment without scans keeps the status the tracker gives it.
|
|
183
|
+
if (!summary)
|
|
184
|
+
throw new NoHistoryError(PROVIDER);
|
|
185
|
+
return { status: languageStageStatus(summary.stage), current_stage: summary.stage, current_stage_source: 'carrier_map',
|
|
186
|
+
last_status_text: text(shipment, 'statusDescription', 80) || summary.wording, provider_code: String(code),
|
|
187
|
+
last_update: null, expected_delivery: null, ...facts, summary_only: true, events: [] };
|
|
188
|
+
}
|
|
189
|
+
// Newest first, as the page lists them by wall clock: scans at different
|
|
190
|
+
// offsets are not all instants, so the reply's order stands.
|
|
191
|
+
const newest = events[0];
|
|
192
|
+
const scanned = events.find((event) => event.stage);
|
|
193
|
+
const stage = cjPacketCurrentStage(summary, scanned?.stage);
|
|
194
|
+
const source = stage === scanned?.stage ? scanned?.stage_source : 'carrier_map';
|
|
195
|
+
// A delivery's moment is its delivered scan's, when that scan has an offset;
|
|
196
|
+
// the summary alone, or a wall clock, gives none.
|
|
197
|
+
const deliveredAt = stage === 'delivered' && scanned?.stage === 'delivered' ? scanned.time : undefined;
|
|
198
|
+
return {
|
|
199
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
200
|
+
...(stage ? { current_stage: stage, current_stage_source: source ?? 'carrier_map' } : {}),
|
|
201
|
+
last_status_text: newest.description,
|
|
202
|
+
last_update: newest.time ?? null,
|
|
203
|
+
...(newest.local_time ? { last_update_local: newest.local_time } : {}),
|
|
204
|
+
expected_delivery: null,
|
|
205
|
+
...(deliveredAt ? { delivered_at: deliveredAt } : {}),
|
|
206
|
+
...facts,
|
|
207
|
+
events: events.slice(0, MAX_EVENTS),
|
|
208
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** One of a shipment's summary statuses: the tracker's name for it and its stage. */
|
|
4
|
+
interface CjPacketSummary {
|
|
5
|
+
readonly wording: string;
|
|
6
|
+
readonly stage: Stage;
|
|
7
|
+
/** A summary that spans several legs: the newest scan says which, when it is no earlier. */
|
|
8
|
+
readonly scansDecide?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function cjPacketSummary(code: number): CjPacketSummary | undefined;
|
|
11
|
+
/** The shipment's stage: its summary, or the newest staged scan within a summary that spans several legs. */
|
|
12
|
+
export declare function cjPacketCurrentStage(summary: CjPacketSummary | undefined, scanned: Stage | undefined): Stage | undefined;
|
|
13
|
+
export declare function cjPacketMappedStage(wording: string): Stage | undefined;
|
|
14
|
+
/** A scan's stage: the map, else the shared wording rules, else nothing. */
|
|
15
|
+
export declare function cjPacketStage(wording: string): ClassifiedWording | null;
|
|
16
|
+
/** Scans carry no code; a summary status is the provider code of a shipment without scans. */
|
|
17
|
+
export declare const statusMap: CarrierStatusMap;
|
|
18
|
+
export {};
|