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.
Files changed (41) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +12 -4
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +32 -0
  5. package/dist/browser/scraper.js +15 -5
  6. package/dist/carriers/cj-packet/adapter.d.ts +15 -0
  7. package/dist/carriers/cj-packet/adapter.js +58 -0
  8. package/dist/carriers/cj-packet/carrier.json +60 -7
  9. package/dist/carriers/cj-packet/numbers.json +4 -4
  10. package/dist/carriers/cj-packet/parser.d.ts +18 -0
  11. package/dist/carriers/cj-packet/parser.js +210 -0
  12. package/dist/carriers/cj-packet/status.d.ts +18 -0
  13. package/dist/carriers/cj-packet/status.js +69 -0
  14. package/dist/carriers/cj-packet/statuses.json +72 -1
  15. package/dist/carriers/kex/adapter.d.ts +22 -0
  16. package/dist/carriers/kex/adapter.js +104 -0
  17. package/dist/carriers/kex/carrier.json +58 -7
  18. package/dist/carriers/kex/numbers.json +76 -0
  19. package/dist/carriers/kex/parser.d.ts +14 -0
  20. package/dist/carriers/kex/parser.js +178 -0
  21. package/dist/carriers/kex/status.d.ts +12 -0
  22. package/dist/carriers/kex/status.js +49 -0
  23. package/dist/carriers/kex/statuses.json +12 -1
  24. package/dist/carriers/sfc/adapter.d.ts +15 -0
  25. package/dist/carriers/sfc/adapter.js +64 -0
  26. package/dist/carriers/sfc/carrier.json +62 -7
  27. package/dist/carriers/sfc/numbers.json +19 -0
  28. package/dist/carriers/sfc/parser.d.ts +29 -0
  29. package/dist/carriers/sfc/parser.js +202 -0
  30. package/dist/carriers/sfc/status.d.ts +10 -0
  31. package/dist/carriers/sfc/status.js +71 -0
  32. package/dist/carriers/sfc/statuses.json +28 -7
  33. package/dist/carriers/steadfast/carrier.json +0 -1
  34. package/dist/core/catalog/statusMaps.js +6 -2
  35. package/dist/data/catalog.json +12 -4
  36. package/dist/generated/catalog.d.ts +11 -4
  37. package/dist/generated/catalog.js +13 -5
  38. package/dist/generated/recognition.js +3 -0
  39. package/dist/generated/registry.js +9 -3
  40. package/dist/server/openapi.json +1 -1
  41. 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: 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 **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,604 such statuses, each filed under one stage.
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": "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}",
@@ -16849,7 +16852,9 @@
16849
16852
  "timezone": "Asia/Shanghai",
16850
16853
  "tracking": {
16851
16854
  "mode": "automatic",
16852
- "adapter": "universal"
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
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 494,
3
- "activeAdapters": 260,
3
+ "activeAdapters": 263,
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",
@@ -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",
@@ -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}",
@@ -12829,7 +12832,9 @@ var Ae = {
12829
12832
  timezone: "Asia/Shanghai",
12830
12833
  tracking: {
12831
12834
  mode: "automatic",
12832
- adapter: "universal"
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": "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 {};