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 CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **272 dedicated adapters** and **5 universal fallbacks**
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,886 such statuses, each filed under one stage.
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": "universal"
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}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 495,
3
- "activeAdapters": 272,
3
+ "activeAdapters": 273,
4
4
  "countries": 113,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -4797,7 +4797,9 @@ var Ae = {
4797
4797
  timezone: "Asia/Riyadh",
4798
4798
  tracking: {
4799
4799
  mode: "automatic",
4800
- adapter: "universal"
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": "universal",
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
- "scraped": [],
30
- "discarded": [],
31
- "unavailable": []
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": "official-doc", "note": "Status label in AyMakan's webhook status table."},
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": "official-doc", "note": "Status label in AyMakan's webhook status table. Received at a hub."},
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,
@@ -5933,7 +5933,9 @@
5933
5933
  "timezone": "Asia/Riyadh",
5934
5934
  "tracking": {
5935
5935
  "mode": "automatic",
5936
- "adapter": "universal"
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: "universal";
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": "universal"
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}",
@@ -16,6 +16,7 @@ export const CARRIER_RECOGNITION_RANKS = {
16
16
  "aras-kargo": 420,
17
17
  "australia-post": 500,
18
18
  "austrian-post": 830,
19
+ "aymakan": 942,
19
20
  "blue-express": 971,
20
21
  "bosta": 951,
21
22
  "box-now": 250,
@@ -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": "universal",
589
+ "aymakan": "aymakan",
588
590
  "azerpost": "universal",
589
591
  "azul-cargo": "universal",
590
592
  "bangladesh-post": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.191.0"
5
+ "version": "0.192.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.191.0",
3
+ "version": "0.192.0-main.1354",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",