universal-parcel-scraper 0.12.0-main.336 → 0.13.1
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 +4 -4
- package/data/catalog.json +3 -2
- package/data/coverage-summary.json +3 -3
- package/data/detection-golden.json +48 -0
- package/dist/browser/scraper.js +11 -10
- package/dist/carriers/dhl-express/adapter.d.ts +2 -0
- package/dist/carriers/dhl-express/adapter.js +68 -20
- package/dist/carriers/dpd-uk/adapter.d.ts +11 -0
- package/dist/carriers/dpd-uk/adapter.js +58 -0
- package/dist/carriers/dpd-uk/carrier.json +18 -6
- package/dist/carriers/dpd-uk/numbers.json +30 -2
- package/dist/carriers/dpd-uk/parser.d.ts +9 -0
- package/dist/carriers/dpd-uk/parser.js +127 -0
- package/dist/carriers/evri-uk/adapter.d.ts +11 -0
- package/dist/carriers/evri-uk/adapter.js +80 -0
- package/dist/carriers/evri-uk/browser.d.ts +56 -0
- package/dist/carriers/evri-uk/browser.js +137 -0
- package/dist/carriers/evri-uk/carrier.json +21 -8
- package/dist/carriers/evri-uk/parser.d.ts +5 -0
- package/dist/carriers/evri-uk/parser.js +89 -0
- package/dist/carriers/evri-uk/statuses.json +8 -1
- package/dist/data/catalog.json +3 -2
- package/dist/generated/catalog.d.ts +3 -2
- package/dist/generated/catalog.js +3 -2
- package/dist/generated/registry.js +6 -2
- package/dist/providers/coverage.json +8 -8
- 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 **98 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>119 catalog entries · 58 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
|
@@ -33,9 +33,9 @@ its own as a command, a Node library or an HTTP server.
|
|
|
33
33
|
## Benchmark
|
|
34
34
|
|
|
35
35
|
<!-- GENERATED:coverage -->
|
|
36
|
-
In a curated benchmark of 113 carriers, dedicated adapters return tracking history for **
|
|
36
|
+
In a curated benchmark of 113 carriers, dedicated adapters return tracking history for **71**, rising to **84** with all fallbacks enabled. The best single aggregator returns 53.
|
|
37
37
|
|
|
38
|
-
<img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled
|
|
38
|
+
<img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled 84, This project, dedicated adapters alone 71, ParcelsApp 53, Postal Ninja 43, 17TRACK 40, Ship24 42, UPU 12." width="760">
|
|
39
39
|
<!-- /GENERATED:coverage -->
|
|
40
40
|
|
|
41
41
|
[Method and results per carrier](providers/COVERAGE.md)
|
|
@@ -149,7 +149,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
|
|
|
149
149
|
<!-- GENERATED:stages -->
|
|
150
150
|
<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">
|
|
151
151
|
|
|
152
|
-
The carrier folders record 1,
|
|
152
|
+
The carrier folders record 1,785 such statuses, each filed under one stage.
|
|
153
153
|
<!-- /GENERATED:stages -->
|
|
154
154
|
|
|
155
155
|
Wording nobody recorded yet goes through a classifier that reads seven European languages.
|
package/data/catalog.json
CHANGED
|
@@ -4172,7 +4172,8 @@
|
|
|
4172
4172
|
"timezone": "Europe/London",
|
|
4173
4173
|
"tracking": {
|
|
4174
4174
|
"mode": "automatic",
|
|
4175
|
-
"adapter": "
|
|
4175
|
+
"adapter": "dpd-uk",
|
|
4176
|
+
"localClocks": true
|
|
4176
4177
|
},
|
|
4177
4178
|
"canaryUrl": "https://track.dpd.co.uk/",
|
|
4178
4179
|
"trackingUrlTemplate": "https://track.dpd.co.uk/?reference={trackingNumber}",
|
|
@@ -4242,7 +4243,7 @@
|
|
|
4242
4243
|
"timezone": "Europe/London",
|
|
4243
4244
|
"tracking": {
|
|
4244
4245
|
"mode": "automatic",
|
|
4245
|
-
"adapter": "
|
|
4246
|
+
"adapter": "evri-uk"
|
|
4246
4247
|
},
|
|
4247
4248
|
"canaryUrl": "https://www.evri.com/track-a-parcel",
|
|
4248
4249
|
"trackingUrlTemplate": "https://www.evri.com/track/parcel/{trackingNumber}/details",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"catalog": 119,
|
|
3
|
-
"activeAdapters":
|
|
3
|
+
"activeAdapters": 98,
|
|
4
4
|
"countries": 58,
|
|
5
5
|
"referenceCarriers": 113,
|
|
6
6
|
"comparison": {
|
|
7
|
-
"combined":
|
|
8
|
-
"direct":
|
|
7
|
+
"combined": 84,
|
|
8
|
+
"direct": 71,
|
|
9
9
|
"providers": {
|
|
10
10
|
"ParcelsApp": 53,
|
|
11
11
|
"Postal Ninja": 43,
|
|
@@ -1380,6 +1380,54 @@
|
|
|
1380
1380
|
"colis-prive"
|
|
1381
1381
|
]
|
|
1382
1382
|
},
|
|
1383
|
+
{
|
|
1384
|
+
"input": "15504366056765",
|
|
1385
|
+
"carrier": "unknown",
|
|
1386
|
+
"confidence": "low",
|
|
1387
|
+
"candidates": [
|
|
1388
|
+
"dpd",
|
|
1389
|
+
"dpd-fr",
|
|
1390
|
+
"relais-colis",
|
|
1391
|
+
"ciblex",
|
|
1392
|
+
"seur",
|
|
1393
|
+
"brt",
|
|
1394
|
+
"delhivery",
|
|
1395
|
+
"dpd-de",
|
|
1396
|
+
"dpd-uk",
|
|
1397
|
+
"xpressbees"
|
|
1398
|
+
],
|
|
1399
|
+
"asked": [
|
|
1400
|
+
"dpd",
|
|
1401
|
+
"seur",
|
|
1402
|
+
"brt",
|
|
1403
|
+
"relais-colis",
|
|
1404
|
+
"ciblex"
|
|
1405
|
+
]
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
"input": "15504366057635",
|
|
1409
|
+
"carrier": "unknown",
|
|
1410
|
+
"confidence": "low",
|
|
1411
|
+
"candidates": [
|
|
1412
|
+
"dpd",
|
|
1413
|
+
"dpd-fr",
|
|
1414
|
+
"relais-colis",
|
|
1415
|
+
"ciblex",
|
|
1416
|
+
"seur",
|
|
1417
|
+
"brt",
|
|
1418
|
+
"delhivery",
|
|
1419
|
+
"dpd-de",
|
|
1420
|
+
"dpd-uk",
|
|
1421
|
+
"xpressbees"
|
|
1422
|
+
],
|
|
1423
|
+
"asked": [
|
|
1424
|
+
"dpd",
|
|
1425
|
+
"seur",
|
|
1426
|
+
"brt",
|
|
1427
|
+
"relais-colis",
|
|
1428
|
+
"ciblex"
|
|
1429
|
+
]
|
|
1430
|
+
},
|
|
1383
1431
|
{
|
|
1384
1432
|
"input": "1595193512495",
|
|
1385
1433
|
"carrier": "unknown",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -3213,7 +3213,8 @@ var ce = {
|
|
|
3213
3213
|
timezone: "Europe/London",
|
|
3214
3214
|
tracking: {
|
|
3215
3215
|
mode: "automatic",
|
|
3216
|
-
adapter: "
|
|
3216
|
+
adapter: "dpd-uk",
|
|
3217
|
+
localClocks: !0
|
|
3217
3218
|
},
|
|
3218
3219
|
canaryUrl: "https://track.dpd.co.uk/",
|
|
3219
3220
|
trackingUrlTemplate: "https://track.dpd.co.uk/?reference={trackingNumber}",
|
|
@@ -3261,7 +3262,7 @@ var ce = {
|
|
|
3261
3262
|
timezone: "Europe/London",
|
|
3262
3263
|
tracking: {
|
|
3263
3264
|
mode: "automatic",
|
|
3264
|
-
adapter: "
|
|
3265
|
+
adapter: "evri-uk"
|
|
3265
3266
|
},
|
|
3266
3267
|
canaryUrl: "https://www.evri.com/track-a-parcel",
|
|
3267
3268
|
trackingUrlTemplate: "https://www.evri.com/track/parcel/{trackingNumber}/details",
|
|
@@ -8289,7 +8290,7 @@ var gs = { carriers: [
|
|
|
8289
8290
|
"Postal Ninja": 4,
|
|
8290
8291
|
UPU: "no_history"
|
|
8291
8292
|
} }, {
|
|
8292
|
-
note: "Alternate public reference returned eight identity-bound full-history scans in an interactive official session
|
|
8293
|
+
note: "Alternate public reference returned eight identity-bound full-history scans in an interactive official session. Fresh automated Chromium obtained the matching microsummary, but its separate events request failed; automated full history remains unverified: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/",
|
|
8293
8294
|
results: {}
|
|
8294
8295
|
}]
|
|
8295
8296
|
},
|
|
@@ -9713,10 +9714,10 @@ var gs = { carriers: [
|
|
|
9713
9714
|
},
|
|
9714
9715
|
{
|
|
9715
9716
|
carrier: "dpd-uk",
|
|
9716
|
-
direct: "
|
|
9717
|
-
sample: "
|
|
9717
|
+
direct: "Yes (UK parcel numbers)",
|
|
9718
|
+
sample: "✓ 7",
|
|
9718
9719
|
references: [{
|
|
9719
|
-
note: "UK
|
|
9720
|
+
note: "Public UK parcel references returned seven and eight history scans through the current anonymous empty-postcode lookup; unqualified scan clocks remain local: https://uk.trustpilot.com/review/primepaint.co.uk?page=3. Order and collection references behind the postcode form are outside the adapter scope.",
|
|
9720
9721
|
results: {
|
|
9721
9722
|
Ship24: "unverified",
|
|
9722
9723
|
ParcelsApp: "unverified",
|
|
@@ -9728,10 +9729,10 @@ var gs = { carriers: [
|
|
|
9728
9729
|
},
|
|
9729
9730
|
{
|
|
9730
9731
|
carrier: "evri-uk",
|
|
9731
|
-
direct: "
|
|
9732
|
-
sample: "
|
|
9732
|
+
direct: "Yes (local Chromium)",
|
|
9733
|
+
sample: "✓ 6",
|
|
9733
9734
|
references: [{
|
|
9734
|
-
note: "
|
|
9735
|
+
note: "Public domestic reference returned six identity-bound, offset-dated history scans in fresh automated local Chromium. The current anonymous flow obtains rotating keys in the browser and requires no postcode: https://www.trustpilot.com/review/printerval.co.uk",
|
|
9735
9736
|
results: {
|
|
9736
9737
|
Ship24: "unverified",
|
|
9737
9738
|
ParcelsApp: "unverified",
|
|
@@ -9866,7 +9867,7 @@ var gs = { carriers: [
|
|
|
9866
9867
|
direct: "No adapter",
|
|
9867
9868
|
sample: "Blocked",
|
|
9868
9869
|
references: [{
|
|
9869
|
-
note: "
|
|
9870
|
+
note: "Sagawa has suspended public shipment inquiry and its carrier inquiry API; fresh anonymous history retrieval is unavailable: https://www2.sagawa-exp.co.jp/information/detail/425/",
|
|
9870
9871
|
results: {
|
|
9871
9872
|
Ship24: "unverified",
|
|
9872
9873
|
ParcelsApp: "unverified",
|
|
@@ -2,6 +2,8 @@ import { type AdapterFactory, type AdapterEnvironment, type TrackingContext } fr
|
|
|
2
2
|
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
3
|
export declare function normalizeNumber(raw: string): string;
|
|
4
4
|
export declare function trackingUrl(raw: string): string;
|
|
5
|
+
/** Public DHL web tracking: bind both the waybill and the Express division. */
|
|
6
|
+
export declare function parseUnified(payload: unknown, raw: string): CarrierResult;
|
|
5
7
|
/** A waybill can be reused. Multiple or duplicate shipments need recipient disambiguation. */
|
|
6
8
|
export declare function parse(payload: unknown, raw: string): CarrierResult;
|
|
7
9
|
export declare class DhlExpressTracker {
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { DateTime } from 'luxon';
|
|
2
|
-
import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { accepted, lookupBudget, recognizeFromBrowserLookup, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
3
3
|
import { isValidDhlExpressWaybill } from '../../core/detection/numericChecksums.js';
|
|
4
4
|
import { BudgetExceededError, ChallengeError, IndeterminateError, InvalidInputError, NotFoundError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
5
5
|
import { runSteps } from '../../core/runner/index.js';
|
|
6
6
|
import { classifyWording } from '../../core/status/index.js';
|
|
7
|
-
import {
|
|
7
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
8
|
+
import { clean, decodeText, fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
8
9
|
import { isRecord } from '../../core/types.js';
|
|
9
10
|
const PROVIDER = 'DHL Express';
|
|
10
11
|
const API = 'https://mydhl.express.dhl/shipmentTracking';
|
|
12
|
+
const BROWSER_API = 'https://www.dhl.com/utapi';
|
|
11
13
|
const MAX_BYTES = 1_000_000;
|
|
14
|
+
// The scoped browser runner bounds context cleanup to 1.5 seconds. Leave room
|
|
15
|
+
// for that and transport without consuming most of a short recognition budget.
|
|
16
|
+
const BROWSER_CLEANUP_ALLOWANCE_MS = 2_000;
|
|
12
17
|
export function normalizeNumber(raw) {
|
|
13
18
|
const number = raw.replace(/[\s.-]/g, '');
|
|
14
19
|
if (!isValidDhlExpressWaybill(number)) {
|
|
@@ -46,6 +51,46 @@ function stage(description) {
|
|
|
46
51
|
return { stage: 'customs', source: 'carrier_map' };
|
|
47
52
|
return classifyWording(description, 'pending');
|
|
48
53
|
}
|
|
54
|
+
/** Public DHL web tracking: bind both the waybill and the Express division. */
|
|
55
|
+
export function parseUnified(payload, raw) {
|
|
56
|
+
const number = normalizeNumber(raw);
|
|
57
|
+
if (!isRecord(payload) || !Array.isArray(payload.shipments) || payload.shipments.length > 20
|
|
58
|
+
|| payload.shipments.some((shipment) => !isRecord(shipment)))
|
|
59
|
+
throw new SchemaError(PROVIDER, 'DHL returned invalid shipments');
|
|
60
|
+
const matching = payload.shipments.filter(isRecord).filter((shipment) => shipment.id === number);
|
|
61
|
+
if (!matching.length)
|
|
62
|
+
throw new SchemaError(PROVIDER, 'DHL returned a different waybill');
|
|
63
|
+
if (matching.length !== 1)
|
|
64
|
+
throw new IndeterminateError(PROVIDER, 'DHL returned a reused waybill');
|
|
65
|
+
const shipment = matching[0];
|
|
66
|
+
if (shipment.service !== 'express')
|
|
67
|
+
throw new IndeterminateError(PROVIDER, 'DHL returned a different division');
|
|
68
|
+
if (!Array.isArray(shipment.events) || shipment.events.length > 1000)
|
|
69
|
+
throw new SchemaError(PROVIDER, 'DHL returned invalid events');
|
|
70
|
+
if (!shipment.events.length)
|
|
71
|
+
throw new IndeterminateError(PROVIDER, 'DHL has no shipment activity');
|
|
72
|
+
const event = (row) => {
|
|
73
|
+
if (!isRecord(row) || typeof row.description !== 'string' || !row.description.trim())
|
|
74
|
+
throw new SchemaError(PROVIDER, 'DHL returned an invalid event');
|
|
75
|
+
const description = row.statusCode === 'delivered' ? 'Delivered' : clean(row.description).slice(0, 1000);
|
|
76
|
+
const mapped = stage(description);
|
|
77
|
+
const time = typeof row.timestamp === 'string' && /(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/i.test(row.timestamp)
|
|
78
|
+
? explicitOffsetTime(row.timestamp)?.iso : undefined;
|
|
79
|
+
const address = isRecord(row.location) && isRecord(row.location.address) ? row.location.address : {};
|
|
80
|
+
return { description, stage: mapped.stage, stage_source: mapped.source,
|
|
81
|
+
...(time ? { time } : {}), ...(typeof address.addressLocality === 'string' ? { location: clean(address.addressLocality).slice(0, 300) } : {}),
|
|
82
|
+
...(typeof row.status === 'string' ? { provider_code: row.status.slice(0, 100) } : {}) };
|
|
83
|
+
};
|
|
84
|
+
const events = shipment.events.map(event);
|
|
85
|
+
const current = event(shipment.status);
|
|
86
|
+
const status = current.stage === 'delivered' ? 'delivered' : current.stage === 'out_for_delivery' ? 'out_for_delivery'
|
|
87
|
+
: ['exception', 'failed_attempt', 'returned'].includes(current.stage) ? 'exception'
|
|
88
|
+
: ['pending', 'registered'].includes(current.stage) ? 'pending' : 'in_transit';
|
|
89
|
+
const destination = isRecord(shipment.destination) && isRecord(shipment.destination.address) ? shipment.destination.address : {};
|
|
90
|
+
return { status, current_stage: current.stage, current_stage_source: current.stage_source,
|
|
91
|
+
last_status_text: current.description, last_update: current.time ?? null, events,
|
|
92
|
+
...(typeof destination.countryCode === 'string' && /^[A-Z]{2}$/.test(destination.countryCode) ? { destination_country: destination.countryCode } : {}) };
|
|
93
|
+
}
|
|
49
94
|
/** A waybill can be reused. Multiple or duplicate shipments need recipient disambiguation. */
|
|
50
95
|
export function parse(payload, raw) {
|
|
51
96
|
const number = normalizeNumber(raw);
|
|
@@ -104,36 +149,48 @@ export class DhlExpressTracker {
|
|
|
104
149
|
const budget = lookupBudget(context, 45_000, PROVIDER);
|
|
105
150
|
if (!this.environment.trawl)
|
|
106
151
|
throw new ChallengeError(PROVIDER, 'DHL Express requires the browser tracking service');
|
|
107
|
-
const timeoutMs = budget.remainingMs() -
|
|
152
|
+
const timeoutMs = budget.remainingMs() - BROWSER_CLEANUP_ALLOWANCE_MS;
|
|
108
153
|
if (timeoutMs < 1)
|
|
109
154
|
throw new BudgetExceededError(PROVIDER, budget.budgetMs);
|
|
110
|
-
const page = await this.environment.trawl.scrape({ url:
|
|
111
|
-
maxTimeout: timeoutMs, captureResponses: [
|
|
155
|
+
const page = await this.environment.trawl.scrape({ url: `https://www.dhl.com/global-en/home/tracking/tracking-parcel.html?submit=1&tracking-id=${normalizeNumber(number)}`, skipHttp: true, maxTier: 3,
|
|
156
|
+
maxTimeout: timeoutMs, captureResponses: [BROWSER_API], settleTimeout: timeoutMs,
|
|
112
157
|
}, { provider: PROVIDER, timeoutMs, signal: budget.signal, maxBytes: 6_000_000, requireSolved: false });
|
|
113
158
|
budget.signal.throwIfAborted();
|
|
114
159
|
const captures = page.capturedResponses.filter((r) => {
|
|
115
160
|
try {
|
|
116
161
|
const url = new URL(r.url);
|
|
117
|
-
return url.origin + url.pathname ===
|
|
118
|
-
&& url.searchParams.getAll('
|
|
162
|
+
return url.origin + url.pathname === BROWSER_API && !url.username && !url.password
|
|
163
|
+
&& url.searchParams.getAll('trackingNumber').length === 1 && url.searchParams.get('trackingNumber') === number && r.status !== 204;
|
|
119
164
|
}
|
|
120
165
|
catch {
|
|
121
166
|
return false;
|
|
122
167
|
}
|
|
123
168
|
});
|
|
124
169
|
const response = captures.at(-1);
|
|
125
|
-
if (!response || captures.length > 10 || response.error || response.truncated || response.base64Encoded
|
|
170
|
+
if (!response || captures.length > 10 || response.error || response.truncated || response.base64Encoded) {
|
|
126
171
|
throw new TransportError(PROVIDER, 'DHL Express returned no complete matching browser response');
|
|
127
172
|
}
|
|
128
173
|
if (response.status === 429)
|
|
129
174
|
throw new RateLimitedError(PROVIDER);
|
|
130
|
-
if ([401, 403].includes(response.status) || /^\s*</.test(response.body))
|
|
175
|
+
if ([401, 403, 428].includes(response.status) || /^\s*</.test(response.body ?? ''))
|
|
131
176
|
throw new ChallengeError(PROVIDER);
|
|
177
|
+
if (response.status === 404 && response.body) {
|
|
178
|
+
let missing;
|
|
179
|
+
try {
|
|
180
|
+
missing = JSON.parse(response.body);
|
|
181
|
+
}
|
|
182
|
+
catch { /* An arbitrary 404 is not a shipment answer. */ }
|
|
183
|
+
if (isRecord(missing) && missing.status === 404 && missing.title === 'No result found'
|
|
184
|
+
&& missing.detail === 'No shipment with given tracking number found.')
|
|
185
|
+
throw new NotFoundError(PROVIDER);
|
|
186
|
+
}
|
|
132
187
|
if (response.status >= 400)
|
|
133
188
|
throw new UpstreamHttpError(PROVIDER, response.status);
|
|
189
|
+
if (response.body === null)
|
|
190
|
+
throw new TransportError(PROVIDER, 'DHL Express returned no complete matching browser response');
|
|
134
191
|
if (response.status !== 200 || Buffer.byteLength(response.body) > MAX_BYTES)
|
|
135
192
|
throw new SchemaError(PROVIDER, 'DHL Express returned invalid browser data');
|
|
136
|
-
return
|
|
193
|
+
return parseUnified(parseJsonBytes(new TextEncoder().encode(response.body), PROVIDER), number);
|
|
137
194
|
}
|
|
138
195
|
decode(bytes, number) {
|
|
139
196
|
let payload;
|
|
@@ -160,15 +217,6 @@ export const adapter = (environment) => {
|
|
|
160
217
|
recognizeWithBrowser: async (number, context, previousError) => {
|
|
161
218
|
if (previousError instanceof RateLimitedError || previousError instanceof SchemaError || previousError instanceof NotFoundError)
|
|
162
219
|
throw previousError;
|
|
163
|
-
|
|
164
|
-
const result = await tracker.browser(normalizeNumber(number), context);
|
|
165
|
-
// Local facility clocks establish dated activity, but never an instant for ranking reuse.
|
|
166
|
-
return result.events?.some((event) => event.time) ? { known: true, lastActivityAt: null, result } : { known: false };
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
if (error instanceof NotFoundError)
|
|
170
|
-
return { known: false };
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
220
|
+
return recognizeFromBrowserLookup(() => tracker.browser(normalizeNumber(number), context));
|
|
173
221
|
} };
|
|
174
222
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
export declare class DpdUkTracker {
|
|
4
|
+
private readonly options;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
fetcher?: typeof fetch;
|
|
7
|
+
recorder?: StepRecorder;
|
|
8
|
+
});
|
|
9
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
10
|
+
}
|
|
11
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CarrierError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
2
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
3
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
4
|
+
import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
5
|
+
import { isDpdUkReferenceAbsent, normalizeDpdUkNumber, parseDpdUkHistory, parseDpdUkReference, PROVIDER, validateDpdUkParcel } from './parser.js';
|
|
6
|
+
const API = 'https://apis.track.dpd.co.uk/v1';
|
|
7
|
+
export class DpdUkTracker {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async fetch(raw, context = {}) {
|
|
13
|
+
const number = normalizeDpdUkNumber(raw);
|
|
14
|
+
return runSteps({ carrier: 'dpd-uk', signal: context.signal, budgetMs: context.budgetMs ?? 15_000,
|
|
15
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [{ id: 'direct', run: async ({ signal, remainingMs }) => {
|
|
16
|
+
const deadline = performance.now() + remainingMs;
|
|
17
|
+
const read = async (path, reference = false) => {
|
|
18
|
+
try {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const { bytes, response } = await fetchBounded(`${API}${path}`, { signal, headers: { Accept: 'application/json' } }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(deadline - performance.now())), maxBytes: 1_000_000,
|
|
21
|
+
fetcher: this.options.fetcher, allowHttpStatuses: [404, 410] });
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
payload = parseJsonBytes(bytes, PROVIDER);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw response.ok ? new SchemaError(PROVIDER) : new TransportError(PROVIDER, 'DPD UK tracking endpoint is unavailable');
|
|
28
|
+
}
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (reference && response.status === 404 && isDpdUkReferenceAbsent(payload))
|
|
31
|
+
throw new NotFoundError(PROVIDER);
|
|
32
|
+
throw new TransportError(PROVIDER, 'DPD UK tracking endpoint is unavailable');
|
|
33
|
+
}
|
|
34
|
+
return payload;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
signal.throwIfAborted();
|
|
38
|
+
// Request diagnostics and malformed bodies can contain an issued parcel
|
|
39
|
+
// handle or personal details. Keep the classification, never its cause.
|
|
40
|
+
if (error instanceof CarrierError)
|
|
41
|
+
throw new CarrierError(error.kind, PROVIDER, `${PROVIDER} tracking failed (${error.kind})`, { status: error.status, retryAfterMs: error.retryAfterMs, reason: error.reason });
|
|
42
|
+
throw new TransportError(PROVIDER);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const query = new URLSearchParams({ origin: 'PRTK', postcode: '', referenceNumber: number });
|
|
46
|
+
const code = parseDpdUkReference(await read(`/reference?${query}`, true), number);
|
|
47
|
+
const parcelPath = `/parcels/${encodeURIComponent(code)}`;
|
|
48
|
+
const detail = await read(parcelPath);
|
|
49
|
+
validateDpdUkParcel(detail, number, code);
|
|
50
|
+
const history = await read(`${parcelPath}/parcelevents`);
|
|
51
|
+
return parseDpdUkHistory(detail, history, number, code);
|
|
52
|
+
} }]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export const adapter = environment => {
|
|
56
|
+
const tracker = new DpdUkTracker({ fetcher: environment.fetcher, recorder: environment.recorder });
|
|
57
|
+
return { id: 'dpd-uk', recordsSteps: true, steps: ['direct'], track: (input, context) => tracker.fetch(input.number, context) };
|
|
58
|
+
};
|
|
@@ -16,13 +16,15 @@
|
|
|
16
16
|
"timezone": "Europe/London",
|
|
17
17
|
"tracking": {
|
|
18
18
|
"mode": "automatic",
|
|
19
|
-
"adapter": "
|
|
20
|
-
"steps": [
|
|
19
|
+
"adapter": "dpd-uk",
|
|
20
|
+
"steps": [
|
|
21
|
+
"direct"
|
|
22
|
+
],
|
|
23
|
+
"localClocks": true
|
|
21
24
|
},
|
|
22
25
|
"capabilities": [
|
|
23
26
|
"history",
|
|
24
|
-
"location"
|
|
25
|
-
"eta"
|
|
27
|
+
"location"
|
|
26
28
|
],
|
|
27
29
|
"portal": {
|
|
28
30
|
"url": "https://track.dpd.co.uk/?reference={trackingNumber}",
|
|
@@ -33,8 +35,18 @@
|
|
|
33
35
|
"location",
|
|
34
36
|
"eta"
|
|
35
37
|
],
|
|
36
|
-
"scraped": [
|
|
37
|
-
|
|
38
|
+
"scraped": [
|
|
39
|
+
"status",
|
|
40
|
+
"history",
|
|
41
|
+
"location"
|
|
42
|
+
],
|
|
43
|
+
"discarded": [
|
|
44
|
+
"parcel_handle",
|
|
45
|
+
"merchant_reference",
|
|
46
|
+
"delivery_partner_reference",
|
|
47
|
+
"recipient_details",
|
|
48
|
+
"proof_of_delivery_images"
|
|
49
|
+
],
|
|
38
50
|
"unavailable": []
|
|
39
51
|
},
|
|
40
52
|
"links": [
|
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "dpd-uk",
|
|
3
|
-
"
|
|
4
|
-
|
|
3
|
+
"records": [
|
|
4
|
+
{
|
|
5
|
+
"number": "15504366056765",
|
|
6
|
+
"role": "shipment",
|
|
7
|
+
"evidence": "public_shipment_report",
|
|
8
|
+
"source": {
|
|
9
|
+
"url": "https://uk.trustpilot.com/review/primepaint.co.uk?page=3"
|
|
10
|
+
},
|
|
11
|
+
"expect": {
|
|
12
|
+
"carrier": "unknown",
|
|
13
|
+
"confidence": "low",
|
|
14
|
+
"candidates": ["dpd", "dpd-fr", "relais-colis", "ciblex", "seur", "brt", "delhivery", "dpd-de", "dpd-uk", "xpressbees"]
|
|
15
|
+
},
|
|
16
|
+
"note": "Prime Paint's public reply identifies this as DPD UK. A numeric shape alone does not identify the service. Tracking numbers can be reused; current history need not belong to the original report."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"number": "15504366057635",
|
|
20
|
+
"role": "shipment",
|
|
21
|
+
"evidence": "public_shipment_report",
|
|
22
|
+
"source": {
|
|
23
|
+
"url": "https://uk.trustpilot.com/review/primepaint.co.uk?page=3"
|
|
24
|
+
},
|
|
25
|
+
"expect": {
|
|
26
|
+
"carrier": "unknown",
|
|
27
|
+
"confidence": "low",
|
|
28
|
+
"candidates": ["dpd", "dpd-fr", "relais-colis", "ciblex", "seur", "brt", "delhivery", "dpd-de", "dpd-uk", "xpressbees"]
|
|
29
|
+
},
|
|
30
|
+
"note": "Prime Paint's public reply identifies this as DPD UK. A numeric shape alone does not identify the service. Tracking numbers can be reused; current history need not belong to the original report."
|
|
31
|
+
}
|
|
32
|
+
]
|
|
5
33
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "DPD UK";
|
|
3
|
+
export declare function normalizeDpdUkNumber(raw: string): string;
|
|
4
|
+
/** Resolve one exact parcel, keeping its issued code opaque and lookup-local. */
|
|
5
|
+
export declare function parseDpdUkReference(payload: unknown, number: string): string;
|
|
6
|
+
export declare function isDpdUkReferenceAbsent(payload: unknown): boolean;
|
|
7
|
+
export declare function validateDpdUkParcel(payload: unknown, number: string, code: string): Record<string, unknown>;
|
|
8
|
+
/** Events have no identity field; the adapter reads them under the exact validated detail handle. */
|
|
9
|
+
export declare function parseDpdUkHistory(detailPayload: unknown, historyPayload: unknown, raw: string, code: string): CarrierResult;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
3
|
+
import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { classifyWording, languageStageStatus } from '../../core/status/index.js';
|
|
5
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { isRecord } from '../../core/types.js';
|
|
8
|
+
export const PROVIDER = 'DPD UK';
|
|
9
|
+
export function normalizeDpdUkNumber(raw) {
|
|
10
|
+
const number = normalizeTrackingNumber(raw);
|
|
11
|
+
if (!/^\d{14}$/.test(number))
|
|
12
|
+
throw new InvalidInputError(PROVIDER, 'DPD UK requires a fourteen-digit parcel number');
|
|
13
|
+
return number;
|
|
14
|
+
}
|
|
15
|
+
// The public client displays spaced digits followed by a printed check letter.
|
|
16
|
+
function parcelNumber(value) {
|
|
17
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
18
|
+
return null;
|
|
19
|
+
const match = /^(\d{14})[A-Z]?$/.exec(value.replace(/\s/g, '').toUpperCase());
|
|
20
|
+
return match?.[1] ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** Resolve one exact parcel, keeping its issued code opaque and lookup-local. */
|
|
23
|
+
export function parseDpdUkReference(payload, number) {
|
|
24
|
+
if (!isRecord(payload) || !Array.isArray(payload.data) || payload.data.length > 100)
|
|
25
|
+
throw new SchemaError(PROVIDER);
|
|
26
|
+
if (!payload.data.length)
|
|
27
|
+
throw new IndeterminateError(PROVIDER, 'DPD UK returned no matching parcel');
|
|
28
|
+
if (payload.data.length !== 1 || !isRecord(payload.data[0]) || parcelNumber(payload.data[0].parcelNumber) !== number) {
|
|
29
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different or ambiguous parcel');
|
|
30
|
+
}
|
|
31
|
+
const code = payload.data[0].parcelCode;
|
|
32
|
+
if (typeof code !== 'string' || !/^[A-Za-z0-9*_-]{1,128}$/.test(code))
|
|
33
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned an invalid parcel handle');
|
|
34
|
+
return code;
|
|
35
|
+
}
|
|
36
|
+
export function isDpdUkReferenceAbsent(payload) {
|
|
37
|
+
return isRecord(payload) && !Object.hasOwn(payload, 'data') && isRecord(payload.error)
|
|
38
|
+
&& payload.error.statusCode === 404 && payload.error.error === 'Not Found'
|
|
39
|
+
&& payload.error.message === 'Your reference number could not be found';
|
|
40
|
+
}
|
|
41
|
+
export function validateDpdUkParcel(payload, number, code) {
|
|
42
|
+
if (!isRecord(payload) || !isRecord(payload.data) || parcelNumber(payload.data.parcelNumber) !== number
|
|
43
|
+
|| payload.data.parcelCode !== code)
|
|
44
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different parcel');
|
|
45
|
+
return payload.data;
|
|
46
|
+
}
|
|
47
|
+
const VERIFIED_WORDING = {
|
|
48
|
+
'your parcel has been delivered': 'delivered',
|
|
49
|
+
'the parcel has been delivered': 'delivered',
|
|
50
|
+
'your parcel will be with you today': 'out_for_delivery',
|
|
51
|
+
'the parcel is on the vehicle for delivery': 'out_for_delivery',
|
|
52
|
+
'your parcel is at our depot': 'in_transit',
|
|
53
|
+
"we have your parcel and it's on its way to our depot": 'in_transit',
|
|
54
|
+
"we have your parcel and it's on its way to you": 'in_transit',
|
|
55
|
+
'we have x-rayed your parcel as part of our normal security procedures': 'in_transit',
|
|
56
|
+
"we've received your order details, but have not yet received your parcel": 'registered',
|
|
57
|
+
};
|
|
58
|
+
function classify(description) {
|
|
59
|
+
const wording = description.toLowerCase().replace(/[’‘]/g, "'").replace(/\s+/g, ' ').trim().replace(/[.!]+$/, '');
|
|
60
|
+
const stage = VERIFIED_WORDING[wording];
|
|
61
|
+
return stage ? { stage, source: 'carrier_map' } : classifyWording(description);
|
|
62
|
+
}
|
|
63
|
+
function publicDescription(value) {
|
|
64
|
+
const text = clean(value, 1000);
|
|
65
|
+
// A sender return is a shipment stage, rather than a recipient's identity.
|
|
66
|
+
if (/\bdelivered (?:back )?to (?:the )?(?:sender|shipper)\b/i.test(text))
|
|
67
|
+
return text;
|
|
68
|
+
return text.replace(/(\bdelivered)\s+(?:to\s+.+|and\s+signed\s+(?:for\s+)?by\s+.+|signed\s+(?:for\s+)?by\s+.+)$/i, '$1');
|
|
69
|
+
}
|
|
70
|
+
function clockFields(clock) {
|
|
71
|
+
const shape = /^(\d{4}-\d{2}-\d{2})[ T]((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)(Z|[+-]\d{2}:?\d{2})?$/.exec(clock);
|
|
72
|
+
if (!shape)
|
|
73
|
+
return { provider_time_text: clock };
|
|
74
|
+
const iso = `${shape[1]}T${shape[2]}${shape[3] ?? ''}`;
|
|
75
|
+
if (!DateTime.fromISO(iso, { zone: 'UTC', setZone: true }).isValid)
|
|
76
|
+
return { provider_time_text: clock };
|
|
77
|
+
if (!shape[3])
|
|
78
|
+
return { local_time: `${shape[1]}T${shape[2]}` };
|
|
79
|
+
const offset = /[+-](\d{2}):?(\d{2})$/.exec(iso);
|
|
80
|
+
if (offset && (Number(offset[1]) > 14 || Number(offset[2]) > 59 || (Number(offset[1]) === 14 && Number(offset[2]) !== 0))) {
|
|
81
|
+
return { provider_time_text: clock };
|
|
82
|
+
}
|
|
83
|
+
const instant = explicitOffsetTime(iso);
|
|
84
|
+
return instant ? { time: instant.iso } : { provider_time_text: clock };
|
|
85
|
+
}
|
|
86
|
+
/** Events have no identity field; the adapter reads them under the exact validated detail handle. */
|
|
87
|
+
export function parseDpdUkHistory(detailPayload, historyPayload, raw, code) {
|
|
88
|
+
const number = normalizeDpdUkNumber(raw);
|
|
89
|
+
const detail = validateDpdUkParcel(detailPayload, number, code);
|
|
90
|
+
if (!isRecord(historyPayload) || !Array.isArray(historyPayload.data) || historyPayload.data.length > 1000)
|
|
91
|
+
throw new SchemaError(PROVIDER);
|
|
92
|
+
if (!historyPayload.data.length)
|
|
93
|
+
throw new IndeterminateError(PROVIDER, 'DPD UK returned no parcel history');
|
|
94
|
+
// The official page renders these rows newest first. Do not reorder uncertain clocks.
|
|
95
|
+
const events = historyPayload.data.map(row => {
|
|
96
|
+
if (!isRecord(row))
|
|
97
|
+
throw new SchemaError(PROVIDER);
|
|
98
|
+
if ((row.parcelNumber !== undefined && parcelNumber(row.parcelNumber) !== number)
|
|
99
|
+
|| (row.parcelCode !== undefined && row.parcelCode !== code))
|
|
100
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned a different scan identity');
|
|
101
|
+
const description = publicDescription(row.eventText);
|
|
102
|
+
const clock = clean(row.eventDate, 64);
|
|
103
|
+
if (!description || !clock || typeof row.eventDate !== 'string' || row.eventDate.length > 64)
|
|
104
|
+
throw new SchemaError(PROVIDER, 'DPD UK returned an incomplete scan');
|
|
105
|
+
const classification = classify(description);
|
|
106
|
+
const location = clean(row.eventLocation, 200);
|
|
107
|
+
return { description, ...clockFields(clock), ...(location ? { location } : {}),
|
|
108
|
+
...(classification.source !== 'none' ? { stage: classification.stage, stage_source: classification.source } : {}) };
|
|
109
|
+
});
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
const retained = events.filter(event => {
|
|
112
|
+
const key = JSON.stringify(event);
|
|
113
|
+
if (seen.has(key))
|
|
114
|
+
return false;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
return true;
|
|
117
|
+
}).slice(0, 100);
|
|
118
|
+
const latest = retained[0];
|
|
119
|
+
const summary = publicDescription(detail.trackingStatusCurrent);
|
|
120
|
+
const summaryClassification = classify(summary);
|
|
121
|
+
const current = summary ? summaryClassification : classify(latest.description);
|
|
122
|
+
return { status: current.source !== 'none' ? languageStageStatus(current.stage) : 'unknown',
|
|
123
|
+
...(current.source !== 'none' ? { current_stage: current.stage, current_stage_source: current.source } : {}),
|
|
124
|
+
last_status_text: summary || latest.description, last_update: latest.time ?? null,
|
|
125
|
+
...(latest.local_time ? { last_update_local: latest.local_time } : {}),
|
|
126
|
+
...(current.stage === 'delivered' && latest.stage === 'delivered' && latest.time ? { delivered_at: latest.time } : {}), events: retained };
|
|
127
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
3
|
+
export declare class EvriUkTracker {
|
|
4
|
+
private readonly options;
|
|
5
|
+
constructor(options?: {
|
|
6
|
+
executablePath?: string | null;
|
|
7
|
+
recorder?: StepRecorder;
|
|
8
|
+
});
|
|
9
|
+
fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
|
|
10
|
+
}
|
|
11
|
+
export declare const adapter: AdapterFactory;
|