universal-parcel-scraper 0.76.0-main.934 → 0.77.0-main.937

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 **132 dedicated adapters** and **5 universal fallbacks**
16
+ **3,500+ carriers** through **134 dedicated adapters** and **5 universal fallbacks**
17
17
 
18
18
  <sub>207 catalog entries · 66 countries represented</sub>
19
19
  <!-- /GENERATED:summary -->
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
155
155
  <!-- GENERATED:stages -->
156
156
  <img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
157
157
 
158
- The carrier folders record 3,704 such statuses, each filed under one stage.
158
+ The carrier folders record 3,732 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
@@ -5480,7 +5480,9 @@
5480
5480
  "timezone": "Europe/Dublin",
5481
5481
  "tracking": {
5482
5482
  "mode": "automatic",
5483
- "adapter": "universal"
5483
+ "adapter": "dpd-ie",
5484
+ "recognitionRank": 62,
5485
+ "localClocks": true
5484
5486
  },
5485
5487
  "canaryUrl": "https://www.dpd.ie/tracking/",
5486
5488
  "trackingUrlTemplate": "https://www.dpd.ie/tracking/?consignmentNumber={trackingNumber}",
@@ -8008,7 +8010,9 @@
8008
8010
  "timezone": "Europe/Vilnius",
8009
8011
  "tracking": {
8010
8012
  "mode": "automatic",
8011
- "adapter": "universal"
8013
+ "adapter": "venipak",
8014
+ "recognitionRank": 63,
8015
+ "localClocks": true
8012
8016
  },
8013
8017
  "canaryUrl": "https://venipak.com/lt/en/parcel-tracking/",
8014
8018
  "trackingUrlTemplate": "https://venipak.com/lt/en/tracking/track/{trackingNumber}",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "catalog": 207,
3
- "activeAdapters": 132,
3
+ "activeAdapters": 134,
4
4
  "countries": 66,
5
5
  "referenceCarriers": 100,
6
6
  "comparison": {
@@ -4443,7 +4443,9 @@ var ye = {
4443
4443
  timezone: "Europe/Dublin",
4444
4444
  tracking: {
4445
4445
  mode: "automatic",
4446
- adapter: "universal"
4446
+ adapter: "dpd-ie",
4447
+ recognitionRank: 62,
4448
+ localClocks: !0
4447
4449
  },
4448
4450
  canaryUrl: "https://www.dpd.ie/tracking/",
4449
4451
  trackingUrlTemplate: "https://www.dpd.ie/tracking/?consignmentNumber={trackingNumber}",
@@ -6320,7 +6322,9 @@ var ye = {
6320
6322
  timezone: "Europe/Vilnius",
6321
6323
  tracking: {
6322
6324
  mode: "automatic",
6323
- adapter: "universal"
6325
+ adapter: "venipak",
6326
+ recognitionRank: 63,
6327
+ localClocks: !0
6324
6328
  },
6325
6329
  canaryUrl: "https://venipak.com/lt/en/parcel-tracking/",
6326
6330
  trackingUrlTemplate: "https://venipak.com/lt/en/tracking/track/{trackingNumber}",
@@ -11710,6 +11714,7 @@ var _c = {
11710
11714
  "dhl-express": 75,
11711
11715
  dpd: 70,
11712
11716
  "dpd-de": 65,
11717
+ "dpd-ie": 62,
11713
11718
  "dpd-pl": 8,
11714
11719
  econt: 56,
11715
11720
  elta: 55,
@@ -11751,7 +11756,8 @@ var _c = {
11751
11756
  tipsa: 24,
11752
11757
  tnt: 44,
11753
11758
  ukrposhta: 5,
11754
- uniuni: 33
11759
+ uniuni: 33,
11760
+ venipak: 63
11755
11761
  }, vc = 5;
11756
11762
  function yc(e) {
11757
11763
  let t = y(e);
@@ -0,0 +1,14 @@
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
+ export declare class DpdIeTracker {
5
+ private readonly options;
6
+ constructor(options?: {
7
+ fetcher?: typeof fetch;
8
+ recorder?: StepRecorder;
9
+ userAgent?: string;
10
+ });
11
+ fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
12
+ private lookup;
13
+ }
14
+ export declare const adapter: AdapterFactory;
@@ -0,0 +1,100 @@
1
+ import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
2
+ import { BudgetExceededError, ChallengeError, IndeterminateError, SchemaError } from '../../core/errors/index.js';
3
+ import { runSteps } from '../../core/runner/index.js';
4
+ import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
5
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
6
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
7
+ import { isDpdIeChallenge, normalizeDpdIeNumber, parseDpdIe, parseDpdIeAbsence, PROVIDER } from './parser.js';
8
+ // The tracker www.dpd.ie/tracking/ shows in a frame, with the frame's device type.
9
+ const ORIGIN = 'https://tracking.dpd.ie';
10
+ const RESULT = '/Home/SearchResult?deviceType=5';
11
+ // Where the tracker sends a consignment it does not have.
12
+ const ABSENT = '/Custom500.html?aspxerrorpath=/Home/SearchResult';
13
+ // The result page is about 10 kB.
14
+ const MAX_BYTES = 512 * 1024;
15
+ const REDIRECTS = [301, 302, 303, 307, 308];
16
+ const COOKIE = /^(ASP\.NET_SessionId|__cf_bm)=([A-Za-z0-9._~:=+/-]{1,512})(?:;|$)/;
17
+ /** The anonymous session the search opens; the result page reads the consignment from it. */
18
+ function sessionCookie(response) {
19
+ const cookies = new Map();
20
+ for (const header of response.headers.getSetCookie()) {
21
+ const match = COOKIE.exec(header);
22
+ if (match)
23
+ cookies.set(match[1], `${match[1]}=${match[2]}`);
24
+ }
25
+ return cookies.has('ASP.NET_SessionId') ? [...cookies.values()].join('; ') : null;
26
+ }
27
+ /** A redirect's target on the tracker's own host, as a path with its query, or null. */
28
+ function redirectPath(response) {
29
+ const location = response.headers.get('location');
30
+ if (!location)
31
+ return null;
32
+ try {
33
+ const target = new URL(location, `${ORIGIN}/`);
34
+ return target.origin === ORIGIN ? target.pathname + target.search : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export class DpdIeTracker {
41
+ options;
42
+ constructor(options = {}) {
43
+ this.options = options;
44
+ }
45
+ async fetch(raw, context = {}) {
46
+ const number = normalizeDpdIeNumber(raw);
47
+ const budgetMs = context.budgetMs ?? 15_000;
48
+ // A request that fails to reach DPD Ireland, or hangs, gets one more try
49
+ // with a new session: the first gets half of the budget.
50
+ return runSteps({ carrier: 'dpd-ie', budgetMs, signal: context.signal, recorder: this.options.recorder ?? NOOP_RECORDER }, [
51
+ { id: 'direct', run: ({ signal, remainingMs }) => this.lookup(number, signal, firstAttemptMs(remainingMs), budgetMs) },
52
+ networkRetryStep(({ signal, remainingMs }) => this.lookup(number, signal, remainingMs, budgetMs)),
53
+ ]);
54
+ }
55
+ async lookup(number, signal, timeoutMs, budgetMs) {
56
+ const deadline = performance.now() + timeoutMs;
57
+ const userAgent = userAgentOf(this.options.userAgent);
58
+ const read = async (path, cookie) => {
59
+ const left = deadline - performance.now();
60
+ if (left <= 0)
61
+ throw new BudgetExceededError(PROVIDER, budgetMs);
62
+ signal.throwIfAborted();
63
+ const { response, bytes } = await fetchBounded(`${ORIGIN}${path}`, { signal, headers: {
64
+ 'User-Agent': userAgent, Accept: 'text/html', ...(cookie ? { Cookie: cookie } : {}),
65
+ } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(left)), maxBytes: MAX_BYTES, redirect: 'manual',
66
+ allowHttpStatuses: [...REDIRECTS, 404, 410], fetcher: this.options.fetcher });
67
+ signal.throwIfAborted();
68
+ // A missing page says nothing about the consignment.
69
+ if (response.status === 404 || response.status === 410)
70
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland tracking is unavailable');
71
+ return { response, html: decodeText(bytes) };
72
+ };
73
+ // The search stores the number in a new session and sends the browser to the result page.
74
+ const search = await read(`/?${new URLSearchParams({ deviceType: '5', consignmentNumber: number })}`);
75
+ if (!REDIRECTS.includes(search.response.status)) {
76
+ if (isDpdIeChallenge(search.html))
77
+ throw new ChallengeError(PROVIDER, 'DPD Ireland asked for a browser check');
78
+ throw new SchemaError(PROVIDER, 'DPD Ireland did not accept the consignment number');
79
+ }
80
+ const cookie = sessionCookie(search.response);
81
+ if (redirectPath(search.response) !== RESULT || !cookie) {
82
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland did not open a tracking session');
83
+ }
84
+ const result = await read(RESULT, cookie);
85
+ if (REDIRECTS.includes(result.response.status)) {
86
+ if (redirectPath(result.response) !== ABSENT)
87
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland did not keep the tracking session');
88
+ return parseDpdIeAbsence((await read(ABSENT)).html);
89
+ }
90
+ return parseDpdIe(result.html, number);
91
+ }
92
+ }
93
+ export const adapter = environment => {
94
+ const tracker = new DpdIeTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
95
+ return {
96
+ id: 'dpd-ie', recordsSteps: true, steps: ['direct', 'retry'],
97
+ track: (input, context) => tracker.fetch(input.number, context),
98
+ recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeDpdIeNumber(number))),
99
+ };
100
+ };
@@ -16,18 +16,50 @@
16
16
  "timezone": "Europe/Dublin",
17
17
  "tracking": {
18
18
  "mode": "automatic",
19
- "adapter": "universal",
20
- "steps": []
19
+ "adapter": "dpd-ie",
20
+ "steps": [
21
+ "direct",
22
+ "retry"
23
+ ],
24
+ "recognition": {
25
+ "rank": 62
26
+ },
27
+ "localClocks": true
21
28
  },
22
- "capabilities": [],
29
+ "capabilities": [
30
+ "history",
31
+ "location",
32
+ "eta",
33
+ "delivered_at"
34
+ ],
23
35
  "portal": {
24
36
  "url": "https://www.dpd.ie/tracking/?consignmentNumber={trackingNumber}",
25
37
  "searchUrl": "https://www.dpd.ie/tracking/",
26
38
  "canaryUrl": "https://www.dpd.ie/tracking/",
27
- "shows": [],
28
- "scraped": [],
29
- "discarded": [],
30
- "unavailable": []
39
+ "shows": [
40
+ "status",
41
+ "history",
42
+ "location",
43
+ "eta",
44
+ "eta_window",
45
+ "parcel_count"
46
+ ],
47
+ "scraped": [
48
+ "status",
49
+ "history",
50
+ "location",
51
+ "eta",
52
+ "eta_window",
53
+ "delivered_at"
54
+ ],
55
+ "discarded": [
56
+ "parcel_count"
57
+ ],
58
+ "unavailable": [
59
+ "weight",
60
+ "sender_name",
61
+ "offset_timestamps"
62
+ ]
31
63
  },
32
64
  "links": [
33
65
  {
@@ -244,13 +244,13 @@
244
244
  "access": "indexed_excerpt"
245
245
  },
246
246
  "context": {
247
- "assessment": "candidate"
247
+ "assessment": "confirmed"
248
248
  },
249
249
  "expect": {
250
250
  "carrier": "unknown",
251
251
  "confidence": "none"
252
252
  },
253
- "note": "Looked up on DPD Ireland's own tracker, as an archived address shows; the capture was not opened."
253
+ "note": "Looked up on DPD Ireland's own tracker, as an archived address shows. DPD Ireland's tracker returns its history."
254
254
  }
255
255
  ]
256
256
  }
@@ -0,0 +1,19 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const PROVIDER = "DPD Ireland";
3
+ export declare const DPD_IE_ZONE = "Europe/Dublin";
4
+ /** A DPD Ireland consignment number: nine or ten digits. */
5
+ export declare function normalizeDpdIeNumber(raw: string): string;
6
+ /** Cloudflare's challenge page in place of the tracker. Its passive script on ordinary pages is not one. */
7
+ export declare function isDpdIeChallenge(html: string): boolean;
8
+ /**
9
+ * The page the tracker sends an unknown consignment to. A redirect to it is
10
+ * the tracker's only answer for a number it does not have, so its notice must
11
+ * be the not-found one.
12
+ */
13
+ export declare function parseDpdIeAbsence(html: string): never;
14
+ /**
15
+ * The tracker's result for one consignment: its scans, oldest first, and a
16
+ * headline with the current status. A consignment of several parcels repeats
17
+ * each scan once per parcel.
18
+ */
19
+ export declare function parseDpdIe(html: string, raw: string): CarrierResult;
@@ -0,0 +1,198 @@
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 { normalizeStatusWording } from '../../core/status/statusMap.js';
7
+ import { clean } from '../../core/transport/index.js';
8
+ import { DPD_IE_DRIVER_COLLECTION, DPD_IE_POINT_COLLECTION, DPD_IE_POINT_DROP_OFF, dpdIeScanStage, isDpdIeNotice } from './status.js';
9
+ export const PROVIDER = 'DPD Ireland';
10
+ export const DPD_IE_ZONE = 'Europe/Dublin';
11
+ // The consignment numbers DPD Ireland's tracker shows have nine digits;
12
+ // lookups archived from it also use ten.
13
+ const NUMBER = /^\d{9,10}$/;
14
+ const HEADERS = ['Status', 'Location', 'Date & Time'];
15
+ // "07/9/2026 at 1:41": day, month without its zero, year, then the hour without its zero.
16
+ const CLOCK = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) at (\d{1,2}):(\d{2})$/;
17
+ // DPD Ireland's own depots, in the Republic and in Northern Ireland, and its
18
+ // head office and website. Their clocks are Irish wall time. Another
19
+ // network's scan names its town and country, as "<town> - <country>".
20
+ const IRISH_PLACE = /^(?:Dpd [A-Za-z][A-Za-z .'-]{0,60}|Head Office|Web)$/i;
21
+ // Labels of DPD Ireland's systems rather than places.
22
+ const NOT_PLACES = new Set(['head office', 'web']);
23
+ const VAN = 'Your parcel is on a van and due for delivery';
24
+ // The scan's text ends with the delivery window, cut to the field's length.
25
+ const VAN_WINDOW = new RegExp(`^(${VAN}), between\\b.*$`);
26
+ // The headline of a parcel on the van, with the whole window.
27
+ const VAN_HEADLINE = new RegExp(`^${VAN}\\. Expected delivery time (\\d{2}):(\\d{2}) - (\\d{2}):(\\d{2})$`);
28
+ const MAX_ROWS = 500;
29
+ const same = (left, right) => normalizeStatusWording(left) === normalizeStatusWording(right);
30
+ const MAX_EVENTS = 100;
31
+ /** A DPD Ireland consignment number: nine or ten digits. */
32
+ export function normalizeDpdIeNumber(raw) {
33
+ const number = normalizeTrackingNumber(raw);
34
+ if (!NUMBER.test(number))
35
+ throw new InvalidInputError(PROVIDER, 'DPD Ireland tracks nine- or ten-digit consignment numbers');
36
+ return number;
37
+ }
38
+ /** Cloudflare's challenge page in place of the tracker. Its passive script on ordinary pages is not one. */
39
+ export function isDpdIeChallenge(html) {
40
+ return /<title>\s*Just a moment|_cf_chl_opt|cf-turnstile/i.test(html);
41
+ }
42
+ /**
43
+ * The page the tracker sends an unknown consignment to. A redirect to it is
44
+ * the tracker's only answer for a number it does not have, so its notice must
45
+ * be the not-found one.
46
+ */
47
+ export function parseDpdIeAbsence(html) {
48
+ const $ = load(html);
49
+ $('script, style, noscript').remove();
50
+ const notice = clean($('body').text(), 400);
51
+ if (/^We couldn't locate the consignment number\.\s*It may not have reached our sorting hub yet, or it might be older than six months\.$/
52
+ .test(notice.replace(/[’‘]/g, "'"))) {
53
+ throw new NotFoundError(PROVIDER);
54
+ }
55
+ if (isDpdIeChallenge(html))
56
+ throw new ChallengeError(PROVIDER, 'DPD Ireland asked for a browser check');
57
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland returned an error page without its not-found notice');
58
+ }
59
+ function clockOf(text) {
60
+ const match = CLOCK.exec(text);
61
+ if (!match)
62
+ return null;
63
+ const [, day, month, year, hour, minute] = match;
64
+ // UTC only checks the calendar fields. The zone is assigned per row.
65
+ const parsed = DateTime.fromObject({ year: Number(year), month: Number(month), day: Number(day), hour: Number(hour),
66
+ minute: Number(minute) }, { zone: 'UTC' });
67
+ if (!parsed.isValid)
68
+ return null;
69
+ return { day: parsed.toFormat('yyyy-MM-dd'), time: parsed.toFormat('HH:mm:ss'), text };
70
+ }
71
+ function irishTime(day, time) {
72
+ const parsed = DateTime.fromISO(`${day}T${time}`, { zone: DPD_IE_ZONE });
73
+ return parsed.isValid ? parsed.toISO({ suppressMilliseconds: true }) : null;
74
+ }
75
+ /** The delivery window the headline gives a parcel on the van, on the day of its van scan. */
76
+ function vanWindow(headline, van) {
77
+ const match = VAN_HEADLINE.exec(headline);
78
+ if (!match || !van.time)
79
+ return null;
80
+ const day = DateTime.fromISO(van.time, { setZone: true }).toFormat('yyyy-MM-dd');
81
+ const from = irishTime(day, `${match[1]}:${match[2]}:00`);
82
+ const to = irishTime(day, `${match[3]}:${match[4]}:00`);
83
+ if (!from || !to || Date.parse(from) >= Date.parse(to) || Date.parse(to) < Date.parse(van.time))
84
+ return null;
85
+ return { expected_delivery_from: from, expected_delivery: to };
86
+ }
87
+ /**
88
+ * The tracker's result for one consignment: its scans, oldest first, and a
89
+ * headline with the current status. A consignment of several parcels repeats
90
+ * each scan once per parcel.
91
+ */
92
+ export function parseDpdIe(html, raw) {
93
+ const number = normalizeDpdIeNumber(raw);
94
+ const $ = load(html);
95
+ const root = $('div.status-container');
96
+ if (root.length !== 1) {
97
+ if (isDpdIeChallenge(html))
98
+ throw new ChallengeError(PROVIDER, 'DPD Ireland asked for a browser check');
99
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland did not return a consignment');
100
+ }
101
+ const info = root.find('.tracking-status-info p').filter((_, node) => /^Consignment\b/.test(clean($(node).text(), 80)));
102
+ if (info.length !== 1 || info.find('strong').length !== 1 || clean(info.find('strong').text(), 40) !== number) {
103
+ throw new SchemaError(PROVIDER, 'DPD Ireland returned a different consignment');
104
+ }
105
+ const lists = root.find('ul.history-list');
106
+ if (lists.length !== 1)
107
+ throw new SchemaError(PROVIDER, 'DPD Ireland tracking history changed');
108
+ // Wording is mapped in English, the tracker's only language.
109
+ const headers = lists.find('li.header-list h4').map((_, node) => clean($(node).text(), 40)).get();
110
+ if (headers.join('|') !== HEADERS.join('|'))
111
+ throw new SchemaError(PROVIDER, 'DPD Ireland history columns changed');
112
+ const rows = lists.children('li').not('.header-list');
113
+ if (!rows.length)
114
+ throw new NoHistoryError(PROVIDER);
115
+ if (rows.length > MAX_ROWS)
116
+ throw new SchemaError(PROVIDER, 'DPD Ireland returned too many scans');
117
+ const scans = [];
118
+ const seen = new Set();
119
+ for (const row of rows.toArray()) {
120
+ const node = $(row);
121
+ const cells = node.children('div');
122
+ if (cells.length !== 3)
123
+ throw new SchemaError(PROVIDER, 'DPD Ireland history columns changed');
124
+ const shown = clean(cells.eq(0).find('strong').first().text(), 200);
125
+ const wording = shown.replace(VAN_WINDOW, '$1');
126
+ const place = clean(cells.eq(1).text(), 120);
127
+ const clock = clockOf(clean(cells.eq(2).text(), 40));
128
+ if (!wording)
129
+ throw new SchemaError(PROVIDER, 'DPD Ireland returned a scan without wording');
130
+ if (!clock)
131
+ throw new SchemaError(PROVIDER, 'DPD Ireland returned an invalid scan time');
132
+ // Irish depots and systems keep Irish time. Another network's scan names
133
+ // its own country, and no offset is established for it.
134
+ const time = IRISH_PLACE.test(place) ? irishTime(clock.day, clock.time) : null;
135
+ const event = {
136
+ ...(time ? { time } : { local_time: `${clock.day}T${clock.time}`, provider_time_text: clock.text }),
137
+ description: wording,
138
+ ...(place && !NOT_PLACES.has(place.toLowerCase()) ? { location: place } : {}),
139
+ };
140
+ // Each parcel of a consignment repeats the scan.
141
+ const identity = JSON.stringify(event);
142
+ if (seen.has(identity))
143
+ continue;
144
+ seen.add(identity);
145
+ scans.push({ wording, event });
146
+ }
147
+ // Oldest first: a collection's stage depends on the scans before it, and a
148
+ // notice keeps the stage of the scan before it.
149
+ let previous;
150
+ let droppedAtPoint = false;
151
+ for (const { wording, event } of scans) {
152
+ let stage;
153
+ if (same(wording, DPD_IE_DRIVER_COLLECTION))
154
+ stage = previous && previous !== 'registered' ? 'in_transit' : 'accepted';
155
+ else if (same(wording, DPD_IE_POINT_COLLECTION)) {
156
+ if (!droppedAtPoint) {
157
+ // Possibly the recipient's collection: no stage of its own.
158
+ Object.assign(event, { stage: previous ?? 'registered', stage_source: 'none' });
159
+ continue;
160
+ }
161
+ stage = 'in_transit';
162
+ }
163
+ else if (isDpdIeNotice(wording)) {
164
+ Object.assign(event, { stage: previous ?? 'registered', stage_source: 'none' });
165
+ continue;
166
+ }
167
+ else
168
+ stage = dpdIeScanStage(wording);
169
+ if (same(wording, DPD_IE_POINT_DROP_OFF))
170
+ droppedAtPoint = true;
171
+ else if (stage && stage !== 'registered' && stage !== 'accepted')
172
+ droppedAtPoint = false;
173
+ if (!stage)
174
+ continue;
175
+ Object.assign(event, { stage, stage_source: 'carrier_map' });
176
+ previous = stage;
177
+ }
178
+ const ordered = [...scans].reverse();
179
+ const events = ordered.map(scan => scan.event);
180
+ const decider = ordered.find(scan => scan.event.stage_source !== 'none')?.event;
181
+ if (!decider)
182
+ throw new IndeterminateError(PROVIDER, 'DPD Ireland returned no parcel scans');
183
+ const stage = decider.stage;
184
+ const latest = events[0];
185
+ const headline = clean(root.find('.delivery-box h3').first().text(), 200);
186
+ const window = stage === 'out_for_delivery' && same(decider.description ?? '', VAN) ? vanWindow(headline, decider) : null;
187
+ return {
188
+ status: stage ? languageStageStatus(stage) : 'unknown',
189
+ ...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
190
+ last_status_text: decider.description ?? null,
191
+ last_update: latest.time ?? null,
192
+ ...(latest.time ? {} : { last_update_local: latest.local_time }),
193
+ expected_delivery: window?.expected_delivery ?? null,
194
+ ...(window ? { expected_delivery_from: window.expected_delivery_from } : {}),
195
+ ...(stage === 'delivered' && decider.time ? { delivered_at: decider.time } : {}),
196
+ events: events.slice(0, MAX_EVENTS),
197
+ };
198
+ }
@@ -0,0 +1,27 @@
1
+ import type { Stage } from '../../core/status/index.js';
2
+ import { type CarrierStatusMap } from '../../core/status/statusMap.js';
3
+ /**
4
+ * The tracking page's scan wording, mapped whole. The page gives no status
5
+ * code. The shared classifier leaves most of these without a stage, and it
6
+ * would read a pickup point's hand-over to DPD as a delivery.
7
+ */
8
+ export declare const DPD_IE_SCANS: Readonly<Record<string, Stage>>;
9
+ /** A DPD driver's collection: it accepts the parcel, unless an earlier scan already did or moved it. */
10
+ export declare const DPD_IE_DRIVER_COLLECTION = "Parcel collected by DPD Driver";
11
+ /**
12
+ * A Pickup shop handing a parcel over. After the sender dropped it at the
13
+ * shop, DPD takes it on; any other time it may be the recipient's
14
+ * collection, which the wording alone does not tell.
15
+ */
16
+ export declare const DPD_IE_POINT_COLLECTION = "Your parcel has been collected from the Pickup Point";
17
+ /** The sender's drop-off at a Pickup shop, after which the shop's hand-over is DPD's collection. */
18
+ export declare const DPD_IE_POINT_DROP_OFF = "Customer dropped parcel at Pick Up Point";
19
+ /** The stage the wording alone gives a scan, if any. */
20
+ export declare function dpdIeScanStage(wording: string): Stage | undefined;
21
+ export declare function isDpdIeNotice(wording: string): boolean;
22
+ /**
23
+ * What the map says about one scan. The two collections take their stage
24
+ * from the scans before them, so the map does not know it from their
25
+ * wording; notices are left without a stage on purpose.
26
+ */
27
+ export declare const statusMap: CarrierStatusMap;
@@ -0,0 +1,60 @@
1
+ import { normalizeStatusWording } from '../../core/status/statusMap.js';
2
+ /**
3
+ * The tracking page's scan wording, mapped whole. The page gives no status
4
+ * code. The shared classifier leaves most of these without a stage, and it
5
+ * would read a pickup point's hand-over to DPD as a delivery.
6
+ */
7
+ export const DPD_IE_SCANS = {
8
+ // The sender's data, before DPD has the parcel.
9
+ 'Order received / DPD awaiting parcel': 'registered',
10
+ 'Order data received from sender': 'registered',
11
+ 'Parcel data received from sender': 'registered',
12
+ // The sender left the parcel at a Pickup shop.
13
+ 'Customer dropped parcel at Pick Up Point': 'accepted',
14
+ 'Your parcel is travelling through the DPD network': 'in_transit',
15
+ 'International Hub - In Transit': 'in_transit',
16
+ // A parcel from DPD's UK network arriving at an Irish depot.
17
+ 'DPD IE UK Inbound': 'in_transit',
18
+ 'Scanned out of HUB': 'in_transit',
19
+ 'Security Scan': 'in_transit',
20
+ 'Parcel going through Customs process': 'customs',
21
+ // The page cuts the delivery window after this wording; the parser removes it.
22
+ 'Your parcel is on a van and due for delivery': 'out_for_delivery',
23
+ "We tried to deliver but you weren't home": 'failed_attempt',
24
+ Delivered: 'delivered',
25
+ 'Delivered Consignee': 'delivered',
26
+ 'Delivered Letterbox': 'delivered',
27
+ 'Delivered Safe Place': 'delivered',
28
+ };
29
+ /** A DPD driver's collection: it accepts the parcel, unless an earlier scan already did or moved it. */
30
+ export const DPD_IE_DRIVER_COLLECTION = 'Parcel collected by DPD Driver';
31
+ /**
32
+ * A Pickup shop handing a parcel over. After the sender dropped it at the
33
+ * shop, DPD takes it on; any other time it may be the recipient's
34
+ * collection, which the wording alone does not tell.
35
+ */
36
+ export const DPD_IE_POINT_COLLECTION = 'Your parcel has been collected from the Pickup Point';
37
+ /** The sender's drop-off at a Pickup shop, after which the shop's hand-over is DPD's collection. */
38
+ export const DPD_IE_POINT_DROP_OFF = 'Customer dropped parcel at Pick Up Point';
39
+ // A new delivery day, not a movement: the parcel keeps its stage.
40
+ const NOTICES = ['Delivery of your parcel has been rescheduled'];
41
+ const SCANS = new Map(Object.entries(DPD_IE_SCANS).map(([wording, stage]) => [normalizeStatusWording(wording), stage]));
42
+ const NOTICE_KEYS = new Set(NOTICES.map(normalizeStatusWording));
43
+ /** The stage the wording alone gives a scan, if any. */
44
+ export function dpdIeScanStage(wording) {
45
+ return SCANS.get(normalizeStatusWording(wording));
46
+ }
47
+ export function isDpdIeNotice(wording) {
48
+ return NOTICE_KEYS.has(normalizeStatusWording(wording));
49
+ }
50
+ /**
51
+ * What the map says about one scan. The two collections take their stage
52
+ * from the scans before them, so the map does not know it from their
53
+ * wording; notices are left without a stage on purpose.
54
+ */
55
+ export const statusMap = {
56
+ stage: (code, wording) => code ? undefined : SCANS.get(wording),
57
+ gaps: [...NOTICE_KEYS].map(wording => ({
58
+ wording, note: 'A new delivery day, not a movement: the parcel keeps the stage it had.',
59
+ })),
60
+ };
@@ -1,4 +1,24 @@
1
1
  {
2
2
  "carrier": "dpd-ie",
3
- "entries": []
3
+ "entries": [
4
+ {"wording": "Order received / DPD awaiting parcel", "stage": "registered", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "The sender's data, before DPD has the parcel."},
5
+ {"wording": "Order data received from sender", "stage": "registered", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "An earlier wording of the sender's data. Seen in archived replies of the tracker."},
6
+ {"wording": "Parcel data received from sender", "stage": "registered", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "The sender's data, before DPD has the parcel. Seen in archived replies of the tracker."},
7
+ {"wording": "Customer dropped parcel at Pick Up Point", "stage": "accepted", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "The sender left the parcel at a Pickup shop."},
8
+ {"wording": "Your parcel has been collected from the Pickup Point", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "A Pickup shop handing the parcel over. After the sender's drop-off at the shop it is DPD's collection, in transit; otherwise it may be the recipient's collection, so it has no stage of its own and the parcel keeps the one it had."},
9
+ {"wording": "Parcel collected by DPD Driver", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "A driver's collection. Its stage depends on the scans before it: it accepts the parcel from the sender, and after an earlier acceptance or movement it is a hand-over in transit."},
10
+ {"wording": "Your parcel is travelling through the DPD network", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "A hub or line-haul scan, usually under Head Office."},
11
+ {"wording": "International Hub - In Transit", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "A scan in another DPD country's network, at a place named with its country."},
12
+ {"wording": "DPD IE UK Inbound", "stage": "in_transit", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "A parcel from DPD's UK network arriving at an Irish depot."},
13
+ {"wording": "Scanned out of HUB", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "Leaving the hub. Seen in archived replies of the tracker."},
14
+ {"wording": "Security Scan", "stage": "in_transit", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "A depot scan before delivery. Seen in archived replies of the tracker."},
15
+ {"wording": "Parcel going through Customs process", "stage": "customs", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "Seen in archived replies of the tracker, on parcels from outside the EU."},
16
+ {"wording": "Your parcel is on a van and due for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "The history cuts the delivery window after this wording, and the parser removes it; the headline gives the whole window."},
17
+ {"wording": "We tried to deliver but you weren't home", "stage": "failed_attempt", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "Seen in archived replies of the tracker, with a link to rearrange the delivery."},
18
+ {"wording": "Delivery of your parcel has been rescheduled", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "A new delivery day, not a movement: it has no stage of its own and the parcel keeps the one it had. Seen in archived replies of the tracker."},
19
+ {"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "An earlier wording of a delivery. Seen in archived replies of the tracker."},
20
+ {"wording": "Delivered Consignee", "stage": "delivered", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "Handed to the recipient."},
21
+ {"wording": "Delivered Letterbox", "stage": "delivered", "language": "en", "confirmedBy": "live", "firstSeen": "2026-10-11", "note": "Left in the letterbox."},
22
+ {"wording": "Delivered Safe Place", "stage": "delivered", "language": "en", "confirmedBy": "portal", "firstSeen": "2026-10-11", "note": "Left in the recipient's safe place. Seen in archived replies of the tracker."}
23
+ ]
4
24
  }
@@ -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
+ export declare const VENIPAK_ENDPOINT = "https://venipak.com/ee/en/wp-json/v1/shipment-tracker/";
5
+ export declare function venipakTrackingUrl(raw: string): string;
6
+ export declare class VenipakTracker {
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;