universal-parcel-scraper 0.183.0-main.1321 → 0.184.0-main.1324

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 **260 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **262 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,604 such statuses, each filed under one stage.
158
+ The carrier folders record 9,686 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": "universal"
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": "universal"
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}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 494,
3
- "activeAdapters": 260,
3
+ "activeAdapters": 262,
4
4
  "countries": 113,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -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",
@@ -5824,7 +5824,9 @@ var Ae = {
5824
5824
  timezone: "Asia/Shanghai",
5825
5825
  tracking: {
5826
5826
  mode: "automatic",
5827
- adapter: "universal"
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: "universal"
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}",
@@ -21048,6 +21051,7 @@ var nl = {
21048
21051
  chronopost: 890,
21049
21052
  ciblex: 640,
21050
21053
  "cj-logistics": 977,
21054
+ "cj-packet": 956,
21051
21055
  "colis-prive": 740,
21052
21056
  coordinadora: 973,
21053
21057
  "correo-argentino": 978,
@@ -21111,6 +21115,7 @@ var nl = {
21111
21115
  "j-and-t": 530,
21112
21116
  "j-and-t-cargo": 910,
21113
21117
  jitsu: 85,
21118
+ kex: 959,
21114
21119
  "kuehne-nagel": 435,
21115
21120
  "la-poste": 1e3,
21116
21121
  "latvijas-pasts": 100,
@@ -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": "universal",
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
- "scraped": [],
31
- "discarded": [],
32
- "unavailable": []
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": "candidate"
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; the capture was not opened, and the page loads its history by script."
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": "candidate"
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; the capture was not opened, and the page loads its history by script."
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 {};
@@ -0,0 +1,69 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The shipment's `status`, as the tracker's tabs name and describe it.
5
+ * Dispatched is the parcel leaving CJ's warehouse with the line; En Route
6
+ * covers the export airport, the flight and the destination's airport;
7
+ * Arrived Courier Facility covers the destination's sorting centre and its
8
+ * customs clearance. Unsuccessful Delivery is a parcel the courier could not
9
+ * deliver for a wrong address or recipient.
10
+ */
11
+ const SUMMARIES = new Map([
12
+ [0, { wording: 'Processing', stage: 'registered', scansDecide: true }],
13
+ [1, { wording: 'Dispatched', stage: 'accepted', scansDecide: true }],
14
+ [2, { wording: 'En Route', stage: 'in_transit', scansDecide: true }],
15
+ [3, { wording: 'Arrived Courier Facility', stage: 'in_transit', scansDecide: true }],
16
+ [4, { wording: 'Out for Delivery', stage: 'out_for_delivery' }],
17
+ [5, { wording: 'Available for Pickup', stage: 'ready_for_pickup' }],
18
+ [6, { wording: 'Unsuccessful Delivery', stage: 'exception' }],
19
+ [7, { wording: 'Delivered', stage: 'delivered' }],
20
+ ]);
21
+ export function cjPacketSummary(code) {
22
+ return SUMMARIES.get(code);
23
+ }
24
+ // The stages a summary that spans several legs leaves to the scans, in order.
25
+ const LEGS = ['registered', 'accepted', 'in_transit', 'customs'];
26
+ /** The shipment's stage: its summary, or the newest staged scan within a summary that spans several legs. */
27
+ export function cjPacketCurrentStage(summary, scanned) {
28
+ if (!summary)
29
+ return scanned;
30
+ if (summary.scansDecide && scanned && LEGS.indexOf(scanned) >= LEGS.indexOf(summary.stage))
31
+ return scanned;
32
+ return summary.stage;
33
+ }
34
+ /**
35
+ * Scans carry wording only: CJ's own label line, then the lines of the
36
+ * forwarder and of the destination's courier, relayed as each words them.
37
+ * The ones the shared rules leave without a stage are mapped here.
38
+ */
39
+ const WORDINGS = new Map([
40
+ ['label created. warehouse is processing this order. please wait in patience.', 'registered'],
41
+ ['shipment info received', 'registered'],
42
+ // The line's pickup at CJ's warehouse.
43
+ ['item has been collected', 'accepted'],
44
+ ['the flight has taken off from its origin', 'in_transit'],
45
+ ['item flight landing', 'in_transit'],
46
+ ['prepared for last mile handover', 'in_transit'],
47
+ ['delivery to local courier', 'in_transit'],
48
+ ]);
49
+ // Notices that move nothing.
50
+ const NOTICES = new Set(['expected delivery date updated']);
51
+ export function cjPacketMappedStage(wording) {
52
+ return WORDINGS.get(normalizeStatusWording(wording));
53
+ }
54
+ /** A scan's stage: the map, else the shared wording rules, else nothing. */
55
+ export function cjPacketStage(wording) {
56
+ const mapped = cjPacketMappedStage(wording);
57
+ if (mapped)
58
+ return { stage: mapped, source: 'carrier_map' };
59
+ if (NOTICES.has(normalizeStatusWording(wording)))
60
+ return null;
61
+ const worded = classifyWording(wording, 'in_transit');
62
+ return worded.source === 'none' ? null : worded;
63
+ }
64
+ /** Scans carry no code; a summary status is the provider code of a shipment without scans. */
65
+ export const statusMap = {
66
+ stage: (code, wording) => code === null ? cjPacketMappedStage(wording)
67
+ : /^\d$/.test(code) ? SUMMARIES.get(Number(code))?.stage : undefined,
68
+ gaps: [...NOTICES].map((wording) => ({ wording, note: 'A new delivery estimate from the destination courier; it moves nothing.' })),
69
+ };