universal-parcel-scraper 0.491.0-main.2362 → 0.495.1
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 +11 -5
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +19 -0
- package/dist/browser/scraper.js +12 -5
- package/dist/carriers/chunghwa-post/adapter.js +2 -2
- package/dist/carriers/chunghwa-post/parser.d.ts +1 -0
- package/dist/carriers/chunghwa-post/parser.js +2 -1
- package/dist/carriers/fastfrate/adapter.d.ts +17 -0
- package/dist/carriers/fastfrate/adapter.js +61 -0
- package/dist/carriers/fastfrate/carrier.json +49 -7
- package/dist/carriers/fastfrate/parser.d.ts +13 -0
- package/dist/carriers/fastfrate/parser.js +150 -0
- package/dist/carriers/fastfrate/status.d.ts +11 -0
- package/dist/carriers/fastfrate/status.js +50 -0
- package/dist/carriers/fastfrate/statuses.json +16 -1
- package/dist/carriers/huahan/adapter.d.ts +14 -0
- package/dist/carriers/huahan/adapter.js +89 -0
- package/dist/carriers/huahan/carrier.json +30 -7
- package/dist/carriers/huahan/parser.d.ts +5 -0
- package/dist/carriers/huahan/parser.js +79 -0
- package/dist/carriers/huahan/status.d.ts +4 -0
- package/dist/carriers/huahan/status.js +14 -0
- package/dist/carriers/midland-transport/adapter.d.ts +18 -0
- package/dist/carriers/midland-transport/adapter.js +52 -0
- package/dist/carriers/midland-transport/carrier.json +56 -7
- package/dist/carriers/midland-transport/parser.d.ts +16 -0
- package/dist/carriers/midland-transport/parser.js +210 -0
- package/dist/carriers/midland-transport/status.d.ts +7 -0
- package/dist/carriers/midland-transport/status.js +33 -0
- package/dist/carriers/midland-transport/statuses.json +8 -12
- package/dist/carriers/pbt/adapter.d.ts +16 -0
- package/dist/carriers/pbt/adapter.js +49 -0
- package/dist/carriers/pbt/carrier.json +50 -7
- package/dist/carriers/pbt/parser.d.ts +23 -0
- package/dist/carriers/pbt/parser.js +228 -0
- package/dist/carriers/pbt/status.d.ts +8 -0
- package/dist/carriers/pbt/status.js +37 -0
- package/dist/carriers/straightship/status.js +2 -0
- package/dist/carriers/straightship/statuses.json +1 -0
- package/dist/carriers/tst-cf-express/adapter.d.ts +13 -0
- package/dist/carriers/tst-cf-express/adapter.js +107 -0
- package/dist/carriers/tst-cf-express/carrier.json +57 -7
- package/dist/carriers/tst-cf-express/numbers.json +28 -0
- package/dist/carriers/tst-cf-express/parser.d.ts +29 -0
- package/dist/carriers/tst-cf-express/parser.js +271 -0
- package/dist/carriers/tst-cf-express/status.d.ts +11 -0
- package/dist/carriers/tst-cf-express/status.js +42 -0
- package/dist/carriers/tst-cf-express/statuses.json +2 -0
- package/dist/core/catalog/statusMaps.js +15 -9
- package/dist/data/catalog.json +11 -5
- package/dist/generated/catalog.d.ts +11 -5
- package/dist/generated/catalog.js +11 -5
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +15 -5
- package/dist/providers/parcelsapp/adapter.js +4 -2
- 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 **539 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,411 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
|
@@ -13516,7 +13516,8 @@
|
|
|
13516
13516
|
"timezone": "America/Toronto",
|
|
13517
13517
|
"tracking": {
|
|
13518
13518
|
"mode": "automatic",
|
|
13519
|
-
"adapter": "
|
|
13519
|
+
"adapter": "fastfrate",
|
|
13520
|
+
"localClocks": true
|
|
13520
13521
|
},
|
|
13521
13522
|
"canaryUrl": "https://tracker.fastfrate.com/quick-track",
|
|
13522
13523
|
"trackingUrlTemplate": "https://apps.fastfrate.com/fastnet/FastNet.aspx?ProbillNo={trackingNumber}",
|
|
@@ -16198,7 +16199,8 @@
|
|
|
16198
16199
|
"timezone": "Asia/Shanghai",
|
|
16199
16200
|
"tracking": {
|
|
16200
16201
|
"mode": "automatic",
|
|
16201
|
-
"adapter": "
|
|
16202
|
+
"adapter": "huahan",
|
|
16203
|
+
"localClocks": true
|
|
16202
16204
|
},
|
|
16203
16205
|
"canaryUrl": "https://www.hh-exp.cn/WebTrack/CargoTrack.html",
|
|
16204
16206
|
"trackingSearchUrl": "https://www.hh-exp.cn/WebTrack/CargoTrack.html",
|
|
@@ -20161,7 +20163,8 @@
|
|
|
20161
20163
|
"timezone": "America/Moncton",
|
|
20162
20164
|
"tracking": {
|
|
20163
20165
|
"mode": "automatic",
|
|
20164
|
-
"adapter": "
|
|
20166
|
+
"adapter": "midland-transport",
|
|
20167
|
+
"localClocks": true
|
|
20165
20168
|
},
|
|
20166
20169
|
"canaryUrl": "https://ship.midlandtransport.com/Tracking/TrackClientTrackings",
|
|
20167
20170
|
"trackingUrlTemplate": "https://ship.midlandtransport.com/Tracking/TrackClientTrackings?TrackingNumber={trackingNumber}&Lang=0",
|
|
@@ -22437,7 +22440,9 @@
|
|
|
22437
22440
|
"timezone": "Pacific/Auckland",
|
|
22438
22441
|
"tracking": {
|
|
22439
22442
|
"mode": "automatic",
|
|
22440
|
-
"adapter": "
|
|
22443
|
+
"adapter": "pbt",
|
|
22444
|
+
"recognitionRank": 623,
|
|
22445
|
+
"localClocks": true
|
|
22441
22446
|
},
|
|
22442
22447
|
"canaryUrl": "https://www.pbt.nz/",
|
|
22443
22448
|
"trackingUrlTemplate": "https://www.pbt.nz/tracking-result/?consignmentNumber={trackingNumber}",
|
|
@@ -29088,7 +29093,8 @@
|
|
|
29088
29093
|
"timezone": "America/Toronto",
|
|
29089
29094
|
"tracking": {
|
|
29090
29095
|
"mode": "automatic",
|
|
29091
|
-
"adapter": "
|
|
29096
|
+
"adapter": "tst-cf-express",
|
|
29097
|
+
"localClocks": true
|
|
29092
29098
|
},
|
|
29093
29099
|
"canaryUrl": "https://m.tst-cfexpress.com/tracing",
|
|
29094
29100
|
"trackingUrlTemplate": "https://www.tst-cfexpress.com/tracing/by-url?lang=e&PRO={trackingNumber}",
|
|
@@ -50363,6 +50363,25 @@
|
|
|
50363
50363
|
"dhl-ecommerce-pl"
|
|
50364
50364
|
]
|
|
50365
50365
|
},
|
|
50366
|
+
{
|
|
50367
|
+
"input": "7011234567",
|
|
50368
|
+
"carrier": "unknown",
|
|
50369
|
+
"confidence": "low",
|
|
50370
|
+
"candidates": [
|
|
50371
|
+
"dhl",
|
|
50372
|
+
"mondial-relay",
|
|
50373
|
+
"relais-colis",
|
|
50374
|
+
"tipsa",
|
|
50375
|
+
"estafeta",
|
|
50376
|
+
"packeta",
|
|
50377
|
+
"dhl-ecommerce-es"
|
|
50378
|
+
],
|
|
50379
|
+
"asked": [
|
|
50380
|
+
"relais-colis",
|
|
50381
|
+
"packeta",
|
|
50382
|
+
"dhl-ecommerce-es"
|
|
50383
|
+
]
|
|
50384
|
+
},
|
|
50366
50385
|
{
|
|
50367
50386
|
"input": "701327991",
|
|
50368
50387
|
"carrier": "unknown",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -10452,7 +10452,8 @@ var Ue = {
|
|
|
10452
10452
|
timezone: "America/Toronto",
|
|
10453
10453
|
tracking: {
|
|
10454
10454
|
mode: "automatic",
|
|
10455
|
-
adapter: "
|
|
10455
|
+
adapter: "fastfrate",
|
|
10456
|
+
localClocks: !0
|
|
10456
10457
|
},
|
|
10457
10458
|
canaryUrl: "https://tracker.fastfrate.com/quick-track",
|
|
10458
10459
|
trackingUrlTemplate: "https://apps.fastfrate.com/fastnet/FastNet.aspx?ProbillNo={trackingNumber}",
|
|
@@ -12444,7 +12445,8 @@ var Ue = {
|
|
|
12444
12445
|
timezone: "Asia/Shanghai",
|
|
12445
12446
|
tracking: {
|
|
12446
12447
|
mode: "automatic",
|
|
12447
|
-
adapter: "
|
|
12448
|
+
adapter: "huahan",
|
|
12449
|
+
localClocks: !0
|
|
12448
12450
|
},
|
|
12449
12451
|
canaryUrl: "https://www.hh-exp.cn/WebTrack/CargoTrack.html",
|
|
12450
12452
|
trackingSearchUrl: "https://www.hh-exp.cn/WebTrack/CargoTrack.html",
|
|
@@ -15362,7 +15364,8 @@ var Ue = {
|
|
|
15362
15364
|
timezone: "America/Moncton",
|
|
15363
15365
|
tracking: {
|
|
15364
15366
|
mode: "automatic",
|
|
15365
|
-
adapter: "
|
|
15367
|
+
adapter: "midland-transport",
|
|
15368
|
+
localClocks: !0
|
|
15366
15369
|
},
|
|
15367
15370
|
canaryUrl: "https://ship.midlandtransport.com/Tracking/TrackClientTrackings",
|
|
15368
15371
|
trackingUrlTemplate: "https://ship.midlandtransport.com/Tracking/TrackClientTrackings?TrackingNumber={trackingNumber}&Lang=0",
|
|
@@ -17044,7 +17047,9 @@ var Ue = {
|
|
|
17044
17047
|
timezone: "Pacific/Auckland",
|
|
17045
17048
|
tracking: {
|
|
17046
17049
|
mode: "automatic",
|
|
17047
|
-
adapter: "
|
|
17050
|
+
adapter: "pbt",
|
|
17051
|
+
recognitionRank: 623,
|
|
17052
|
+
localClocks: !0
|
|
17048
17053
|
},
|
|
17049
17054
|
canaryUrl: "https://www.pbt.nz/",
|
|
17050
17055
|
trackingUrlTemplate: "https://www.pbt.nz/tracking-result/?consignmentNumber={trackingNumber}",
|
|
@@ -21908,7 +21913,8 @@ var Ue = {
|
|
|
21908
21913
|
timezone: "America/Toronto",
|
|
21909
21914
|
tracking: {
|
|
21910
21915
|
mode: "automatic",
|
|
21911
|
-
adapter: "
|
|
21916
|
+
adapter: "tst-cf-express",
|
|
21917
|
+
localClocks: !0
|
|
21912
21918
|
},
|
|
21913
21919
|
canaryUrl: "https://m.tst-cfexpress.com/tracing",
|
|
21914
21920
|
trackingUrlTemplate: "https://www.tst-cfexpress.com/tracing/by-url?lang=e&PRO={trackingNumber}",
|
|
@@ -29762,6 +29768,7 @@ var Tl = {
|
|
|
29762
29768
|
paquetexpress: 35,
|
|
29763
29769
|
passport: 55,
|
|
29764
29770
|
"pathao-courier": 945,
|
|
29771
|
+
pbt: 623,
|
|
29765
29772
|
"pcp-express": 709,
|
|
29766
29773
|
"pdn-express": 838,
|
|
29767
29774
|
"pfc-express": 856,
|
|
@@ -7,7 +7,7 @@ import { runSteps } from '../../core/runner/index.js';
|
|
|
7
7
|
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
8
8
|
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
9
9
|
import { ChunghwaPostOcr } from './ocr.js';
|
|
10
|
-
import { isChunghwaCaptchaFailure, normalizeChunghwaPostNumber, parseChunghwaPost, PROVIDER } from './parser.js';
|
|
10
|
+
import { isChunghwaCaptchaFailure, isSupportedChunghwaPostNumber, normalizeChunghwaPostNumber, parseChunghwaPost, PROVIDER } from './parser.js';
|
|
11
11
|
export { normalizeChunghwaPostNumber, parseChunghwaPost } from './parser.js';
|
|
12
12
|
const BASE = 'https://postserv.post.gov.tw/pstmail/';
|
|
13
13
|
export class ChunghwaPostTracker {
|
|
@@ -21,7 +21,7 @@ export class ChunghwaPostTracker {
|
|
|
21
21
|
// Domestic, proprietary and other S10 services' references are refused before any request:
|
|
22
22
|
// the international gateway cannot read them, and enabled universal providers follow them.
|
|
23
23
|
if (/^(?:\d{14}|\d{20}|(?:PB|FT|FZ)[A-Z0-9]{11})$/.test(normalized)
|
|
24
|
-
|| /^[A-Z]{2}\d{9}[A-Z]{2}$/.test(normalized) &&
|
|
24
|
+
|| /^[A-Z]{2}\d{9}[A-Z]{2}$/.test(normalized) && !isSupportedChunghwaPostNumber(normalized)) {
|
|
25
25
|
throw new InvalidInputError(PROVIDER, 'This Chunghwa Post reference uses another tracking service');
|
|
26
26
|
}
|
|
27
27
|
const number = normalizeChunghwaPostNumber(raw);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
2
|
export declare const PROVIDER = "Chunghwa Post";
|
|
3
|
+
export declare const isSupportedChunghwaPostNumber: (number: string) => boolean;
|
|
3
4
|
export declare function normalizeChunghwaPostNumber(raw: string): string;
|
|
4
5
|
export declare function isChunghwaCaptchaFailure(payload: unknown): boolean;
|
|
5
6
|
export declare function parseChunghwaPost(payload: unknown, raw: string): CarrierResult;
|
|
@@ -6,9 +6,10 @@ import { clean } from '../../core/transport/text.js';
|
|
|
6
6
|
import { isRecord } from '../../core/types.js';
|
|
7
7
|
import { chunghwaPostStage } from './status.js';
|
|
8
8
|
export const PROVIDER = 'Chunghwa Post';
|
|
9
|
+
export const isSupportedChunghwaPostNumber = (number) => /^(?:[CELR][A-Z]\d{9}[A-Z]{2}|UH\d{9}TW)$/.test(number);
|
|
9
10
|
export function normalizeChunghwaPostNumber(raw) {
|
|
10
11
|
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
11
|
-
if (
|
|
12
|
+
if (!isSupportedChunghwaPostNumber(number)) {
|
|
12
13
|
throw new InvalidInputError(PROVIDER, 'Chunghwa Post requires an international postal S10 number');
|
|
13
14
|
}
|
|
14
15
|
return number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AdapterFactory, 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 tracker's quick track page makes for a probill, without an account. */
|
|
5
|
+
export declare const FASTFRATE_API = "https://tracker.fastfrate.com/trackerapi/shipmentinformation/quicktrack";
|
|
6
|
+
/** The lookup's address for one probill. */
|
|
7
|
+
export declare function fastfrateLookupUrl(number: string): string;
|
|
8
|
+
export declare class FastfrateTracker {
|
|
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,61 @@
|
|
|
1
|
+
import { ChallengeError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
2
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
3
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
4
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
5
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
6
|
+
import { FASTFRATE_MAX_BYTES, normalizeFastfrateNumber, parseFastfrate, PROVIDER } from './parser.js';
|
|
7
|
+
/** The lookup the tracker's quick track page makes for a probill, without an account. */
|
|
8
|
+
export const FASTFRATE_API = 'https://tracker.fastfrate.com/trackerapi/shipmentinformation/quicktrack';
|
|
9
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
10
|
+
// A missing route is not a missing probill: the lookup answers an unknown one with an empty timeline.
|
|
11
|
+
const GONE = [404, 410];
|
|
12
|
+
// Bot checks a CDN or firewall could put in place of the reply.
|
|
13
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|<title>\s*(?:Just a moment|Attention Required|Access Denied|Request Rejected)|requested URL was rejected|AwsWafIntegration|awswaf\.com|captcha/i;
|
|
14
|
+
/** The lookup's address for one probill. */
|
|
15
|
+
export function fastfrateLookupUrl(number) {
|
|
16
|
+
return `${FASTFRATE_API}?probillnumber=${encodeURIComponent(normalizeFastfrateNumber(number))}`;
|
|
17
|
+
}
|
|
18
|
+
function json(body) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(body);
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
if (CHALLENGE.test(body.slice(0, 64_000)))
|
|
24
|
+
throw new ChallengeError(PROVIDER, 'Fastfrate answered with a browser check', { cause });
|
|
25
|
+
throw new SchemaError(PROVIDER, 'Fastfrate returned a reply that is not JSON', { cause });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class FastfrateTracker {
|
|
29
|
+
options;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
async fetch(raw, context = {}) {
|
|
34
|
+
const number = normalizeFastfrateNumber(raw);
|
|
35
|
+
const read = async (signal, timeoutMs) => {
|
|
36
|
+
const { response, bytes } = await fetchBounded(fastfrateLookupUrl(number), { method: 'GET', signal, headers: {
|
|
37
|
+
Accept: 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
38
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: FASTFRATE_MAX_BYTES, redirect: 'manual',
|
|
39
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, ...GONE] });
|
|
40
|
+
if (REDIRECTS.includes(response.status))
|
|
41
|
+
throw new TransportError(PROVIDER, 'Fastfrate redirected its tracking lookup');
|
|
42
|
+
if (GONE.includes(response.status))
|
|
43
|
+
throw new TransportError(PROVIDER, 'Fastfrate tracking lookup is unavailable');
|
|
44
|
+
return parseFastfrate(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: 'fastfrate', 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 FastfrateTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
57
|
+
return {
|
|
58
|
+
id: 'fastfrate', recordsSteps: true, steps: ['direct', 'retry'],
|
|
59
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
60
|
+
};
|
|
61
|
+
};
|
|
@@ -17,18 +17,60 @@
|
|
|
17
17
|
"timezone": "America/Toronto",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "fastfrate",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"localClocks": true
|
|
22
26
|
},
|
|
23
|
-
"capabilities": [
|
|
27
|
+
"capabilities": [
|
|
28
|
+
"history",
|
|
29
|
+
"location",
|
|
30
|
+
"eta"
|
|
31
|
+
],
|
|
24
32
|
"portal": {
|
|
25
33
|
"url": "https://apps.fastfrate.com/fastnet/FastNet.aspx?ProbillNo={trackingNumber}",
|
|
26
34
|
"searchUrl": "https://tracker.fastfrate.com/quick-track",
|
|
27
35
|
"canaryUrl": "https://tracker.fastfrate.com/quick-track",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
"shows": [
|
|
37
|
+
"status",
|
|
38
|
+
"history",
|
|
39
|
+
"location",
|
|
40
|
+
"eta",
|
|
41
|
+
"customer_reference",
|
|
42
|
+
"origin",
|
|
43
|
+
"destination",
|
|
44
|
+
"weight",
|
|
45
|
+
"parcel_count",
|
|
46
|
+
"recipient_contact",
|
|
47
|
+
"remarks"
|
|
48
|
+
],
|
|
49
|
+
"scraped": [
|
|
50
|
+
"status",
|
|
51
|
+
"history",
|
|
52
|
+
"location",
|
|
53
|
+
"eta"
|
|
54
|
+
],
|
|
55
|
+
"discarded": [
|
|
56
|
+
"customer_reference",
|
|
57
|
+
"origin",
|
|
58
|
+
"destination",
|
|
59
|
+
"weight",
|
|
60
|
+
"parcel_count",
|
|
61
|
+
"recipient_contact",
|
|
62
|
+
"remarks"
|
|
63
|
+
],
|
|
64
|
+
"unavailable": [
|
|
65
|
+
"offset_timestamps",
|
|
66
|
+
"delivered_at",
|
|
67
|
+
"service_name",
|
|
68
|
+
"provider_code",
|
|
69
|
+
"sender_name",
|
|
70
|
+
"recipient_name",
|
|
71
|
+
"delivery_partner",
|
|
72
|
+
"proof_of_delivery"
|
|
73
|
+
]
|
|
32
74
|
},
|
|
33
75
|
"links": [
|
|
34
76
|
{
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Fastfrate";
|
|
3
|
+
export declare const FASTFRATE_MAX_BYTES: number;
|
|
4
|
+
/** A Fastfrate probill: six to ten digits. Anything else is not one the tracker is asked for. */
|
|
5
|
+
export declare function normalizeFastfrateNumber(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* The reply of `trackerapi/shipmentinformation/quicktrack?probillnumber={number}`, the lookup
|
|
8
|
+
* the tracker's quick track page makes: `{"probillNumber":…,"timeline":[…],"references":[…],
|
|
9
|
+
* "serviceMsg":…}`, the timeline oldest first. The page reports an empty timeline as not
|
|
10
|
+
* found. The references are the shipper's own, and the service message stands in for a
|
|
11
|
+
* missing estimate; neither is read.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseFastfrate(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
2
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
4
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
5
|
+
import { calendarDay } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
import { fastfrateStage } from './status.js';
|
|
9
|
+
export const PROVIDER = 'Fastfrate';
|
|
10
|
+
// A long timeline is a few kilobytes.
|
|
11
|
+
export const FASTFRATE_MAX_BYTES = 128 * 1024;
|
|
12
|
+
// A probill as the trackers take it: more than five digits, as the tracker's page checks, and
|
|
13
|
+
// at most ten, as the former page's field. The service reads it as a number, so one starting
|
|
14
|
+
// with 0 would not be the same digits.
|
|
15
|
+
const NUMBER = /^[1-9]\d{5,9}$/;
|
|
16
|
+
// "2026-03-03T15:58:00", with milliseconds on some rows: a wall clock without an offset.
|
|
17
|
+
const CLOCK = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,7})?$/;
|
|
18
|
+
// Two rows are not scans. The page shows the estimate's day over the list and leaves its row
|
|
19
|
+
// out, and an appointment's row is dated with the appointment's own time.
|
|
20
|
+
const ESTIMATE = 'estimated delivery date';
|
|
21
|
+
const APPOINTMENT = 'scheduled appointment';
|
|
22
|
+
// A canonical province or territory code.
|
|
23
|
+
const PROVINCE = /^[A-Z]{2}$/;
|
|
24
|
+
// Stages a delivery settles: the driver's device stamps its rows on its own clock, so its
|
|
25
|
+
// arrival at the consignee can come after the delivery.
|
|
26
|
+
const BEFORE_DELIVERY = new Set(['registered', 'accepted', 'in_transit', 'out_for_delivery']);
|
|
27
|
+
const MAX_ROWS = 300;
|
|
28
|
+
/** A Fastfrate probill: six to ten digits. Anything else is not one the tracker is asked for. */
|
|
29
|
+
export function normalizeFastfrateNumber(raw) {
|
|
30
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
|
|
31
|
+
if (!NUMBER.test(number))
|
|
32
|
+
throw new InvalidInputError(PROVIDER, 'Fastfrate probills are six to ten digits');
|
|
33
|
+
return number;
|
|
34
|
+
}
|
|
35
|
+
function invalid(message) {
|
|
36
|
+
throw new SchemaError(PROVIDER, `Fastfrate returned ${message}`);
|
|
37
|
+
}
|
|
38
|
+
/** An optional text field; anything but a string is a changed reply. */
|
|
39
|
+
function text(row, key, maxLength = 200) {
|
|
40
|
+
const value = row[key];
|
|
41
|
+
if (value === null || value === undefined)
|
|
42
|
+
return '';
|
|
43
|
+
if (typeof value !== 'string')
|
|
44
|
+
invalid(`an invalid ${key}`);
|
|
45
|
+
return clean(value, maxLength);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A row's wall clock to the second, or its day when the clock is midnight, which the
|
|
49
|
+
* timeline gives a row dated without a time. No clock names its zone.
|
|
50
|
+
*/
|
|
51
|
+
function clockOf(value) {
|
|
52
|
+
const match = typeof value === 'string' ? CLOCK.exec(value.trim()) : null;
|
|
53
|
+
if (!match)
|
|
54
|
+
invalid('an invalid timeStamp');
|
|
55
|
+
const [, year, month, date, hour, minute, second] = match;
|
|
56
|
+
const day = calendarDay(Number(year), Number(month), Number(date));
|
|
57
|
+
// The clock's fields are checked here, since date libraries accept hour 24.
|
|
58
|
+
if (!day || Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59)
|
|
59
|
+
invalid('an invalid timeStamp');
|
|
60
|
+
return `${hour}${minute}${second}` === '000000' ? day : `${day}T${hour}:${minute}:${second}`;
|
|
61
|
+
}
|
|
62
|
+
/** A terminal's town and province; a town holding a digit could be an address, so it is left out. */
|
|
63
|
+
function placeOf(row) {
|
|
64
|
+
const city = text(row, 'city', 80);
|
|
65
|
+
const province = text(row, 'province', 8).toUpperCase();
|
|
66
|
+
if (!city || /\d/.test(city))
|
|
67
|
+
return '';
|
|
68
|
+
return PROVINCE.test(province) ? `${city}, ${province}` : city;
|
|
69
|
+
}
|
|
70
|
+
function rowOf(value) {
|
|
71
|
+
if (!isRecord(value))
|
|
72
|
+
invalid('an invalid timeline row');
|
|
73
|
+
const wording = text(value, 'description');
|
|
74
|
+
if (!wording)
|
|
75
|
+
invalid('a timeline row without its description');
|
|
76
|
+
return { wording, clock: clockOf(value.timeStamp), place: placeOf(value) };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The reply of `trackerapi/shipmentinformation/quicktrack?probillnumber={number}`, the lookup
|
|
80
|
+
* the tracker's quick track page makes: `{"probillNumber":…,"timeline":[…],"references":[…],
|
|
81
|
+
* "serviceMsg":…}`, the timeline oldest first. The page reports an empty timeline as not
|
|
82
|
+
* found. The references are the shipper's own, and the service message stands in for a
|
|
83
|
+
* missing estimate; neither is read.
|
|
84
|
+
*/
|
|
85
|
+
export function parseFastfrate(payload, raw) {
|
|
86
|
+
const number = normalizeFastfrateNumber(raw);
|
|
87
|
+
if (!isRecord(payload) || !Array.isArray(payload.timeline))
|
|
88
|
+
invalid('an invalid envelope');
|
|
89
|
+
const probill = payload.probillNumber;
|
|
90
|
+
if (typeof probill !== 'number' || !Number.isSafeInteger(probill) || probill < 0)
|
|
91
|
+
invalid('a reply without its probill');
|
|
92
|
+
// The service answers for the number it read: a missing one reads as 0, whose record holds stray rows.
|
|
93
|
+
if (String(probill) !== number)
|
|
94
|
+
throw new IndeterminateError(PROVIDER, 'Fastfrate answered for another probill');
|
|
95
|
+
const rows = payload.timeline;
|
|
96
|
+
if (rows.length > MAX_ROWS)
|
|
97
|
+
invalid('an oversized timeline');
|
|
98
|
+
if (!rows.length)
|
|
99
|
+
throw new NotFoundError(PROVIDER);
|
|
100
|
+
let estimate = null;
|
|
101
|
+
let appointment = null;
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const events = [];
|
|
104
|
+
for (const value of rows) {
|
|
105
|
+
const row = rowOf(value);
|
|
106
|
+
const wording = normalizeStatusWording(row.wording);
|
|
107
|
+
// The page shows the first estimate row's day.
|
|
108
|
+
if (wording === ESTIMATE) {
|
|
109
|
+
estimate ??= row.clock.slice(0, 10);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (wording === APPOINTMENT) {
|
|
113
|
+
appointment = row.clock.slice(0, 10);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const key = JSON.stringify([row.clock, row.wording, row.place]);
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
const staged = fastfrateStage(row.wording);
|
|
121
|
+
events.push({
|
|
122
|
+
local_time: row.clock,
|
|
123
|
+
...(row.place ? { location: row.place } : {}),
|
|
124
|
+
description: row.wording,
|
|
125
|
+
...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!events.length)
|
|
129
|
+
throw new NoHistoryError(PROVIDER);
|
|
130
|
+
// Newest first; rows on the same clock keep the reply's order, the later one first.
|
|
131
|
+
events.sort((a, b) => (a.local_time === b.local_time ? 0 : String(a.local_time) < String(b.local_time) ? -1 : 1)).reverse();
|
|
132
|
+
// The newest staged row decides, except that the driver's arrival at the shipper never steps
|
|
133
|
+
// a shipment back, and a delivery settles the rows its stages come before.
|
|
134
|
+
const staged = events.filter((event) => event.stage);
|
|
135
|
+
const newest = staged.find((event) => event.stage !== 'registered') ?? staged[0];
|
|
136
|
+
const delivered = staged.find((event) => event.stage === 'delivered');
|
|
137
|
+
const decided = delivered && newest && BEFORE_DELIVERY.has(newest.stage) ? delivered : newest;
|
|
138
|
+
const stage = decided?.stage;
|
|
139
|
+
const done = stage === 'delivered' || stage === 'returned';
|
|
140
|
+
return {
|
|
141
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
142
|
+
...(decided ? { current_stage: stage, current_stage_source: decided.stage_source } : {}),
|
|
143
|
+
last_status_text: (decided ?? events[0]).description,
|
|
144
|
+
last_update: null,
|
|
145
|
+
last_update_local: events[0].local_time,
|
|
146
|
+
// The appointment's day, else the estimate's, until the delivery.
|
|
147
|
+
expected_delivery: done ? null : appointment ?? estimate,
|
|
148
|
+
events,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage the map gives Fastfrate's wording, if it knows it. */
|
|
4
|
+
export declare function fastfrateMappedStage(wording: string): Stage | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* A row's stage from the map, else from the shared wording rules. Wording
|
|
7
|
+
* neither knows stays without a stage, so it remains visible for review.
|
|
8
|
+
*/
|
|
9
|
+
export declare function fastfrateStage(wording: string): ClassifiedWording | null;
|
|
10
|
+
/** The timeline carries wording only; the map leaves none of it out on purpose. */
|
|
11
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The wording of Fastfrate's tracker timeline. Rows carry no code. The driver's
|
|
5
|
+
* device adds its arrival at and departure from the shipper and the consignee.
|
|
6
|
+
*/
|
|
7
|
+
const STAGES = new Map([
|
|
8
|
+
['shipment created', 'registered'],
|
|
9
|
+
// The shipper's electronic order, before anyone collects the freight.
|
|
10
|
+
['shipment accepted from edi', 'registered'],
|
|
11
|
+
['driver assigned for pickup', 'registered'],
|
|
12
|
+
// The pickup driver at the shipper, before the freight is on board.
|
|
13
|
+
['arrived at shipper', 'registered'],
|
|
14
|
+
['picked up', 'accepted'],
|
|
15
|
+
// The pickup driver leaving the shipper with the freight.
|
|
16
|
+
['departed shipper', 'accepted'],
|
|
17
|
+
['on dock at origin terminal', 'in_transit'],
|
|
18
|
+
['enroute to destination terminal', 'in_transit'],
|
|
19
|
+
['on dock at destination terminal', 'in_transit'],
|
|
20
|
+
['appointment requested', 'in_transit'],
|
|
21
|
+
// A delivery appointment, dated with the appointment's own time.
|
|
22
|
+
['scheduled appointment', 'in_transit'],
|
|
23
|
+
['loaded to delivery unit', 'out_for_delivery'],
|
|
24
|
+
// The delivery driver at the consignee and leaving it, around the delivery.
|
|
25
|
+
['arrived at consignee', 'out_for_delivery'],
|
|
26
|
+
['departed consignee', 'out_for_delivery'],
|
|
27
|
+
['delivered', 'delivered'],
|
|
28
|
+
// The former FastNet page's delivery status.
|
|
29
|
+
['delivered by appointment', 'delivered'],
|
|
30
|
+
]);
|
|
31
|
+
/** The stage the map gives Fastfrate's wording, if it knows it. */
|
|
32
|
+
export function fastfrateMappedStage(wording) {
|
|
33
|
+
return STAGES.get(normalizeStatusWording(wording));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* A row's stage from the map, else from the shared wording rules. Wording
|
|
37
|
+
* neither knows stays without a stage, so it remains visible for review.
|
|
38
|
+
*/
|
|
39
|
+
export function fastfrateStage(wording) {
|
|
40
|
+
const mapped = fastfrateMappedStage(wording);
|
|
41
|
+
if (mapped)
|
|
42
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
43
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
44
|
+
return worded.source === 'none' ? null : worded;
|
|
45
|
+
}
|
|
46
|
+
/** The timeline carries wording only; the map leaves none of it out on purpose. */
|
|
47
|
+
export const statusMap = {
|
|
48
|
+
stage: (code, wording) => (code ? undefined : fastfrateMappedStage(wording)),
|
|
49
|
+
gaps: [],
|
|
50
|
+
};
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "fastfrate",
|
|
3
3
|
"entries": [
|
|
4
|
-
{"wording": "DELIVERED BY APPOINTMENT", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "Delivery status on the FastNet quick probill tracker, shown with the delivery and appointment dates."}
|
|
4
|
+
{"wording": "DELIVERED BY APPOINTMENT", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "Delivery status on the FastNet quick probill tracker, shown with the delivery and appointment dates."},
|
|
5
|
+
{"wording": "Shipment Created", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup."},
|
|
6
|
+
{"wording": "Shipment accepted from EDI", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup: the shipper's electronic order, before the pickup."},
|
|
7
|
+
{"wording": "Driver assigned for Pickup", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup."},
|
|
8
|
+
{"wording": "Arrived At Shipper", "stage": "registered", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, stamped by the pickup driver's device, sometimes after the pickup row."},
|
|
9
|
+
{"wording": "Picked Up", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, with the pickup town."},
|
|
10
|
+
{"wording": "Departed Shipper", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, stamped by the pickup driver's device."},
|
|
11
|
+
{"wording": "On Dock at Origin Terminal", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, with the terminal's town."},
|
|
12
|
+
{"wording": "Enroute to Destination Terminal", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, with the origin terminal's town."},
|
|
13
|
+
{"wording": "Appointment Requested", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, dated without a time."},
|
|
14
|
+
{"wording": "On Dock at Destination Terminal", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, with the terminal's town."},
|
|
15
|
+
{"wording": "Scheduled Appointment", "stage": "in_transit", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup dated with the delivery appointment's own time; the adapter reads it as the estimate, not as a scan."},
|
|
16
|
+
{"wording": "Loaded to Delivery Unit", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, with the delivering terminal's town."},
|
|
17
|
+
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup."},
|
|
18
|
+
{"wording": "Arrived at Consignee", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, stamped by the delivery driver's device, seen after the delivery row."},
|
|
19
|
+
{"wording": "Departed Consignee", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "note": "Timeline row of the quick track lookup, stamped by the delivery driver's device, seen after the delivery row."}
|
|
5
20
|
]
|
|
6
21
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
/** Only the public widget's bounded, two-operand arithmetic grammar is accepted. */
|
|
4
|
+
export declare function solveHuahanQuestion(question: unknown): string;
|
|
5
|
+
export declare class HuahanTracker {
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(options?: {
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
recorder?: StepRecorder;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
});
|
|
12
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare const adapter: AdapterFactory;
|