universal-parcel-scraper 0.487.0-main.2349 → 0.490.0-main.2360

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 (48) hide show
  1. package/README.md +2 -2
  2. package/data/catalog.json +7 -3
  3. package/data/coverage-summary.json +1 -1
  4. package/data/detection-golden.json +31 -22
  5. package/dist/browser/scraper.js +10 -3
  6. package/dist/carriers/better-trucks/adapter.d.ts +17 -0
  7. package/dist/carriers/better-trucks/adapter.js +62 -0
  8. package/dist/carriers/better-trucks/carrier.json +51 -7
  9. package/dist/carriers/better-trucks/numbers.json +20 -1
  10. package/dist/carriers/better-trucks/parser.d.ts +12 -0
  11. package/dist/carriers/better-trucks/parser.js +211 -0
  12. package/dist/carriers/better-trucks/status.d.ts +9 -0
  13. package/dist/carriers/better-trucks/status.js +66 -0
  14. package/dist/carriers/better-trucks/statuses.json +27 -1
  15. package/dist/carriers/chronopost/status.d.ts +2 -0
  16. package/dist/carriers/chronopost/status.js +2 -0
  17. package/dist/carriers/chunil/adapter.d.ts +17 -0
  18. package/dist/carriers/chunil/adapter.js +54 -0
  19. package/dist/carriers/chunil/carrier.json +50 -7
  20. package/dist/carriers/chunil/numbers.json +5 -12
  21. package/dist/carriers/chunil/parser.d.ts +14 -0
  22. package/dist/carriers/chunil/parser.js +141 -0
  23. package/dist/carriers/chunil/status.d.ts +8 -0
  24. package/dist/carriers/chunil/status.js +39 -0
  25. package/dist/carriers/chunil/statuses.json +13 -7
  26. package/dist/carriers/geodis/adapter.js +2 -0
  27. package/dist/carriers/ilyang-logis/adapter.d.ts +15 -0
  28. package/dist/carriers/ilyang-logis/adapter.js +59 -0
  29. package/dist/carriers/ilyang-logis/carrier.json +56 -7
  30. package/dist/carriers/ilyang-logis/parser.d.ts +29 -0
  31. package/dist/carriers/ilyang-logis/parser.js +182 -0
  32. package/dist/carriers/ilyang-logis/status.d.ts +13 -0
  33. package/dist/carriers/ilyang-logis/status.js +43 -0
  34. package/dist/carriers/ilyang-logis/statuses.json +7 -0
  35. package/dist/carriers/paack/adapter.js +47 -16
  36. package/dist/carriers/paack/status.d.ts +3 -0
  37. package/dist/carriers/paack/status.js +15 -2
  38. package/dist/carriers/paack/statuses.json +16 -8
  39. package/dist/core/catalog/statusMaps.js +7 -3
  40. package/dist/data/catalog.json +7 -3
  41. package/dist/generated/catalog.d.ts +7 -3
  42. package/dist/generated/catalog.js +7 -3
  43. package/dist/generated/recognition.js +3 -0
  44. package/dist/generated/registry.js +9 -3
  45. package/dist/providers/status.d.ts +1 -12
  46. package/dist/providers/status.js +18 -1
  47. package/dist/server/openapi.json +1 -1
  48. 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 **530 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **533 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>772 catalog entries · 134 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 15,354 such statuses, each filed under one stage.
158
+ The carrier folders record 15,394 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
@@ -7355,7 +7355,8 @@
7355
7355
  "timezone": "America/Chicago",
7356
7356
  "tracking": {
7357
7357
  "mode": "automatic",
7358
- "adapter": "universal"
7358
+ "adapter": "better-trucks",
7359
+ "recognitionRank": 738
7359
7360
  },
7360
7361
  "canaryUrl": "https://tracking.bettertrucks.com/",
7361
7362
  "trackingUrlTemplate": "https://tracking.bettertrucks.com/tracking?trackingNumber={trackingNumber}",
@@ -8915,7 +8916,9 @@
8915
8916
  "timezone": "Asia/Seoul",
8916
8917
  "tracking": {
8917
8918
  "mode": "automatic",
8918
- "adapter": "universal"
8919
+ "adapter": "chunil",
8920
+ "recognitionRank": 621,
8921
+ "localClocks": true
8919
8922
  },
8920
8923
  "canaryUrl": "https://www.chunil.co.kr/HTrace/HTrace.jsp",
8921
8924
  "trackingUrlTemplate": "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -16502,7 +16505,8 @@
16502
16505
  "timezone": "Asia/Seoul",
16503
16506
  "tracking": {
16504
16507
  "mode": "automatic",
16505
- "adapter": "universal"
16508
+ "adapter": "ilyang-logis",
16509
+ "recognitionRank": 703
16506
16510
  },
16507
16511
  "canaryUrl": "https://www.ilyanglogis.co.kr/page/Tracking.do",
16508
16512
  "trackingUrlTemplate": "https://www.ilyanglogis.co.kr/page/TrackingResult.do?trackingType=0&blNum={trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 772,
3
- "activeAdapters": 530,
3
+ "activeAdapters": 533,
4
4
  "countries": 134,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -9406,6 +9406,28 @@
9406
9406
  "j-and-t"
9407
9407
  ]
9408
9408
  },
9409
+ {
9410
+ "input": "10000000001",
9411
+ "carrier": "unknown",
9412
+ "confidence": "low",
9413
+ "candidates": [
9414
+ "postlogistics",
9415
+ "gls-ch",
9416
+ "gls-fr",
9417
+ "gls-de",
9418
+ "dhl-ecommerce-pl",
9419
+ "gls-at",
9420
+ "gls-pl",
9421
+ "ppl"
9422
+ ],
9423
+ "asked": [
9424
+ "gls-fr",
9425
+ "gls-de",
9426
+ "gls-ch",
9427
+ "postlogistics",
9428
+ "dhl-ecommerce-pl"
9429
+ ]
9430
+ },
9409
9431
  {
9410
9432
  "input": "1000000157123",
9411
9433
  "carrier": "unknown",
@@ -13483,28 +13505,6 @@
13483
13505
  "zto"
13484
13506
  ]
13485
13507
  },
13486
- {
13487
- "input": "11633265509",
13488
- "carrier": "unknown",
13489
- "confidence": "low",
13490
- "candidates": [
13491
- "postlogistics",
13492
- "gls-ch",
13493
- "gls-fr",
13494
- "gls-de",
13495
- "dhl-ecommerce-pl",
13496
- "gls-at",
13497
- "gls-pl",
13498
- "ppl"
13499
- ],
13500
- "asked": [
13501
- "gls-fr",
13502
- "gls-de",
13503
- "gls-ch",
13504
- "postlogistics",
13505
- "dhl-ecommerce-pl"
13506
- ]
13507
- },
13508
13508
  {
13509
13509
  "input": "116439049496",
13510
13510
  "carrier": "unknown",
@@ -64490,6 +64490,9 @@
64490
64490
  "confidence": "low",
64491
64491
  "candidates": [
64492
64492
  "better-trucks"
64493
+ ],
64494
+ "asked": [
64495
+ "better-trucks"
64493
64496
  ]
64494
64497
  },
64495
64498
  {
@@ -64498,6 +64501,12 @@
64498
64501
  "confidence": "none",
64499
64502
  "candidates": []
64500
64503
  },
64504
+ {
64505
+ "input": "BTS_0261SMTF8S",
64506
+ "carrier": "unknown",
64507
+ "confidence": "none",
64508
+ "candidates": []
64509
+ },
64501
64510
  {
64502
64511
  "input": "BTYCN0092384464YQ",
64503
64512
  "carrier": "anserx",
@@ -5961,7 +5961,8 @@ var Ue = {
5961
5961
  timezone: "America/Chicago",
5962
5962
  tracking: {
5963
5963
  mode: "automatic",
5964
- adapter: "universal"
5964
+ adapter: "better-trucks",
5965
+ recognitionRank: 738
5965
5966
  },
5966
5967
  canaryUrl: "https://tracking.bettertrucks.com/",
5967
5968
  trackingUrlTemplate: "https://tracking.bettertrucks.com/tracking?trackingNumber={trackingNumber}",
@@ -7075,7 +7076,9 @@ var Ue = {
7075
7076
  timezone: "Asia/Seoul",
7076
7077
  tracking: {
7077
7078
  mode: "automatic",
7078
- adapter: "universal"
7079
+ adapter: "chunil",
7080
+ recognitionRank: 621,
7081
+ localClocks: !0
7079
7082
  },
7080
7083
  canaryUrl: "https://www.chunil.co.kr/HTrace/HTrace.jsp",
7081
7084
  trackingUrlTemplate: "https://www.chunil.co.kr/HTrace/HTrace.jsp?transNo={trackingNumber}",
@@ -12668,7 +12671,8 @@ var Ue = {
12668
12671
  timezone: "Asia/Seoul",
12669
12672
  tracking: {
12670
12673
  mode: "automatic",
12671
- adapter: "universal"
12674
+ adapter: "ilyang-logis",
12675
+ recognitionRank: 703
12672
12676
  },
12673
12677
  canaryUrl: "https://www.ilyanglogis.co.kr/page/Tracking.do",
12674
12678
  trackingUrlTemplate: "https://www.ilyanglogis.co.kr/page/TrackingResult.do?trackingType=0&blNum={trackingNumber}",
@@ -29520,6 +29524,7 @@ var Tl = {
29520
29524
  "bangladesh-post": 909,
29521
29525
  "barid-al-maghrib": 901,
29522
29526
  belpost: 929,
29527
+ "better-trucks": 738,
29523
29528
  "bex-express": 827,
29524
29529
  "bjs-home-delivery": 844,
29525
29530
  "blue-express": 971,
@@ -29550,6 +29555,7 @@ var Tl = {
29550
29555
  "chit-chats": 115,
29551
29556
  chronodiali: 781,
29552
29557
  chronopost: 890,
29558
+ chunil: 621,
29553
29559
  ciblex: 640,
29554
29560
  "city-express-rs": 817,
29555
29561
  citymail: 868,
@@ -29676,6 +29682,7 @@ var Tl = {
29676
29682
  "hr-parcel": 887,
29677
29683
  "hrvatska-posta": 160,
29678
29684
  hywl: 993,
29685
+ "ilyang-logis": 703,
29679
29686
  imile: 997,
29680
29687
  "indah-logistik": 798,
29681
29688
  inpost: 820,
@@ -0,0 +1,17 @@
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 tracking.bettertrucks.com makes for one number. */
5
+ export declare const BETTER_TRUCKS_API = "https://tracking.bettertrucks.com/api/tracking/";
6
+ /** The lookup's address for one number, with the flag the page adds. */
7
+ export declare function betterTrucksLookupUrl(number: string): string;
8
+ export declare class BetterTrucksTracker {
9
+ private readonly options;
10
+ constructor(options?: {
11
+ fetcher?: typeof fetch;
12
+ recorder?: StepRecorder;
13
+ userAgent?: string;
14
+ });
15
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
16
+ }
17
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,62 @@
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 { BETTER_TRUCKS_MAX_BYTES, normalizeBetterTrucksNumber, parseBetterTrucks, PROVIDER } from './parser.js';
8
+ /** The lookup the tracking page on tracking.bettertrucks.com makes for one number. */
9
+ export const BETTER_TRUCKS_API = 'https://tracking.bettertrucks.com/api/tracking/';
10
+ const REDIRECTS = [301, 302, 303, 307, 308];
11
+ // Bot checks a CDN or firewall could put in place of the reply.
12
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*Just a moment|<title>\s*Attention Required|captcha/i;
13
+ /** The lookup's address for one number, with the flag the page adds. */
14
+ export function betterTrucksLookupUrl(number) {
15
+ return `${BETTER_TRUCKS_API}${encodeURIComponent(normalizeBetterTrucksNumber(number))}?isFromExternalTracking=true`;
16
+ }
17
+ function json(body) {
18
+ try {
19
+ return JSON.parse(body);
20
+ }
21
+ catch (cause) {
22
+ if (CHALLENGE.test(body.slice(0, 64_000)))
23
+ throw new ChallengeError(PROVIDER, 'Better Trucks answered with a browser check', { cause });
24
+ throw new SchemaError(PROVIDER, 'Better Trucks returned a reply that is not JSON', { cause });
25
+ }
26
+ }
27
+ export class BetterTrucksTracker {
28
+ options;
29
+ constructor(options = {}) {
30
+ this.options = options;
31
+ }
32
+ async fetch(raw, context = {}) {
33
+ const number = normalizeBetterTrucksNumber(raw);
34
+ const read = async (signal, timeoutMs) => {
35
+ const { response, bytes } = await fetchBounded(betterTrucksLookupUrl(number), { method: 'GET', signal, headers: {
36
+ Accept: 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
37
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: BETTER_TRUCKS_MAX_BYTES, redirect: 'manual',
38
+ fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
39
+ if (REDIRECTS.includes(response.status))
40
+ throw new TransportError(PROVIDER, 'Better Trucks redirected its tracking lookup');
41
+ // The page reads an unknown number from a 200 reply; a 404 is a moved route.
42
+ if (response.status === 404 || response.status === 410)
43
+ throw new TransportError(PROVIDER, 'Better Trucks tracking lookup is unavailable');
44
+ return parseBetterTrucks(json(decodeText(bytes)), number);
45
+ };
46
+ // A request that fails to reach the service, or hangs, gets one more try: the
47
+ // first gets half of the budget, so the retry fits in the rest.
48
+ return runSteps({ carrier: 'better-trucks', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
49
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
50
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
51
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
52
+ ]);
53
+ }
54
+ }
55
+ export const adapter = (environment) => {
56
+ const tracker = new BetterTrucksTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
57
+ return {
58
+ id: 'better-trucks', recordsSteps: true, steps: ['direct', 'retry'],
59
+ track: (input, context) => tracker.fetch(input.number, context),
60
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeBetterTrucksNumber(number))),
61
+ };
62
+ };
@@ -16,18 +16,62 @@
16
16
  "timezone": "America/Chicago",
17
17
  "tracking": {
18
18
  "mode": "automatic",
19
- "adapter": "universal",
20
- "steps": []
19
+ "adapter": "better-trucks",
20
+ "steps": [
21
+ "direct",
22
+ "retry"
23
+ ],
24
+ "recognition": {
25
+ "rank": 738
26
+ }
21
27
  },
22
- "capabilities": [],
28
+ "capabilities": [
29
+ "history",
30
+ "location",
31
+ "provider_code",
32
+ "delivered_at",
33
+ "destination_country",
34
+ "offset_timestamps"
35
+ ],
23
36
  "portal": {
24
37
  "url": "https://tracking.bettertrucks.com/tracking?trackingNumber={trackingNumber}",
25
38
  "searchUrl": "https://tracking.bettertrucks.com/",
26
39
  "canaryUrl": "https://tracking.bettertrucks.com/",
27
- "shows": [],
28
- "scraped": [],
29
- "discarded": [],
30
- "unavailable": []
40
+ "shows": [
41
+ "status",
42
+ "progress_rail",
43
+ "history",
44
+ "location",
45
+ "coordinates",
46
+ "delivered_at",
47
+ "proof_of_delivery",
48
+ "pickup_point",
49
+ "delivery_options"
50
+ ],
51
+ "scraped": [
52
+ "status",
53
+ "history",
54
+ "location",
55
+ "coordinates",
56
+ "provider_code",
57
+ "delivered_at",
58
+ "destination_country",
59
+ "offset_timestamps"
60
+ ],
61
+ "discarded": [
62
+ "progress_rail",
63
+ "proof_of_delivery",
64
+ "pickup_point",
65
+ "delivery_options"
66
+ ],
67
+ "unavailable": [
68
+ "eta",
69
+ "weight",
70
+ "service_name",
71
+ "sender_name",
72
+ "recipient_name",
73
+ "delivery_partner"
74
+ ]
31
75
  },
32
76
  "links": [
33
77
  {
@@ -12,7 +12,7 @@
12
12
  "access": "full_page"
13
13
  },
14
14
  "context": {
15
- "assessment": "candidate"
15
+ "assessment": "confirmed"
16
16
  },
17
17
  "expect": {
18
18
  "carrier": "unknown",
@@ -23,6 +23,25 @@
23
23
  },
24
24
  "note": "A PissedConsumer review of Better Trucks gives it for a parcel the tracker called delivered but that never arrived."
25
25
  },
26
+ {
27
+ "number": "BTS_0261SMTF8S",
28
+ "role": "shipment",
29
+ "evidence": "public_shipment_report",
30
+ "source": {
31
+ "url": "https://better-trucks.pissedconsumer.com/5/RT-P.html",
32
+ "date": "2025-04-10",
33
+ "dateKind": "publication",
34
+ "access": "full_page"
35
+ },
36
+ "context": {
37
+ "assessment": "candidate"
38
+ },
39
+ "expect": {
40
+ "carrier": "unknown",
41
+ "confidence": "none"
42
+ },
43
+ "note": "A PissedConsumer review of Better Trucks gives it as the tracking number of a TikTok Shop order Better Trucks marked delivered; the tracker no longer knows it."
44
+ },
26
45
  {
27
46
  "number": "BTS_0018P2LD557",
28
47
  "role": "shipment",
@@ -0,0 +1,12 @@
1
+ import { type CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Better Trucks";
3
+ export declare const BETTER_TRUCKS_MAX_BYTES: number;
4
+ /** A number for Better Trucks' tracker, in upper case; anything else, package codes included, is not one it is asked. */
5
+ export declare function normalizeBetterTrucksNumber(raw: string): string;
6
+ /**
7
+ * The reply of the tracker's `/api/tracking/{number}`. A number it does not
8
+ * know is HTTP 200 with the status `UNKNOWN` ("Invalid tracking no") and an
9
+ * empty history, which the page shows as "Shipment can't be found". The reply
10
+ * must name the number asked.
11
+ */
12
+ export declare function parseBetterTrucks(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,211 @@
1
+ import { DateTime, FixedOffsetZone } from 'luxon';
2
+ import { normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { eventPoint } from '../../core/result/index.js';
5
+ import { languageStageStatus } from '../../core/status/index.js';
6
+ import { calendarDay } from '../../core/time/index.js';
7
+ import { clean } from '../../core/transport/index.js';
8
+ import { isRecord } from '../../core/types.js';
9
+ import { betterTrucksStage, isHubScan } from './status.js';
10
+ export const PROVIDER = 'Better Trucks';
11
+ // A history of a dozen scans is about ten kilobytes; its delivery photo links are most of it.
12
+ export const BETTER_TRUCKS_MAX_BYTES = 512 * 1024;
13
+ // Letters, digits and underscores with at least one digit, as BTPA… and BTS_… numbers are written.
14
+ const NUMBER = /^(?=[A-Z_]*\d)[A-Z0-9_]{10,40}$/;
15
+ // The tracker's package codes: five characters from a 32-symbol alphabet, or
16
+ // BTS and a code without the underscore. The page swaps them for the tracking
17
+ // number they stand for, which lets a made-up code open a stranger's parcel.
18
+ const PACKAGE_CODE = /^(?:[2-9A-HJ-NP-Z]{5}|BTS[^_].*)$/;
19
+ // A scan's clock as the reply writes it: to the second, without an offset; one with an offset is read in it.
20
+ const CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})?$/;
21
+ // The zone the reply gives a scan's local reading, as GMT-05:00.
22
+ const GMT_OFFSET = /^GMT([+-])(\d{2}):(\d{2})$/;
23
+ // Rows the page leaves out of its history: a duplicate of the label's
24
+ // creation and a failure inside the dispatch system.
25
+ const HIDDEN = new Set(['TRANSACTION_CREATED', 'ONFLEET_FAILED']);
26
+ const MAX_ROWS = 500;
27
+ const MAX_EVENTS = 100;
28
+ /** A number for Better Trucks' tracker, in upper case; anything else, package codes included, is not one it is asked. */
29
+ export function normalizeBetterTrucksNumber(raw) {
30
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
31
+ if (PACKAGE_CODE.test(number))
32
+ throw new InvalidInputError(PROVIDER, 'Better Trucks package codes are not tracking numbers');
33
+ if (!NUMBER.test(number))
34
+ throw new InvalidInputError(PROVIDER, 'Better Trucks numbers are ten or more letters, digits and underscores');
35
+ return number;
36
+ }
37
+ function invalid(message) {
38
+ throw new SchemaError(PROVIDER, message);
39
+ }
40
+ /** An optional text field; anything but a string is a changed reply. */
41
+ function text(row, key, maxLength = 200) {
42
+ const value = row[key];
43
+ if (value === null || value === undefined)
44
+ return '';
45
+ if (typeof value !== 'string')
46
+ invalid(`Better Trucks returned an invalid ${key}`);
47
+ return clean(value, maxLength);
48
+ }
49
+ /** An optional record: absent is empty, anything but an object is a changed reply. */
50
+ function record(row, key) {
51
+ const value = row[key];
52
+ if (value === null || value === undefined)
53
+ return {};
54
+ if (!isRecord(value))
55
+ invalid(`Better Trucks returned an invalid ${key}`);
56
+ return value;
57
+ }
58
+ /** A clock as an instant in UTC, its digits read as UTC without an offset; null when it is not a clock. Date libraries accept hour 24, so the fields are checked here. */
59
+ function utcClock(value) {
60
+ const match = CLOCK.exec(value);
61
+ if (!match)
62
+ return null;
63
+ const [, year, month, date, hour, minute, second] = match;
64
+ if (!calendarDay(Number(year), Number(month), Number(date)) || Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59)
65
+ return null;
66
+ const parsed = DateTime.fromISO(value, { zone: 'UTC' });
67
+ return parsed.isValid ? parsed.startOf('second') : null;
68
+ }
69
+ /**
70
+ * The offset of the scan's local reading in `relative_time`, in minutes, when
71
+ * that reading is the same instant as `object_created`; else null.
72
+ */
73
+ function localOffset(row, utc) {
74
+ const readings = row.relative_time;
75
+ if (readings === null || readings === undefined)
76
+ return null;
77
+ if (!Array.isArray(readings))
78
+ invalid('Better Trucks returned an invalid relative_time');
79
+ const local = readings.find((reading) => isRecord(reading) && reading.name === 'LOCAL_TIME');
80
+ if (!isRecord(local) || typeof local.time !== 'string' || typeof local.timezone !== 'string')
81
+ return null;
82
+ const zone = GMT_OFFSET.exec(local.timezone.trim());
83
+ const wall = utcClock(local.time.trim());
84
+ if (!zone || !wall || Number(zone[2]) > 14 || Number(zone[3]) > 59)
85
+ return null;
86
+ const offset = (zone[1] === '-' ? -1 : 1) * (Number(zone[2]) * 60 + Number(zone[3]));
87
+ return wall.toMillis() - offset * 60_000 === utc.toMillis() ? offset : null;
88
+ }
89
+ /**
90
+ * A scan's instant. `object_created` is UTC without its offset: the page reads
91
+ * it as UTC, and `relative_time` labels the same digits Greenwich Mean Time.
92
+ * The instant is written in the offset of the scan's local reading when that
93
+ * reading agrees with it. The reply gives GMT+00:00 there when it has no
94
+ * position for the scan, which names no US clock, so that scan stays in UTC.
95
+ */
96
+ function scanTime(row) {
97
+ const utc = utcClock(text(row, 'object_created', 40));
98
+ if (!utc)
99
+ invalid('Better Trucks returned an invalid scan time');
100
+ const offset = localOffset(row, utc);
101
+ const shown = offset ? utc.setZone(FixedOffsetZone.instance(offset)) : utc;
102
+ return shown.toISO({ suppressMilliseconds: true });
103
+ }
104
+ /** A scan's place: its town and state, and its country outside the US. */
105
+ function placeOf(row) {
106
+ const place = record(row, 'location');
107
+ const town = text(place, 'city', 80);
108
+ const state = text(place, 'state', 40);
109
+ const country = text(place, 'country', 40);
110
+ const parts = [town, state, country && country.toUpperCase() !== 'US' ? country : ''].filter(Boolean);
111
+ return parts.join(', ');
112
+ }
113
+ /** The wording the page shows for a scan: the reply's own, or the page's label for a code it is given as. */
114
+ function wordingOf(code, details) {
115
+ if (details.toUpperCase() === 'RTS_RETURNED_TO_STATION')
116
+ return 'Return To Station';
117
+ return details || code.toLowerCase().split('_').join(' ').replace(/^\w/, (letter) => letter.toUpperCase());
118
+ }
119
+ /**
120
+ * One history row: its `status` code, `status_details`, clock and place. Its
121
+ * `event_details` (who took the parcel, where it was left, why a delivery
122
+ * failed), `images` (delivery photos) and `location_info` are not read. Only a
123
+ * hub scan keeps its coordinates: the others can be the shipper's or the
124
+ * recipient's door.
125
+ */
126
+ function projectRow(row) {
127
+ const code = text(row, 'status', 80).toUpperCase();
128
+ if (!code)
129
+ invalid('Better Trucks returned a scan without its status');
130
+ if (HIDDEN.has(code))
131
+ return null;
132
+ const description = wordingOf(code, text(row, 'status_details', 200));
133
+ const time = scanTime(row);
134
+ const location = placeOf(row);
135
+ const point = location && isHubScan(code) ? eventPoint(row.latitude, row.longitude) : null;
136
+ const staged = betterTrucksStage(code, description);
137
+ return {
138
+ time, ...(location ? { location } : {}), ...(point ? { point } : {}), description, provider_code: code,
139
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
140
+ };
141
+ }
142
+ /**
143
+ * The scan that sets the status: the newest with a stage. A corrected address
144
+ * ends the wait for its confirmation, so the wait before it no longer counts,
145
+ * as the page shows the scan before both instead.
146
+ */
147
+ function statusScan(events) {
148
+ let corrected = false;
149
+ for (const event of events) {
150
+ if (event.provider_code === 'ADDRESS_CORRECTED')
151
+ corrected = true;
152
+ else if (event.stage && !(corrected && event.provider_code === 'AWAITING_ADDRESS_CONFIRMATION'))
153
+ return event;
154
+ }
155
+ return undefined;
156
+ }
157
+ /**
158
+ * The reply of the tracker's `/api/tracking/{number}`. A number it does not
159
+ * know is HTTP 200 with the status `UNKNOWN` ("Invalid tracking no") and an
160
+ * empty history, which the page shows as "Shipment can't be found". The reply
161
+ * must name the number asked.
162
+ */
163
+ export function parseBetterTrucks(payload, raw) {
164
+ const number = normalizeBetterTrucksNumber(raw);
165
+ if (!isRecord(payload) || typeof payload.tracking_number !== 'string' || !Array.isArray(payload.tracking_history)) {
166
+ invalid('Better Trucks returned an invalid tracking reply');
167
+ }
168
+ if (normalizeTrackingNumber(clean(payload.tracking_number, 64)) !== number) {
169
+ throw new IndeterminateError(PROVIDER, 'Better Trucks answered for another parcel');
170
+ }
171
+ const rows = payload.tracking_history;
172
+ if (rows.length > MAX_ROWS)
173
+ invalid('Better Trucks returned an oversized history');
174
+ if (!rows.length) {
175
+ if (text(record(payload, 'tracking_status'), 'status', 40).toUpperCase() === 'UNKNOWN')
176
+ throw new NotFoundError(PROVIDER);
177
+ throw new NoHistoryError(PROVIDER);
178
+ }
179
+ const seen = new Set();
180
+ const events = [];
181
+ for (const row of rows) {
182
+ if (!isRecord(row))
183
+ invalid('Better Trucks returned an invalid scan');
184
+ const event = projectRow(row);
185
+ if (!event)
186
+ continue;
187
+ const key = JSON.stringify([event.time, event.provider_code, event.description, event.location]);
188
+ if (seen.has(key))
189
+ continue;
190
+ seen.add(key);
191
+ events.push(event);
192
+ }
193
+ if (!events.length)
194
+ throw new NoHistoryError(PROVIDER);
195
+ // Every scan is an instant; newest first by the clock, the order stays stable.
196
+ events.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
197
+ const newest = events[0];
198
+ const scan = statusScan(events);
199
+ const stage = scan?.stage;
200
+ const country = text(record(payload, 'address_to'), 'country', 8).toUpperCase();
201
+ return {
202
+ status: stage ? languageStageStatus(stage) : 'unknown',
203
+ ...(stage ? { current_stage: stage, current_stage_source: scan.stage_source } : {}),
204
+ last_status_text: newest.description,
205
+ last_update: newest.time,
206
+ ...(stage === 'delivered' ? { delivered_at: scan.time } : {}),
207
+ ...(/^[A-Z]{2}$/.test(country) ? { destination_country: country } : {}),
208
+ events: events.slice(0, MAX_EVENTS),
209
+ ...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
210
+ };
211
+ }
@@ -0,0 +1,9 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import type { CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** Whether the code is a hub scan, whose point is the hub. */
4
+ export declare function isHubScan(code: string): boolean;
5
+ /** The stage Better Trucks' codes give a scan code. */
6
+ export declare function betterTrucksMappedStage(code: string): Stage | undefined;
7
+ /** A scan's stage: its code, else the shared wording rules, else nothing. */
8
+ export declare function betterTrucksStage(code: string, wording: string): ClassifiedWording | null;
9
+ export declare const statusMap: CarrierStatusMap;