universal-parcel-scraper 0.71.0-main.912 → 0.72.0-main.916
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 +9 -3
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +30 -0
- package/dist/browser/scraper.js +12 -3
- package/dist/carriers/fan-courier/adapter.d.ts +12 -0
- package/dist/carriers/fan-courier/adapter.js +51 -0
- package/dist/carriers/fan-courier/carrier.json +43 -7
- package/dist/carriers/fan-courier/numbers.json +6 -6
- package/dist/carriers/fan-courier/parser.d.ts +11 -0
- package/dist/carriers/fan-courier/parser.js +92 -0
- package/dist/carriers/fan-courier/status.d.ts +10 -0
- package/dist/carriers/fan-courier/status.js +46 -0
- package/dist/carriers/fan-courier/statuses.json +481 -1
- package/dist/carriers/foxpost/adapter.d.ts +14 -0
- package/dist/carriers/foxpost/adapter.js +56 -0
- package/dist/carriers/foxpost/carrier.json +40 -7
- package/dist/carriers/foxpost/parser.d.ts +10 -0
- package/dist/carriers/foxpost/parser.js +127 -0
- package/dist/carriers/foxpost/status.d.ts +22 -0
- package/dist/carriers/foxpost/status.js +77 -0
- package/dist/carriers/foxpost/statuses.json +32 -1
- package/dist/carriers/sameday/adapter.d.ts +17 -0
- package/dist/carriers/sameday/adapter.js +55 -0
- package/dist/carriers/sameday/carrier.json +35 -7
- package/dist/carriers/sameday/parser.d.ts +18 -0
- package/dist/carriers/sameday/parser.js +156 -0
- package/dist/carriers/sameday/status.d.ts +24 -0
- package/dist/carriers/sameday/status.js +98 -0
- package/dist/carriers/sameday/statuses.json +241 -1
- package/dist/core/catalog/statusMaps.js +5 -2
- package/dist/data/catalog.json +9 -3
- package/dist/generated/catalog.d.ts +9 -3
- package/dist/generated/catalog.js +9 -3
- package/dist/generated/recognition.js +3 -0
- package/dist/generated/registry.js +9 -3
- 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 **125 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>207 catalog entries · 66 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 3,
|
|
158
|
+
The carrier folders record 3,501 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
|
@@ -6069,7 +6069,9 @@
|
|
|
6069
6069
|
"timezone": "Europe/Bucharest",
|
|
6070
6070
|
"tracking": {
|
|
6071
6071
|
"mode": "automatic",
|
|
6072
|
-
"adapter": "
|
|
6072
|
+
"adapter": "fan-courier",
|
|
6073
|
+
"recognitionRank": 51,
|
|
6074
|
+
"localClocks": true
|
|
6073
6075
|
},
|
|
6074
6076
|
"canaryUrl": "https://www.fancourier.ro/awb-tracking/",
|
|
6075
6077
|
"trackingUrlTemplate": "https://www.fancourier.ro/awb-tracking/?tracking={trackingNumber}",
|
|
@@ -6109,7 +6111,9 @@
|
|
|
6109
6111
|
"timezone": "Europe/Budapest",
|
|
6110
6112
|
"tracking": {
|
|
6111
6113
|
"mode": "automatic",
|
|
6112
|
-
"adapter": "
|
|
6114
|
+
"adapter": "foxpost",
|
|
6115
|
+
"recognitionRank": 52,
|
|
6116
|
+
"localClocks": true
|
|
6113
6117
|
},
|
|
6114
6118
|
"canaryUrl": "https://foxpost.hu/csomagkovetes/",
|
|
6115
6119
|
"trackingUrlTemplate": "https://foxpost.hu/csomagkovetes/?code={trackingNumber}",
|
|
@@ -7648,9 +7652,11 @@
|
|
|
7648
7652
|
"timezone": "Europe/Bucharest",
|
|
7649
7653
|
"tracking": {
|
|
7650
7654
|
"mode": "automatic",
|
|
7651
|
-
"adapter": "
|
|
7655
|
+
"adapter": "sameday",
|
|
7656
|
+
"recognitionRank": 53
|
|
7652
7657
|
},
|
|
7653
7658
|
"canaryUrl": "https://sameday.ro/",
|
|
7659
|
+
"trackingSearchUrl": "https://sameday.ro/",
|
|
7654
7660
|
"linkRules": [],
|
|
7655
7661
|
"detectionRules": [
|
|
7656
7662
|
{
|
|
@@ -8410,6 +8410,9 @@
|
|
|
8410
8410
|
"confidence": "low",
|
|
8411
8411
|
"candidates": [
|
|
8412
8412
|
"sameday"
|
|
8413
|
+
],
|
|
8414
|
+
"asked": [
|
|
8415
|
+
"sameday"
|
|
8413
8416
|
]
|
|
8414
8417
|
},
|
|
8415
8418
|
{
|
|
@@ -8418,6 +8421,9 @@
|
|
|
8418
8421
|
"confidence": "low",
|
|
8419
8422
|
"candidates": [
|
|
8420
8423
|
"sameday"
|
|
8424
|
+
],
|
|
8425
|
+
"asked": [
|
|
8426
|
+
"sameday"
|
|
8421
8427
|
]
|
|
8422
8428
|
},
|
|
8423
8429
|
{
|
|
@@ -8426,6 +8432,9 @@
|
|
|
8426
8432
|
"confidence": "low",
|
|
8427
8433
|
"candidates": [
|
|
8428
8434
|
"sameday"
|
|
8435
|
+
],
|
|
8436
|
+
"asked": [
|
|
8437
|
+
"sameday"
|
|
8429
8438
|
]
|
|
8430
8439
|
},
|
|
8431
8440
|
{
|
|
@@ -8434,6 +8443,9 @@
|
|
|
8434
8443
|
"confidence": "low",
|
|
8435
8444
|
"candidates": [
|
|
8436
8445
|
"sameday"
|
|
8446
|
+
],
|
|
8447
|
+
"asked": [
|
|
8448
|
+
"sameday"
|
|
8437
8449
|
]
|
|
8438
8450
|
},
|
|
8439
8451
|
{
|
|
@@ -8442,6 +8454,9 @@
|
|
|
8442
8454
|
"confidence": "low",
|
|
8443
8455
|
"candidates": [
|
|
8444
8456
|
"sameday"
|
|
8457
|
+
],
|
|
8458
|
+
"asked": [
|
|
8459
|
+
"sameday"
|
|
8445
8460
|
]
|
|
8446
8461
|
},
|
|
8447
8462
|
{
|
|
@@ -8450,6 +8465,9 @@
|
|
|
8450
8465
|
"confidence": "low",
|
|
8451
8466
|
"candidates": [
|
|
8452
8467
|
"sameday"
|
|
8468
|
+
],
|
|
8469
|
+
"asked": [
|
|
8470
|
+
"sameday"
|
|
8453
8471
|
]
|
|
8454
8472
|
},
|
|
8455
8473
|
{
|
|
@@ -8458,6 +8476,9 @@
|
|
|
8458
8476
|
"confidence": "low",
|
|
8459
8477
|
"candidates": [
|
|
8460
8478
|
"sameday"
|
|
8479
|
+
],
|
|
8480
|
+
"asked": [
|
|
8481
|
+
"sameday"
|
|
8461
8482
|
]
|
|
8462
8483
|
},
|
|
8463
8484
|
{
|
|
@@ -8466,6 +8487,9 @@
|
|
|
8466
8487
|
"confidence": "low",
|
|
8467
8488
|
"candidates": [
|
|
8468
8489
|
"sameday"
|
|
8490
|
+
],
|
|
8491
|
+
"asked": [
|
|
8492
|
+
"sameday"
|
|
8469
8493
|
]
|
|
8470
8494
|
},
|
|
8471
8495
|
{
|
|
@@ -8474,6 +8498,9 @@
|
|
|
8474
8498
|
"confidence": "low",
|
|
8475
8499
|
"candidates": [
|
|
8476
8500
|
"sameday"
|
|
8501
|
+
],
|
|
8502
|
+
"asked": [
|
|
8503
|
+
"sameday"
|
|
8477
8504
|
]
|
|
8478
8505
|
},
|
|
8479
8506
|
{
|
|
@@ -8482,6 +8509,9 @@
|
|
|
8482
8509
|
"confidence": "low",
|
|
8483
8510
|
"candidates": [
|
|
8484
8511
|
"sameday"
|
|
8512
|
+
],
|
|
8513
|
+
"asked": [
|
|
8514
|
+
"sameday"
|
|
8485
8515
|
]
|
|
8486
8516
|
},
|
|
8487
8517
|
{
|
package/dist/browser/scraper.js
CHANGED
|
@@ -4878,7 +4878,9 @@ var ye = {
|
|
|
4878
4878
|
timezone: "Europe/Bucharest",
|
|
4879
4879
|
tracking: {
|
|
4880
4880
|
mode: "automatic",
|
|
4881
|
-
adapter: "
|
|
4881
|
+
adapter: "fan-courier",
|
|
4882
|
+
recognitionRank: 51,
|
|
4883
|
+
localClocks: !0
|
|
4882
4884
|
},
|
|
4883
4885
|
canaryUrl: "https://www.fancourier.ro/awb-tracking/",
|
|
4884
4886
|
trackingUrlTemplate: "https://www.fancourier.ro/awb-tracking/?tracking={trackingNumber}",
|
|
@@ -4906,7 +4908,9 @@ var ye = {
|
|
|
4906
4908
|
timezone: "Europe/Budapest",
|
|
4907
4909
|
tracking: {
|
|
4908
4910
|
mode: "automatic",
|
|
4909
|
-
adapter: "
|
|
4911
|
+
adapter: "foxpost",
|
|
4912
|
+
recognitionRank: 52,
|
|
4913
|
+
localClocks: !0
|
|
4910
4914
|
},
|
|
4911
4915
|
canaryUrl: "https://foxpost.hu/csomagkovetes/",
|
|
4912
4916
|
trackingUrlTemplate: "https://foxpost.hu/csomagkovetes/?code={trackingNumber}",
|
|
@@ -6058,9 +6062,11 @@ var ye = {
|
|
|
6058
6062
|
timezone: "Europe/Bucharest",
|
|
6059
6063
|
tracking: {
|
|
6060
6064
|
mode: "automatic",
|
|
6061
|
-
adapter: "
|
|
6065
|
+
adapter: "sameday",
|
|
6066
|
+
recognitionRank: 53
|
|
6062
6067
|
},
|
|
6063
6068
|
canaryUrl: "https://sameday.ro/",
|
|
6069
|
+
trackingSearchUrl: "https://sameday.ro/",
|
|
6064
6070
|
linkRules: [],
|
|
6065
6071
|
detectionRules: [{
|
|
6066
6072
|
pattern: "^1[A-Z]{3,5}\\d{9,11}$",
|
|
@@ -11683,7 +11689,9 @@ var _c = {
|
|
|
11683
11689
|
"dpd-de": 65,
|
|
11684
11690
|
"dpd-pl": 8,
|
|
11685
11691
|
emile: 4,
|
|
11692
|
+
"fan-courier": 51,
|
|
11686
11693
|
"four-px": 13,
|
|
11694
|
+
foxpost: 52,
|
|
11687
11695
|
"gls-ch": 50,
|
|
11688
11696
|
"gls-de": 60,
|
|
11689
11697
|
"gls-fr": 61,
|
|
@@ -11707,6 +11715,7 @@ var _c = {
|
|
|
11707
11715
|
postnord: 7,
|
|
11708
11716
|
purolator: 12,
|
|
11709
11717
|
"relais-colis": 25,
|
|
11718
|
+
sameday: 53,
|
|
11710
11719
|
seur: 43,
|
|
11711
11720
|
sto: 10,
|
|
11712
11721
|
"swiss-post-cargo": 11,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
export declare class FanCourierTracker {
|
|
4
|
+
private readonly options;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
fetcher?: typeof fetch;
|
|
7
|
+
recorder?: StepRecorder;
|
|
8
|
+
userAgent?: string;
|
|
9
|
+
});
|
|
10
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
11
|
+
}
|
|
12
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { ChallengeError, IndeterminateError, SchemaError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
5
|
+
import { decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
6
|
+
import { normalizeFanCourierNumber, parseFanCourier, PROVIDER } from './parser.js';
|
|
7
|
+
/** The tracking page's own lookup, which it posts as a form. */
|
|
8
|
+
const ENDPOINT = 'https://www.fancourier.ro/limit-tracking.php';
|
|
9
|
+
// Cloudflare's interstitial, served with a 200 or a 403.
|
|
10
|
+
const CHALLENGE = /<title>\s*(?:Just a moment|Attention Required)|\bcf[-_]chl|challenge-platform/i;
|
|
11
|
+
export class FanCourierTracker {
|
|
12
|
+
options;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async fetch(raw, context = {}) {
|
|
17
|
+
const number = normalizeFanCourierNumber(raw);
|
|
18
|
+
return runSteps({ carrier: 'fan-courier', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
19
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const { response, bytes } = await fetchBounded(ENDPOINT, { method: 'POST', signal,
|
|
22
|
+
headers: { Accept: 'application/json', 'User-Agent': userAgentOf(this.options.userAgent) },
|
|
23
|
+
// The English page's locale, which words the events in English.
|
|
24
|
+
body: new URLSearchParams({ action: 'get_awb', awb: number, lang: 'en-US' }),
|
|
25
|
+
}, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(remainingMs)), maxBytes: 1_000_000,
|
|
26
|
+
allowHttpStatuses: [404, 422], fetcher: this.options.fetcher });
|
|
27
|
+
// The number was checked before the request: a rejection or a missing page says nothing about the parcel.
|
|
28
|
+
if (response.status !== 200)
|
|
29
|
+
throw new IndeterminateError(PROVIDER, 'FAN Courier tracking is unavailable');
|
|
30
|
+
const text = decodeText(bytes.subarray(0, 4_096));
|
|
31
|
+
if (CHALLENGE.test(text))
|
|
32
|
+
throw new ChallengeError(PROVIDER, 'FAN Courier asked for a browser check');
|
|
33
|
+
// The page asks again after an empty reply, as after a rate limit.
|
|
34
|
+
if (!text.trim())
|
|
35
|
+
throw new IndeterminateError(PROVIDER, 'FAN Courier returned an empty reply');
|
|
36
|
+
let payload;
|
|
37
|
+
try {
|
|
38
|
+
payload = parseJsonBytes(bytes, PROVIDER);
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
throw new SchemaError(PROVIDER, 'FAN Courier returned invalid tracking JSON', { cause });
|
|
42
|
+
}
|
|
43
|
+
return parseFanCourier(payload, number);
|
|
44
|
+
} }]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const adapter = environment => {
|
|
48
|
+
const tracker = new FanCourierTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
49
|
+
return { id: 'fan-courier', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context),
|
|
50
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeFanCourierNumber(number))) };
|
|
51
|
+
};
|
|
@@ -17,18 +17,54 @@
|
|
|
17
17
|
"timezone": "Europe/Bucharest",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "fan-courier",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct"
|
|
23
|
+
],
|
|
24
|
+
"recognition": {
|
|
25
|
+
"rank": 51
|
|
26
|
+
},
|
|
27
|
+
"localClocks": true
|
|
22
28
|
},
|
|
23
|
-
"capabilities": [
|
|
29
|
+
"capabilities": [
|
|
30
|
+
"history",
|
|
31
|
+
"location",
|
|
32
|
+
"provider_code"
|
|
33
|
+
],
|
|
24
34
|
"portal": {
|
|
25
35
|
"url": "https://www.fancourier.ro/awb-tracking/?tracking={trackingNumber}",
|
|
26
36
|
"searchUrl": "https://www.fancourier.ro/awb-tracking/",
|
|
27
37
|
"canaryUrl": "https://www.fancourier.ro/awb-tracking/",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
"shows": [
|
|
39
|
+
"status",
|
|
40
|
+
"milestones",
|
|
41
|
+
"awb_date",
|
|
42
|
+
"related_awb_numbers",
|
|
43
|
+
"recipient_name"
|
|
44
|
+
],
|
|
45
|
+
"scraped": [
|
|
46
|
+
"status",
|
|
47
|
+
"history",
|
|
48
|
+
"location",
|
|
49
|
+
"provider_code"
|
|
50
|
+
],
|
|
51
|
+
"discarded": [
|
|
52
|
+
"milestones",
|
|
53
|
+
"awb_date",
|
|
54
|
+
"related_awb_numbers",
|
|
55
|
+
"recipient_name",
|
|
56
|
+
"order_contents",
|
|
57
|
+
"weight",
|
|
58
|
+
"dimensions",
|
|
59
|
+
"payment_details",
|
|
60
|
+
"service_options"
|
|
61
|
+
],
|
|
62
|
+
"unavailable": [
|
|
63
|
+
"eta",
|
|
64
|
+
"pickup_point",
|
|
65
|
+
"sender_name",
|
|
66
|
+
"offset_timestamps"
|
|
67
|
+
]
|
|
32
68
|
},
|
|
33
69
|
"links": [
|
|
34
70
|
{
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"access": "indexed_excerpt"
|
|
162
162
|
},
|
|
163
163
|
"context": {
|
|
164
|
-
"assessment": "
|
|
164
|
+
"assessment": "confirmed"
|
|
165
165
|
},
|
|
166
166
|
"expect": {
|
|
167
167
|
"carrier": "unknown",
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
"korea-post"
|
|
175
175
|
]
|
|
176
176
|
},
|
|
177
|
-
"note": "Looked up on FAN Courier's own tracker, as an archived address shows;
|
|
177
|
+
"note": "Looked up on FAN Courier's own tracker, as an archived address shows; FAN Courier's tracking returns its history."
|
|
178
178
|
},
|
|
179
179
|
{
|
|
180
180
|
"number": "7000025622933",
|
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
"access": "indexed_excerpt"
|
|
188
188
|
},
|
|
189
189
|
"context": {
|
|
190
|
-
"assessment": "
|
|
190
|
+
"assessment": "confirmed"
|
|
191
191
|
},
|
|
192
192
|
"expect": {
|
|
193
193
|
"carrier": "unknown",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"yunda"
|
|
200
200
|
]
|
|
201
201
|
},
|
|
202
|
-
"note": "Looked up on FAN Courier's own tracker, as an archived address shows;
|
|
202
|
+
"note": "Looked up on FAN Courier's own tracker, as an archived address shows; FAN Courier's tracking returns its history."
|
|
203
203
|
},
|
|
204
204
|
{
|
|
205
205
|
"number": "7000090535614",
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"access": "indexed_excerpt"
|
|
213
213
|
},
|
|
214
214
|
"context": {
|
|
215
|
-
"assessment": "
|
|
215
|
+
"assessment": "confirmed"
|
|
216
216
|
},
|
|
217
217
|
"expect": {
|
|
218
218
|
"carrier": "unknown",
|
|
@@ -224,7 +224,7 @@
|
|
|
224
224
|
"yunda"
|
|
225
225
|
]
|
|
226
226
|
},
|
|
227
|
-
"note": "Looked up on FAN Courier's own tracker, as an archived address shows;
|
|
227
|
+
"note": "Looked up on FAN Courier's own tracker, as an archived address shows; FAN Courier's tracking returns its history."
|
|
228
228
|
}
|
|
229
229
|
]
|
|
230
230
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "FAN Courier";
|
|
3
|
+
/** A thirteen-digit AWB, the only length FAN Courier's documentation, clients and tracker show. */
|
|
4
|
+
export declare function normalizeFanCourierNumber(raw: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The tracker's reply for one AWB: its events, oldest first, each with its
|
|
7
|
+
* code, wording, town and wall clock. The contents, weight, dimensions, the
|
|
8
|
+
* payment date, the related AWBs and the masked name of whoever signed are
|
|
9
|
+
* never read.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseFanCourier(payload: unknown, raw: string): CarrierResult;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { fanCourierStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'FAN Courier';
|
|
9
|
+
const MAX_EVENTS = 500;
|
|
10
|
+
// The tracker's answer for an AWB it does not know, in English and in Romanian.
|
|
11
|
+
const ABSENT = new Set(['the awb was not found', 'awb-ul nu a fost gasit']);
|
|
12
|
+
/** A thirteen-digit AWB, the only length FAN Courier's documentation, clients and tracker show. */
|
|
13
|
+
export function normalizeFanCourierNumber(raw) {
|
|
14
|
+
const number = normalizeTrackingNumber(raw);
|
|
15
|
+
if (!/^\d{13}$/.test(number))
|
|
16
|
+
throw new InvalidInputError(PROVIDER, 'FAN Courier requires a thirteen-digit AWB number');
|
|
17
|
+
return number;
|
|
18
|
+
}
|
|
19
|
+
/** A scan's wall clock. The reply gives no offset, so the clock stays local. */
|
|
20
|
+
function clock(raw) {
|
|
21
|
+
if (raw != null && typeof raw !== 'string')
|
|
22
|
+
throw new SchemaError(PROVIDER, 'FAN Courier returned an invalid scan clock');
|
|
23
|
+
const text = clean(raw, 32);
|
|
24
|
+
const parsed = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)
|
|
25
|
+
? DateTime.fromFormat(text, 'yyyy-MM-dd HH:mm:ss', { zone: 'UTC' }) : null;
|
|
26
|
+
return parsed?.isValid ? { local_time: parsed.toISO({ includeOffset: false, suppressMilliseconds: true }) }
|
|
27
|
+
: text ? { provider_time_text: text } : {};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The tracker's reply for one AWB: its events, oldest first, each with its
|
|
31
|
+
* code, wording, town and wall clock. The contents, weight, dimensions, the
|
|
32
|
+
* payment date, the related AWBs and the masked name of whoever signed are
|
|
33
|
+
* never read.
|
|
34
|
+
*/
|
|
35
|
+
export function parseFanCourier(payload, raw) {
|
|
36
|
+
const number = normalizeFanCourierNumber(raw);
|
|
37
|
+
if (!isRecord(payload))
|
|
38
|
+
throw new SchemaError(PROVIDER);
|
|
39
|
+
if (payload.awbNumber == null) {
|
|
40
|
+
// An unknown AWB comes back without one, the requested number in `content`
|
|
41
|
+
// and the page's message.
|
|
42
|
+
const message = clean(payload.message, 200).toLowerCase().replace(/\.$/, '');
|
|
43
|
+
if (!ABSENT.has(message))
|
|
44
|
+
throw new IndeterminateError(PROVIDER, 'FAN Courier did not return the AWB');
|
|
45
|
+
if (normalizeTrackingNumber(cleanScalar(payload.content, 64)) !== number) {
|
|
46
|
+
throw new SchemaError(PROVIDER, 'FAN Courier answered for a different number');
|
|
47
|
+
}
|
|
48
|
+
throw new NotFoundError(PROVIDER);
|
|
49
|
+
}
|
|
50
|
+
if (typeof payload.awbNumber !== 'string' && typeof payload.awbNumber !== 'number')
|
|
51
|
+
throw new SchemaError(PROVIDER);
|
|
52
|
+
if (normalizeTrackingNumber(cleanScalar(payload.awbNumber, 64)) !== number) {
|
|
53
|
+
throw new SchemaError(PROVIDER, 'FAN Courier answered for a different AWB');
|
|
54
|
+
}
|
|
55
|
+
const rows = payload.events;
|
|
56
|
+
if (!Array.isArray(rows) || rows.length > MAX_EVENTS)
|
|
57
|
+
throw new SchemaError(PROVIDER, 'FAN Courier returned invalid events');
|
|
58
|
+
// The tracker shows an error for an AWB without events: it proves neither a parcel on its way nor absence.
|
|
59
|
+
if (!rows.length)
|
|
60
|
+
throw new IndeterminateError(PROVIDER, 'FAN Courier returned no events for the AWB');
|
|
61
|
+
const scans = rows.map(row => {
|
|
62
|
+
if (!isRecord(row))
|
|
63
|
+
throw new SchemaError(PROVIDER, 'FAN Courier returned an invalid event');
|
|
64
|
+
const code = cleanScalar(row.id, 16);
|
|
65
|
+
const description = clean(row.name, 300);
|
|
66
|
+
if (!/^[A-Z]{1,3}\d{1,4}$/.test(code) || !description) {
|
|
67
|
+
throw new SchemaError(PROVIDER, 'FAN Courier returned an event without its code or wording');
|
|
68
|
+
}
|
|
69
|
+
return { code, description, location: clean(row.location, 100), time: clock(row.date) };
|
|
70
|
+
});
|
|
71
|
+
// The tracker hides what follows the last delivery, as the page does.
|
|
72
|
+
const delivered = scans.map(scan => scan.code).lastIndexOf('S2');
|
|
73
|
+
const kept = delivered === -1 ? scans : scans.slice(0, delivered + 1);
|
|
74
|
+
const events = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
// Newest first; equal clocks keep the reply's order.
|
|
77
|
+
for (const scan of kept.reverse()) {
|
|
78
|
+
const stage = fanCourierStage(scan.code);
|
|
79
|
+
const event = { description: scan.description, provider_code: scan.code, ...scan.time,
|
|
80
|
+
...(scan.location ? { location: scan.location } : {}), ...(stage ? { stage } : {}) };
|
|
81
|
+
const key = JSON.stringify(event);
|
|
82
|
+
if (!seen.has(key)) {
|
|
83
|
+
seen.add(key);
|
|
84
|
+
events.push(event);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const latest = events[0];
|
|
88
|
+
const stage = fanCourierStage(latest.provider_code);
|
|
89
|
+
return { status: stage ? languageStageStatus(stage) : 'unknown', ...(stage ? { current_stage: stage } : {}),
|
|
90
|
+
last_status_text: latest.description ?? null, last_update: null, last_update_local: latest.local_time ?? null,
|
|
91
|
+
events };
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Stage } from '../../core/status/index.js';
|
|
2
|
+
import type { CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage of one event code, if the map gives it one. */
|
|
4
|
+
export declare function fanCourierStage(code: string): Stage | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* What the map says about one scan. Every scan carries its code, so wording
|
|
7
|
+
* alone gets nothing. Codes whose meaning no source settles (S8, S38, S46,
|
|
8
|
+
* S49) stay unknown, open for review.
|
|
9
|
+
*/
|
|
10
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const each = (codes, stage) => codes.map(code => [code, stage]);
|
|
2
|
+
/**
|
|
3
|
+
* FAN Courier's event codes, the same in every language. The names come from
|
|
4
|
+
* the event list in FAN's API documentation; S56 and S65 are named only by the
|
|
5
|
+
* tracker's own code. The shared wording rules misread several of them, such
|
|
6
|
+
* as a parcel taken over for delivery.
|
|
7
|
+
*/
|
|
8
|
+
const CODES = new Map([
|
|
9
|
+
// The courier collected the parcel from its sender.
|
|
10
|
+
['C0', 'accepted'],
|
|
11
|
+
// Handed in at a FANbox locker or a collect point, for a courier to collect:
|
|
12
|
+
// the tracker puts it on the same milestone as C0.
|
|
13
|
+
['S56', 'accepted'],
|
|
14
|
+
// Line hauls, sorting and depot scans, and the depot a parcel waits in
|
|
15
|
+
// between delivery rounds.
|
|
16
|
+
...each(['H0', 'H1', 'H2', 'H3', 'H4', 'H10', 'H11', 'H12', 'H13', 'H15', 'H17'], 'in_transit'),
|
|
17
|
+
// Held for the next transport abroad, which the tracker counts as in transit.
|
|
18
|
+
['S65', 'in_transit'],
|
|
19
|
+
// Redirected, resent for delivery or handed to an external partner: the
|
|
20
|
+
// parcel goes on, a redirected one under its redirection AWB.
|
|
21
|
+
...each(['S9', 'S35', 'S47'], 'in_transit'),
|
|
22
|
+
// Taken over for delivery, then on the round.
|
|
23
|
+
...each(['C1', 'S1'], 'out_for_delivery'),
|
|
24
|
+
['S2', 'delivered'],
|
|
25
|
+
// A round that ended without a delivery: the recipient was notified or
|
|
26
|
+
// could not be reached, the address was wrong or incomplete, or a later
|
|
27
|
+
// delivery was agreed.
|
|
28
|
+
...each(['S3', 'S4', 'S5', 'S10', 'S11', 'S12', 'S14', 'S19', 'S20', 'S21', 'S22', 'S24', 'S25', 'S27', 'S28', 'S30', 'S42'], 'failed_attempt'),
|
|
29
|
+
// Refusals, returns and compensation: this AWB will not be delivered. A
|
|
30
|
+
// return travels back to the sender under its own AWB, so none of these
|
|
31
|
+
// says the sender has it.
|
|
32
|
+
...each(['S6', 'S7', 'S15', 'S16', 'S33', 'S37', 'S43', 'S50'], 'exception'),
|
|
33
|
+
]);
|
|
34
|
+
/** The stage of one event code, if the map gives it one. */
|
|
35
|
+
export function fanCourierStage(code) {
|
|
36
|
+
return CODES.get(code);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* What the map says about one scan. Every scan carries its code, so wording
|
|
40
|
+
* alone gets nothing. Codes whose meaning no source settles (S8, S38, S46,
|
|
41
|
+
* S49) stay unknown, open for review.
|
|
42
|
+
*/
|
|
43
|
+
export const statusMap = {
|
|
44
|
+
stage: code => (code ? fanCourierStage(code) : undefined),
|
|
45
|
+
gaps: [],
|
|
46
|
+
};
|