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 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 **96 dedicated adapters** and **5 universal fallbacks**
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 **69**, rising to **82** with all fallbacks enabled. The best single aggregator returns 53.
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 82, This project, dedicated adapters alone 69, ParcelsApp 53, Postal Ninja 43, 17TRACK 40, Ship24 42, UPU 12." width="760">
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,779 such statuses, each filed under one stage.
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": "universal"
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": "universal"
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": 96,
3
+ "activeAdapters": 98,
4
4
  "countries": 58,
5
5
  "referenceCarriers": 113,
6
6
  "comparison": {
7
- "combined": 82,
8
- "direct": 69,
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",
@@ -3213,7 +3213,8 @@ var ce = {
3213
3213
  timezone: "Europe/London",
3214
3214
  tracking: {
3215
3215
  mode: "automatic",
3216
- adapter: "universal"
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: "universal"
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; fresh automated browser retrieval remains denied: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/",
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: "No adapter",
9717
- sample: "Blocked",
9717
+ direct: "Yes (UK parcel numbers)",
9718
+ sample: "✓ 7",
9718
9719
  references: [{
9719
- note: "UK reference-plus-postcode flow requires a challenge and session before retrieval.",
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: "No adapter",
9732
- sample: "Blocked",
9732
+ direct: "Yes (local Chromium)",
9733
+ sample: "✓ 6",
9733
9734
  references: [{
9734
- note: "The official interactive navigation flow obtained rotating keys and six dated parcel scans. Fresh automated browser sessions are denied the keys: https://www.trustpilot.com/review/printerval.co.uk",
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: "The current corporate tracking form posts to the public tracking service, which returns access denied. This is not evidence of parcel absence.",
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 { clean, decodeText, fetchBounded, parseJsonBytes, TRAWL_TRANSPORT_ALLOWANCE_MS } from '../../core/transport/index.js';
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() - TRAWL_TRANSPORT_ALLOWANCE_MS;
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: trackingUrl(number), skipHttp: true, maxTier: 3,
111
- maxTimeout: timeoutMs, captureResponses: [API], settleTimeout: Math.min(timeoutMs, 10_000),
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 === API && !url.username && !url.password
118
- && url.searchParams.getAll('AWB').length === 1 && url.searchParams.get('AWB') === number && r.status !== 204;
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 || response.body === null) {
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 this.decode(new TextEncoder().encode(response.body), number);
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
- try {
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": "universal",
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
- "discarded": [],
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
- "gap": "no public sample found yet",
4
- "records": []
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;