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.
- package/README.md +2 -2
- package/data/catalog.json +7 -3
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +31 -22
- package/dist/browser/scraper.js +10 -3
- package/dist/carriers/better-trucks/adapter.d.ts +17 -0
- package/dist/carriers/better-trucks/adapter.js +62 -0
- package/dist/carriers/better-trucks/carrier.json +51 -7
- package/dist/carriers/better-trucks/numbers.json +20 -1
- package/dist/carriers/better-trucks/parser.d.ts +12 -0
- package/dist/carriers/better-trucks/parser.js +211 -0
- package/dist/carriers/better-trucks/status.d.ts +9 -0
- package/dist/carriers/better-trucks/status.js +66 -0
- package/dist/carriers/better-trucks/statuses.json +27 -1
- package/dist/carriers/chronopost/status.d.ts +2 -0
- package/dist/carriers/chronopost/status.js +2 -0
- package/dist/carriers/chunil/adapter.d.ts +17 -0
- package/dist/carriers/chunil/adapter.js +54 -0
- package/dist/carriers/chunil/carrier.json +50 -7
- package/dist/carriers/chunil/numbers.json +5 -12
- package/dist/carriers/chunil/parser.d.ts +14 -0
- package/dist/carriers/chunil/parser.js +141 -0
- package/dist/carriers/chunil/status.d.ts +8 -0
- package/dist/carriers/chunil/status.js +39 -0
- package/dist/carriers/chunil/statuses.json +13 -7
- package/dist/carriers/geodis/adapter.js +2 -0
- package/dist/carriers/ilyang-logis/adapter.d.ts +15 -0
- package/dist/carriers/ilyang-logis/adapter.js +59 -0
- package/dist/carriers/ilyang-logis/carrier.json +56 -7
- package/dist/carriers/ilyang-logis/parser.d.ts +29 -0
- package/dist/carriers/ilyang-logis/parser.js +182 -0
- package/dist/carriers/ilyang-logis/status.d.ts +13 -0
- package/dist/carriers/ilyang-logis/status.js +43 -0
- package/dist/carriers/ilyang-logis/statuses.json +7 -0
- package/dist/carriers/paack/adapter.js +47 -16
- package/dist/carriers/paack/status.d.ts +3 -0
- package/dist/carriers/paack/status.js +15 -2
- package/dist/carriers/paack/statuses.json +16 -8
- package/dist/core/catalog/statusMaps.js +7 -3
- package/dist/data/catalog.json +7 -3
- package/dist/generated/catalog.d.ts +7 -3
- package/dist/generated/catalog.js +7 -3
- package/dist/generated/recognition.js +3 -0
- package/dist/generated/registry.js +9 -3
- package/dist/providers/status.d.ts +1 -12
- package/dist/providers/status.js +18 -1
- 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 **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,
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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}",
|
|
@@ -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",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -5961,7 +5961,8 @@ var Ue = {
|
|
|
5961
5961
|
timezone: "America/Chicago",
|
|
5962
5962
|
tracking: {
|
|
5963
5963
|
mode: "automatic",
|
|
5964
|
-
adapter: "
|
|
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: "
|
|
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: "
|
|
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": "
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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": "
|
|
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;
|