universal-parcel-scraper 0.76.0-main.933 → 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);
@@ -132,10 +132,10 @@ export declare function sharedDpdAppService(options?: Pick<AdapterEnvironment, '
132
132
  * background, and keeps one open for the life of the process, so that no lookup waits the tens of
133
133
  * seconds an opening takes. For a long-lived host, with the `fetcher` and `userAgent` it gives
134
134
  * `createTracker` or its adapter environment. With a `store`, a restart takes the saved session up
135
- * again, and the sessions no longer used are checked every hour until the service refuses them,
136
- * which the store records. `onSession` hears of each session taken up and each opening that ends,
137
- * so the host can tell how long openings take and when one fails. The timers do not keep the
138
- * process alive.
135
+ * again, and the sessions no longer used are checked every hour, also across restarts, until the
136
+ * service refuses them, which the store records. `onSession` hears of each session taken up and
137
+ * each opening that ends, so the host can tell how long openings take and when one fails. The
138
+ * timers do not keep the process alive.
139
139
  */
140
140
  export declare function warmDpdSession(environment?: Pick<AdapterEnvironment, 'fetcher' | 'userAgent'>, options?: {
141
141
  store?: DpdSessionStore;
@@ -16,9 +16,9 @@ const MAX_BYTES = 2_000_000;
16
16
  /** Opening a session has taken up to 75 seconds. */
17
17
  const SESSION_OPEN_MS = 120_000;
18
18
  /**
19
- * The service has accepted a session for over eighteen hours, used once an hour after its first
20
- * eight, and one left idle for four. From eight hours old the next one opens beside it, and
21
- * replaces it once open.
19
+ * The service has accepted a session for nearly twenty-seven hours, used once an hour after its
20
+ * first eight, and one left idle for four, and refused another from twenty-seven hours old. From
21
+ * eight hours old the next one opens beside it, and replaces it once open.
22
22
  */
23
23
  const SESSION_RENEW_MS = 8 * 3_600_000;
24
24
  /** A kept session that failed to open is tried again after this long. */
@@ -26,8 +26,9 @@ const SESSION_RETRY_MS = 15 * 60_000;
26
26
  /** A host's store has this long to give back its sessions before the service opens one. */
27
27
  const STORE_LOAD_MS = 5_000;
28
28
  /**
29
- * With a store, a session lookups no longer use is checked this often, until the service refuses
30
- * it twice in a row: while slow, it can refuse a session it accepts again later.
29
+ * With a store, a session lookups no longer use is checked once it was last asked about this
30
+ * long ago, counting a check saved before a restart, until the service refuses it twice in a row:
31
+ * while slow, it can refuse a session it accepts again later.
31
32
  */
32
33
  const CHECK_EVERY_MS = 3_600_000;
33
34
  /** And for at most this long after it opened. */
@@ -224,7 +225,7 @@ export class DpdAppService {
224
225
  else if (newest)
225
226
  older.unshift(newest);
226
227
  for (const session of older)
227
- this.#retired.set(session.token, { session: { ...session } });
228
+ this.#retired.set(session.token, { session: { ...session }, askedAt: session.checkedAt ?? session.openedAt });
228
229
  }
229
230
  /** Saves a session's state in the host's store, if there is one, without waiting for it. */
230
231
  #save(session) {
@@ -234,18 +235,22 @@ export class DpdAppService {
234
235
  }
235
236
  /** Follows a session lookups no longer use, when a store keeps what its checks find. */
236
237
  #retire(session, refusedAt) {
237
- if (this.#store && !this.#retired.has(session.token))
238
- this.#retired.set(session.token, { session: { ...session }, refusedSince: refusedAt });
238
+ if (this.#store && !this.#retired.has(session.token)) {
239
+ this.#retired.set(session.token, { session: { ...session }, askedAt: this.#now(), refusedSince: refusedAt });
240
+ }
239
241
  }
240
- /** Checks the sessions lookups no longer use, every hour. */
242
+ /** Checks the sessions lookups no longer use when the first of them is due. */
241
243
  #follow() {
242
- const timer = setTimeout(() => void this.#check().finally(() => this.#follow()), CHECK_EVERY_MS);
244
+ let due = this.#now() + CHECK_EVERY_MS;
245
+ for (const retired of this.#retired.values())
246
+ due = Math.min(due, retired.askedAt + CHECK_EVERY_MS);
247
+ const timer = setTimeout(() => void this.#check().finally(() => this.#follow()), Math.max(0, due - this.#now()));
243
248
  timer.unref?.();
244
249
  }
245
250
  /**
246
- * Asks the service once for each followed session whether it still accepts it. A refusal
247
- * confirmed by the next check is saved as the time it began; a reply that is not about the
248
- * session says nothing.
251
+ * Asks the service once for each followed session that is due whether it still accepts it. A
252
+ * refusal confirmed by the next check is saved as the time it began; a reply that is not about
253
+ * the session says nothing.
249
254
  */
250
255
  async #check() {
251
256
  let first = true;
@@ -255,9 +260,12 @@ export class DpdAppService {
255
260
  this.#retired.delete(token);
256
261
  continue;
257
262
  }
263
+ if (now - retired.askedAt < CHECK_EVERY_MS)
264
+ continue;
258
265
  if (!first)
259
266
  await new Promise((resolve) => { setTimeout(resolve, CHECK_GAP_MS).unref?.(); });
260
267
  first = false;
268
+ retired.askedAt = this.#now();
261
269
  let accepted;
262
270
  try {
263
271
  await this.call('getParcelShopByID', { SessionToken: token, ParcelShopID: '0', PudoID: CHECK_PUDO_ID, ParcelShopOnly: 'false' }, AbortSignal.timeout(CHECK_TIMEOUT_MS), CHECK_TIMEOUT_MS);
@@ -439,10 +447,10 @@ export function sharedDpdAppService(options = {}) {
439
447
  * background, and keeps one open for the life of the process, so that no lookup waits the tens of
440
448
  * seconds an opening takes. For a long-lived host, with the `fetcher` and `userAgent` it gives
441
449
  * `createTracker` or its adapter environment. With a `store`, a restart takes the saved session up
442
- * again, and the sessions no longer used are checked every hour until the service refuses them,
443
- * which the store records. `onSession` hears of each session taken up and each opening that ends,
444
- * so the host can tell how long openings take and when one fails. The timers do not keep the
445
- * process alive.
450
+ * again, and the sessions no longer used are checked every hour, also across restarts, until the
451
+ * service refuses them, which the store records. `onSession` hears of each session taken up and
452
+ * each opening that ends, so the host can tell how long openings take and when one fails. The
453
+ * timers do not keep the process alive.
446
454
  */
447
455
  export function warmDpdSession(environment = {}, options = {}) {
448
456
  sharedDpdAppService(environment).keep(options);
@@ -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;