universal-parcel-scraper 0.31.0-main.374 → 0.32.0-main.376

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
@@ -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 2,188 such statuses, each filed under one stage.
152
+ The carrier folders record 2,204 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.
@@ -35,6 +35,15 @@ export interface DPDTrackerOptions {
35
35
  trawl?: TrawlClient | null;
36
36
  recorder?: StepRecorder;
37
37
  userAgent?: string;
38
+ /** A further German tier, and the failures of the guest tier it may answer after. */
39
+ app?: DPDAppTier;
40
+ }
41
+ export interface DPDAppTier {
42
+ run(number: string, context: {
43
+ signal: AbortSignal;
44
+ timeoutMs: number;
45
+ }): Promise<CarrierResult>;
46
+ recovers(error: unknown): boolean;
38
47
  }
39
48
  export declare class DPDTracker {
40
49
  #private;
@@ -47,6 +56,7 @@ export declare class DPDTracker {
47
56
  private readonly trawl?;
48
57
  private readonly recorder?;
49
58
  private readonly userAgent;
59
+ private readonly app?;
50
60
  constructor(options?: DPDTrackerOptions);
51
61
  /** The browser service, resolved late so a malformed URL fails the page step, not construction. */
52
62
  private browserService;
@@ -501,6 +501,7 @@ export class DPDTracker {
501
501
  trawl;
502
502
  recorder;
503
503
  userAgent;
504
+ app;
504
505
  #accessToken = '';
505
506
  #accessTokenExpiresAt = 0;
506
507
  #basicToken = '';
@@ -519,6 +520,7 @@ export class DPDTracker {
519
520
  this.trawl = options.trawl;
520
521
  this.recorder = options.recorder;
521
522
  this.userAgent = userAgentOf(options.userAgent);
523
+ this.app = options.app;
522
524
  }
523
525
  /** The browser service, resolved late so a malformed URL fails the page step, not construction. */
524
526
  browserService() {
@@ -567,6 +569,10 @@ export class DPDTracker {
567
569
  id: 'page',
568
570
  recovers: pageRecovers,
569
571
  run: ({ previousError }) => this.pageFetch(trackingNumber, previousError !== undefined, lookup),
572
+ }] : this.app ? [{
573
+ id: 'app',
574
+ recovers: (error) => !context.signal?.aborted && this.app.recovers(error),
575
+ run: ({ signal, remainingMs }) => this.app.run(trackingNumber, { signal, timeoutMs: remainingMs }),
570
576
  }] : []),
571
577
  ]);
572
578
  result.tracking_url = this.country === 'DE'
@@ -629,7 +635,7 @@ export class DPDTracker {
629
635
  if (this.country === 'DE' && isRecord(payload.status)) {
630
636
  const country = known(payload.status.countryCode).toUpperCase();
631
637
  if (/^[A-Z]{2}$/.test(country) && country !== 'DE') {
632
- throw new IndeterminateError('DPD Germany', 'DPD returned activity in another country');
638
+ throw new IndeterminateError('DPD Germany', 'DPD returned activity in another country', { reason: 'other_country' });
633
639
  }
634
640
  }
635
641
  if (this.country === 'DE')
@@ -1,3 +1,3 @@
1
1
  import type { AdapterFactory } from '../../core/adapter/index.js';
2
- /** Germany uses the same guest protocol, with its own business-unit selector. */
2
+ /** Germany uses the same guest protocol, with its own business-unit selector, then the German app's service. */
3
3
  export declare const adapter: AdapterFactory;
@@ -1,15 +1,25 @@
1
+ import { CarrierError, carrierErrorKind } from '../../core/errors/index.js';
2
+ import { recoverableByDefault } from '../../core/runner/index.js';
1
3
  import { DPDTracker } from '../dpd/adapter.js';
2
- /** Germany uses the same guest protocol, with its own business-unit selector. */
4
+ import { DPD_DE_OTHER_COUNTRY, DpdDeAppClient } from './app.js';
5
+ /** Germany uses the same guest protocol, with its own business-unit selector, then the German app's service. */
3
6
  export const adapter = (environment) => {
7
+ const app = new DpdDeAppClient({ fetcher: environment.fetcher, userAgent: environment.userAgent });
4
8
  const tracker = new DPDTracker({
5
9
  country: 'DE',
6
10
  fetcher: environment.fetcher,
7
11
  firebaseApiKey: environment.env.DPD_FIREBASE_API_KEY,
8
12
  recorder: environment.recorder,
9
13
  userAgent: environment.userAgent,
14
+ app: {
15
+ run: (number, context) => app.track(number, context),
16
+ // The app reads the same parcel, so it cannot place another country's delivery in Germany.
17
+ recovers: error => (recoverableByDefault(error) || carrierErrorKind(error) === 'schema')
18
+ && !(error instanceof CarrierError && error.reason === DPD_DE_OTHER_COUNTRY),
19
+ },
10
20
  });
11
21
  return {
12
- id: 'dpd-de', recordsSteps: true, steps: ['direct'],
22
+ id: 'dpd-de', recordsSteps: true, steps: ['direct', 'app'],
13
23
  track: (input, context) => tracker.fetch(input.number, input.postcode ?? '', context),
14
24
  };
15
25
  };
@@ -0,0 +1,37 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ import type { ClassifiedStatus, Stage } from '../../core/status/index.js';
3
+ import { type XmlNode } from '../../core/transport/xml.js';
4
+ export declare const DPD_DE_APP_API = "https://api.paketnavigator.de/services/v1/Navigator3Service.asmx";
5
+ /** Activity the service places outside Germany; the app tier would read the same parcel. */
6
+ export declare const DPD_DE_OTHER_COUNTRY = "other_country";
7
+ export interface DpdDePartner {
8
+ name: string;
9
+ token: string;
10
+ password: string;
11
+ }
12
+ /** The app's progress rail: one coarse state for the whole parcel. */
13
+ export declare const DPD_DE_APP_RAIL: Readonly<Record<string, ClassifiedStatus>>;
14
+ /** Scans carry wording only. The shared classifier misreads several of these, so each is mapped whole. */
15
+ export declare const DPD_DE_APP_SCANS: Readonly<Record<string, Stage>>;
16
+ /** Scans arrive oldest first, with a facility's wall clock and no offset. */
17
+ export declare function parseDpdDeApp(data: XmlNode, scans: XmlNode, number: string): CarrierResult;
18
+ /**
19
+ * The German app's SOAP service. Tracking needs an anonymous device session, which the
20
+ * service takes tens of seconds to open and then accepts for hours: one is kept per client.
21
+ */
22
+ export declare class DpdDeAppClient {
23
+ #private;
24
+ constructor(options?: {
25
+ partner?: DpdDePartner;
26
+ fetcher?: typeof fetch;
27
+ userAgent?: string;
28
+ now?: () => number;
29
+ });
30
+ track(number: string, options: {
31
+ signal: AbortSignal;
32
+ timeoutMs: number;
33
+ }): Promise<CarrierResult>;
34
+ /** One session for every lookup; the lookup that opens it lends its signal, the others only wait. */
35
+ private session;
36
+ private call;
37
+ }
@@ -0,0 +1,229 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { CarrierError, ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
3
+ import { zonedTime } from '../../core/time/index.js';
4
+ import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
5
+ import { xmlDocument } from '../../core/transport/xml.js';
6
+ export const DPD_DE_APP_API = 'https://api.paketnavigator.de/services/v1/Navigator3Service.asmx';
7
+ /** Activity the service places outside Germany; the app tier would read the same parcel. */
8
+ export const DPD_DE_OTHER_COUNTRY = 'other_country';
9
+ const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
10
+ const SERVICE = 'https://cloud.dpd.com/';
11
+ const MAX_BYTES = 2_000_000;
12
+ // Shared partner credentials of the public app, distributed with the maintainer's approval.
13
+ const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
14
+ /** The app's progress rail: one coarse state for the whole parcel. */
15
+ export const DPD_DE_APP_RAIL = {
16
+ ACCEPTED: { status: 'pending', stage: 'registered' },
17
+ ON_THE_ROAD: { status: 'in_transit', stage: 'in_transit' },
18
+ AT_DELIVERY_DEPOT: { status: 'in_transit', stage: 'in_transit' },
19
+ OUT_FOR_DELIVERY: { status: 'out_for_delivery', stage: 'out_for_delivery' },
20
+ DELIVERED: { status: 'delivered', stage: 'delivered' },
21
+ };
22
+ /** Scans carry wording only. The shared classifier misreads several of these, so each is mapped whole. */
23
+ export const DPD_DE_APP_SCANS = {
24
+ 'Order information has been transmitted to DPD.': 'registered',
25
+ 'Parcel handed to Pickup parcelshop by consignor.': 'accepted',
26
+ 'In transit.': 'in_transit',
27
+ 'At parcel delivery centre.': 'in_transit',
28
+ 'Transfer to DPD Pickup station by DPD driver.': 'in_transit',
29
+ 'Out for delivery.': 'out_for_delivery',
30
+ 'Unfortunately we have not been able to deliver your parcel.': 'failed_attempt',
31
+ "We're sorry but your parcel couldn't be delivered as arranged.": 'exception',
32
+ 'Delivered by driver to DPD Pickup parcelshop/ station.': 'ready_for_pickup',
33
+ 'Picked up from DPD Pickup station by consignee.': 'delivered',
34
+ 'Delivered.': 'delivered',
35
+ };
36
+ function invalid() { throw new SchemaError('DPD Germany', 'DPD Germany returned invalid tracking XML'); }
37
+ function children(node, name, uri = SERVICE) {
38
+ return node.children.filter(child => child.name === name && child.uri === uri);
39
+ }
40
+ function one(node, name, uri = SERVICE) {
41
+ const matches = children(node, name, uri);
42
+ return matches.length === 1 ? matches[0] : invalid();
43
+ }
44
+ function scalar(node, name, max = 200) {
45
+ const matches = children(node, name);
46
+ if (matches.length > 1 || matches[0]?.children.length)
47
+ invalid();
48
+ return clean(matches[0]?.text ?? '', max);
49
+ }
50
+ const escaped = (value) => value.replace(/[<>&"']/g, character => `&#${character.charCodeAt(0)};`);
51
+ const elements = (fields) => Object.entries(fields)
52
+ .map(([name, value]) => `<${name}>${typeof value === 'string' ? escaped(value) : elements(value)}</${name}>`).join('');
53
+ class SessionExpired extends Error {
54
+ }
55
+ function statusOf(stage) {
56
+ if (stage === 'delivered' || stage === 'out_for_delivery' || stage === 'exception')
57
+ return stage;
58
+ if (stage === 'registered')
59
+ return 'pending';
60
+ return stage ? 'in_transit' : 'unknown';
61
+ }
62
+ /** Scans arrive oldest first, with a facility's wall clock and no offset. */
63
+ export function parseDpdDeApp(data, scans, number) {
64
+ const tracking = one(data, 'TrackingData');
65
+ if (scalar(tracking, 'ParcelNo', 40) !== number)
66
+ throw new SchemaError('DPD Germany', 'DPD Germany returned a different parcel');
67
+ // The service also answers for parcels delivered elsewhere in the group.
68
+ const destination = scalar(one(tracking, 'ShipAddress'), 'Country', 8);
69
+ if (/^[A-Z]{2}$/.test(destination) && destination !== 'DE') {
70
+ throw new IndeterminateError('DPD Germany', 'DPD returned activity in another country', { reason: DPD_DE_OTHER_COUNTRY });
71
+ }
72
+ const rows = children(one(scans, 'TrackingScanList'), 'TrackingScan');
73
+ if (rows.length > 500)
74
+ invalid();
75
+ const events = [];
76
+ const seen = new Set();
77
+ for (const row of rows) {
78
+ const wording = scalar(row, 'StatusText', 500);
79
+ const day = scalar(row, 'ScanDate', 16);
80
+ const time = scalar(row, 'ScanTime', 16);
81
+ if (!wording || !/^\d{2}\.\d{2}\.\d{4}$/.test(day) || !/^\d{2}:\d{2}$/.test(time))
82
+ invalid();
83
+ // An announced delivery day is not a scan.
84
+ if (/^Your parcel is estimated to be delivered\b/i.test(wording))
85
+ continue;
86
+ const place = /^(.{1,120}) \(([A-Z]{2})\)$/.exec(scalar(row, 'Location'));
87
+ // German facilities keep German civil time; a clock elsewhere has no established zone.
88
+ const clock = zonedTime(`${day} ${time}`, 'dd.MM.yyyy HH:mm', place?.[2] === 'DE' ? 'Europe/Berlin' : 'UTC');
89
+ if (!clock)
90
+ invalid();
91
+ const stage = DPD_DE_APP_SCANS[wording];
92
+ const event = {
93
+ ...(place?.[2] === 'DE' ? { time: clock.iso } : { local_time: clock.iso.slice(0, 19), provider_time_text: `${day} ${time}` }),
94
+ ...(place ? { location: `${place[1]}, ${place[2]}` } : {}),
95
+ description: /\b(?:delivered to|signed (?:for )?by|received by)\b/i.test(wording) ? 'Delivery update' : wording,
96
+ ...(stage ? { stage, stage_source: 'carrier_map' } : {}),
97
+ };
98
+ // Reason codes, shop names and service descriptions are not projected.
99
+ const identity = JSON.stringify(event);
100
+ if (seen.has(identity))
101
+ continue;
102
+ seen.add(identity);
103
+ events.push(event);
104
+ }
105
+ if (!events.length)
106
+ throw new IndeterminateError('DPD Germany', 'DPD Germany returned no parcel scans');
107
+ events.reverse();
108
+ const current = events[0];
109
+ const rail = DPD_DE_APP_RAIL[scalar(one(tracking, 'LastStatusInfo'), 'StatusID', 64)];
110
+ const stage = current.stage ?? rail?.stage;
111
+ const kilograms = Number(scalar(one(tracking, 'OrderInfo'), 'Weight', 16).replace(',', '.'));
112
+ return {
113
+ status: rail?.status ?? statusOf(current.stage), ...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
114
+ last_status_text: current.description ?? null, last_update: current.time ?? null,
115
+ ...(current.time ? {} : { last_update_local: current.local_time }),
116
+ ...(Number.isFinite(kilograms) && kilograms > 0 ? { weight_kg: kilograms } : {}),
117
+ events: events.slice(0, 100),
118
+ };
119
+ }
120
+ /**
121
+ * The German app's SOAP service. Tracking needs an anonymous device session, which the
122
+ * service takes tens of seconds to open and then accepts for hours: one is kept per client.
123
+ */
124
+ export class DpdDeAppClient {
125
+ #partner;
126
+ #fetcher;
127
+ #userAgent;
128
+ #now;
129
+ #device = randomBytes(8).toString('hex');
130
+ #session = '';
131
+ #opening = null;
132
+ constructor(options = {}) {
133
+ this.#partner = options.partner ?? PARTNER;
134
+ this.#fetcher = options.fetcher;
135
+ this.#userAgent = userAgentOf(options.userAgent);
136
+ this.#now = options.now ?? Date.now;
137
+ }
138
+ async track(number, options) {
139
+ if (!/^\d{14}$/.test(number))
140
+ throw new TypeError('DPD Germany app tracking takes 14 digits');
141
+ const deadline = performance.now() + options.timeoutMs;
142
+ const left = () => Math.max(1, Math.floor(deadline - performance.now()));
143
+ try {
144
+ for (let attempt = 0;; attempt += 1) {
145
+ const session = await this.session(options.signal, left());
146
+ try {
147
+ const data = await this.call('getTrackingData', { SessionToken: session, ParcelNo: number, DeliveryZipCode: '',
148
+ UpdateNewDeliveryData: 'false', addParcelIfNoTrackingdataAvailable: 'false', ParcelFlowTypeID: 'receiving' }, options.signal, left());
149
+ const scans = await this.call('getTrackingScanList', { SessionToken: session, ParcelNo: number, DeliveryZipCode: '' }, options.signal, left());
150
+ return parseDpdDeApp(data, scans, number);
151
+ }
152
+ catch (error) {
153
+ if (!(error instanceof SessionExpired) || attempt)
154
+ throw error;
155
+ if (this.#session === session)
156
+ this.#session = '';
157
+ }
158
+ }
159
+ }
160
+ catch (error) {
161
+ options.signal.throwIfAborted();
162
+ // Transport diagnostics include the credentials, the session and recipient XML.
163
+ // Preserve the failure contract without forwarding request or body data.
164
+ if (error instanceof CarrierError)
165
+ throw new CarrierError(error.kind, 'DPD Germany', `DPD Germany app tracking failed (${error.kind})`, {
166
+ status: error.status, retryAfterMs: error.retryAfterMs, reason: error.reason,
167
+ });
168
+ throw new TransportError('DPD Germany', 'DPD Germany app request failed');
169
+ }
170
+ }
171
+ /** One session for every lookup; the lookup that opens it lends its signal, the others only wait. */
172
+ async session(signal, timeoutMs) {
173
+ if (this.#session)
174
+ return this.#session;
175
+ if (!this.#opening) {
176
+ const opening = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
177
+ HardwareID: this.#device, BootSystemID: 'Android_Phone', Version: '15', AppVersion: '4.2.0',
178
+ } }, signal, timeoutMs).then(result => {
179
+ const token = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
180
+ if (!/^[A-Za-z0-9+/=]{16,512}$/.test(token))
181
+ invalid();
182
+ return this.#session = token;
183
+ });
184
+ this.#opening = opening;
185
+ void opening.catch(() => undefined).finally(() => { if (this.#opening === opening)
186
+ this.#opening = null; });
187
+ }
188
+ const shared = this.#opening;
189
+ signal.throwIfAborted();
190
+ let leave;
191
+ const left = new Promise((_resolve, reject) => { leave = () => reject(signal.reason); });
192
+ signal.addEventListener('abort', leave, { once: true });
193
+ try {
194
+ return await Promise.race([shared, left]);
195
+ }
196
+ finally {
197
+ signal.removeEventListener('abort', leave);
198
+ }
199
+ }
200
+ async call(operation, fields, signal, timeoutMs) {
201
+ // The service checks a key derived from the minute of the UTC day.
202
+ const now = new Date(this.#now());
203
+ const phase = String((now.getUTCHours() * 60 + now.getUTCMinutes() + 1000) * 3);
204
+ const key = phase + createHash('md5').update(`${phase}${this.#partner.name}0${operation}${this.#partner.password}`).digest('base64').slice(0, 16);
205
+ const body = `<?xml version="1.0" encoding="utf-8"?><soap:Envelope xmlns:soap="${SOAP}"><soap:Body><${operation} xmlns="${SERVICE}"><${operation}Request>${elements({
206
+ Version: '100', Language: 'de_EN', PartnerCredentials: { Name: this.#partner.name, Token: this.#partner.token, KeyPhase: key }, ...fields,
207
+ })}</${operation}Request></${operation}></soap:Body></soap:Envelope>`;
208
+ const { response, bytes } = await fetchBounded(DPD_DE_APP_API, { method: 'POST', signal, body,
209
+ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: `"${SERVICE}${operation}"`, Accept: 'text/xml', 'User-Agent': this.#userAgent } }, {
210
+ provider: 'DPD Germany', maxBytes: MAX_BYTES, timeoutMs, fetcher: this.#fetcher, allowHttpStatuses: [404, 410],
211
+ });
212
+ if (response.status !== 200)
213
+ throw new TransportError('DPD Germany', 'DPD Germany app service is unavailable', { status: response.status });
214
+ const root = xmlDocument(decodeText(bytes), MAX_BYTES) ?? invalid();
215
+ if (root.name !== 'Envelope' || root.uri !== SOAP)
216
+ invalid();
217
+ const result = one(one(one(root, 'Body', SOAP), `${operation}Response`), `${operation}Result`);
218
+ const codes = children(result, 'ErrorDataList').flatMap(list => children(list, 'ErrorData')).map(error => scalar(error, 'ErrorCode', 80));
219
+ if (codes.some(code => code === 'ERROR_PARTNER' || code === 'ERROR_KEYPHASE')) {
220
+ throw new ChallengeError('DPD Germany', 'DPD Germany refused the app credential');
221
+ }
222
+ if (codes.includes('ERROR_SESSION_NOT_VALID'))
223
+ throw new SessionExpired();
224
+ // "No tracking data" also answers for parcels the scan list still knows: it proves no absence.
225
+ if (scalar(result, 'Ack', 8) !== 'true')
226
+ throw new IndeterminateError('DPD Germany', 'DPD Germany returned no confirmed parcel');
227
+ return result;
228
+ }
229
+ }
@@ -18,7 +18,8 @@
18
18
  "mode": "automatic",
19
19
  "adapter": "dpd-de",
20
20
  "steps": [
21
- "direct"
21
+ "direct",
22
+ "app"
22
23
  ],
23
24
  "requirements": [
24
25
  {
@@ -33,6 +33,139 @@
33
33
  "confirmedBy": "live",
34
34
  "firstSeen": "2026-10-05",
35
35
  "note": "A retry, not a terminal state: the result status stays in_transit."
36
+ },
37
+ {
38
+ "wording": "Order information has been transmitted to DPD.",
39
+ "language": "en",
40
+ "stage": "registered",
41
+ "confirmedBy": "live",
42
+ "firstSeen": "2026-10-07",
43
+ "note": "Seen in the German app's scan list, which carries wording only."
44
+ },
45
+ {
46
+ "wording": "Parcel handed to Pickup parcelshop by consignor.",
47
+ "language": "en",
48
+ "stage": "accepted",
49
+ "confirmedBy": "live",
50
+ "firstSeen": "2026-10-07",
51
+ "note": "Seen in the German app's scan list, which carries wording only."
52
+ },
53
+ {
54
+ "wording": "In transit.",
55
+ "language": "en",
56
+ "stage": "in_transit",
57
+ "confirmedBy": "live",
58
+ "firstSeen": "2026-10-07",
59
+ "note": "Seen in the German app's scan list, which carries wording only."
60
+ },
61
+ {
62
+ "wording": "At parcel delivery centre.",
63
+ "language": "en",
64
+ "stage": "in_transit",
65
+ "confirmedBy": "live",
66
+ "firstSeen": "2026-10-07",
67
+ "note": "Seen in the German app's scan list, which carries wording only."
68
+ },
69
+ {
70
+ "wording": "Transfer to DPD Pickup station by DPD driver.",
71
+ "language": "en",
72
+ "stage": "in_transit",
73
+ "confirmedBy": "live",
74
+ "firstSeen": "2026-10-07",
75
+ "note": "Seen in the German app's scan list, which carries wording only."
76
+ },
77
+ {
78
+ "wording": "Out for delivery.",
79
+ "language": "en",
80
+ "stage": "out_for_delivery",
81
+ "confirmedBy": "live",
82
+ "firstSeen": "2026-10-07",
83
+ "note": "Seen in the German app's scan list, which carries wording only."
84
+ },
85
+ {
86
+ "wording": "Unfortunately we have not been able to deliver your parcel.",
87
+ "language": "en",
88
+ "stage": "failed_attempt",
89
+ "confirmedBy": "live",
90
+ "firstSeen": "2026-10-07",
91
+ "note": "Seen in the German app's scan list, which carries wording only. Matches the guest API's UNSUCCESSFUL_DELIVERY_ATTEMPT at the same minute."
92
+ },
93
+ {
94
+ "wording": "We're sorry but your parcel couldn't be delivered as arranged.",
95
+ "language": "en",
96
+ "stage": "exception",
97
+ "confirmedBy": "live",
98
+ "firstSeen": "2026-10-07",
99
+ "note": "Seen in the German app's scan list, which carries wording only. Mapped whole because the shared classifier reads it as a delivery."
100
+ },
101
+ {
102
+ "wording": "Delivered by driver to DPD Pickup parcelshop/ station.",
103
+ "language": "en",
104
+ "stage": "ready_for_pickup",
105
+ "confirmedBy": "live",
106
+ "firstSeen": "2026-10-07",
107
+ "note": "Seen in the German app's scan list, which carries wording only. Mapped whole because the shared classifier reads it as a delivery."
108
+ },
109
+ {
110
+ "wording": "Picked up from DPD Pickup station by consignee.",
111
+ "language": "en",
112
+ "stage": "delivered",
113
+ "confirmedBy": "live",
114
+ "firstSeen": "2026-10-07",
115
+ "note": "Seen in the German app's scan list, which carries wording only. Mapped whole because the shared classifier reads it as an acceptance."
116
+ },
117
+ {
118
+ "wording": "Delivered.",
119
+ "language": "en",
120
+ "stage": "delivered",
121
+ "confirmedBy": "live",
122
+ "firstSeen": "2026-10-07",
123
+ "note": "Seen in the German app's scan list, which carries wording only."
124
+ },
125
+ {
126
+ "code": "ACCEPTED",
127
+ "wording": "Paketinfo an DPD übergeben",
128
+ "language": "de",
129
+ "stage": "registered",
130
+ "confirmedBy": "live",
131
+ "firstSeen": "2026-10-07",
132
+ "note": "The German app's progress rail, one state for the whole parcel."
133
+ },
134
+ {
135
+ "code": "ON_THE_ROAD",
136
+ "wording": "Paket unterwegs",
137
+ "language": "de",
138
+ "stage": "in_transit",
139
+ "confirmedBy": "live",
140
+ "firstSeen": "2026-10-07",
141
+ "note": "The German app's progress rail, one state for the whole parcel."
142
+ },
143
+ {
144
+ "code": "AT_DELIVERY_DEPOT",
145
+ "wording": "At parcel delivery centre",
146
+ "language": "en",
147
+ "stage": "in_transit",
148
+ "confirmedBy": "live",
149
+ "firstSeen": "2026-10-07",
150
+ "note": "The German app's progress rail, one state for the whole parcel."
151
+ },
152
+ {
153
+ "code": "DELIVERED",
154
+ "wording": "Parcel delivered",
155
+ "language": "en",
156
+ "stage": "delivered",
157
+ "confirmedBy": "live",
158
+ "firstSeen": "2026-10-07",
159
+ "note": "The German app's progress rail, one state for the whole parcel."
160
+ },
161
+ {
162
+ "code": "OUT_FOR_DELIVERY",
163
+ "wording": "In Zustellung",
164
+ "language": "de",
165
+ "stage": "out_for_delivery",
166
+ "confirmedBy": "live",
167
+ "firstSeen": "2026-10-07",
168
+ "note": "The German app's progress rail, one state for the whole parcel. Seen as a state the parcel had not reached."
36
169
  }
37
170
  ]
38
171
  }
@@ -5,6 +5,10 @@ export declare class EvriUkTracker {
5
5
  constructor(options?: {
6
6
  executablePath?: string | null;
7
7
  recorder?: StepRecorder;
8
+ fetcher?: typeof fetch;
9
+ userAgent?: string;
10
+ /** Guest API key. Omit for the included key; null or blank leaves the page as the only tier. */
11
+ key?: string | null;
8
12
  });
9
13
  fetch(raw: string, context?: TrackingContext): Promise<import("../../index.js").CarrierResult>;
10
14
  }
@@ -1,9 +1,12 @@
1
- import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
- import { runSteps } from '../../core/runner/index.js';
1
+ import { CarrierError, carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
+ import { recoverableByDefault, runSteps } from '../../core/runner/index.js';
3
3
  import { NOOP_RECORDER } from '../../core/telemetry/index.js';
4
4
  import { withLocalBrowser } from '../../core/transport/localBrowser.js';
5
5
  import { requestEvriUkInPage } from './browser.js';
6
+ import { EVRI_UK_UNCONFIRMED, readEvriUkMobile } from './mobile.js';
6
7
  import { normalizeEvriUkNumber, parseEvriUk } from './parser.js';
8
+ /** How long the guest API may take while the page can still answer. */
9
+ const DIRECT_TIMEOUT_MS = 10_000;
7
10
  export class EvriUkTracker {
8
11
  options;
9
12
  constructor(options = {}) {
@@ -14,10 +17,20 @@ export class EvriUkTracker {
14
17
  const budgetMs = context.budgetMs ?? 45_000;
15
18
  if (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > 60_000)
16
19
  throw new TypeError('Evri UK budget must be between 1 and 60000 ms');
17
- if (!this.options.executablePath)
20
+ const direct = this.options.key === undefined || Boolean(this.options.key?.trim());
21
+ const browser = Boolean(this.options.executablePath);
22
+ if (!direct && !browser)
18
23
  throw new ChallengeError('Evri UK', 'Evri UK requires TRACKING_CHROMIUM_PATH');
19
24
  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',
25
+ id: 'direct', enabled: direct, run: ({ signal, remainingMs }) => readEvriUkMobile(number, {
26
+ key: this.options.key, fetcher: this.options.fetcher, userAgent: this.options.userAgent, signal,
27
+ timeoutMs: browser ? Math.min(remainingMs, DIRECT_TIMEOUT_MS) : remainingMs,
28
+ }),
29
+ }, {
30
+ id: 'browser', enabled: browser,
31
+ // The page reads the same history service, so its answer about the parcel would not differ.
32
+ recovers: error => recoverableByDefault(error) && !(error instanceof CarrierError && error.reason === EVRI_UK_UNCONFIRMED),
33
+ run: ({ signal, remainingMs: remaining }) => withLocalBrowser({ provider: 'Evri UK',
21
34
  executablePath: this.options.executablePath, signal, timeoutMs: Math.max(1, Math.floor(remaining)),
22
35
  args: ['--disable-blink-features=AutomationControlled'], }, async ({ browser, signal: session, remainingMs }) => {
23
36
  try {
@@ -75,6 +88,9 @@ export class EvriUkTracker {
75
88
  }
76
89
  }
77
90
  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) };
91
+ const tracker = new EvriUkTracker({
92
+ executablePath: environment.browserExecutablePath, recorder: environment.recorder, fetcher: environment.fetcher,
93
+ userAgent: environment.userAgent, key: environment.env.EVRI_UK_TRACKING_KEY,
94
+ });
95
+ return { id: 'evri-uk', recordsSteps: true, steps: ['direct', 'browser'], track: (input, context) => tracker.fetch(input.number, context) };
80
96
  };
@@ -20,6 +20,7 @@
20
20
  "mode": "automatic",
21
21
  "adapter": "evri-uk",
22
22
  "steps": [
23
+ "direct",
23
24
  "browser"
24
25
  ]
25
26
  },
@@ -0,0 +1,11 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const EVRI_UK_MOBILE_API = "https://tracking.platform-apis.evri.com/v1";
3
+ /** The history service answered and confirmed no domestic parcel activity; the page asks the same service. */
4
+ export declare const EVRI_UK_UNCONFIRMED = "parcel_unconfirmed";
5
+ export declare function readEvriUkMobile(raw: string, options: {
6
+ key?: string | null;
7
+ fetcher?: typeof fetch;
8
+ userAgent?: string;
9
+ signal: AbortSignal;
10
+ timeoutMs: number;
11
+ }): Promise<CarrierResult>;
@@ -0,0 +1,84 @@
1
+ import { CarrierError, ChallengeError, IndeterminateError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
+ import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
3
+ import { isRecord } from '../../core/types.js';
4
+ import { normalizeEvriUkNumber, parseEvriUk } from './parser.js';
5
+ export const EVRI_UK_MOBILE_API = 'https://tracking.platform-apis.evri.com/v1';
6
+ /** The history service answered and confirmed no domestic parcel activity; the page asks the same service. */
7
+ export const EVRI_UK_UNCONFIRMED = 'parcel_unconfirmed';
8
+ const MAX_BYTES = 2_000_000;
9
+ // Shared guest-tracking application key, distributed with the maintainer's approval.
10
+ const APPLICATION_KEY = 'NpxtibhIgliySB4JRfjAQq2vytmiWGUQ';
11
+ function unconfirmed() {
12
+ throw new IndeterminateError('Evri UK', 'Evri UK could not confirm a domestic parcel', { reason: EVRI_UK_UNCONFIRMED });
13
+ }
14
+ export async function readEvriUkMobile(raw, options) {
15
+ const number = normalizeEvriUkNumber(raw);
16
+ const key = options.key === undefined ? APPLICATION_KEY : options.key?.trim();
17
+ if (!key || !/^[\w.~+/=-]{8,512}$/.test(key))
18
+ throw new ChallengeError('Evri UK', 'Evri UK tracking API key is unavailable');
19
+ const deadline = performance.now() + options.timeoutMs;
20
+ const read = async (url) => {
21
+ const { response, bytes } = await fetchBounded(url, { signal: options.signal,
22
+ headers: { Accept: 'application/json', apiKey: key, 'User-Agent': userAgentOf(options.userAgent) } }, {
23
+ provider: 'Evri UK', maxBytes: MAX_BYTES, timeoutMs: Math.max(1, Math.floor(deadline - performance.now())),
24
+ fetcher: options.fetcher, allowHttpStatuses: [400, 401, 403, 404, 410],
25
+ });
26
+ if ([401, 403].includes(response.status))
27
+ throw new ChallengeError('Evri UK', 'Evri UK refused the tracking API key');
28
+ let payload;
29
+ try {
30
+ payload = JSON.parse(decodeText(bytes));
31
+ }
32
+ catch {
33
+ payload = undefined;
34
+ }
35
+ // The service reports a reference it rejects as JSON; a bare status is the endpoint's.
36
+ if (response.status !== 200 && !(isRecord(payload) && Array.isArray(payload.errors) && payload.errors.length)) {
37
+ if ([404, 410].includes(response.status))
38
+ throw new TransportError('Evri UK', 'Evri UK tracking API is unavailable', { status: response.status });
39
+ throw new UpstreamHttpError('Evri UK', response.status);
40
+ }
41
+ if (response.status === 200 && payload === undefined)
42
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid tracking data');
43
+ return { status: response.status, payload };
44
+ };
45
+ try {
46
+ const reference = await read(new URL(`${EVRI_UK_MOBILE_API}/parcels/reference/${number}`));
47
+ if (reference.status !== 200)
48
+ unconfirmed();
49
+ const identifiers = isRecord(reference.payload) ? reference.payload.parcelIdentifiers : undefined;
50
+ if (!Array.isArray(identifiers) || identifiers.length > 50)
51
+ throw new SchemaError('Evri UK', 'Evri UK returned invalid parcel identifiers');
52
+ // Several parcels or an international redirect do not settle one domestic parcel.
53
+ const identifier = identifiers.length === 1 ? identifiers[0] : undefined;
54
+ if (identifiers.length !== 1 || (isRecord(identifier) && identifier.redirectUrl))
55
+ unconfirmed();
56
+ const urn = isRecord(identifier) ? identifier.urn : undefined;
57
+ if (typeof urn !== 'string' || !new RegExp(`^urn:parcel_id:barcode:date:\\d{1,30}:${number}:\\d{4}-\\d{2}-\\d{2}$`).test(urn)) {
58
+ throw new SchemaError('Evri UK', 'Evri UK did not return the requested parcel');
59
+ }
60
+ const endpoint = new URL(`${EVRI_UK_MOBILE_API}/parcels/`);
61
+ endpoint.searchParams.set('uniqueIds', urn);
62
+ const history = await read(endpoint);
63
+ if (history.status !== 200)
64
+ unconfirmed();
65
+ try {
66
+ return parseEvriUk(history.payload, number, urn);
67
+ }
68
+ catch (error) {
69
+ if (error instanceof IndeterminateError)
70
+ unconfirmed();
71
+ throw error;
72
+ }
73
+ }
74
+ catch (error) {
75
+ options.signal.throwIfAborted();
76
+ // Transport diagnostics include the key, the barcode and recipient fields.
77
+ // Preserve the failure contract without forwarding request or body data.
78
+ if (error instanceof CarrierError)
79
+ throw new CarrierError(error.kind, 'Evri UK', `Evri UK tracking failed (${error.kind})`, {
80
+ status: error.status, retryAfterMs: error.retryAfterMs, reason: error.reason,
81
+ });
82
+ throw new TransportError('Evri UK', 'Evri UK tracking API request failed');
83
+ }
84
+ }
@@ -1,7 +1,7 @@
1
1
  import { DateTime } from 'luxon';
2
- import { SaxesParser } from 'saxes';
3
2
  import { CarrierError, ChallengeError, IndeterminateError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
4
3
  import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
4
+ import { xmlDocument } from '../../core/transport/xml.js';
5
5
  import { normalizeLbcNumber } from './parser.js';
6
6
  export const LBC_MOBILE_API = 'https://lbcapigateway.lbcapps.com/lbctrackingapi2/v2';
7
7
  const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
@@ -10,42 +10,6 @@ const MAX_BYTES = 1_000_000;
10
10
  // Shared guest-tracking application key, distributed with the maintainer's approval.
11
11
  const APPLICATION_KEY = '4dc2cebad70043c79346dc66d5a94fa8';
12
12
  function invalidXml() { throw new SchemaError('lbc-express', 'LBC returned invalid tracking XML'); }
13
- function document(xml) {
14
- if (new TextEncoder().encode(xml).byteLength > MAX_BYTES)
15
- invalidXml();
16
- const parser = new SaxesParser({ xmlns: true });
17
- const stack = [];
18
- let root;
19
- let count = 0;
20
- parser.on('error', invalidXml);
21
- parser.on('doctype', invalidXml);
22
- parser.on('opentag', tag => {
23
- if (++count > 20_000 || stack.length > 24)
24
- invalidXml();
25
- const node = { name: tag.local, uri: tag.uri, text: '', children: [] };
26
- if (stack.length)
27
- stack.at(-1).children.push(node);
28
- else if (root)
29
- invalidXml();
30
- else
31
- root = node;
32
- stack.push(node);
33
- });
34
- const text = (value) => { if (stack.length)
35
- stack.at(-1).text += value; };
36
- parser.on('text', text);
37
- parser.on('cdata', text);
38
- parser.on('closetag', () => { stack.pop(); });
39
- try {
40
- parser.write(xml).close();
41
- }
42
- catch {
43
- invalidXml();
44
- }
45
- if (!root || stack.length)
46
- invalidXml();
47
- return root;
48
- }
49
13
  function children(node, name, uri = SERVICE) {
50
14
  return node.children.filter(child => child.name === name && child.uri === uri);
51
15
  }
@@ -90,7 +54,7 @@ function scanClock(day, time) {
90
54
  }
91
55
  export function parseLbcMobile(xml, raw) {
92
56
  const number = normalizeLbcNumber(raw);
93
- const root = document(xml);
57
+ const root = xmlDocument(xml, MAX_BYTES) ?? invalidXml();
94
58
  if (root.name !== 'Envelope' || root.uri !== SOAP)
95
59
  invalidXml();
96
60
  const body = one(root, 'Body', SOAP);
@@ -0,0 +1,8 @@
1
+ export interface XmlNode {
2
+ name: string;
3
+ uri: string;
4
+ text: string;
5
+ children: XmlNode[];
6
+ }
7
+ /** The root element, or null when `xml` is not one well-formed document within the limits. */
8
+ export declare function xmlDocument(xml: string, maxBytes: number): XmlNode | null;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A bounded element tree for a provider's XML reply. Namespaces are resolved,
3
+ * attributes are not kept, and a document type declaration is refused.
4
+ */
5
+ import { SaxesParser } from 'saxes';
6
+ const MAX_NODES = 20_000;
7
+ const MAX_DEPTH = 24;
8
+ /** The root element, or null when `xml` is not one well-formed document within the limits. */
9
+ export function xmlDocument(xml, maxBytes) {
10
+ if (new TextEncoder().encode(xml).byteLength > maxBytes)
11
+ return null;
12
+ const invalid = () => { throw new SyntaxError('invalid XML'); };
13
+ const parser = new SaxesParser({ xmlns: true });
14
+ const stack = [];
15
+ let root;
16
+ let count = 0;
17
+ parser.on('error', invalid);
18
+ parser.on('doctype', invalid);
19
+ parser.on('opentag', tag => {
20
+ if (++count > MAX_NODES || stack.length > MAX_DEPTH)
21
+ invalid();
22
+ const node = { name: tag.local, uri: tag.uri, text: '', children: [] };
23
+ if (stack.length)
24
+ stack.at(-1).children.push(node);
25
+ else if (root)
26
+ invalid();
27
+ else
28
+ root = node;
29
+ stack.push(node);
30
+ });
31
+ const text = (value) => { if (stack.length)
32
+ stack.at(-1).text += value; };
33
+ parser.on('text', text);
34
+ parser.on('cdata', text);
35
+ parser.on('closetag', () => { stack.pop(); });
36
+ try {
37
+ parser.write(xml).close();
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ return root && !stack.length ? root : null;
43
+ }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.31.0"
5
+ "version": "0.32.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.31.0-main.374",
3
+ "version": "0.32.0-main.376",
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",