universal-parcel-scraper 0.12.0-main.336 → 0.13.0-main.337

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",
@@ -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;
@@ -0,0 +1,80 @@
1
+ import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
+ import { runSteps } from '../../core/runner/index.js';
3
+ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
4
+ import { withLocalBrowser } from '../../core/transport/localBrowser.js';
5
+ import { requestEvriUkInPage } from './browser.js';
6
+ import { normalizeEvriUkNumber, parseEvriUk } from './parser.js';
7
+ export class EvriUkTracker {
8
+ options;
9
+ constructor(options = {}) {
10
+ this.options = options;
11
+ }
12
+ fetch(raw, context = {}) {
13
+ const number = normalizeEvriUkNumber(raw);
14
+ const budgetMs = context.budgetMs ?? 45_000;
15
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > 60_000)
16
+ throw new TypeError('Evri UK budget must be between 1 and 60000 ms');
17
+ if (!this.options.executablePath)
18
+ throw new ChallengeError('Evri UK', 'Evri UK requires TRACKING_CHROMIUM_PATH');
19
+ return runSteps({ carrier: 'evri-uk', budgetMs, signal: context.signal, recorder: this.options.recorder ?? NOOP_RECORDER }, [{
20
+ id: 'browser', run: ({ signal, remainingMs: remaining }) => withLocalBrowser({ provider: 'Evri UK',
21
+ executablePath: this.options.executablePath, signal, timeoutMs: Math.max(1, Math.floor(remaining)),
22
+ args: ['--disable-blink-features=AutomationControlled'], }, async ({ browser, signal: session, remainingMs }) => {
23
+ try {
24
+ const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
25
+ : process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
26
+ const major = browser.version().split('.')[0];
27
+ const userAgent = `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
28
+ const browserContext = await browser.newContext({ locale: 'en-GB', userAgent, acceptDownloads: false, serviceWorkers: 'block' });
29
+ session.throwIfAborted();
30
+ await browserContext.route('**/*', async (route) => {
31
+ const url = new URL(route.request().url());
32
+ const allowed = url.protocol === 'https:' && (['www.evri.com', 'api.evri.com', 'tracking.platform-apis.evri.com'].includes(url.hostname)
33
+ || url.hostname.endsWith('.edge.sdk.awswaf.com'));
34
+ await (allowed ? route.continue() : route.abort());
35
+ });
36
+ const page = await browserContext.newPage();
37
+ const response = await page.goto('https://www.evri.com/track-a-parcel', { waitUntil: 'domcontentloaded', timeout: Math.min(15_000, remainingMs()) });
38
+ session.throwIfAborted();
39
+ if (!response)
40
+ throw new TransportError('Evri UK', 'Evri UK tracking page is unavailable');
41
+ if ([401, 403].includes(response.status()))
42
+ throw new ChallengeError('Evri UK');
43
+ if (response.status() !== 200)
44
+ throw new UpstreamHttpError('Evri UK', response.status());
45
+ await page.waitForFunction(() => typeof globalThis.AwsWafIntegration?.getToken === 'function', undefined, { timeout: Math.min(10_000, remainingMs()) });
46
+ session.throwIfAborted();
47
+ const reply = await page.evaluate(requestEvriUkInPage, { number, budgetMs: remainingMs() });
48
+ session.throwIfAborted();
49
+ if (reply.kind === 'ok')
50
+ return parseEvriUk(reply.payload, number, reply.urn);
51
+ if (reply.kind === 'http') {
52
+ if ([401, 403, 405].includes(reply.status ?? 0))
53
+ throw new ChallengeError('Evri UK');
54
+ if (reply.status === 429)
55
+ throw new RateLimitedError('Evri UK', reply.retryAfter && /^\d+$/.test(reply.retryAfter) ? Number(reply.retryAfter) * 1_000 : undefined);
56
+ if ([404, 410].includes(reply.status ?? 0))
57
+ throw new TransportError('Evri UK', 'Evri UK tracking endpoint is unavailable');
58
+ throw new UpstreamHttpError('Evri UK', reply.status ?? 0);
59
+ }
60
+ if (['schema', 'identity', 'context'].includes(reply.kind))
61
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking data');
62
+ if (['indeterminate', 'scope'].includes(reply.kind))
63
+ throw new IndeterminateError('Evri UK', 'Evri UK could not confirm a domestic parcel');
64
+ throw new TransportError('Evri UK', 'Evri UK browser tracking failed');
65
+ }
66
+ catch (error) {
67
+ session.throwIfAborted();
68
+ if (carrierErrorKind(error))
69
+ throw error;
70
+ // Browser failures can include private tracking or issued URLs.
71
+ throw new TransportError('Evri UK', 'Evri UK browser tracking failed');
72
+ }
73
+ }),
74
+ }]);
75
+ }
76
+ }
77
+ export const adapter = environment => {
78
+ const tracker = new EvriUkTracker({ executablePath: environment.browserExecutablePath, recorder: environment.recorder });
79
+ return { id: 'evri-uk', recordsSteps: true, steps: ['browser'], track: (input, context) => tracker.fetch(input.number, context) };
80
+ };
@@ -0,0 +1,56 @@
1
+ /** Runs in the official page. No issued key or browser token leaves this context. */
2
+ export declare function requestEvriUkInPage({ number, budgetMs }: {
3
+ number: string;
4
+ budgetMs: number;
5
+ }): Promise<{
6
+ kind: "context";
7
+ phase?: undefined;
8
+ urn?: undefined;
9
+ payload?: undefined;
10
+ } | {
11
+ kind: "indeterminate";
12
+ phase: string;
13
+ urn?: undefined;
14
+ payload?: undefined;
15
+ } | {
16
+ kind: "scope";
17
+ phase: string;
18
+ urn?: undefined;
19
+ payload?: undefined;
20
+ } | {
21
+ kind: "identity";
22
+ phase: string;
23
+ urn?: undefined;
24
+ payload?: undefined;
25
+ } | {
26
+ kind: "ok";
27
+ urn: string;
28
+ payload: {
29
+ failures: never[];
30
+ results: {
31
+ uniqueId: string;
32
+ parcelIdentifiers: {
33
+ type: string;
34
+ value: string;
35
+ }[];
36
+ trackingEvents: {
37
+ dateTime: string;
38
+ trackingPoint: {
39
+ trackingPointCode: string;
40
+ description: string;
41
+ };
42
+ trackingStage: {
43
+ trackingStageCode: string;
44
+ };
45
+ }[];
46
+ }[];
47
+ };
48
+ phase?: undefined;
49
+ } | {
50
+ retryAfter?: string | undefined;
51
+ status?: number | undefined;
52
+ kind: "http" | "schema" | "transport";
53
+ phase: string;
54
+ urn?: undefined;
55
+ payload?: undefined;
56
+ }>;
@@ -0,0 +1,137 @@
1
+ /** Runs in the official page. No issued key or browser token leaves this context. */
2
+ export async function requestEvriUkInPage({ number, budgetMs }) {
3
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
4
+ const sdk = globalThis.AwsWafIntegration;
5
+ if (location.origin !== 'https://www.evri.com' || !/^\/track-a-parcel\/?$/.test(location.pathname)
6
+ || !/^[A-Z0-9]{16}$/.test(number) || !sdk || !(budgetMs > 0))
7
+ return { kind: 'context' };
8
+ const signal = AbortSignal.timeout(Math.max(1, Math.floor(Math.min(budgetMs, 20_000))));
9
+ let total = 0;
10
+ let phase = 'keys';
11
+ const failure = (kind, status, retryAfter) => Object.assign(new Error('Evri UK request failed'), { kind, phase, status, retryAfter });
12
+ const json = async (response, maxBytes) => {
13
+ if (response.status !== 200)
14
+ throw failure('http', response.status, response.headers.get('retry-after'));
15
+ if (!(response.headers.get('content-type') ?? '').includes('application/json')
16
+ || Number(response.headers.get('content-length') ?? 0) > maxBytes || !response.body)
17
+ throw failure('schema');
18
+ const reader = response.body.getReader();
19
+ const decoder = new TextDecoder();
20
+ let text = '';
21
+ let size = 0;
22
+ try {
23
+ for (;;) {
24
+ const { done, value } = await reader.read();
25
+ if (done)
26
+ break;
27
+ size += value.byteLength;
28
+ total += value.byteLength;
29
+ if (size > maxBytes || total > 3_000_000) {
30
+ await reader.cancel();
31
+ throw failure('schema');
32
+ }
33
+ text += decoder.decode(value, { stream: true });
34
+ }
35
+ text += decoder.decode();
36
+ }
37
+ finally {
38
+ reader.releaseLock();
39
+ }
40
+ try {
41
+ return JSON.parse(text);
42
+ }
43
+ catch {
44
+ throw failure('schema');
45
+ }
46
+ };
47
+ try {
48
+ await Promise.race([sdk.getToken(), new Promise((_, reject) => {
49
+ signal.addEventListener('abort', () => reject(failure('transport')), { once: true });
50
+ })]);
51
+ const keyData = await json(await sdk.fetch('/protected/keys.json', { method: 'GET', signal,
52
+ headers: { 'Content-Type': 'application/json' } }), 100_000);
53
+ if (!record(keyData) || !record(keyData.keys))
54
+ return { kind: 'schema', phase };
55
+ const customerKey = keyData.keys['spa-customer-track-key'];
56
+ const trackKey = keyData.keys['spa-track-key'];
57
+ if (typeof customerKey !== 'string' || !customerKey || typeof trackKey !== 'string' || !trackKey)
58
+ return { kind: 'schema', phase };
59
+ phase = 'search';
60
+ const data = await json(await fetch(`https://api.evri.com/customer-tracking/v1/search/${number}`, {
61
+ signal, redirect: 'error', headers: { apiKey: customerKey, 'Content-Type': 'application/json' },
62
+ }), 200_000);
63
+ if (!record(data) || !Array.isArray(data.parcels) || data.parcels.length > 50)
64
+ return { kind: 'schema', phase };
65
+ if (data.parcels.length !== 1)
66
+ return { kind: 'indeterminate', phase };
67
+ const parcel = data.parcels[0];
68
+ if (!record(parcel) || !Array.isArray(parcel.identifiers) || !record(parcel.brand))
69
+ return { kind: 'schema', phase };
70
+ if (parcel.brand.name !== 'EVRI' || parcel.externalRedirectUrl)
71
+ return { kind: 'scope', phase };
72
+ const parcelIds = parcel.identifiers.filter(id => record(id) && id.type === 'PARCEL_ID');
73
+ const barcodes = parcel.identifiers.filter(id => record(id) && id.type === 'BARCODE');
74
+ if (parcelIds.length !== 1 || barcodes.length !== 1 || !record(parcelIds[0]) || !record(barcodes[0])
75
+ || typeof parcelIds[0].value !== 'string' || !/^\d{1,30}$/.test(parcelIds[0].value) || barcodes[0].value !== number) {
76
+ return { kind: 'identity', phase };
77
+ }
78
+ // The current application constructs this URN from the search identifiers
79
+ // and the lookup's UTC calendar day, rather than accepting a reused handle.
80
+ const urn = `urn:parcel_id:barcode:date:${parcelIds[0].value}:${number}:${new Date().toISOString().slice(0, 10)}`;
81
+ phase = 'history';
82
+ const endpoint = new URL('https://tracking.platform-apis.evri.com/v1/parcels');
83
+ endpoint.searchParams.set('uniqueIds', urn);
84
+ const history = await json(await fetch(endpoint.href, { signal, redirect: 'error',
85
+ headers: { apiKey: trackKey, 'Content-Type': 'application/json' } }), 2_000_000);
86
+ if (!record(history) || !Array.isArray(history.results) || !Array.isArray(history.failures))
87
+ return { kind: 'schema', phase };
88
+ if (history.failures.length || history.results.length === 0)
89
+ return { kind: 'indeterminate', phase };
90
+ if (history.results.length !== 1 || !record(history.results[0]))
91
+ return { kind: 'schema', phase };
92
+ const result = history.results[0];
93
+ if (!Array.isArray(result.parcelIdentifiers) || result.parcelIdentifiers.length > 50
94
+ || !result.parcelIdentifiers.every(id => record(id) && typeof id.type === 'string' && typeof id.value === 'string')) {
95
+ return { kind: 'identity', phase };
96
+ }
97
+ const returnedBarcodes = result.parcelIdentifiers.filter(id => record(id) && id.type === 'BARCODE');
98
+ if (!returnedBarcodes.length || returnedBarcodes.some(id => !record(id) || id.value !== number)
99
+ || result.uniqueId !== urn)
100
+ return { kind: 'identity', phase };
101
+ if (!Array.isArray(result.trackingEvents) || result.trackingEvents.length > 500) {
102
+ return { kind: 'schema', phase };
103
+ }
104
+ const scalar = (value, limit) => typeof value === 'string' && value.length <= limit && value.trim().length > 0;
105
+ const events = [];
106
+ for (const event of result.trackingEvents) {
107
+ if (!record(event) || !record(event.trackingPoint) || !record(event.trackingStage)
108
+ || !scalar(event.dateTime, 64) || !scalar(event.trackingPoint.trackingPointCode, 64)
109
+ || !scalar(event.trackingPoint.description, 1000) || !scalar(event.trackingStage.trackingStageCode, 64)
110
+ || !/^[A-Za-z0-9_-]{1,64}$/.test(event.trackingPoint.trackingPointCode)
111
+ || !/^[A-Za-z0-9_-]{1,64}$/.test(event.trackingStage.trackingStageCode)) {
112
+ return { kind: 'schema', phase };
113
+ }
114
+ const stageCode = event.trackingStage.trackingStageCode.trim();
115
+ const description = event.trackingPoint.description;
116
+ const safeDescription = stageCode === '5_COURIER' ? 'Delivered'
117
+ : /\b(?:delivered (?:to|by)|signed (?:for )?by)\b/i.test(description) ? 'Delivery update' : description;
118
+ events.push({ dateTime: event.dateTime,
119
+ trackingPoint: { trackingPointCode: event.trackingPoint.trackingPointCode, description: safeDescription },
120
+ trackingStage: { trackingStageCode: stageCode } });
121
+ }
122
+ // Copy only validated identity and scan scalars. Ownership, contacts,
123
+ // photos, map links, GPS, instructions and issued credentials stay here.
124
+ return { kind: 'ok', urn, payload: { failures: [], results: [{
125
+ uniqueId: urn, parcelIdentifiers: returnedBarcodes.map(() => ({ type: 'BARCODE', value: number })),
126
+ trackingEvents: events,
127
+ }] } };
128
+ }
129
+ catch (error) {
130
+ if (record(error) && ['http', 'schema', 'transport'].includes(String(error.kind))) {
131
+ return { kind: error.kind, phase,
132
+ ...(typeof error.status === 'number' ? { status: error.status } : {}),
133
+ ...(typeof error.retryAfter === 'string' ? { retryAfter: error.retryAfter.slice(0, 100) } : {}) };
134
+ }
135
+ return { kind: 'transport', phase };
136
+ }
137
+ }
@@ -18,13 +18,13 @@
18
18
  "timezone": "Europe/London",
19
19
  "tracking": {
20
20
  "mode": "automatic",
21
- "adapter": "universal",
22
- "steps": []
21
+ "adapter": "evri-uk",
22
+ "steps": [
23
+ "browser"
24
+ ]
23
25
  },
24
26
  "capabilities": [
25
- "history",
26
- "location",
27
- "eta"
27
+ "history"
28
28
  ],
29
29
  "portal": {
30
30
  "url": "https://www.evri.com/track/parcel/{trackingNumber}/details",
@@ -35,9 +35,22 @@
35
35
  "eta",
36
36
  "proof_of_delivery"
37
37
  ],
38
- "scraped": [],
39
- "discarded": [],
40
- "unavailable": []
38
+ "scraped": [
39
+ "status",
40
+ "history"
41
+ ],
42
+ "discarded": [
43
+ "recipient_details",
44
+ "address",
45
+ "proof_of_delivery",
46
+ "gps",
47
+ "sender_details",
48
+ "issued_credentials"
49
+ ],
50
+ "unavailable": [
51
+ "location",
52
+ "eta"
53
+ ]
41
54
  },
42
55
  "links": [
43
56
  {
@@ -0,0 +1,5 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare function normalizeEvriUkNumber(raw: string): string;
3
+ export declare function evriUkTrackingUrl(raw: string): string;
4
+ /** The redacted anonymous feed contains scans; ownership fields are unnecessary. */
5
+ export declare function parseEvriUk(payload: unknown, raw: string, expectedUrn?: string): CarrierResult;
@@ -0,0 +1,89 @@
1
+ import { IndeterminateError, InvalidInputError, SchemaError } from '../../core/errors/index.js';
2
+ import { explicitOffsetTime } from '../../core/time/index.js';
3
+ import { cleanScalar } from '../../core/transport/text.js';
4
+ import { isRecord } from '../../core/types.js';
5
+ const STAGES = new Map([
6
+ ['1', 'registered'], ['2', 'in_transit'], ['3', 'in_transit'],
7
+ ['4_COURIER', 'out_for_delivery'], ['5_COURIER', 'delivered'],
8
+ ]);
9
+ export function normalizeEvriUkNumber(raw) {
10
+ const number = raw.toUpperCase().replace(/[\s-]/g, '');
11
+ if (!/^[A-Z0-9]{16}$/.test(number))
12
+ throw new InvalidInputError('Evri UK', 'Evri UK requires a 16-character parcel barcode');
13
+ return number;
14
+ }
15
+ export function evriUkTrackingUrl(raw) {
16
+ return `https://www.evri.com/track/parcel/${normalizeEvriUkNumber(raw)}/details`;
17
+ }
18
+ function scanTime(raw) {
19
+ const text = cleanScalar(raw, 64);
20
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-](?:(?:0\d|1[0-3]):?[0-5]\d|14:?00))$/i.test(text)
21
+ ? explicitOffsetTime(text) : null;
22
+ }
23
+ /** The redacted anonymous feed contains scans; ownership fields are unnecessary. */
24
+ export function parseEvriUk(payload, raw, expectedUrn) {
25
+ const number = normalizeEvriUkNumber(raw);
26
+ if (!isRecord(payload) || !Array.isArray(payload.results) || !Array.isArray(payload.failures)) {
27
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid parcel history');
28
+ }
29
+ if (payload.failures.length || payload.results.length === 0)
30
+ throw new IndeterminateError('Evri UK', 'Evri UK could not confirm parcel history');
31
+ if (payload.results.length !== 1 || !isRecord(payload.results[0]))
32
+ throw new SchemaError('Evri UK', 'Evri UK returned ambiguous parcel history');
33
+ const parcel = payload.results[0];
34
+ const identifiers = parcel.parcelIdentifiers;
35
+ if (!Array.isArray(identifiers) || identifiers.length > 50
36
+ || !identifiers.every(id => isRecord(id) && typeof id.type === 'string' && typeof id.value === 'string')) {
37
+ throw new SchemaError('Evri UK', 'Evri UK did not return the requested parcel');
38
+ }
39
+ const barcodes = identifiers.filter(id => isRecord(id) && id.type === 'BARCODE');
40
+ if (!barcodes.length || barcodes.some(id => !isRecord(id) || id.value !== number)
41
+ || (expectedUrn !== undefined && parcel.uniqueId !== expectedUrn)) {
42
+ throw new SchemaError('Evri UK', 'Evri UK did not return the requested parcel');
43
+ }
44
+ if (!Array.isArray(parcel.trackingEvents) || parcel.trackingEvents.length > 500)
45
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking events');
46
+ if (!parcel.trackingEvents.length)
47
+ throw new IndeterminateError('Evri UK', 'Evri UK returned no parcel activity');
48
+ const events = [];
49
+ const seen = new Set();
50
+ for (const rawEvent of parcel.trackingEvents) {
51
+ if (!isRecord(rawEvent) || !isRecord(rawEvent.trackingPoint) || !isRecord(rawEvent.trackingStage)) {
52
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking events');
53
+ }
54
+ const description = cleanScalar(rawEvent.trackingPoint.description);
55
+ const code = cleanScalar(rawEvent.trackingPoint.trackingPointCode, 64);
56
+ const stageCode = cleanScalar(rawEvent.trackingStage.trackingStageCode, 64);
57
+ if (!description || !code || !stageCode)
58
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking events');
59
+ const stage = STAGES.get(stageCode);
60
+ const privateDeliveryProse = /\b(?:delivered (?:to|by)|signed (?:for )?by)\b/i.test(description);
61
+ const time = scanTime(rawEvent.dateTime);
62
+ const clock = cleanScalar(rawEvent.dateTime, 64);
63
+ const event = {
64
+ ...(time ? { time: time.iso } : clock ? { provider_time_text: clock } : {}),
65
+ description: stage === 'delivered' ? 'Delivered' : privateDeliveryProse ? 'Delivery update' : description,
66
+ ...(stage ? { stage, stage_source: 'carrier_map' } : {}),
67
+ provider_code: code,
68
+ provider_stage_code: stageCode,
69
+ };
70
+ const identity = JSON.stringify([time?.iso ?? clock, code, description]);
71
+ if (!seen.has(identity)) {
72
+ seen.add(identity);
73
+ events.push(event);
74
+ }
75
+ }
76
+ if (!events.some(event => event.time))
77
+ throw new IndeterminateError('Evri UK', 'Evri UK returned no dated parcel activity');
78
+ if (events.every(event => event.time))
79
+ events.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
80
+ const newest = events[0];
81
+ const stage = newest.stage;
82
+ const status = stage === 'registered' ? 'pending'
83
+ : stage === 'delivered' || stage === 'out_for_delivery' || stage === 'in_transit' ? stage : 'unknown';
84
+ return {
85
+ status, ...(stage ? { current_stage: stage } : {}), last_status_text: newest.description ?? null,
86
+ last_update: newest.time ?? null, events: events.slice(0, 100),
87
+ tracking_url: evriUkTrackingUrl(number), tracking_source: 'structured-web-response',
88
+ };
89
+ }
@@ -1,4 +1,11 @@
1
1
  {
2
2
  "carrier": "evri-uk",
3
- "entries": []
3
+ "entries": [
4
+ { "code": "EXPECTED", "language": "en", "stage": "registered", "confirmedBy": "live" },
5
+ { "code": "PROCESSING_HUB", "language": "en", "stage": "in_transit", "confirmedBy": "live" },
6
+ { "code": "LOCAL_DEPOT", "language": "en", "stage": "in_transit", "confirmedBy": "live" },
7
+ { "code": "COURIER_DELIVERY", "language": "en", "stage": "out_for_delivery", "confirmedBy": "live" },
8
+ { "code": "DELIVERY_1900_2100", "language": "en", "stage": "out_for_delivery", "confirmedBy": "live" },
9
+ { "code": "LEFT_GARAGE", "language": "en", "stage": "delivered", "confirmedBy": "live" }
10
+ ]
4
11
  }
@@ -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",
@@ -2805,7 +2805,8 @@ export declare const CARRIER_CATALOG: {
2805
2805
  readonly timezone: "Europe/London";
2806
2806
  readonly tracking: {
2807
2807
  readonly mode: "automatic";
2808
- readonly adapter: "universal";
2808
+ readonly adapter: "dpd-uk";
2809
+ readonly localClocks: true;
2809
2810
  };
2810
2811
  readonly canaryUrl: "https://track.dpd.co.uk/";
2811
2812
  readonly trackingUrlTemplate: "https://track.dpd.co.uk/?reference={trackingNumber}";
@@ -2849,7 +2850,7 @@ export declare const CARRIER_CATALOG: {
2849
2850
  readonly timezone: "Europe/London";
2850
2851
  readonly tracking: {
2851
2852
  readonly mode: "automatic";
2852
- readonly adapter: "universal";
2853
+ readonly adapter: "evri-uk";
2853
2854
  };
2854
2855
  readonly canaryUrl: "https://www.evri.com/track-a-parcel";
2855
2856
  readonly trackingUrlTemplate: "https://www.evri.com/track/parcel/{trackingNumber}/details";
@@ -4173,7 +4173,8 @@ export const CARRIER_CATALOG = {
4173
4173
  "timezone": "Europe/London",
4174
4174
  "tracking": {
4175
4175
  "mode": "automatic",
4176
- "adapter": "universal"
4176
+ "adapter": "dpd-uk",
4177
+ "localClocks": true
4177
4178
  },
4178
4179
  "canaryUrl": "https://track.dpd.co.uk/",
4179
4180
  "trackingUrlTemplate": "https://track.dpd.co.uk/?reference={trackingNumber}",
@@ -4243,7 +4244,7 @@ export const CARRIER_CATALOG = {
4243
4244
  "timezone": "Europe/London",
4244
4245
  "tracking": {
4245
4246
  "mode": "automatic",
4246
- "adapter": "universal"
4247
+ "adapter": "evri-uk"
4247
4248
  },
4248
4249
  "canaryUrl": "https://www.evri.com/track-a-parcel",
4249
4250
  "trackingUrlTemplate": "https://www.evri.com/track/parcel/{trackingNumber}/details",
@@ -30,12 +30,14 @@ import { adapter as dhlExpress } from '../carriers/dhl-express/adapter.js';
30
30
  import { adapter as dpd } from '../carriers/dpd/adapter.js';
31
31
  import { adapter as dpdDe } from '../carriers/dpd-de/adapter.js';
32
32
  import { adapter as dpdFr } from '../carriers/dpd-fr/adapter.js';
33
+ import { adapter as dpdUk } from '../carriers/dpd-uk/adapter.js';
33
34
  import { adapter as dtdc } from '../carriers/dtdc/adapter.js';
34
35
  import { adapter as ecoscooting } from '../carriers/ecoscooting/adapter.js';
35
36
  import { adapter as ekart } from '../carriers/ekart/adapter.js';
36
37
  import { adapter as ems } from '../carriers/ems/adapter.js';
37
38
  import { adapter as estafeta } from '../carriers/estafeta/adapter.js';
38
39
  import { adapter as evri } from '../carriers/evri/adapter.js';
40
+ import { adapter as evriUk } from '../carriers/evri-uk/adapter.js';
39
41
  import { adapter as fedex } from '../carriers/fedex/adapter.js';
40
42
  import { adapter as fourPx } from '../carriers/four-px/adapter.js';
41
43
  import { adapter as geodis } from '../carriers/geodis/adapter.js';
@@ -129,12 +131,14 @@ export const REGISTRY = {
129
131
  "dpd": dpd,
130
132
  "dpd-de": dpdDe,
131
133
  "dpd-fr": dpdFr,
134
+ "dpd-uk": dpdUk,
132
135
  "dtdc": dtdc,
133
136
  "ecoscooting": ecoscooting,
134
137
  "ekart": ekart,
135
138
  "ems": ems,
136
139
  "estafeta": estafeta,
137
140
  "evri": evri,
141
+ "evri-uk": evriUk,
138
142
  "fedex": fedex,
139
143
  "four-px": fourPx,
140
144
  "geodis": geodis,
@@ -233,14 +237,14 @@ export const REGISTRY = {
233
237
  "dpd": "dpd",
234
238
  "dpd-de": "dpd-de",
235
239
  "dpd-fr": "dpd-fr",
236
- "dpd-uk": "universal",
240
+ "dpd-uk": "dpd-uk",
237
241
  "dtdc": "dtdc",
238
242
  "ecoscooting": "ecoscooting",
239
243
  "ekart": "ekart",
240
244
  "ems": "ems",
241
245
  "estafeta": "estafeta",
242
246
  "evri": "evri",
243
- "evri-uk": "universal",
247
+ "evri-uk": "evri-uk",
244
248
  "fedex": "fedex",
245
249
  "four-px": "four-px",
246
250
  "geodis": "geodis",
@@ -152,7 +152,7 @@
152
152
  }
153
153
  },
154
154
  {
155
- "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/",
155
+ "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/",
156
156
  "results": {}
157
157
  }
158
158
  ]
@@ -1916,11 +1916,11 @@
1916
1916
  },
1917
1917
  {
1918
1918
  "carrier": "dpd-uk",
1919
- "direct": "No adapter",
1920
- "sample": "Blocked",
1919
+ "direct": "Yes (UK parcel numbers)",
1920
+ "sample": "\u2713 7",
1921
1921
  "references": [
1922
1922
  {
1923
- "note": "UK reference-plus-postcode flow requires a challenge and session before retrieval.",
1923
+ "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.",
1924
1924
  "results": {
1925
1925
  "Ship24": "unverified",
1926
1926
  "ParcelsApp": "unverified",
@@ -1933,11 +1933,11 @@
1933
1933
  },
1934
1934
  {
1935
1935
  "carrier": "evri-uk",
1936
- "direct": "No adapter",
1937
- "sample": "Blocked",
1936
+ "direct": "Yes (local Chromium)",
1937
+ "sample": "\u2713 6",
1938
1938
  "references": [
1939
1939
  {
1940
- "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",
1940
+ "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",
1941
1941
  "results": {
1942
1942
  "Ship24": "unverified",
1943
1943
  "ParcelsApp": "unverified",
@@ -2090,7 +2090,7 @@
2090
2090
  "sample": "Blocked",
2091
2091
  "references": [
2092
2092
  {
2093
- "note": "The current corporate tracking form posts to the public tracking service, which returns access denied. This is not evidence of parcel absence.",
2093
+ "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/",
2094
2094
  "results": {
2095
2095
  "Ship24": "unverified",
2096
2096
  "ParcelsApp": "unverified",
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.12.0"
5
+ "version": "0.13.0"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.12.0-main.336",
3
+ "version": "0.13.0-main.337",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",