universal-parcel-scraper 0.191.0 → 0.192.0-main.1354
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 +3 -1
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +4 -1
- package/dist/carriers/aymakan/adapter.d.ts +16 -0
- package/dist/carriers/aymakan/adapter.js +43 -0
- package/dist/carriers/aymakan/carrier.json +42 -7
- package/dist/carriers/aymakan/parser.d.ts +30 -0
- package/dist/carriers/aymakan/parser.js +154 -0
- package/dist/carriers/aymakan/status.d.ts +11 -0
- package/dist/carriers/aymakan/status.js +92 -0
- package/dist/carriers/aymakan/statuses.json +12 -3
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/data/catalog.json +3 -1
- package/dist/generated/catalog.d.ts +3 -1
- package/dist/generated/catalog.js +3 -1
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **273 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>495 catalog entries · 113 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 9,
|
|
158
|
+
The carrier folders record 9,895 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
|
@@ -5933,7 +5933,9 @@
|
|
|
5933
5933
|
"timezone": "Asia/Riyadh",
|
|
5934
5934
|
"tracking": {
|
|
5935
5935
|
"mode": "automatic",
|
|
5936
|
-
"adapter": "
|
|
5936
|
+
"adapter": "aymakan",
|
|
5937
|
+
"recognitionRank": 942,
|
|
5938
|
+
"localClocks": true
|
|
5937
5939
|
},
|
|
5938
5940
|
"canaryUrl": "https://aymakan.com/tracking",
|
|
5939
5941
|
"trackingUrlTemplate": "https://aymakan.com/tracking/{trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -4797,7 +4797,9 @@ var Ae = {
|
|
|
4797
4797
|
timezone: "Asia/Riyadh",
|
|
4798
4798
|
tracking: {
|
|
4799
4799
|
mode: "automatic",
|
|
4800
|
-
adapter: "
|
|
4800
|
+
adapter: "aymakan",
|
|
4801
|
+
recognitionRank: 942,
|
|
4802
|
+
localClocks: !0
|
|
4801
4803
|
},
|
|
4802
4804
|
canaryUrl: "https://aymakan.com/tracking",
|
|
4803
4805
|
trackingUrlTemplate: "https://aymakan.com/tracking/{trackingNumber}",
|
|
@@ -21116,6 +21118,7 @@ var nl = {
|
|
|
21116
21118
|
"aras-kargo": 420,
|
|
21117
21119
|
"australia-post": 500,
|
|
21118
21120
|
"austrian-post": 830,
|
|
21121
|
+
aymakan: 942,
|
|
21119
21122
|
"blue-express": 971,
|
|
21120
21123
|
bosta: 951,
|
|
21121
21124
|
"box-now": 250,
|
|
@@ -0,0 +1,16 @@
|
|
|
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 tracker's page for one number, which its tracking form opens. */
|
|
5
|
+
export declare const AYMAKAN_TRACKING = "https://aymakan.com/tracking/";
|
|
6
|
+
export declare class AymakanTracker {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: {
|
|
9
|
+
fetcher?: typeof fetch;
|
|
10
|
+
recorder?: StepRecorder;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
});
|
|
14
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
15
|
+
}
|
|
16
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { AYMAKAN_MAX_BYTES, normalizeAymakanNumber, parseAymakan, PROVIDER } from './parser.js';
|
|
8
|
+
/** The tracker's page for one number, which its tracking form opens. */
|
|
9
|
+
export const AYMAKAN_TRACKING = 'https://aymakan.com/tracking/';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
export class AymakanTracker {
|
|
12
|
+
options;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.options = options;
|
|
15
|
+
}
|
|
16
|
+
async fetch(raw, context = {}) {
|
|
17
|
+
const number = normalizeAymakanNumber(raw);
|
|
18
|
+
const read = async (signal, timeoutMs) => {
|
|
19
|
+
const { response, bytes } = await fetchBounded(`${AYMAKAN_TRACKING}${encodeURIComponent(number)}`, { signal, headers: {
|
|
20
|
+
Accept: 'text/html', 'Accept-Language': 'en', 'User-Agent': userAgentOf(this.options.userAgent),
|
|
21
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: AYMAKAN_MAX_BYTES, redirect: 'manual',
|
|
22
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 404, 410] });
|
|
23
|
+
// The page answers HTTP 200 for a number it does not know; a redirect or a 404 is a moved route.
|
|
24
|
+
if (response.status !== 200)
|
|
25
|
+
throw new TransportError(PROVIDER, 'AyMakan tracking page moved');
|
|
26
|
+
return parseAymakan(decodeText(bytes), number, (this.options.now ?? Date.now)());
|
|
27
|
+
};
|
|
28
|
+
// A read that fails to reach the site, or hangs, gets one more: the first gets half of the budget.
|
|
29
|
+
return runSteps({ carrier: 'aymakan', budgetMs: context.budgetMs ?? 20_000, signal: context.signal,
|
|
30
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
31
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
32
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const adapter = (environment) => {
|
|
37
|
+
const tracker = new AymakanTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
38
|
+
return {
|
|
39
|
+
id: 'aymakan', recordsSteps: true, steps: ['direct', 'retry'],
|
|
40
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
41
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeAymakanNumber(number))),
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -17,18 +17,53 @@
|
|
|
17
17
|
"timezone": "Asia/Riyadh",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "aymakan",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 942
|
|
27
|
+
},
|
|
28
|
+
"localClocks": true
|
|
22
29
|
},
|
|
23
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history"
|
|
32
|
+
],
|
|
24
33
|
"portal": {
|
|
25
34
|
"url": "https://aymakan.com/tracking/{trackingNumber}",
|
|
26
35
|
"searchUrl": "https://aymakan.com/tracking",
|
|
27
36
|
"canaryUrl": "https://aymakan.com/tracking",
|
|
28
|
-
"shows": [
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
"shows": [
|
|
38
|
+
"status",
|
|
39
|
+
"progress_rail",
|
|
40
|
+
"history",
|
|
41
|
+
"origin",
|
|
42
|
+
"destination",
|
|
43
|
+
"signatory"
|
|
44
|
+
],
|
|
45
|
+
"scraped": [
|
|
46
|
+
"status",
|
|
47
|
+
"history"
|
|
48
|
+
],
|
|
49
|
+
"discarded": [
|
|
50
|
+
"progress_rail",
|
|
51
|
+
"origin",
|
|
52
|
+
"destination",
|
|
53
|
+
"signatory"
|
|
54
|
+
],
|
|
55
|
+
"unavailable": [
|
|
56
|
+
"location",
|
|
57
|
+
"scan_times",
|
|
58
|
+
"offset_timestamps",
|
|
59
|
+
"provider_code",
|
|
60
|
+
"eta",
|
|
61
|
+
"weight",
|
|
62
|
+
"delivered_at",
|
|
63
|
+
"proof_of_delivery",
|
|
64
|
+
"sender_name",
|
|
65
|
+
"recipient_name"
|
|
66
|
+
]
|
|
32
67
|
},
|
|
33
68
|
"links": [
|
|
34
69
|
{
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "AyMakan";
|
|
3
|
+
export declare const AYMAKAN_MAX_BYTES = 1000000;
|
|
4
|
+
/** A number for AyMakan's tracker, in capitals as the page shows it; anything else is not one it issues. */
|
|
5
|
+
export declare function normalizeAymakanNumber(raw: string): string;
|
|
6
|
+
/** A Cloudflare challenge or block page in place of the tracker. */
|
|
7
|
+
export declare function assertNotChallenged(html: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* The newest day a scan can carry: the lookup's day in Saudi Arabia, and one
|
|
10
|
+
* more in case the page dates scans in a zone further east.
|
|
11
|
+
*/
|
|
12
|
+
export declare function latestScanDay(now: number): string;
|
|
13
|
+
/**
|
|
14
|
+
* A scan's day, which the page writes "Sat, Apr 05", without a year. The year
|
|
15
|
+
* is the latest one, not after `latest`, in which that day falls on that
|
|
16
|
+
* weekday: a day comes round on the same weekday after five years at the
|
|
17
|
+
* soonest, so a scan less than five years old is dated exactly.
|
|
18
|
+
*/
|
|
19
|
+
export declare function aymakanDay(label: string, latest: string): string;
|
|
20
|
+
/** A timeline entry's wording, without who took the parcel or a phone number. */
|
|
21
|
+
export declare function aymakanWording(text: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* The tracker's page for one number, `aymakan.com/tracking/{number}`, which
|
|
24
|
+
* the site renders on its server: the shipment's status over "Tracking #:" and
|
|
25
|
+
* the number, a progress rail, the route, masked parties and the timeline. A
|
|
26
|
+
* number it does not know gets "No shipment found" under its search form,
|
|
27
|
+
* which keeps the number. The route, the rail and who took the parcel are not
|
|
28
|
+
* kept. `now` is the lookup's time, which sets the years of the scans' days.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseAymakan(html: string, raw: string, now?: number): CarrierResult;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { calendarDay } from '../../core/time/index.js';
|
|
7
|
+
import { clean } from '../../core/transport/index.js';
|
|
8
|
+
import { aymakanMappedStage, aymakanStage } from './status.js';
|
|
9
|
+
export const PROVIDER = 'AyMakan';
|
|
10
|
+
// The pages are 50 to 90 kB.
|
|
11
|
+
export const AYMAKAN_MAX_BYTES = 1_000_000;
|
|
12
|
+
// `AY` and nine to eleven digits; older shipments have a bare six-digit number.
|
|
13
|
+
const NUMBER = /^(?:AY\d{9,11}|\d{6})$/;
|
|
14
|
+
// Cloudflare's challenge and block pages, which the site's CDN can serve in place of the tracker.
|
|
15
|
+
const CHALLENGE = /_cf_chl_opt|cf-turnstile|\/cdn-cgi\/challenge-platform\/|<title>\s*(?:Just a moment|Attention Required!)/i;
|
|
16
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
17
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
18
|
+
const DAY = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{2})$/;
|
|
19
|
+
// Every weekday, month and day comes round within 28 years, 29 February included.
|
|
20
|
+
const CALENDAR_CYCLE_YEARS = 28;
|
|
21
|
+
const TRACKING = /^Tracking #:\s*(\S+)$/;
|
|
22
|
+
const NOT_FOUND = 'No shipment found';
|
|
23
|
+
// Who took the parcel, after a delivery's wording: "… - Received by {name}".
|
|
24
|
+
const RECEIVER = /\s*-\s*(?:received|collected|signed|retrieved) by\b[\s\S]*$/i;
|
|
25
|
+
// A run of nine or more digits, with spaces, brackets, dots or hyphens between them, as phone numbers are written.
|
|
26
|
+
const PHONE = /(?<![A-Z\d])\+?\d(?:[\s().-]{0,2}\d){8,}(?![A-Z\d])/gi;
|
|
27
|
+
const MAX_ROWS = 300;
|
|
28
|
+
const MAX_EVENTS = 100;
|
|
29
|
+
/** A number for AyMakan's tracker, in capitals as the page shows it; anything else is not one it issues. */
|
|
30
|
+
export function normalizeAymakanNumber(raw) {
|
|
31
|
+
const number = raw.length <= 64 ? normalizeTrackingNumber(raw).toUpperCase() : '';
|
|
32
|
+
if (!NUMBER.test(number))
|
|
33
|
+
throw new InvalidInputError(PROVIDER, 'AyMakan numbers are AY and nine to eleven digits, or six digits');
|
|
34
|
+
return number;
|
|
35
|
+
}
|
|
36
|
+
/** A Cloudflare challenge or block page in place of the tracker. */
|
|
37
|
+
export function assertNotChallenged(html) {
|
|
38
|
+
if (CHALLENGE.test(html.slice(0, 64_000)))
|
|
39
|
+
throw new ChallengeError(PROVIDER, 'AyMakan answered with a browser check');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The newest day a scan can carry: the lookup's day in Saudi Arabia, and one
|
|
43
|
+
* more in case the page dates scans in a zone further east.
|
|
44
|
+
*/
|
|
45
|
+
export function latestScanDay(now) {
|
|
46
|
+
return DateTime.fromMillis(now, { zone: 'Asia/Riyadh' }).plus({ days: 1 }).toISODate();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A scan's day, which the page writes "Sat, Apr 05", without a year. The year
|
|
50
|
+
* is the latest one, not after `latest`, in which that day falls on that
|
|
51
|
+
* weekday: a day comes round on the same weekday after five years at the
|
|
52
|
+
* soonest, so a scan less than five years old is dated exactly.
|
|
53
|
+
*/
|
|
54
|
+
export function aymakanDay(label, latest) {
|
|
55
|
+
const match = DAY.exec(label);
|
|
56
|
+
if (!match)
|
|
57
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned an invalid scan date');
|
|
58
|
+
const month = MONTHS.indexOf(match[2]) + 1;
|
|
59
|
+
const weekday = WEEKDAYS.indexOf(match[1]);
|
|
60
|
+
const newest = Number(latest.slice(0, 4));
|
|
61
|
+
for (let year = newest; year >= newest - CALENDAR_CYCLE_YEARS; year -= 1) {
|
|
62
|
+
const day = calendarDay(year, month, Number(match[3]));
|
|
63
|
+
if (day && day <= latest && new Date(`${day}T00:00:00Z`).getUTCDay() === weekday)
|
|
64
|
+
return day;
|
|
65
|
+
}
|
|
66
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned an invalid scan date');
|
|
67
|
+
}
|
|
68
|
+
/** A timeline entry's wording, without who took the parcel or a phone number. */
|
|
69
|
+
export function aymakanWording(text) {
|
|
70
|
+
return clean(clean(text, 500).replace(RECEIVER, '').replace(PHONE, ''), 300).replace(/[\s,;:\-–]+$/, '');
|
|
71
|
+
}
|
|
72
|
+
/** The timeline's entries, newest first as the page lists them: a heading with the wording and the day under it. */
|
|
73
|
+
function eventsOf($, main, latest) {
|
|
74
|
+
const titles = main.find('h3').filter((_, heading) => clean($(heading).text(), 40) === 'Timeline');
|
|
75
|
+
const timeline = titles.closest('aside');
|
|
76
|
+
if (titles.length !== 1 || timeline.length !== 1)
|
|
77
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned a page without its timeline');
|
|
78
|
+
const headings = timeline.find('h4').toArray();
|
|
79
|
+
if (headings.length > MAX_ROWS)
|
|
80
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned an oversized timeline');
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const events = [];
|
|
83
|
+
for (const heading of headings) {
|
|
84
|
+
const days = $(heading).siblings('p');
|
|
85
|
+
const description = aymakanWording($(heading).text());
|
|
86
|
+
if (days.length !== 1 || !description)
|
|
87
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned an invalid timeline entry');
|
|
88
|
+
const day = aymakanDay(clean(days.text(), 40), latest);
|
|
89
|
+
// Scans are dated to the day: the same wording twice on one day adds nothing.
|
|
90
|
+
const key = `${day} ${description}`;
|
|
91
|
+
if (seen.has(key))
|
|
92
|
+
continue;
|
|
93
|
+
seen.add(key);
|
|
94
|
+
const staged = aymakanStage(description);
|
|
95
|
+
events.push({ local_time: day, description, ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}) });
|
|
96
|
+
}
|
|
97
|
+
return events;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The tracker's page for one number, `aymakan.com/tracking/{number}`, which
|
|
101
|
+
* the site renders on its server: the shipment's status over "Tracking #:" and
|
|
102
|
+
* the number, a progress rail, the route, masked parties and the timeline. A
|
|
103
|
+
* number it does not know gets "No shipment found" under its search form,
|
|
104
|
+
* which keeps the number. The route, the rail and who took the parcel are not
|
|
105
|
+
* kept. `now` is the lookup's time, which sets the years of the scans' days.
|
|
106
|
+
*/
|
|
107
|
+
export function parseAymakan(html, raw, now = Date.now()) {
|
|
108
|
+
const number = normalizeAymakanNumber(raw);
|
|
109
|
+
assertNotChallenged(html);
|
|
110
|
+
const $ = load(html);
|
|
111
|
+
const main = $('main');
|
|
112
|
+
if (main.length !== 1)
|
|
113
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned an unexpected page');
|
|
114
|
+
const searched = clean(main.find('form input').first().attr('value') ?? '', 64).toUpperCase();
|
|
115
|
+
const labels = main.find('p').filter((_, line) => TRACKING.test(clean($(line).text(), 100)));
|
|
116
|
+
if (labels.length === 0) {
|
|
117
|
+
const missing = main.find('h3').filter((_, heading) => clean($(heading).text(), 60) === NOT_FOUND).length === 1;
|
|
118
|
+
if (missing && searched === number)
|
|
119
|
+
throw new NotFoundError(PROVIDER);
|
|
120
|
+
if (missing)
|
|
121
|
+
throw new IndeterminateError(PROVIDER, 'AyMakan answered for another search');
|
|
122
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned a page without a shipment');
|
|
123
|
+
}
|
|
124
|
+
if (labels.length > 1)
|
|
125
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned several shipments');
|
|
126
|
+
const shown = TRACKING.exec(clean(labels.text(), 100))[1].toUpperCase();
|
|
127
|
+
if (shown !== number)
|
|
128
|
+
throw new IndeterminateError(PROVIDER, 'AyMakan answered for another shipment');
|
|
129
|
+
const summary = clean(labels.siblings('h3').text(), 120);
|
|
130
|
+
if (!summary)
|
|
131
|
+
throw new SchemaError(PROVIDER, 'AyMakan returned a shipment without its status');
|
|
132
|
+
const events = eventsOf($, main, latestScanDay(now));
|
|
133
|
+
const newest = events[0];
|
|
134
|
+
// The status over the shipment is the carrier's current one; the newest scan
|
|
135
|
+
// with a stage decides only a status the list does not know.
|
|
136
|
+
const listed = aymakanMappedStage(summary);
|
|
137
|
+
const scanned = events.find((event) => event.stage);
|
|
138
|
+
const worded = listed || scanned ? null : aymakanStage(summary);
|
|
139
|
+
const stage = (listed ?? scanned?.stage ?? worded?.stage);
|
|
140
|
+
const source = listed ? 'carrier_map' : scanned ? scanned.stage_source : worded?.source;
|
|
141
|
+
if (!newest && !stage)
|
|
142
|
+
throw new NoHistoryError(PROVIDER);
|
|
143
|
+
return {
|
|
144
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
145
|
+
...(stage ? { current_stage: stage, current_stage_source: source ?? 'carrier_map' } : {}),
|
|
146
|
+
last_status_text: newest?.description ?? summary,
|
|
147
|
+
last_update: null,
|
|
148
|
+
last_update_local: typeof newest?.local_time === 'string' ? newest.local_time : null,
|
|
149
|
+
expected_delivery: null,
|
|
150
|
+
...(newest ? {} : { summary_only: true }),
|
|
151
|
+
events: events.slice(0, MAX_EVENTS),
|
|
152
|
+
...(events.length > MAX_EVENTS ? { history_truncated: true } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -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
|
+
/**
|
|
4
|
+
* The stage AyMakan's list gives a status label or a timeline description.
|
|
5
|
+
* The reverse pickups of returns and "Pending" are left to the shared rules.
|
|
6
|
+
*/
|
|
7
|
+
export declare function aymakanMappedStage(wording: string): Stage | undefined;
|
|
8
|
+
/** A status's or scan's stage: the list's, else the shared wording rules', else nothing. */
|
|
9
|
+
export declare function aymakanStage(wording: string): ClassifiedWording | null;
|
|
10
|
+
/** What the map says about one status or scan, by its wording: the tracker shows no codes. */
|
|
11
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* AyMakan's statuses, from its list of status codes: the label the tracker
|
|
5
|
+
* shows over a shipment, the description its timeline shows for the scan, and
|
|
6
|
+
* the stage. A parcel is collected from the shipper's collection point, sorted
|
|
7
|
+
* at a hub and received at the delivering city's warehouse. Lockers, external
|
|
8
|
+
* partners and holds have statuses of their own. The tracker shows no codes.
|
|
9
|
+
*/
|
|
10
|
+
const STATUSES = [
|
|
11
|
+
['AWB created at origin', 'Shipment is created at collection point', 'registered'], // AY-0001
|
|
12
|
+
['Picked from Collection Point', 'Shipment was collected from collection point', 'accepted'], // AY-0002
|
|
13
|
+
['Received at Warehouse', 'Shipment is received at hub', 'in_transit'], // AY-0003
|
|
14
|
+
['Out for Delivery', 'Shipment is out for its final destination', 'out_for_delivery'], // AY-0004
|
|
15
|
+
['Delivered', 'Shipment is delivered to customer', 'delivered'], // AY-0005
|
|
16
|
+
['Not Delivered', 'Shipment delivery attempt failed', 'failed_attempt'], // AY-0006
|
|
17
|
+
['Cancelled', 'Shipment was cancelled', 'exception'], // AY-0007
|
|
18
|
+
['Returned', 'Shipment is returned to shipper', 'returned'], // AY-0008
|
|
19
|
+
['In Transit', 'Shipment is in transit', 'in_transit'], // AY-0009
|
|
20
|
+
['Delayed', 'Shipment is delayed upon customer request', 'exception'], // AY-0010
|
|
21
|
+
['Cancelled', 'Shipment cancelled', 'exception'], // AY-0011
|
|
22
|
+
['Missing Phone / Address', 'Missing consignee information', 'exception'], // AY-0013
|
|
23
|
+
// Back at the warehouse after failed attempts; it goes out again or back to the shipper.
|
|
24
|
+
['Received Back in Warehouse', 'Returned after failed delivery attempts', 'exception'], // AY-0017
|
|
25
|
+
['Out for Return to Shipper', 'Shipment is returning to shipper', 'returned'], // AY-0028
|
|
26
|
+
['Pickup Cancelled', 'Pickup cancelled', 'exception'], // AY-0029
|
|
27
|
+
['Assigned to Collection Driver', 'Driver assigned for collection', 'registered'], // AY-0039
|
|
28
|
+
['No Answer', 'Customer did not answer', 'failed_attempt'], // AY-0040
|
|
29
|
+
['Out of Network', 'Destination outside delivery network', 'exception'], // AY-0041
|
|
30
|
+
['On Hold', 'Shipment is on hold', 'exception'], // AY-0050
|
|
31
|
+
['Lost', 'Shipment lost', 'exception'], // AY-0058
|
|
32
|
+
['Processing for Return', 'Processing return to shipper', 'returned'], // AY-0059
|
|
33
|
+
['Waiting Address Validation', 'Shipment awaiting address validation', 'exception'], // AY-0064
|
|
34
|
+
['Address Validated', 'Address validated', 'in_transit'], // AY-0065
|
|
35
|
+
['Remote City', 'Awaiting remote area service day', 'in_transit'], // AY-0066
|
|
36
|
+
['Arrived at Delivery Warehouse', 'Shipment arrived at delivery warehouse', 'in_transit'], // AY-0068
|
|
37
|
+
['Created at External', 'Shipment created by external partner', 'registered'], // AY-0069
|
|
38
|
+
['Picked Up - External', 'Picked up by external partner', 'accepted'], // AY-0070
|
|
39
|
+
['Processing for Remote Area', 'Processing remote area shipment', 'in_transit'], // AY-0079
|
|
40
|
+
['Return to Aymakan Warehouse', 'Returned to Aymakan warehouse', 'exception'], // AY-0084
|
|
41
|
+
['Received in Clearing Department', 'Customs clearing', 'customs'], // AY-0088
|
|
42
|
+
['Hold for Dispatch', 'Shipment on dispatch hold', 'exception'], // AY-0090
|
|
43
|
+
['Cancelled by Consignee', 'Shipment cancelled by consignee', 'exception'], // AY-0091
|
|
44
|
+
['Mailroom On Hold', 'Mailroom shipment on hold', 'exception'], // AY-0093
|
|
45
|
+
['Mailroom Processing for Return', 'Mailroom return processing', 'returned'], // AY-0094
|
|
46
|
+
['Scheduled Delivery', 'Delivery scheduled upon customer request', 'in_transit'], // AY-0095
|
|
47
|
+
['Dispatch External', 'Dispatched to external carrier', 'in_transit'], // AY-0097
|
|
48
|
+
['Arrived at Destination Warehouse', 'Shipment arrived at destination warehouse', 'in_transit'], // AY-0099
|
|
49
|
+
['Cancelled by Shipper', 'Shipment cancelled by shipper', 'exception'], // AY-0105
|
|
50
|
+
['Deposited to Locker', 'Shipment deposited into locker', 'ready_for_pickup'], // AY-0108
|
|
51
|
+
['Locker Expired', 'Locker holding period expired', 'exception'], // AY-0109
|
|
52
|
+
['Locker AWB Generated', 'Locker shipment created', 'registered'], // AY-0110
|
|
53
|
+
['Out for Deposit', 'Driver is depositing shipment', 'out_for_delivery'], // AY-0111
|
|
54
|
+
['Retrieved', 'Shipment retrieved from locker', 'delivered'], // AY-0112
|
|
55
|
+
['Locker Cancelled', 'Locker delivery cancelled', 'exception'], // AY-0113
|
|
56
|
+
// The driver goes to collect the parcel from the shipper.
|
|
57
|
+
['Out for Pickup', 'Driver out to collect shipment', 'registered'], // AY-0114
|
|
58
|
+
['External Assigned', 'Assigned to external logistics provider', 'in_transit'], // AY-0116
|
|
59
|
+
];
|
|
60
|
+
/** A wording in `normalizeStatusWording` form, without the full stop some descriptions end with. */
|
|
61
|
+
function key(wording) {
|
|
62
|
+
return normalizeStatusWording(wording).replace(/\s*\.$/, '');
|
|
63
|
+
}
|
|
64
|
+
const WORDINGS = new Map([
|
|
65
|
+
...STATUSES.flatMap(([label, description, stage]) => [[key(label), stage], [key(description), stage]]),
|
|
66
|
+
// The timeline's wording for the creation of older shipments.
|
|
67
|
+
[key('Shipping Created'), 'registered'],
|
|
68
|
+
]);
|
|
69
|
+
// A receipt at a city's hub, warehouse or station, such as AY-0026's "Received at Riyadh
|
|
70
|
+
// Warehouse", or older shipments' "Shipment is received at Riyadh hub".
|
|
71
|
+
const RECEIVED = /^(?:shipment (?:is )?)?received (?:at|in) /;
|
|
72
|
+
/**
|
|
73
|
+
* The stage AyMakan's list gives a status label or a timeline description.
|
|
74
|
+
* The reverse pickups of returns and "Pending" are left to the shared rules.
|
|
75
|
+
*/
|
|
76
|
+
export function aymakanMappedStage(wording) {
|
|
77
|
+
const normalized = key(wording);
|
|
78
|
+
return WORDINGS.get(normalized) ?? (RECEIVED.test(normalized) ? 'in_transit' : undefined);
|
|
79
|
+
}
|
|
80
|
+
/** A status's or scan's stage: the list's, else the shared wording rules', else nothing. */
|
|
81
|
+
export function aymakanStage(wording) {
|
|
82
|
+
const mapped = aymakanMappedStage(wording);
|
|
83
|
+
if (mapped)
|
|
84
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
85
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
86
|
+
return worded.source === 'none' ? null : worded;
|
|
87
|
+
}
|
|
88
|
+
/** What the map says about one status or scan, by its wording: the tracker shows no codes. */
|
|
89
|
+
export const statusMap = {
|
|
90
|
+
stage: (code, wording) => (code ? undefined : aymakanMappedStage(wording)),
|
|
91
|
+
gaps: [],
|
|
92
|
+
};
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
{"wording": "في مرحلة انتقالية", "stage": "in_transit", "language": "ar", "confirmedBy": "portal", "note": "Arabic progress-rail step for In Transit."},
|
|
12
12
|
{"wording": "خارج للتوصيل", "stage": "out_for_delivery", "language": "ar", "confirmedBy": "portal", "note": "Arabic progress-rail step for Out for Delivery."},
|
|
13
13
|
{"wording": "مرحلة التسليم", "stage": "delivered", "language": "ar", "confirmedBy": "portal", "note": "Arabic progress-rail step for Delivered."},
|
|
14
|
-
{"code": "AY-0001", "wording": "AWB created at origin", "stage": "registered", "language": "en", "confirmedBy": "
|
|
14
|
+
{"code": "AY-0001", "wording": "AWB created at origin", "stage": "registered", "language": "en", "confirmedBy": "portal", "note": "Status over a shipment on the tracker; the label of AY-0001 in AyMakan's status table."},
|
|
15
15
|
{"code": "AY-0002", "wording": "Picked from Collection Point", "stage": "accepted", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table."},
|
|
16
|
-
{"code": "AY-0003", "wording": "Received at Warehouse", "stage": "in_transit", "language": "en", "confirmedBy": "
|
|
16
|
+
{"code": "AY-0003", "wording": "Received at Warehouse", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Status over a shipment on the tracker; the label of AY-0003 in AyMakan's status table. Received at a hub."},
|
|
17
17
|
{"code": "AY-0006", "wording": "Not Delivered", "stage": "failed_attempt", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. A delivery attempt failed; a reason code follows."},
|
|
18
18
|
{"code": "AY-0007", "wording": "Cancelled", "stage": "exception", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. AY-0011 has the same label."},
|
|
19
19
|
{"code": "AY-0008", "wording": "Returned", "stage": "returned", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. Returned to the shipper."},
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
{"code": "AY-0088", "wording": "Received in Clearing Department", "stage": "customs", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. Customs clearing."},
|
|
30
30
|
{"code": "AY-0099", "wording": "Arrived at Destination Warehouse", "stage": "in_transit", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table."},
|
|
31
31
|
{"code": "AY-0108", "wording": "Deposited to Locker", "stage": "ready_for_pickup", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table."},
|
|
32
|
-
{"code": "AY-0112", "wording": "Retrieved", "stage": "delivered", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. Collected from the locker."}
|
|
32
|
+
{"code": "AY-0112", "wording": "Retrieved", "stage": "delivered", "language": "en", "confirmedBy": "official-doc", "note": "Status label in AyMakan's webhook status table. Collected from the locker."},
|
|
33
|
+
{"code": "AY-0002", "wording": "Shipment was collected from collection point", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "Timeline entry; the description of AY-0002 in the status table."},
|
|
34
|
+
{"code": "AY-0003", "wording": "Shipment is received at hub", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Timeline entry; the description of AY-0003 in the status table."},
|
|
35
|
+
{"wording": "Shipment is received at Riyadh hub", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Timeline entry of older shipments, naming the hub's city."},
|
|
36
|
+
{"code": "AY-0026", "wording": "Received at Riyadh Warehouse", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Timeline entry; the label of AY-0026 in the status table. Other cities' warehouses have codes of their own."},
|
|
37
|
+
{"code": "AY-0009", "wording": "Shipment is in transit", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "note": "Timeline entry; the description of AY-0009 in the status table."},
|
|
38
|
+
{"code": "AY-0005", "wording": "Shipment is delivered to customer", "stage": "delivered", "language": "en", "confirmedBy": "portal", "note": "Timeline entry; the description of AY-0005. The tracker follows it with \" - Received by\" and who took the parcel, which the adapter drops."},
|
|
39
|
+
{"wording": "Shipping Created", "stage": "registered", "language": "en", "confirmedBy": "portal", "note": "Timeline entry for the creation of an older shipment."},
|
|
40
|
+
{"wording": "Shipment Created", "stage": "registered", "language": "en", "confirmedBy": "portal", "note": "Step of the current tracker's progress rail, which the adapter does not read."},
|
|
41
|
+
{"wording": "Picked Up", "stage": "accepted", "language": "en", "confirmedBy": "portal", "note": "Step of the current tracker's progress rail, which the adapter does not read."}
|
|
33
42
|
]
|
|
34
43
|
}
|
|
@@ -25,6 +25,7 @@ import { statusMap as apcOvernight } from '../../carriers/apc-overnight/status.j
|
|
|
25
25
|
import { statusMap as aramexAu } from '../../carriers/aramex-au/status.js';
|
|
26
26
|
import { statusMap as aramexNz } from '../../carriers/aramex-nz/status.js';
|
|
27
27
|
import { statusMap as arasKargo } from '../../carriers/aras-kargo/status.js';
|
|
28
|
+
import { statusMap as aymakan } from '../../carriers/aymakan/status.js';
|
|
28
29
|
import { statusMap as blueExpress } from '../../carriers/blue-express/status.js';
|
|
29
30
|
import { statusMap as bosta } from '../../carriers/bosta/status.js';
|
|
30
31
|
import { statusMap as boxNow } from '../../carriers/box-now/status.js';
|
|
@@ -197,7 +198,7 @@ import { CARRIER_DEFINITIONS } from './definitions.js';
|
|
|
197
198
|
export const STATUS_MAPS = {
|
|
198
199
|
'17feia': seventeenFeia, '5post': fivePost, '99minutos': ninetynineMinutos, 'abf-freight': abfFreight, 'acs-courier': acsCourier, aliexpress,
|
|
199
200
|
'allegro-one': allegroOne, 'an-post': anPost, andreani, anjun, anteraja, 'apc-overnight': apcOvernight, 'aramex-au': aramexAu, 'aramex-nz': aramexNz,
|
|
200
|
-
'aras-kargo': arasKargo, 'blue-express': blueExpress, bosta, 'box-now': boxNow, cacesa, cargus, cdek, cdl, celeritas, 'ceska-posta': ceskaPosta,
|
|
201
|
+
'aras-kargo': arasKargo, aymakan, 'blue-express': blueExpress, bosta, 'box-now': boxNow, cacesa, cargus, cdek, cdl, celeritas, 'ceska-posta': ceskaPosta,
|
|
201
202
|
chilexpress, 'china-post': chinaPost, 'chit-chats': chitChats, chronopost, 'chunghwa-post': chunghwaPost, 'cj-logistics': cjLogistics,
|
|
202
203
|
'cj-packet': cjPacket,
|
|
203
204
|
coordinadora, 'correo-argentino': correoArgentino, 'correos-de-mexico': correosDeMexico, 'correos-spain': correosSpain,
|
package/dist/data/catalog.json
CHANGED
|
@@ -5933,7 +5933,9 @@
|
|
|
5933
5933
|
"timezone": "Asia/Riyadh",
|
|
5934
5934
|
"tracking": {
|
|
5935
5935
|
"mode": "automatic",
|
|
5936
|
-
"adapter": "
|
|
5936
|
+
"adapter": "aymakan",
|
|
5937
|
+
"recognitionRank": 942,
|
|
5938
|
+
"localClocks": true
|
|
5937
5939
|
},
|
|
5938
5940
|
"canaryUrl": "https://aymakan.com/tracking",
|
|
5939
5941
|
"trackingUrlTemplate": "https://aymakan.com/tracking/{trackingNumber}",
|
|
@@ -3981,7 +3981,9 @@ export declare const CARRIER_CATALOG: {
|
|
|
3981
3981
|
readonly timezone: "Asia/Riyadh";
|
|
3982
3982
|
readonly tracking: {
|
|
3983
3983
|
readonly mode: "automatic";
|
|
3984
|
-
readonly adapter: "
|
|
3984
|
+
readonly adapter: "aymakan";
|
|
3985
|
+
readonly recognitionRank: 942;
|
|
3986
|
+
readonly localClocks: true;
|
|
3985
3987
|
};
|
|
3986
3988
|
readonly canaryUrl: "https://aymakan.com/tracking";
|
|
3987
3989
|
readonly trackingUrlTemplate: "https://aymakan.com/tracking/{trackingNumber}";
|
|
@@ -5934,7 +5934,9 @@ export const CARRIER_CATALOG = {
|
|
|
5934
5934
|
"timezone": "Asia/Riyadh",
|
|
5935
5935
|
"tracking": {
|
|
5936
5936
|
"mode": "automatic",
|
|
5937
|
-
"adapter": "
|
|
5937
|
+
"adapter": "aymakan",
|
|
5938
|
+
"recognitionRank": 942,
|
|
5939
|
+
"localClocks": true
|
|
5938
5940
|
},
|
|
5939
5941
|
"canaryUrl": "https://aymakan.com/tracking",
|
|
5940
5942
|
"trackingUrlTemplate": "https://aymakan.com/tracking/{trackingNumber}",
|
|
@@ -19,6 +19,7 @@ import { adapter as arasKargo } from '../carriers/aras-kargo/adapter.js';
|
|
|
19
19
|
import { adapter as asendia } from '../carriers/asendia/adapter.js';
|
|
20
20
|
import { adapter as australiaPost } from '../carriers/australia-post/adapter.js';
|
|
21
21
|
import { adapter as austrianPost } from '../carriers/austrian-post/adapter.js';
|
|
22
|
+
import { adapter as aymakan } from '../carriers/aymakan/adapter.js';
|
|
22
23
|
import { adapter as blueDart } from '../carriers/blue-dart/adapter.js';
|
|
23
24
|
import { adapter as blueExpress } from '../carriers/blue-express/adapter.js';
|
|
24
25
|
import { adapter as bosta } from '../carriers/bosta/adapter.js';
|
|
@@ -293,6 +294,7 @@ export const REGISTRY = {
|
|
|
293
294
|
"asendia": asendia,
|
|
294
295
|
"australia-post": australiaPost,
|
|
295
296
|
"austrian-post": austrianPost,
|
|
297
|
+
"aymakan": aymakan,
|
|
296
298
|
"blue-dart": blueDart,
|
|
297
299
|
"blue-express": blueExpress,
|
|
298
300
|
"bosta": bosta,
|
|
@@ -584,7 +586,7 @@ export const REGISTRY = {
|
|
|
584
586
|
"australia-post": "australia-post",
|
|
585
587
|
"austrian-post": "austrian-post",
|
|
586
588
|
"averitt": "universal",
|
|
587
|
-
"aymakan": "
|
|
589
|
+
"aymakan": "aymakan",
|
|
588
590
|
"azerpost": "universal",
|
|
589
591
|
"azul-cargo": "universal",
|
|
590
592
|
"bangladesh-post": "universal",
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED