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 +2 -2
- package/data/catalog.json +6 -2
- package/data/coverage-summary.json +1 -1
- package/dist/browser/scraper.js +9 -3
- package/dist/carriers/dpd-de/service.d.ts +4 -4
- package/dist/carriers/dpd-de/service.js +25 -17
- package/dist/carriers/dpd-ie/adapter.d.ts +14 -0
- package/dist/carriers/dpd-ie/adapter.js +100 -0
- package/dist/carriers/dpd-ie/carrier.json +39 -7
- package/dist/carriers/dpd-ie/numbers.json +2 -2
- package/dist/carriers/dpd-ie/parser.d.ts +19 -0
- package/dist/carriers/dpd-ie/parser.js +198 -0
- package/dist/carriers/dpd-ie/status.d.ts +27 -0
- package/dist/carriers/dpd-ie/status.js +60 -0
- package/dist/carriers/dpd-ie/statuses.json +21 -1
- package/dist/carriers/venipak/adapter.d.ts +15 -0
- package/dist/carriers/venipak/adapter.js +63 -0
- package/dist/carriers/venipak/carrier.json +42 -7
- package/dist/carriers/venipak/numbers.json +10 -10
- package/dist/carriers/venipak/parser.d.ts +19 -0
- package/dist/carriers/venipak/parser.js +173 -0
- package/dist/carriers/venipak/status.d.ts +14 -0
- package/dist/carriers/venipak/status.js +34 -0
- package/dist/carriers/venipak/statuses.json +11 -1
- package/dist/core/catalog/statusMaps.js +5 -3
- package/dist/data/catalog.json +6 -2
- package/dist/generated/catalog.d.ts +6 -2
- package/dist/generated/catalog.js +6 -2
- package/dist/generated/recognition.js +2 -0
- package/dist/generated/registry.js +6 -2
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **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,
|
|
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": "
|
|
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": "
|
|
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}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -4443,7 +4443,9 @@ var ye = {
|
|
|
4443
4443
|
timezone: "Europe/Dublin",
|
|
4444
4444
|
tracking: {
|
|
4445
4445
|
mode: "automatic",
|
|
4446
|
-
adapter: "
|
|
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: "
|
|
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
|
|
136
|
-
* which the store records. `onSession` hears of each session taken up and
|
|
137
|
-
* so the host can tell how long openings take and when one fails. 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
|
|
20
|
-
* eight, and one left idle for four
|
|
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
|
|
30
|
-
*
|
|
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
|
|
242
|
+
/** Checks the sessions lookups no longer use when the first of them is due. */
|
|
241
243
|
#follow() {
|
|
242
|
-
|
|
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
|
|
247
|
-
* confirmed by the next check is saved as the time it began; a reply that is not about
|
|
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
|
|
443
|
-
* which the store records. `onSession` hears of each session taken up and
|
|
444
|
-
* so the host can tell how long openings take and when one fails. 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": "
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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": "
|
|
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
|
|
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;
|