universal-parcel-scraper 0.78.0-main.939 → 0.80.0-main.943
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 +19 -5
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +16 -6
- package/dist/carriers/aras-kargo/adapter.d.ts +15 -0
- package/dist/carriers/aras-kargo/adapter.js +105 -0
- package/dist/carriers/aras-kargo/carrier.json +69 -10
- package/dist/carriers/aras-kargo/parser.d.ts +20 -0
- package/dist/carriers/aras-kargo/parser.js +205 -0
- package/dist/carriers/aras-kargo/status.d.ts +25 -0
- package/dist/carriers/aras-kargo/status.js +59 -0
- package/dist/carriers/aras-kargo/statuses.json +130 -1
- package/dist/carriers/yurtici-kargo/adapter.d.ts +18 -0
- package/dist/carriers/yurtici-kargo/adapter.js +72 -0
- package/dist/carriers/yurtici-kargo/carrier.json +47 -7
- package/dist/carriers/yurtici-kargo/numbers.json +20 -20
- package/dist/carriers/yurtici-kargo/parser.d.ts +26 -0
- package/dist/carriers/yurtici-kargo/parser.js +250 -0
- package/dist/carriers/yurtici-kargo/status.d.ts +11 -0
- package/dist/carriers/yurtici-kargo/status.js +58 -0
- package/dist/carriers/yurtici-kargo/statuses.json +21 -1
- package/dist/core/catalog/statusMaps.js +4 -2
- package/dist/data/catalog.json +19 -5
- package/dist/generated/catalog.d.ts +13 -5
- package/dist/generated/catalog.js +19 -5
- package/dist/generated/recognition.js +2 -0
- package/dist/generated/registry.js +6 -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 **137 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,887 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
|
@@ -4808,12 +4808,24 @@
|
|
|
4808
4808
|
"timezone": "Europe/Istanbul",
|
|
4809
4809
|
"tracking": {
|
|
4810
4810
|
"mode": "automatic",
|
|
4811
|
-
"adapter": "
|
|
4811
|
+
"adapter": "aras-kargo",
|
|
4812
|
+
"recognitionRank": 67,
|
|
4813
|
+
"localClocks": true
|
|
4812
4814
|
},
|
|
4813
|
-
"canaryUrl": "https://
|
|
4814
|
-
"trackingUrlTemplate": "https://
|
|
4815
|
+
"canaryUrl": "https://kargotakip.araskargo.com.tr/mainpage.aspx",
|
|
4816
|
+
"trackingUrlTemplate": "https://kargotakip.araskargo.com.tr/mainpage.aspx?code={trackingNumber}",
|
|
4815
4817
|
"trackingSearchUrl": "https://www.araskargo.com.tr/",
|
|
4816
|
-
"linkRules": [
|
|
4818
|
+
"linkRules": [
|
|
4819
|
+
{
|
|
4820
|
+
"domains": [
|
|
4821
|
+
"kargotakip.araskargo.com.tr"
|
|
4822
|
+
],
|
|
4823
|
+
"pathPattern": "^/mainpage\\.aspx$",
|
|
4824
|
+
"params": [
|
|
4825
|
+
"code"
|
|
4826
|
+
]
|
|
4827
|
+
}
|
|
4828
|
+
],
|
|
4817
4829
|
"detectionRules": []
|
|
4818
4830
|
},
|
|
4819
4831
|
"box-now": {
|
|
@@ -8285,7 +8297,9 @@
|
|
|
8285
8297
|
"timezone": "Europe/Istanbul",
|
|
8286
8298
|
"tracking": {
|
|
8287
8299
|
"mode": "automatic",
|
|
8288
|
-
"adapter": "
|
|
8300
|
+
"adapter": "yurtici-kargo",
|
|
8301
|
+
"recognitionRank": 66,
|
|
8302
|
+
"localClocks": true
|
|
8289
8303
|
},
|
|
8290
8304
|
"canaryUrl": "https://www.yurticikargo.com/tr/online-servisler/gonderi-sorgula",
|
|
8291
8305
|
"trackingUrlTemplate": "https://www.yurticikargo.com/tr/online-servisler/gonderi-sorgula?code={trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -3937,12 +3937,18 @@ var ye = {
|
|
|
3937
3937
|
timezone: "Europe/Istanbul",
|
|
3938
3938
|
tracking: {
|
|
3939
3939
|
mode: "automatic",
|
|
3940
|
-
adapter: "
|
|
3940
|
+
adapter: "aras-kargo",
|
|
3941
|
+
recognitionRank: 67,
|
|
3942
|
+
localClocks: !0
|
|
3941
3943
|
},
|
|
3942
|
-
canaryUrl: "https://
|
|
3943
|
-
trackingUrlTemplate: "https://
|
|
3944
|
+
canaryUrl: "https://kargotakip.araskargo.com.tr/mainpage.aspx",
|
|
3945
|
+
trackingUrlTemplate: "https://kargotakip.araskargo.com.tr/mainpage.aspx?code={trackingNumber}",
|
|
3944
3946
|
trackingSearchUrl: "https://www.araskargo.com.tr/",
|
|
3945
|
-
linkRules: [
|
|
3947
|
+
linkRules: [{
|
|
3948
|
+
domains: ["kargotakip.araskargo.com.tr"],
|
|
3949
|
+
pathPattern: "^/mainpage\\.aspx$",
|
|
3950
|
+
params: ["code"]
|
|
3951
|
+
}],
|
|
3946
3952
|
detectionRules: []
|
|
3947
3953
|
},
|
|
3948
3954
|
"box-now": {
|
|
@@ -6522,7 +6528,9 @@ var ye = {
|
|
|
6522
6528
|
timezone: "Europe/Istanbul",
|
|
6523
6529
|
tracking: {
|
|
6524
6530
|
mode: "automatic",
|
|
6525
|
-
adapter: "
|
|
6531
|
+
adapter: "yurtici-kargo",
|
|
6532
|
+
recognitionRank: 66,
|
|
6533
|
+
localClocks: !0
|
|
6526
6534
|
},
|
|
6527
6535
|
canaryUrl: "https://www.yurticikargo.com/tr/online-servisler/gonderi-sorgula",
|
|
6528
6536
|
trackingUrlTemplate: "https://www.yurticikargo.com/tr/online-servisler/gonderi-sorgula?code={trackingNumber}",
|
|
@@ -11707,6 +11715,7 @@ function gc(e) {
|
|
|
11707
11715
|
var _c = {
|
|
11708
11716
|
"acs-courier": 57,
|
|
11709
11717
|
aliexpress: 14,
|
|
11718
|
+
"aras-kargo": 67,
|
|
11710
11719
|
"australia-post": 6,
|
|
11711
11720
|
"austrian-post": 39,
|
|
11712
11721
|
"box-now": 48,
|
|
@@ -11772,7 +11781,8 @@ var _c = {
|
|
|
11772
11781
|
tnt: 44,
|
|
11773
11782
|
ukrposhta: 5,
|
|
11774
11783
|
uniuni: 33,
|
|
11775
|
-
venipak: 63
|
|
11784
|
+
venipak: 63,
|
|
11785
|
+
"yurtici-kargo": 66
|
|
11776
11786
|
}, vc = 5;
|
|
11777
11787
|
function yc(e) {
|
|
11778
11788
|
let t = y(e);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
/** The legacy tracker's link for one parcel, the one merchants send. */
|
|
5
|
+
export declare function arasTrackingUrl(raw: string): string;
|
|
6
|
+
export declare class ArasKargoTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
});
|
|
13
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
14
|
+
}
|
|
15
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { IndeterminateError, SchemaError } 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 { arasWaybillFrame, normalizeArasNumber, parseAras, parseArasLookup, PROVIDER } from './parser.js';
|
|
8
|
+
const ORIGIN = 'https://kargotakip.araskargo.com.tr';
|
|
9
|
+
// Each page is under 30 kB; a long history adds a few hundred bytes a row.
|
|
10
|
+
const MAX_BYTES = 512 * 1024;
|
|
11
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
12
|
+
const COOKIE = /^([A-Za-z0-9!#$%&'*+.^_`|~-]{1,64})=([\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]{0,1024})$/;
|
|
13
|
+
const MAX_COOKIES = 16;
|
|
14
|
+
/** The legacy tracker's link for one parcel, the one merchants send. */
|
|
15
|
+
export function arasTrackingUrl(raw) {
|
|
16
|
+
return `${ORIGIN}/mainpage.aspx?${new URLSearchParams({ code: normalizeArasNumber(raw) })}`;
|
|
17
|
+
}
|
|
18
|
+
/** Keeps each cookie the tracker sets, by name, as a browser would send it back. */
|
|
19
|
+
function keepCookies(jar, response) {
|
|
20
|
+
for (const header of response.headers.getSetCookie()) {
|
|
21
|
+
const match = COOKIE.exec(header.split(';', 1)[0].trim());
|
|
22
|
+
if (match)
|
|
23
|
+
jar.set(match[1], match[2]);
|
|
24
|
+
}
|
|
25
|
+
if (jar.size > MAX_COOKIES)
|
|
26
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo set too many cookies');
|
|
27
|
+
}
|
|
28
|
+
/** Whether a reply sends the browser on to this page of the tracker. */
|
|
29
|
+
function redirectsTo(response, page) {
|
|
30
|
+
if (!REDIRECTS.includes(response.status))
|
|
31
|
+
return false;
|
|
32
|
+
const location = response.headers.get('location');
|
|
33
|
+
if (!location || location.length > 200)
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(location, ORIGIN);
|
|
37
|
+
return url.origin === ORIGIN && url.pathname.toLowerCase() === page && !url.search && !url.hash && !url.username;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class ArasKargoTracker {
|
|
44
|
+
options;
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.options = options;
|
|
47
|
+
}
|
|
48
|
+
async fetch(raw, context = {}) {
|
|
49
|
+
const number = normalizeArasNumber(raw);
|
|
50
|
+
// The tracker keeps the parcel in a session: the lookup page finds it and
|
|
51
|
+
// redirects to the tracking page, whose frames read it from the session.
|
|
52
|
+
// The load balancer's cookie keeps every request on the server that holds it.
|
|
53
|
+
const read = async (signal, timeoutMs) => {
|
|
54
|
+
const deadline = performance.now() + timeoutMs;
|
|
55
|
+
const jar = new Map();
|
|
56
|
+
const get = async (path) => {
|
|
57
|
+
const headers = {
|
|
58
|
+
'User-Agent': userAgentOf(this.options.userAgent), Accept: 'text/html', 'Accept-Language': 'tr-TR,tr;q=0.9',
|
|
59
|
+
};
|
|
60
|
+
if (jar.size)
|
|
61
|
+
headers.Cookie = [...jar].map(([name, value]) => `${name}=${value}`).join('; ');
|
|
62
|
+
const { response, bytes } = await fetchBounded(`${ORIGIN}/${path}`, { signal, headers }, {
|
|
63
|
+
provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(deadline - performance.now())), maxBytes: MAX_BYTES,
|
|
64
|
+
redirect: 'manual', allowHttpStatuses: [...REDIRECTS, 404, 410], fetcher: this.options.fetcher,
|
|
65
|
+
});
|
|
66
|
+
keepCookies(jar, response);
|
|
67
|
+
return { response, html: () => decodeText(bytes) };
|
|
68
|
+
};
|
|
69
|
+
const lookup = await get(`mainpage.aspx?${new URLSearchParams({ code: number })}`);
|
|
70
|
+
if (lookup.response.status === 200)
|
|
71
|
+
parseArasLookup(lookup.html(), number);
|
|
72
|
+
if (!redirectsTo(lookup.response, '/kargotakip.aspx'))
|
|
73
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo tracker moved');
|
|
74
|
+
if (!jar.has('ASP.NET_SessionId'))
|
|
75
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo did not open a session');
|
|
76
|
+
const tracker = await get('kargotakip.aspx');
|
|
77
|
+
// Without the shipment in its session, the tracking page sends the browser back to the lookup.
|
|
78
|
+
if (tracker.response.status !== 200)
|
|
79
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo tracking page is unavailable');
|
|
80
|
+
const summary = await get(arasWaybillFrame(tracker.html()));
|
|
81
|
+
if (summary.response.status !== 200)
|
|
82
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo shipment summary is unavailable');
|
|
83
|
+
// The movements frame reads the shipment the summary frame put in the session.
|
|
84
|
+
const movements = await get('CargoInfoTransactionAndRedirection.aspx');
|
|
85
|
+
if (movements.response.status !== 200)
|
|
86
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo shipment movements are unavailable');
|
|
87
|
+
return parseAras(summary.html(), movements.html(), number);
|
|
88
|
+
};
|
|
89
|
+
// A lookup that fails to reach Aras, or hangs, starts again in a new
|
|
90
|
+
// session: the first gets half of the budget, so the retry fits in the rest.
|
|
91
|
+
return runSteps({ carrier: 'aras-kargo', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
92
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
93
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
94
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export const adapter = (environment) => {
|
|
99
|
+
const tracker = new ArasKargoTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
100
|
+
return {
|
|
101
|
+
id: 'aras-kargo', recordsSteps: true, steps: ['direct', 'retry'],
|
|
102
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
103
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeArasNumber(number))),
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -16,19 +16,78 @@
|
|
|
16
16
|
"timezone": "Europe/Istanbul",
|
|
17
17
|
"tracking": {
|
|
18
18
|
"mode": "automatic",
|
|
19
|
-
"adapter": "
|
|
20
|
-
"steps": [
|
|
19
|
+
"adapter": "aras-kargo",
|
|
20
|
+
"steps": [
|
|
21
|
+
"direct",
|
|
22
|
+
"retry"
|
|
23
|
+
],
|
|
24
|
+
"recognition": {
|
|
25
|
+
"rank": 67
|
|
26
|
+
},
|
|
27
|
+
"localClocks": true
|
|
21
28
|
},
|
|
22
|
-
"capabilities": [
|
|
29
|
+
"capabilities": [
|
|
30
|
+
"history",
|
|
31
|
+
"location",
|
|
32
|
+
"eta"
|
|
33
|
+
],
|
|
23
34
|
"portal": {
|
|
24
|
-
"url": "https://
|
|
35
|
+
"url": "https://kargotakip.araskargo.com.tr/mainpage.aspx?code={trackingNumber}",
|
|
25
36
|
"searchUrl": "https://www.araskargo.com.tr/",
|
|
26
|
-
"canaryUrl": "https://
|
|
27
|
-
"shows": [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
"canaryUrl": "https://kargotakip.araskargo.com.tr/mainpage.aspx",
|
|
38
|
+
"shows": [
|
|
39
|
+
"status",
|
|
40
|
+
"history",
|
|
41
|
+
"location",
|
|
42
|
+
"eta",
|
|
43
|
+
"sender_name",
|
|
44
|
+
"recipient_name",
|
|
45
|
+
"signatory",
|
|
46
|
+
"delivery_pin",
|
|
47
|
+
"recipient_verification",
|
|
48
|
+
"payment_details",
|
|
49
|
+
"invoice_reference",
|
|
50
|
+
"volume",
|
|
51
|
+
"parcel_count",
|
|
52
|
+
"progress_rail",
|
|
53
|
+
"delivery_comments",
|
|
54
|
+
"customer_messages"
|
|
55
|
+
],
|
|
56
|
+
"scraped": [
|
|
57
|
+
"status",
|
|
58
|
+
"history",
|
|
59
|
+
"location",
|
|
60
|
+
"eta"
|
|
61
|
+
],
|
|
62
|
+
"discarded": [
|
|
63
|
+
"sender_name",
|
|
64
|
+
"recipient_name",
|
|
65
|
+
"signatory",
|
|
66
|
+
"delivery_pin",
|
|
67
|
+
"recipient_verification",
|
|
68
|
+
"payment_details",
|
|
69
|
+
"invoice_reference",
|
|
70
|
+
"volume",
|
|
71
|
+
"parcel_count",
|
|
72
|
+
"progress_rail",
|
|
73
|
+
"delivery_comments",
|
|
74
|
+
"customer_messages"
|
|
75
|
+
],
|
|
76
|
+
"unavailable": [
|
|
77
|
+
"pickup_point",
|
|
78
|
+
"offset_timestamps"
|
|
79
|
+
]
|
|
31
80
|
},
|
|
32
|
-
"links": [
|
|
81
|
+
"links": [
|
|
82
|
+
{
|
|
83
|
+
"domains": [
|
|
84
|
+
"kargotakip.araskargo.com.tr"
|
|
85
|
+
],
|
|
86
|
+
"pathPattern": "^/mainpage\\.aspx$",
|
|
87
|
+
"params": [
|
|
88
|
+
"code"
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
],
|
|
33
92
|
"detection": []
|
|
34
93
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "Aras Kargo";
|
|
3
|
+
/** A thirteen-digit Aras Kargo tracking number. */
|
|
4
|
+
export declare function normalizeArasNumber(raw: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The lookup page's answer when it does not open the tracker: its notice
|
|
7
|
+
* for a number it does not know, over the form for that number. Anything else
|
|
8
|
+
* says nothing about the parcel.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseArasLookup(html: string, raw: string): never;
|
|
11
|
+
/** The summary frame of the tracking page, relative to the tracker. */
|
|
12
|
+
export declare function arasWaybillFrame(html: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* The tracker's two frames for one parcel: the summary, with the current
|
|
15
|
+
* status, the delivery, the estimate and the days a delivery was not made,
|
|
16
|
+
* and the vehicle movements with any redirection. Names, the signatory, the
|
|
17
|
+
* delivery code, invoice and payment details and every free-text note are
|
|
18
|
+
* never read.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseAras(waybillHtml: string, movementsHtml: string, raw: string): CarrierResult;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { arasStage } from './status.js';
|
|
8
|
+
export const PROVIDER = 'Aras Kargo';
|
|
9
|
+
/** Aras Kargo's tracking numbers, as on every label and sample seen. */
|
|
10
|
+
const NUMBER = /^\d{13}$/;
|
|
11
|
+
/** The lookup page's answer for a number it does not know. */
|
|
12
|
+
const ABSENT = 'Girdiğiniz bilgiler için bir sonuç bulunamamıştır.';
|
|
13
|
+
/** The summary frame the tracking page opens, bound to the shipment by an opaque id. */
|
|
14
|
+
const WAYBILL_FRAME = /^CargoInfoWaybillAndDelivered\.aspx\?shipmentid=[0-9A-F]{32}&subekod=\d{0,6}$/;
|
|
15
|
+
const MOVEMENTS_FRAME = 'CargoInfoTransactionAndRedirection.aspx';
|
|
16
|
+
const MOVEMENT_HEADER = ['İşlem Tarihi', 'İl', 'Birim', 'İşlem Açıklaması'];
|
|
17
|
+
const NON_DELIVERY_HEADER = ['Tarih', 'Teslim Edilememe Nedeni', 'Açıklama'];
|
|
18
|
+
const REDIRECTION_HEADER = ['Tarih', 'Kargo Tipi', 'Kargo Durumu', 'Yönlendiren Birim', 'Yönlendirilen Birim', 'Yönlendirme Nedeni'];
|
|
19
|
+
const MAX_ROWS = 300;
|
|
20
|
+
const MAX_EVENTS = 100;
|
|
21
|
+
const MOVING = new Set(['registered', 'accepted', 'in_transit', 'out_for_delivery']);
|
|
22
|
+
/** A thirteen-digit Aras Kargo tracking number. */
|
|
23
|
+
export function normalizeArasNumber(raw) {
|
|
24
|
+
const number = normalizeTrackingNumber(raw);
|
|
25
|
+
if (!NUMBER.test(number))
|
|
26
|
+
throw new InvalidInputError(PROVIDER, 'Aras Kargo tracking numbers are thirteen digits');
|
|
27
|
+
return number;
|
|
28
|
+
}
|
|
29
|
+
/** A wall clock or a day in the page's day-first form ("3.10.2026 12:01:33", "05.10.2026"). It carries no offset. */
|
|
30
|
+
function wallClock(text, form) {
|
|
31
|
+
const shapes = { day: 'd.M.yyyy', minute: 'd.M.yyyy H:mm', second: 'd.M.yyyy H:mm:ss' };
|
|
32
|
+
if (!/^[\d.: ]{8,19}$/.test(text))
|
|
33
|
+
return null;
|
|
34
|
+
// UTC only checks the calendar fields. No offset is assigned.
|
|
35
|
+
const parsed = DateTime.fromFormat(text, shapes[form], { zone: 'UTC' });
|
|
36
|
+
if (!parsed.isValid)
|
|
37
|
+
return null;
|
|
38
|
+
return parsed.toFormat(form === 'day' ? 'yyyy-MM-dd' : "yyyy-MM-dd'T'HH:mm:ss");
|
|
39
|
+
}
|
|
40
|
+
/** The one element with this id, or a schema failure. */
|
|
41
|
+
function only($, selector, what) {
|
|
42
|
+
const found = $(selector);
|
|
43
|
+
if (found.length !== 1)
|
|
44
|
+
throw new SchemaError(PROVIDER, `Aras Kargo ${what} changed`);
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A grid's rows as cell texts, after its header. A grid the server did not
|
|
49
|
+
* fill is missing from the page: the session did not carry the shipment.
|
|
50
|
+
*/
|
|
51
|
+
function grid($, id, header, what) {
|
|
52
|
+
const tables = $(`table#${id}`);
|
|
53
|
+
if (!tables.length)
|
|
54
|
+
throw new IndeterminateError(PROVIDER, `Aras Kargo did not return the ${what}`);
|
|
55
|
+
if (tables.length !== 1)
|
|
56
|
+
throw new SchemaError(PROVIDER, `Aras Kargo repeated the ${what}`);
|
|
57
|
+
const rows = tables.find('tr').toArray().map(row => $(row).children('td').toArray().map(cell => clean($(cell).text(), 200)));
|
|
58
|
+
if (!rows.length || rows[0].join('\u0000') !== header.join('\u0000'))
|
|
59
|
+
throw new SchemaError(PROVIDER, `Aras Kargo ${what} changed`);
|
|
60
|
+
if (rows.length - 1 > MAX_ROWS)
|
|
61
|
+
throw new SchemaError(PROVIDER, `Aras Kargo returned too many rows in the ${what}`);
|
|
62
|
+
return rows.slice(1).map(cells => {
|
|
63
|
+
if (cells.length !== header.length)
|
|
64
|
+
throw new SchemaError(PROVIDER, `Aras Kargo ${what} changed`);
|
|
65
|
+
return cells;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** A movement's place: its unit, then its province unless the unit already names it. */
|
|
69
|
+
function place(unit, province) {
|
|
70
|
+
if (!unit)
|
|
71
|
+
return province;
|
|
72
|
+
if (!province || unit === province || unit.startsWith(`${province} `))
|
|
73
|
+
return unit;
|
|
74
|
+
return `${unit}, ${province}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The lookup page's answer when it does not open the tracker: its notice
|
|
78
|
+
* for a number it does not know, over the form for that number. Anything else
|
|
79
|
+
* says nothing about the parcel.
|
|
80
|
+
*/
|
|
81
|
+
export function parseArasLookup(html, raw) {
|
|
82
|
+
const number = normalizeArasNumber(raw);
|
|
83
|
+
const $ = load(html);
|
|
84
|
+
const notice = $('span#Label1');
|
|
85
|
+
const form = $('form#form1');
|
|
86
|
+
if (notice.length === 1 && form.length === 1 && clean(notice.text(), 200) === ABSENT
|
|
87
|
+
&& form.attr('action') === `./mainpage.aspx?code=${number}`) {
|
|
88
|
+
throw new NotFoundError(PROVIDER);
|
|
89
|
+
}
|
|
90
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo did not open the tracker for this number');
|
|
91
|
+
}
|
|
92
|
+
/** The summary frame of the tracking page, relative to the tracker. */
|
|
93
|
+
export function arasWaybillFrame(html) {
|
|
94
|
+
const $ = load(html);
|
|
95
|
+
const panels = $('#tabPanels');
|
|
96
|
+
if (!panels.length)
|
|
97
|
+
throw new IndeterminateError(PROVIDER, 'Aras Kargo did not return its tracking page');
|
|
98
|
+
const waybill = only($, 'iframe#panel0', 'tracking page').attr('src') ?? '';
|
|
99
|
+
const movements = only($, 'iframe#panel1', 'tracking page').attr('data-src') ?? '';
|
|
100
|
+
if (!WAYBILL_FRAME.test(waybill) || movements !== MOVEMENTS_FRAME)
|
|
101
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo tracking page changed');
|
|
102
|
+
return waybill;
|
|
103
|
+
}
|
|
104
|
+
/** Each frame names the number it answers for; an empty one did not get the shipment. */
|
|
105
|
+
function bind($, selector, number, what) {
|
|
106
|
+
if ($('html').attr('lang') !== 'tr')
|
|
107
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo tracking page changed language');
|
|
108
|
+
const shown = $(selector);
|
|
109
|
+
if (shown.length > 1)
|
|
110
|
+
throw new SchemaError(PROVIDER, `Aras Kargo ${what} changed`);
|
|
111
|
+
const text = clean(shown.text(), 80);
|
|
112
|
+
if (!text)
|
|
113
|
+
throw new IndeterminateError(PROVIDER, `Aras Kargo did not return the ${what}`);
|
|
114
|
+
if (normalizeTrackingNumber(text) !== number)
|
|
115
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo returned a different parcel');
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The tracker's two frames for one parcel: the summary, with the current
|
|
119
|
+
* status, the delivery, the estimate and the days a delivery was not made,
|
|
120
|
+
* and the vehicle movements with any redirection. Names, the signatory, the
|
|
121
|
+
* delivery code, invoice and payment details and every free-text note are
|
|
122
|
+
* never read.
|
|
123
|
+
*/
|
|
124
|
+
export function parseAras(waybillHtml, movementsHtml, raw) {
|
|
125
|
+
const number = normalizeArasNumber(raw);
|
|
126
|
+
const summary = load(waybillHtml);
|
|
127
|
+
const movements = load(movementsHtml);
|
|
128
|
+
bind(summary, '#labelShipmentCode', number, 'shipment summary');
|
|
129
|
+
bind(movements, '.sno-value', number, 'shipment movements');
|
|
130
|
+
const status = clean(only(summary, 'span#Son_Durum', 'shipment summary').text(), 120);
|
|
131
|
+
const deliveredText = clean(only(summary, 'span#Teslim_Tarihi', 'shipment summary').text(), 40);
|
|
132
|
+
const branch = clean(only(summary, 'span#varis_subesi', 'shipment summary').text(), 80);
|
|
133
|
+
const plannedText = clean(only(summary, 'span#planlanan_teslim_tarihi', 'shipment summary').text(), 40);
|
|
134
|
+
const scans = [];
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
// Within a day, rows dated by the day (order 1) follow the clocked movements (0), and the delivery (2) ends it.
|
|
137
|
+
const add = (local, description, location, order) => {
|
|
138
|
+
const event = { local_time: local, description, ...(location ? { location } : {}) };
|
|
139
|
+
const key = JSON.stringify(event);
|
|
140
|
+
if (seen.has(key))
|
|
141
|
+
return;
|
|
142
|
+
seen.add(key);
|
|
143
|
+
const stage = arasStage(description);
|
|
144
|
+
if (stage)
|
|
145
|
+
Object.assign(event, { stage, stage_source: 'carrier_map' });
|
|
146
|
+
scans.push({ event, local, order });
|
|
147
|
+
};
|
|
148
|
+
for (const [time, province, unit, description] of grid(movements, 'transactionsDataGrid', MOVEMENT_HEADER, 'vehicle movements')) {
|
|
149
|
+
const local = wallClock(time, 'second');
|
|
150
|
+
if (!local || !description)
|
|
151
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo returned an invalid movement');
|
|
152
|
+
add(local, description, place(unit, province), 0);
|
|
153
|
+
}
|
|
154
|
+
// A redirection's reason follows its state in brackets.
|
|
155
|
+
for (const [date, , state, from, , cause] of grid(movements, 'redirectionDataGrid', REDIRECTION_HEADER, 'redirections')) {
|
|
156
|
+
const day = wallClock(date, 'day');
|
|
157
|
+
if (!day || !state)
|
|
158
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo returned an invalid redirection');
|
|
159
|
+
const reason = cause.replace(/[()]/g, '').trim();
|
|
160
|
+
add(day, reason ? `${state} (${reason})` : state, from, 1);
|
|
161
|
+
}
|
|
162
|
+
// The third cell is the courier's free-text note.
|
|
163
|
+
for (const [date, reason] of grid(summary, 'notDeliveredDataGrid', NON_DELIVERY_HEADER, 'failed deliveries')) {
|
|
164
|
+
const day = wallClock(date, 'day');
|
|
165
|
+
if (!day || !reason)
|
|
166
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo returned an invalid failed delivery');
|
|
167
|
+
add(day, reason, '', 1);
|
|
168
|
+
}
|
|
169
|
+
if (deliveredText) {
|
|
170
|
+
const local = wallClock(deliveredText, 'minute');
|
|
171
|
+
if (!local || !status)
|
|
172
|
+
throw new SchemaError(PROVIDER, 'Aras Kargo returned an invalid delivery time');
|
|
173
|
+
add(local, status, branch, 2);
|
|
174
|
+
}
|
|
175
|
+
// Newest first. The movement list comes sorted as text, not by time.
|
|
176
|
+
const day = (scan) => scan.local.slice(0, 10);
|
|
177
|
+
scans.sort((left, right) => day(left) !== day(right) ? (day(left) < day(right) ? 1 : -1)
|
|
178
|
+
: left.order !== right.order ? right.order - left.order
|
|
179
|
+
: left.local < right.local ? 1 : left.local > right.local ? -1 : 0);
|
|
180
|
+
const events = scans.map(({ event }) => event);
|
|
181
|
+
const newest = events[0];
|
|
182
|
+
const newestDay = scans[0] ? day(scans[0]) : null;
|
|
183
|
+
if (!status && !newest)
|
|
184
|
+
throw new NoHistoryError(PROVIDER);
|
|
185
|
+
// The current status settles a delivery. Otherwise a failed delivery after
|
|
186
|
+
// the last movement is the parcel's news, though the status still has it
|
|
187
|
+
// at the delivery branch.
|
|
188
|
+
const current = status ? arasStage(status) : newest.stage;
|
|
189
|
+
const failed = current !== 'delivered' && newest?.stage === 'failed_attempt';
|
|
190
|
+
const stage = failed ? 'failed_attempt' : current;
|
|
191
|
+
const text = failed ? newest.description : status || newest.description;
|
|
192
|
+
// The planned day is an estimate: it goes once the parcel stops moving or the day is behind its news.
|
|
193
|
+
const planned = wallClock(plannedText, 'day');
|
|
194
|
+
const expected = stage && MOVING.has(stage) && planned && (!newestDay || planned >= newestDay) ? planned : null;
|
|
195
|
+
return {
|
|
196
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
197
|
+
...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
|
|
198
|
+
last_status_text: text,
|
|
199
|
+
last_update: null,
|
|
200
|
+
...(scans[0] ? { last_update_local: scans[0].local } : {}),
|
|
201
|
+
expected_delivery: expected,
|
|
202
|
+
...(events.length ? {} : { summary_only: true }),
|
|
203
|
+
events: events.slice(0, MAX_EVENTS),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The tracker's wording, mapped whole. It gives no status codes. "Son Durum"
|
|
5
|
+
* is the parcel's current status; the delivery is read from it, as the
|
|
6
|
+
* movement list stops at the delivery branch.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ARAS_STATUSES: Readonly<Record<string, Stage>>;
|
|
9
|
+
/**
|
|
10
|
+
* The vehicle movements ("Araç Hareketleri"): loading and unloading at a
|
|
11
|
+
* branch ("şube") or a transfer centre ("aktarma"), and bagging there. The
|
|
12
|
+
* page spells some without Turkish letters.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ARAS_MOVEMENTS: Readonly<Record<string, Stage>>;
|
|
15
|
+
/** The reasons a delivery was not made ("Teslim Edilememe Nedeni"), one row a day. */
|
|
16
|
+
export declare const ARAS_NON_DELIVERIES: Readonly<Record<string, Stage>>;
|
|
17
|
+
/**
|
|
18
|
+
* A redirection's state ("Kargo Durumu"). The parser adds the reason in
|
|
19
|
+
* brackets, and the map reads the state without it.
|
|
20
|
+
*/
|
|
21
|
+
export declare const ARAS_REDIRECTIONS: Readonly<Record<string, Stage>>;
|
|
22
|
+
/** The stage the tracker's wording gives a status or a scan, if any. */
|
|
23
|
+
export declare function arasStage(wording: string): Stage | undefined;
|
|
24
|
+
/** The tracker's scans carry wording and no code. */
|
|
25
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tracker's wording, mapped whole. It gives no status codes. "Son Durum"
|
|
4
|
+
* is the parcel's current status; the delivery is read from it, as the
|
|
5
|
+
* movement list stops at the delivery branch.
|
|
6
|
+
*/
|
|
7
|
+
export const ARAS_STATUSES = {
|
|
8
|
+
// At the branch that took the parcel in.
|
|
9
|
+
'ÇIKIŞ ŞUBESİNDE': 'accepted',
|
|
10
|
+
'YOLDA': 'in_transit',
|
|
11
|
+
// At the branch that delivers it, also after a failed delivery.
|
|
12
|
+
'TESLİMAT ŞUBESİNDE': 'in_transit',
|
|
13
|
+
'TESLİM EDİLDİ': 'delivered',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The vehicle movements ("Araç Hareketleri"): loading and unloading at a
|
|
17
|
+
* branch ("şube") or a transfer centre ("aktarma"), and bagging there. The
|
|
18
|
+
* page spells some without Turkish letters.
|
|
19
|
+
*/
|
|
20
|
+
export const ARAS_MOVEMENTS = {
|
|
21
|
+
'ŞUBEDE ARAÇ YÜKLEME': 'in_transit',
|
|
22
|
+
'ŞUBEDE ARAÇ İNDİRME': 'in_transit',
|
|
23
|
+
'AKTARMADA HAT ARACI YÜKLEME': 'in_transit',
|
|
24
|
+
'AKTARMADA HAT ARACI İNDİRME': 'in_transit',
|
|
25
|
+
'AKTARMADA SUBE ARACI YUKLEME': 'in_transit',
|
|
26
|
+
'AKTARMADA SUBE ARACI INDIRME': 'in_transit',
|
|
27
|
+
'AKTARMADA TORBA DOLDURMA': 'in_transit',
|
|
28
|
+
};
|
|
29
|
+
/** The reasons a delivery was not made ("Teslim Edilememe Nedeni"), one row a day. */
|
|
30
|
+
export const ARAS_NON_DELIVERIES = {
|
|
31
|
+
'TESLİMAT GÜN İÇERİSİNDE YAPILAMADI': 'failed_attempt',
|
|
32
|
+
'TATİL DOLAYISIYLA TESLİMAT YAPILAMADI': 'failed_attempt',
|
|
33
|
+
'UĞRAMA NOTU BIRAKILDI (1-2)': 'failed_attempt',
|
|
34
|
+
'ALICI ADRESİ DAĞITIM ALANI DIŞINDA': 'failed_attempt',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A redirection's state ("Kargo Durumu"). The parser adds the reason in
|
|
38
|
+
* brackets, and the map reads the state without it.
|
|
39
|
+
*/
|
|
40
|
+
export const ARAS_REDIRECTIONS = {
|
|
41
|
+
'YÖNLENDİRİLDİ': 'in_transit',
|
|
42
|
+
};
|
|
43
|
+
/** A redirection's reason, which the parser adds in brackets. */
|
|
44
|
+
const REASON = /\s*\([^()]*\)$/;
|
|
45
|
+
const WORDINGS = new Map(Object.entries({ ...ARAS_STATUSES, ...ARAS_MOVEMENTS, ...ARAS_NON_DELIVERIES })
|
|
46
|
+
.map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
|
|
47
|
+
const REDIRECTIONS = new Map(Object.entries(ARAS_REDIRECTIONS).map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
|
|
48
|
+
function stageOf(wording) {
|
|
49
|
+
return WORDINGS.get(wording) ?? REDIRECTIONS.get(wording.replace(REASON, ''));
|
|
50
|
+
}
|
|
51
|
+
/** The stage the tracker's wording gives a status or a scan, if any. */
|
|
52
|
+
export function arasStage(wording) {
|
|
53
|
+
return stageOf(normalizeStatusWording(wording));
|
|
54
|
+
}
|
|
55
|
+
/** The tracker's scans carry wording and no code. */
|
|
56
|
+
export const statusMap = {
|
|
57
|
+
stage: (code, wording) => code ? undefined : stageOf(wording),
|
|
58
|
+
gaps: [],
|
|
59
|
+
};
|