universal-parcel-scraper 0.372.0-main.2012 → 0.373.0-main.2014

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 **402 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **403 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>727 catalog entries · 133 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 13,584 such statuses, each filed under one stage.
158
+ The carrier folders record 13,597 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
@@ -22117,9 +22117,11 @@
22117
22117
  "timezone": "Europe/Tirane",
22118
22118
  "tracking": {
22119
22119
  "mode": "automatic",
22120
- "adapter": "universal"
22120
+ "adapter": "posta-shqiptare",
22121
+ "recognitionRank": 829,
22122
+ "localClocks": true
22121
22123
  },
22122
- "canaryUrl": "https://www.postashqiptare.al/c/207/tracked",
22124
+ "canaryUrl": "https://eposta.postashqiptare.al/tracking",
22123
22125
  "trackingUrlTemplate": "https://eposta.postashqiptare.al/tracking/{trackingNumber}",
22124
22126
  "trackingSearchUrl": "https://www.postashqiptare.al/c/207/tracked",
22125
22127
  "linkRules": [
@@ -22130,7 +22132,14 @@
22130
22132
  "path": "^/tracking/([^/?#]+)/?$"
22131
22133
  }
22132
22134
  ],
22133
- "detectionRules": []
22135
+ "detectionRules": [
22136
+ {
22137
+ "pattern": "^[A-Z]{2}\\d{9}AL$",
22138
+ "confidence": "low",
22139
+ "checksum": "s10",
22140
+ "preferred": true
22141
+ }
22142
+ ]
22134
22143
  },
22135
22144
  "posta-slovenije": {
22136
22145
  "displayName": "Pošta Slovenije",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 727,
3
- "activeAdapters": 402,
3
+ "activeAdapters": 403,
4
4
  "countries": 133,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -64492,6 +64492,7 @@
64492
64492
  "intl-post"
64493
64493
  ],
64494
64494
  "asked": [
64495
+ "posta-shqiptare",
64495
64496
  "chronopost"
64496
64497
  ]
64497
64498
  },
@@ -64562,6 +64563,7 @@
64562
64563
  "intl-post"
64563
64564
  ],
64564
64565
  "asked": [
64566
+ "posta-shqiptare",
64565
64567
  "chronopost"
64566
64568
  ]
64567
64569
  },
@@ -67006,6 +67008,19 @@
67006
67008
  "intl-post"
67007
67009
  ],
67008
67010
  "asked": [
67011
+ "posta-shqiptare",
67012
+ "chronopost"
67013
+ ]
67014
+ },
67015
+ {
67016
+ "input": "EE200190512AL",
67017
+ "carrier": "intl-post",
67018
+ "confidence": "high",
67019
+ "candidates": [
67020
+ "intl-post"
67021
+ ],
67022
+ "asked": [
67023
+ "posta-shqiptare",
67009
67024
  "chronopost"
67010
67025
  ]
67011
67026
  },
@@ -73651,6 +73666,7 @@
73651
73666
  "intl-post"
73652
73667
  ],
73653
73668
  "asked": [
73669
+ "posta-shqiptare",
73654
73670
  "chronopost"
73655
73671
  ]
73656
73672
  },
@@ -73662,6 +73678,7 @@
73662
73678
  "intl-post"
73663
73679
  ],
73664
73680
  "asked": [
73681
+ "posta-shqiptare",
73665
73682
  "chronopost"
73666
73683
  ]
73667
73684
  },
@@ -73673,6 +73690,7 @@
73673
73690
  "intl-post"
73674
73691
  ],
73675
73692
  "asked": [
73693
+ "posta-shqiptare",
73676
73694
  "chronopost"
73677
73695
  ]
73678
73696
  },
@@ -16778,16 +16778,23 @@ var Pe = {
16778
16778
  timezone: "Europe/Tirane",
16779
16779
  tracking: {
16780
16780
  mode: "automatic",
16781
- adapter: "universal"
16781
+ adapter: "posta-shqiptare",
16782
+ recognitionRank: 829,
16783
+ localClocks: !0
16782
16784
  },
16783
- canaryUrl: "https://www.postashqiptare.al/c/207/tracked",
16785
+ canaryUrl: "https://eposta.postashqiptare.al/tracking",
16784
16786
  trackingUrlTemplate: "https://eposta.postashqiptare.al/tracking/{trackingNumber}",
16785
16787
  trackingSearchUrl: "https://www.postashqiptare.al/c/207/tracked",
16786
16788
  linkRules: [{
16787
16789
  domains: ["eposta.postashqiptare.al"],
16788
16790
  path: "^/tracking/([^/?#]+)/?$"
16789
16791
  }],
16790
- detectionRules: []
16792
+ detectionRules: [{
16793
+ pattern: "^[A-Z]{2}\\d{9}AL$",
16794
+ confidence: "low",
16795
+ checksum: "s10",
16796
+ preferred: !0
16797
+ }]
16791
16798
  },
16792
16799
  "posta-slovenije": {
16793
16800
  displayName: "Pošta Slovenije",
@@ -22638,7 +22645,7 @@ var Pe = {
22638
22645
  "posta-moldovei": ["posta-moldovei-1"],
22639
22646
  "posta-north-macedonia": ["posta-north-macedonia-s10"],
22640
22647
  "posta-romana": ["posta-romana-s10"],
22641
- "posta-shqiptare": [],
22648
+ "posta-shqiptare": ["posta-shqiptare-s10"],
22642
22649
  "posta-slovenije": ["posta-slovenije-s10"],
22643
22650
  "posta-srbije": ["posta-srbije-s10"],
22644
22651
  postaplus: [],
@@ -28158,6 +28165,7 @@ var pl = {
28158
28165
  "post-luxembourg": 80,
28159
28166
  "posta-north-macedonia": 899,
28160
28167
  "posta-romana": 948,
28168
+ "posta-shqiptare": 829,
28161
28169
  "posta-slovenije": 130,
28162
28170
  "posta-srbije": 110,
28163
28171
  "poste-italiane": 900,
@@ -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 lookup the e-Posta tracking page makes, with the item's code as the last part of the address. */
5
+ export declare const POSTA_SHQIPTARE_API = "https://eposta.postashqiptare.al:8443/api/mailItemEvents/getTrackApi";
6
+ export declare class PostaShqiptareTracker {
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,73 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { ChallengeError, IndeterminateError, NotFoundError, SchemaError, 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 { normalizePostaShqiptareNumber, parsePostaShqiptare, POSTA_SHQIPTARE_MAX_BYTES, PROVIDER } from './parser.js';
8
+ /** The lookup the e-Posta tracking page makes, with the item's code as the last part of the address. */
9
+ export const POSTA_SHQIPTARE_API = 'https://eposta.postashqiptare.al:8443/api/mailItemEvents/getTrackApi';
10
+ const REDIRECTS = [301, 302, 303, 307, 308];
11
+ // Bot checks a CDN or firewall could put in place of the reply.
12
+ const CHALLENGE = /_cf_chl_opt|cf-turnstile|challenge-platform|<title>\s*(?:Just a moment|Attention Required|Request Rejected|Access Denied)|requested URL was rejected/i;
13
+ function json(body) {
14
+ try {
15
+ return JSON.parse(body);
16
+ }
17
+ catch (cause) {
18
+ if (CHALLENGE.test(body.slice(0, 64_000)))
19
+ throw new ChallengeError(PROVIDER, 'Posta Shqiptare answered with a browser check', { cause });
20
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned a reply that is not JSON', { cause });
21
+ }
22
+ }
23
+ /** Whether an error reply is the page's own notice for a missing item. */
24
+ function missing(body) {
25
+ try {
26
+ return JSON.parse(body) === 'package_not_found';
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export class PostaShqiptareTracker {
33
+ options;
34
+ constructor(options = {}) {
35
+ this.options = options;
36
+ }
37
+ async fetch(raw, context = {}) {
38
+ const number = normalizePostaShqiptareNumber(raw);
39
+ const read = async (signal, timeoutMs) => {
40
+ const { response, bytes } = await fetchBounded(`${POSTA_SHQIPTARE_API}/${number}`, { method: 'GET', signal, headers: {
41
+ Accept: 'application/json', 'User-Agent': userAgentOf(this.options.userAgent),
42
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: POSTA_SHQIPTARE_MAX_BYTES,
43
+ redirect: 'manual', fetcher: this.options.fetcher, allowHttpStatuses: [...REDIRECTS, 400, 404, 410] });
44
+ if (REDIRECTS.includes(response.status))
45
+ throw new TransportError(PROVIDER, 'Posta Shqiptare redirected its tracking lookup');
46
+ const body = decodeText(bytes);
47
+ if (response.status !== 200) {
48
+ if (response.status !== 410 && missing(body))
49
+ throw new NotFoundError(PROVIDER);
50
+ // The tracker answers an unknown code with HTTP 200; a 404 is a moved route.
51
+ if (response.status === 400)
52
+ throw new IndeterminateError(PROVIDER, 'Posta Shqiptare refused the lookup');
53
+ throw new TransportError(PROVIDER, 'Posta Shqiptare tracking lookup is unavailable');
54
+ }
55
+ return parsePostaShqiptare(json(body), number);
56
+ };
57
+ // A request that fails to reach the tracker, or hangs, gets one more try: the
58
+ // first gets half of the budget, so the retry fits in the rest.
59
+ return runSteps({ carrier: 'posta-shqiptare', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
60
+ recorder: this.options.recorder ?? NOOP_RECORDER }, [
61
+ { id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
62
+ networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
63
+ ]);
64
+ }
65
+ }
66
+ export const adapter = (environment) => {
67
+ const tracker = new PostaShqiptareTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
68
+ return {
69
+ id: 'posta-shqiptare', recordsSteps: true, steps: ['direct', 'retry'],
70
+ track: (input, context) => tracker.fetch(input.number, context),
71
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizePostaShqiptareNumber(number))),
72
+ };
73
+ };
@@ -19,18 +19,44 @@
19
19
  "timezone": "Europe/Tirane",
20
20
  "tracking": {
21
21
  "mode": "automatic",
22
- "adapter": "universal",
23
- "steps": []
22
+ "adapter": "posta-shqiptare",
23
+ "steps": [
24
+ "direct",
25
+ "retry"
26
+ ],
27
+ "recognition": {
28
+ "rank": 829
29
+ },
30
+ "localClocks": true
24
31
  },
25
- "capabilities": [],
32
+ "capabilities": [
33
+ "history",
34
+ "location"
35
+ ],
26
36
  "portal": {
27
37
  "url": "https://eposta.postashqiptare.al/tracking/{trackingNumber}",
28
38
  "searchUrl": "https://www.postashqiptare.al/c/207/tracked",
29
- "canaryUrl": "https://www.postashqiptare.al/c/207/tracked",
30
- "shows": [],
31
- "scraped": [],
39
+ "canaryUrl": "https://eposta.postashqiptare.al/tracking",
40
+ "shows": [
41
+ "status",
42
+ "history",
43
+ "location"
44
+ ],
45
+ "scraped": [
46
+ "status",
47
+ "history",
48
+ "location"
49
+ ],
32
50
  "discarded": [],
33
- "unavailable": []
51
+ "unavailable": [
52
+ "eta",
53
+ "weight",
54
+ "service_name",
55
+ "pickup_point",
56
+ "offset_timestamps",
57
+ "proof_of_delivery",
58
+ "delivery_tracking_number"
59
+ ]
34
60
  },
35
61
  "links": [
36
62
  {
@@ -40,5 +66,13 @@
40
66
  "path": "^/tracking/([^/?#]+)/?$"
41
67
  }
42
68
  ],
43
- "detection": []
69
+ "detection": [
70
+ {
71
+ "id": "posta-shqiptare-s10",
72
+ "pattern": "^[A-Z]{2}\\d{9}AL$",
73
+ "confidence": "low",
74
+ "preferred": true,
75
+ "checksum": "s10"
76
+ }
77
+ ]
44
78
  }
@@ -12,13 +12,13 @@
12
12
  "access": "indexed_excerpt"
13
13
  },
14
14
  "context": {
15
- "assessment": "candidate"
15
+ "assessment": "confirmed"
16
16
  },
17
17
  "expect": {
18
18
  "carrier": "intl-post",
19
19
  "confidence": "high"
20
20
  },
21
- "note": "Looked up on Posta Shqiptare's e-Posta tracker; the opened capture holds only the app shell, which loads the history by script."
21
+ "note": "Looked up on Posta Shqiptare's e-Posta tracker; the opened capture holds only the app shell, which loads the history by script. Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
22
22
  },
23
23
  {
24
24
  "number": "EE200190509AL",
@@ -30,13 +30,13 @@
30
30
  },
31
31
  "context": {
32
32
  "service": "EMS",
33
- "assessment": "candidate"
33
+ "assessment": "confirmed"
34
34
  },
35
35
  "expect": {
36
36
  "carrier": "intl-post",
37
37
  "confidence": "high"
38
38
  },
39
- "note": "ParcelPanel's Albania Post page offered it as a sample to try; it is not described as a live shipment."
39
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
40
40
  },
41
41
  {
42
42
  "number": "CP339538880AL",
@@ -48,13 +48,13 @@
48
48
  },
49
49
  "context": {
50
50
  "service": "Parcel",
51
- "assessment": "candidate"
51
+ "assessment": "confirmed"
52
52
  },
53
53
  "expect": {
54
54
  "carrier": "intl-post",
55
55
  "confidence": "high"
56
56
  },
57
- "note": "ParcelPanel's Albania Post page offered it as a sample to try; it is not described as a live shipment."
57
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
58
58
  },
59
59
  {
60
60
  "number": "LG014636582AL",
@@ -65,13 +65,13 @@
65
65
  "access": "full_page"
66
66
  },
67
67
  "context": {
68
- "assessment": "candidate"
68
+ "assessment": "confirmed"
69
69
  },
70
70
  "expect": {
71
71
  "carrier": "intl-post",
72
72
  "confidence": "high"
73
73
  },
74
- "note": "ParcelPanel's Albania Post page offered it as a sample to try; it is not described as a live shipment."
74
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
75
75
  },
76
76
  {
77
77
  "number": "LG014771021AL",
@@ -82,13 +82,13 @@
82
82
  "access": "full_page"
83
83
  },
84
84
  "context": {
85
- "assessment": "candidate"
85
+ "assessment": "confirmed"
86
86
  },
87
87
  "expect": {
88
88
  "carrier": "intl-post",
89
89
  "confidence": "high"
90
90
  },
91
- "note": "ParcelPanel's Albania Post page offered it as a sample to try; it is not described as a live shipment."
91
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
92
92
  },
93
93
  {
94
94
  "number": "LG010568652AL",
@@ -99,13 +99,33 @@
99
99
  "access": "full_page"
100
100
  },
101
101
  "context": {
102
- "assessment": "candidate"
102
+ "assessment": "confirmed"
103
103
  },
104
104
  "expect": {
105
105
  "carrier": "intl-post",
106
106
  "confidence": "high"
107
107
  },
108
- "note": "ParcelPanel's Albania Post page offered it as a sample to try; it is not described as a live shipment."
108
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history. Detection selects the generic postal carrier."
109
+ },
110
+ {
111
+ "number": "EE200190512AL",
112
+ "role": "shipment",
113
+ "evidence": "public_shipment_report",
114
+ "source": {
115
+ "url": "https://www.parcelpanel.com/couriers/posta-shqiptare/",
116
+ "date": "2026-10-18",
117
+ "dateKind": "capture",
118
+ "access": "full_page"
119
+ },
120
+ "context": {
121
+ "service": "EMS",
122
+ "assessment": "confirmed"
123
+ },
124
+ "expect": {
125
+ "carrier": "intl-post",
126
+ "confidence": "high"
127
+ },
128
+ "note": "ParcelPanel's Albania Post page offered it as a sample to try; Posta Shqiptare's own tracker returns its history, with the scans of the courier that carried it abroad. Detection selects the generic postal carrier."
109
129
  }
110
130
  ]
111
131
  }
@@ -0,0 +1,14 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "Posta Shqiptare";
3
+ export declare const POSTA_SHQIPTARE_MAX_BYTES: number;
4
+ /** Posta Shqiptare's tracker follows S10 item codes: its own, ending AL, and those other posts send to Albania. */
5
+ export declare function normalizePostaShqiptareNumber(raw: string): string;
6
+ /**
7
+ * The reply of the e-Posta tracker's `getTrackApi` for one item: its scans,
8
+ * newest first, each with a wall clock, the wording and the office or the
9
+ * foreign post's country. The reply does not repeat the number; the request's
10
+ * address holds the whole of it. A code without scans gets an empty list,
11
+ * which the page reports as having no events, and the page's own notices for
12
+ * a missing item are read the same way.
13
+ */
14
+ export declare function parsePostaShqiptare(payload: unknown, raw: string): CarrierResult;
@@ -0,0 +1,150 @@
1
+ import { DateTime } from 'luxon';
2
+ import { isValidS10TrackingNumber, normalizeTrackingNumber } from '../../core/detection/index.js';
3
+ import { InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
4
+ import { languageStageStatus } from '../../core/status/index.js';
5
+ import { countryCode, isoTime } from '../../core/time/index.js';
6
+ import { clean } from '../../core/transport/index.js';
7
+ import { isRecord } from '../../core/types.js';
8
+ import { postaShqiptareStage } from './status.js';
9
+ export const PROVIDER = 'Posta Shqiptare';
10
+ // A long history is a few kilobytes.
11
+ export const POSTA_SHQIPTARE_MAX_BYTES = 256 * 1024;
12
+ const ZONE = 'Europe/Tirane';
13
+ const MAX_ROWS = 500;
14
+ // The tracker's wall clock, with no offset.
15
+ const WALL_CLOCK = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d{1,7})?$/;
16
+ // The notices the tracker's page shows for a code it does not have.
17
+ const MISSING = new Set(['Pakoja nuk ekziston', 'package_not_found']);
18
+ // Between a scan's Albanian wording and the IPS event name in English that
19
+ // Posta Shqiptare's own scans add: "Pranim objekti nga klienti / Receive item from customer".
20
+ const HALVES = /^(.*?)\s*\/(?:\s+(.*))?$/;
21
+ // A delivery's wording, which could go on to name who took the item.
22
+ const DELIVERY = /^(?:dorëzuar|dorezuar|objekti u dorëzua|objekti u dorezua)(?=$|[\s,;:.\-–])/i;
23
+ // A run of four digits or more, as a postcode is written, names the delivery area.
24
+ const POSTCODE = /\d{4,}/;
25
+ const ENGLISH_REGIONS = new Intl.DisplayNames(['en'], { type: 'region' });
26
+ const ALBANIAN_REGIONS = new Intl.DisplayNames(['sq'], { type: 'region' });
27
+ /** A name compared without case, accents or spacing differences. */
28
+ const nameKey = (name) => name.normalize('NFD').replace(/\p{Mn}/gu, '').toUpperCase().replace(/\s+/g, ' ').trim();
29
+ // The country names the tracker writes for a foreign post's scans, in Albanian ("Gjermani").
30
+ const ALBANIAN_COUNTRIES = new Map(Array.from({ length: 26 * 26 }, (_, index) => String.fromCharCode(65 + Math.floor(index / 26), 65 + (index % 26)))
31
+ .filter(code => countryCode(code) === code)
32
+ .map(code => [nameKey(ALBANIAN_REGIONS.of(code) ?? code), code]));
33
+ /** Posta Shqiptare's tracker follows S10 item codes: its own, ending AL, and those other posts send to Albania. */
34
+ export function normalizePostaShqiptareNumber(raw) {
35
+ const number = raw.length <= 64 ? normalizeTrackingNumber(raw) : '';
36
+ if (isValidS10TrackingNumber(number))
37
+ return number;
38
+ throw new InvalidInputError(PROVIDER, 'Posta Shqiptare tracks S10 item codes');
39
+ }
40
+ /** A text field the reply may leave out or null; anything but a string is a changed reply. */
41
+ function text(row, key, maxLength) {
42
+ const value = row[key];
43
+ if (value === null || value === undefined)
44
+ return '';
45
+ if (typeof value !== 'string')
46
+ throw new SchemaError(PROVIDER, `Posta Shqiptare returned an invalid ${key}`);
47
+ return clean(value, maxLength);
48
+ }
49
+ /**
50
+ * The scan's wording as the page shows it, without an empty English half. A
51
+ * delivery keeps only its own words, as anything after them could name who
52
+ * took the item. `own` is true for Posta Shqiptare's own scans, the ones that
53
+ * add the IPS event name in English.
54
+ */
55
+ function wordingOf(raw) {
56
+ const halves = HALVES.exec(raw);
57
+ const albanian = halves ? halves[1] : raw;
58
+ const english = halves?.[2] ?? '';
59
+ const delivery = DELIVERY.exec(albanian);
60
+ const own = delivery ? delivery[0] : albanian;
61
+ return { description: english ? `${own} / ${english}` : own, own: Boolean(english) };
62
+ }
63
+ /** The scan's place: a foreign post's country by its English name, else the office as the tracker names it. */
64
+ function placeOf(office) {
65
+ const code = ALBANIAN_COUNTRIES.get(nameKey(office)) ?? countryCode(office);
66
+ if (code)
67
+ return ENGLISH_REGIONS.of(code) ?? office;
68
+ return POSTCODE.test(office) ? '' : office;
69
+ }
70
+ /**
71
+ * When the scan happened. Posta Shqiptare's own scans are on Tirana's clock.
72
+ * The scans it relays from a foreign post or a courier carry a clock hours
73
+ * off that partner's own, so they keep their wall clock without an offset, as
74
+ * does a wall clock the clocks skip or repeat in Albania.
75
+ */
76
+ function clockOf(raw, own) {
77
+ const wall = WALL_CLOCK.exec(raw)?.[1] ?? '';
78
+ // Parsing in UTC checks the calendar digits only; Luxon rolls 24:00 over, so the clock must read back unchanged.
79
+ const parsed = DateTime.fromISO(wall, { zone: 'UTC' });
80
+ if (!wall || !parsed.isValid || parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") !== wall) {
81
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned an invalid scan time');
82
+ }
83
+ if (own && DateTime.fromISO(wall, { zone: ZONE }).getPossibleOffsets().length === 1) {
84
+ const placed = isoTime(wall, ZONE);
85
+ if (placed?.iso.startsWith(wall))
86
+ return { time: placed.iso };
87
+ }
88
+ return { local_time: wall };
89
+ }
90
+ function scanOf(row) {
91
+ if (!isRecord(row))
92
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned an invalid scan');
93
+ const { description, own } = wordingOf(text(row, 'description', 300));
94
+ if (!description)
95
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned a scan without wording');
96
+ const clock = clockOf(text(row, 'date', 40), own);
97
+ const location = placeOf(text(row, 'office', 120));
98
+ const staged = postaShqiptareStage(description);
99
+ return {
100
+ ...clock,
101
+ description,
102
+ ...(location ? { location } : {}),
103
+ ...(staged ? { stage: staged.stage, stage_source: staged.source } : {}),
104
+ };
105
+ }
106
+ /**
107
+ * The reply of the e-Posta tracker's `getTrackApi` for one item: its scans,
108
+ * newest first, each with a wall clock, the wording and the office or the
109
+ * foreign post's country. The reply does not repeat the number; the request's
110
+ * address holds the whole of it. A code without scans gets an empty list,
111
+ * which the page reports as having no events, and the page's own notices for
112
+ * a missing item are read the same way.
113
+ */
114
+ export function parsePostaShqiptare(payload, raw) {
115
+ normalizePostaShqiptareNumber(raw);
116
+ if (typeof payload === 'string' && MISSING.has(payload.trim()))
117
+ throw new NotFoundError(PROVIDER);
118
+ if (!Array.isArray(payload))
119
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned an invalid reply');
120
+ if (!payload.length)
121
+ throw new NotFoundError(PROVIDER);
122
+ if (payload.length > MAX_ROWS)
123
+ throw new SchemaError(PROVIDER, 'Posta Shqiptare returned an oversized history');
124
+ const seen = new Set();
125
+ const events = [];
126
+ for (const row of payload) {
127
+ const event = scanOf(row);
128
+ const identity = JSON.stringify(event);
129
+ if (seen.has(identity))
130
+ continue;
131
+ seen.add(identity);
132
+ events.push(event);
133
+ }
134
+ // Newest first, as the page lists them; an oldest-first reply is turned round.
135
+ const wall = (event) => (event.time ?? event.local_time);
136
+ if (wall(events[0]).slice(0, 19) < wall(events.at(-1)).slice(0, 19))
137
+ events.reverse();
138
+ const latest = events[0];
139
+ const scanned = events.find(event => event.stage);
140
+ const stage = scanned?.stage;
141
+ return {
142
+ status: stage ? languageStageStatus(stage) : 'unknown',
143
+ ...(stage ? { current_stage: stage, current_stage_source: scanned.stage_source } : {}),
144
+ last_status_text: latest.description,
145
+ last_update: latest.time ?? null,
146
+ ...(latest.time ? {} : { last_update_local: latest.local_time }),
147
+ expected_delivery: null,
148
+ events,
149
+ };
150
+ }
@@ -0,0 +1,11 @@
1
+ import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /** The stage the map gives Posta Shqiptare's wording, if it knows it. */
4
+ export declare function postaShqiptareScanStage(wording: string): Stage | undefined;
5
+ /**
6
+ * A scan's stage from the map, else from the shared wording rules. Wording
7
+ * neither knows stays without a stage, so it remains visible for review.
8
+ */
9
+ export declare function postaShqiptareStage(wording: string): ClassifiedWording | null;
10
+ /** The scans carry wording only. */
11
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,53 @@
1
+ import { classifyWording } from '../../core/status/index.js';
2
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
3
+ /**
4
+ * Posta Shqiptare's tracker words each scan in Albanian. Its own scans add the
5
+ * IPS event name in English after a slash; the scans it relays from a foreign
6
+ * post, and from the courier that carries some EMS items abroad, have the
7
+ * Albanian wording alone. The scans carry no code. The map reads the Albanian
8
+ * wording, without accents, since the relayed scans are written without them
9
+ * and the courier's with them.
10
+ */
11
+ const STAGES = new Map([
12
+ ['pranim objekti nga klienti', 'accepted'],
13
+ // Taken out of its bag at the office of exchange or a sorting centre.
14
+ ['objekti u nxor nga thesi per perpunim', 'in_transit'],
15
+ // The foreign post's office of exchange received it.
16
+ ['objekti u pranua ne zyren e shkembimit', 'in_transit'],
17
+ // Presented to customs, or held there, in the destination country.
18
+ ['regjistrimi i informacionit doganor te objektit', 'customs'],
19
+ // Sent on from the office of exchange.
20
+ ['objekti u dergua ne zyren destinacion', 'in_transit'],
21
+ ['objekti u pranua ne zyren e shperndarjes', 'in_transit'],
22
+ ['tentative e pasuksesshme ne shperndarjen e objektit', 'failed_attempt'],
23
+ ['objekti u dorezua', 'delivered'],
24
+ // The courier's scans.
25
+ ['nisur nga origjina', 'in_transit'],
26
+ ['mbajtur ne dogane', 'customs'],
27
+ ['mberritur ne qendren e shperndarjes', 'in_transit'],
28
+ // Seen between the arrival at the delivery centre and the delivery; it does not say the item went out.
29
+ ['marre nga korrieri', 'in_transit'],
30
+ ['dorezuar', 'delivered'],
31
+ ]);
32
+ /** The Albanian wording, before the English half, without accents. */
33
+ const albanianOf = (wording) => normalizeStatusWording(wording).split(/\s+\/(?:\s|$)/, 1)[0].normalize('NFD').replace(/\p{Mn}/gu, '');
34
+ /** The stage the map gives Posta Shqiptare's wording, if it knows it. */
35
+ export function postaShqiptareScanStage(wording) {
36
+ return STAGES.get(albanianOf(wording));
37
+ }
38
+ /**
39
+ * A scan's stage from the map, else from the shared wording rules. Wording
40
+ * neither knows stays without a stage, so it remains visible for review.
41
+ */
42
+ export function postaShqiptareStage(wording) {
43
+ const mapped = postaShqiptareScanStage(wording);
44
+ if (mapped)
45
+ return { stage: mapped, source: 'carrier_map' };
46
+ const worded = classifyWording(wording, 'in_transit');
47
+ return worded.source === 'none' ? null : worded;
48
+ }
49
+ /** The scans carry wording only. */
50
+ export const statusMap = {
51
+ stage: (code, wording) => (code ? undefined : postaShqiptareScanStage(wording)),
52
+ gaps: [],
53
+ };
@@ -1,4 +1,18 @@
1
1
  {
2
2
  "carrier": "posta-shqiptare",
3
- "entries": []
3
+ "entries": [
4
+ {"wording": "Pranim objekti nga klienti / Receive item from customer", "stage": "accepted", "language": "sq", "confirmedBy": "live", "note": "Posted at a Posta Shqiptare office; UPU's tracker has the posting at the same minute."},
5
+ {"wording": "Objekti u nxor nga thesi per perpunim / Receive item at location (Inb)", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "Taken out of its bag for processing, seen at the office of exchange before the item leaves Albania."},
6
+ {"wording": "Objekti u pranua ne zyren e shkembimit", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: its office of exchange received the item (UPU EMD)."},
7
+ {"wording": "Regjistrimi i informacionit doganor te objektit", "stage": "customs", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: presented to customs or held there (UPU EDB and EME)."},
8
+ {"wording": "Objekti u dergua ne zyren destinacion", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: sent on from its office of exchange (UPU EMF)."},
9
+ {"wording": "Objekti u pranua ne zyren e shperndarjes", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: the delivery office received the item (UPU EMG)."},
10
+ {"wording": "Tentative e pasuksesshme ne shperndarjen e objektit", "stage": "failed_attempt", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: an unsuccessful delivery (UPU EMH); a delivery can follow."},
11
+ {"wording": "Objekti u dorezua", "stage": "delivered", "language": "sq", "confirmedBy": "live", "note": "Relayed from the foreign post: the final delivery (UPU EMI)."},
12
+ {"wording": "Nisur nga origjina", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "A courier's scan on an EMS item it carried abroad: it left Albania."},
13
+ {"wording": "Mbajtur ne dogane", "stage": "customs", "language": "sq", "confirmedBy": "live", "note": "A courier's scan: held at customs in the destination country."},
14
+ {"wording": "Mbërritur në qendrën e shpërndarjes", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "A courier's scan: arrived at its delivery centre."},
15
+ {"wording": "Marrë nga korrieri", "stage": "in_transit", "language": "sq", "confirmedBy": "live", "note": "A courier's scan, seen between the arrival at the delivery centre and the delivery; it does not say the item went out for delivery."},
16
+ {"wording": "Dorëzuar", "stage": "delivered", "language": "sq", "confirmedBy": "live", "note": "A courier's delivery."}
17
+ ]
4
18
  }
@@ -230,6 +230,7 @@ import { statusMap as posIndonesia } from '../../carriers/pos-indonesia/status.j
230
230
  import { statusMap as postLuxembourg } from '../../carriers/post-luxembourg/status.js';
231
231
  import { statusMap as postaNorthMacedonia } from '../../carriers/posta-north-macedonia/status.js';
232
232
  import { statusMap as postaRomana } from '../../carriers/posta-romana/status.js';
233
+ import { statusMap as postaShqiptare } from '../../carriers/posta-shqiptare/status.js';
233
234
  import { statusMap as postaSlovenije } from '../../carriers/posta-slovenije/status.js';
234
235
  import { statusMap as postaSrbije } from '../../carriers/posta-srbije/status.js';
235
236
  import { statusMap as postlogistics } from '../../carriers/postlogistics/status.js';
@@ -360,6 +361,7 @@ export const STATUS_MAPS = {
360
361
  'pfc-express': pfcExpress, phlpost,
361
362
  'pitney-bowes': pitneyBowes, 'pos-indonesia': posIndonesia,
362
363
  'post-luxembourg': postLuxembourg, 'posta-north-macedonia': postaNorthMacedonia, 'posta-romana': postaRomana,
364
+ 'posta-shqiptare': postaShqiptare,
363
365
  'posta-slovenije': postaSlovenije, 'posta-srbije': postaSrbije, postlogistics, posturinn, ppl, 'pro-carrier': proCarrier,
364
366
  ptt, raben, redpack, redur, redx, relaytech, 'rl-carriers': rlCarriers, 'rpx-indonesia': rpxIndonesia, sagawa, saia, sailpost, sameday, 'saudi-post': saudiPost, seino, seko,
365
367
  sending, serpost, servientrega, sfc, shadowfax, shipentegra, 'skroutz-last-mile': skroutzLastMile, 'skynet-worldwide': skynetWorldwide, 'slovak-parcel-service': slovakParcelService,
@@ -22117,9 +22117,11 @@
22117
22117
  "timezone": "Europe/Tirane",
22118
22118
  "tracking": {
22119
22119
  "mode": "automatic",
22120
- "adapter": "universal"
22120
+ "adapter": "posta-shqiptare",
22121
+ "recognitionRank": 829,
22122
+ "localClocks": true
22121
22123
  },
22122
- "canaryUrl": "https://www.postashqiptare.al/c/207/tracked",
22124
+ "canaryUrl": "https://eposta.postashqiptare.al/tracking",
22123
22125
  "trackingUrlTemplate": "https://eposta.postashqiptare.al/tracking/{trackingNumber}",
22124
22126
  "trackingSearchUrl": "https://www.postashqiptare.al/c/207/tracked",
22125
22127
  "linkRules": [
@@ -22130,7 +22132,14 @@
22130
22132
  "path": "^/tracking/([^/?#]+)/?$"
22131
22133
  }
22132
22134
  ],
22133
- "detectionRules": []
22135
+ "detectionRules": [
22136
+ {
22137
+ "pattern": "^[A-Z]{2}\\d{9}AL$",
22138
+ "confidence": "low",
22139
+ "checksum": "s10",
22140
+ "preferred": true
22141
+ }
22142
+ ]
22134
22143
  },
22135
22144
  "posta-slovenije": {
22136
22145
  "displayName": "Pošta Slovenije",
@@ -14145,16 +14145,23 @@ export declare const CARRIER_CATALOG: {
14145
14145
  readonly timezone: "Europe/Tirane";
14146
14146
  readonly tracking: {
14147
14147
  readonly mode: "automatic";
14148
- readonly adapter: "universal";
14148
+ readonly adapter: "posta-shqiptare";
14149
+ readonly recognitionRank: 829;
14150
+ readonly localClocks: true;
14149
14151
  };
14150
- readonly canaryUrl: "https://www.postashqiptare.al/c/207/tracked";
14152
+ readonly canaryUrl: "https://eposta.postashqiptare.al/tracking";
14151
14153
  readonly trackingUrlTemplate: "https://eposta.postashqiptare.al/tracking/{trackingNumber}";
14152
14154
  readonly trackingSearchUrl: "https://www.postashqiptare.al/c/207/tracked";
14153
14155
  readonly linkRules: readonly [{
14154
14156
  readonly domains: readonly ["eposta.postashqiptare.al"];
14155
14157
  readonly path: "^/tracking/([^/?#]+)/?$";
14156
14158
  }];
14157
- readonly detectionRules: readonly [];
14159
+ readonly detectionRules: readonly [{
14160
+ readonly pattern: "^[A-Z]{2}\\d{9}AL$";
14161
+ readonly confidence: "low";
14162
+ readonly checksum: "s10";
14163
+ readonly preferred: true;
14164
+ }];
14158
14165
  };
14159
14166
  readonly "posta-slovenije": {
14160
14167
  readonly displayName: "Pošta Slovenije";
@@ -22118,9 +22118,11 @@ export const CARRIER_CATALOG = {
22118
22118
  "timezone": "Europe/Tirane",
22119
22119
  "tracking": {
22120
22120
  "mode": "automatic",
22121
- "adapter": "universal"
22121
+ "adapter": "posta-shqiptare",
22122
+ "recognitionRank": 829,
22123
+ "localClocks": true
22122
22124
  },
22123
- "canaryUrl": "https://www.postashqiptare.al/c/207/tracked",
22125
+ "canaryUrl": "https://eposta.postashqiptare.al/tracking",
22124
22126
  "trackingUrlTemplate": "https://eposta.postashqiptare.al/tracking/{trackingNumber}",
22125
22127
  "trackingSearchUrl": "https://www.postashqiptare.al/c/207/tracked",
22126
22128
  "linkRules": [
@@ -22131,7 +22133,14 @@ export const CARRIER_CATALOG = {
22131
22133
  "path": "^/tracking/([^/?#]+)/?$"
22132
22134
  }
22133
22135
  ],
22134
- "detectionRules": []
22136
+ "detectionRules": [
22137
+ {
22138
+ "pattern": "^[A-Z]{2}\\d{9}AL$",
22139
+ "confidence": "low",
22140
+ "checksum": "s10",
22141
+ "preferred": true
22142
+ }
22143
+ ]
22135
22144
  },
22136
22145
  "posta-slovenije": {
22137
22146
  "displayName": "Pošta Slovenije",
@@ -30214,7 +30223,7 @@ export const DETECTION_RULE_IDS = {
30214
30223
  "posta-moldovei": ["posta-moldovei-1"],
30215
30224
  "posta-north-macedonia": ["posta-north-macedonia-s10"],
30216
30225
  "posta-romana": ["posta-romana-s10"],
30217
- "posta-shqiptare": [],
30226
+ "posta-shqiptare": ["posta-shqiptare-s10"],
30218
30227
  "posta-slovenije": ["posta-slovenije-s10"],
30219
30228
  "posta-srbije": ["posta-srbije-s10"],
30220
30229
  "postaplus": [],
@@ -211,6 +211,7 @@ export const CARRIER_RECOGNITION_RANKS = {
211
211
  "post-luxembourg": 80,
212
212
  "posta-north-macedonia": 899,
213
213
  "posta-romana": 948,
214
+ "posta-shqiptare": 829,
214
215
  "posta-slovenije": 130,
215
216
  "posta-srbije": 110,
216
217
  "poste-italiane": 900,
@@ -283,6 +283,7 @@ import { adapter as posMalaysia } from '../carriers/pos-malaysia/adapter.js';
283
283
  import { adapter as postLuxembourg } from '../carriers/post-luxembourg/adapter.js';
284
284
  import { adapter as postaNorthMacedonia } from '../carriers/posta-north-macedonia/adapter.js';
285
285
  import { adapter as postaRomana } from '../carriers/posta-romana/adapter.js';
286
+ import { adapter as postaShqiptare } from '../carriers/posta-shqiptare/adapter.js';
286
287
  import { adapter as postaSlovenije } from '../carriers/posta-slovenije/adapter.js';
287
288
  import { adapter as postaSrbije } from '../carriers/posta-srbije/adapter.js';
288
289
  import { adapter as posteItaliane } from '../carriers/poste-italiane/adapter.js';
@@ -687,6 +688,7 @@ export const REGISTRY = {
687
688
  "post-luxembourg": postLuxembourg,
688
689
  "posta-north-macedonia": postaNorthMacedonia,
689
690
  "posta-romana": postaRomana,
691
+ "posta-shqiptare": postaShqiptare,
690
692
  "posta-slovenije": postaSlovenije,
691
693
  "posta-srbije": postaSrbije,
692
694
  "poste-italiane": posteItaliane,
@@ -1320,7 +1322,7 @@ export const REGISTRY = {
1320
1322
  "posta-moldovei": "universal",
1321
1323
  "posta-north-macedonia": "posta-north-macedonia",
1322
1324
  "posta-romana": "posta-romana",
1323
- "posta-shqiptare": "universal",
1325
+ "posta-shqiptare": "posta-shqiptare",
1324
1326
  "posta-slovenije": "posta-slovenije",
1325
1327
  "posta-srbije": "posta-srbije",
1326
1328
  "postaplus": "universal",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.372.0"
5
+ "version": "0.373.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.372.0-main.2012",
3
+ "version": "0.373.0-main.2014",
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",