universal-parcel-scraper 0.34.0-main.379 → 0.35.0-main.384

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/data/catalog.json CHANGED
@@ -354,8 +354,8 @@
354
354
  "adapter": "postlogistics",
355
355
  "recognitionRank": 26
356
356
  },
357
- "canaryUrl": "https://tracking.postlogistics.ch/public/trackandtrace",
358
- "trackingUrlTemplate": "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}",
357
+ "canaryUrl": "https://apv.swisspost-cargo.com/public/trackandtrace",
358
+ "trackingUrlTemplate": "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}",
359
359
  "linkRules": [
360
360
  {
361
361
  "domains": [
@@ -390,8 +390,8 @@ var ce = {
390
390
  adapter: "postlogistics",
391
391
  recognitionRank: 26
392
392
  },
393
- canaryUrl: "https://tracking.postlogistics.ch/public/trackandtrace",
394
- trackingUrlTemplate: "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}",
393
+ canaryUrl: "https://apv.swisspost-cargo.com/public/trackandtrace",
394
+ trackingUrlTemplate: "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}",
395
395
  linkRules: [{
396
396
  domains: ["tracking.postlogistics.ch"],
397
397
  path: "^/public/trackandtrace/([^/?#]+)/?$"
@@ -35,14 +35,17 @@ 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. */
38
+ /** A further German tier, ahead of the guest tier when no postcode is given. */
39
39
  app?: DPDAppTier;
40
40
  }
41
41
  export interface DPDAppTier {
42
+ /** `leads` when the guest tier is still to come and needs time left. */
42
43
  run(number: string, context: {
43
44
  signal: AbortSignal;
44
45
  timeoutMs: number;
46
+ leads: boolean;
45
47
  }): Promise<CarrierResult>;
48
+ /** The failures of either tier the other may answer after. */
46
49
  recovers(error: unknown): boolean;
47
50
  }
48
51
  export declare class DPDTracker {
@@ -550,11 +550,23 @@ export class DPDTracker {
550
550
  const kind = carrierErrorKind(error);
551
551
  return !context.signal?.aborted && kind !== null && PAGE_RECOVERS.has(kind);
552
552
  };
553
+ const app = this.app;
554
+ const appRecovers = (error) => !context.signal?.aborted && app.recovers(error);
555
+ // Without a postcode the app's scans are the richer history; with one, the
556
+ // guest tier's verified reply comes first.
557
+ const appLeads = app !== undefined && !resolvedPostcode;
558
+ const appStep = (leads) => ({
559
+ id: 'app',
560
+ ...(leads ? {} : { recovers: appRecovers }),
561
+ run: ({ signal, remainingMs }) => app.run(trackingNumber, { signal, timeoutMs: remainingMs, leads }),
562
+ });
553
563
  const result = await runSteps({
554
564
  carrier, budgetMs: lookup.budgetMs, signal: lookup.signal, recorder: this.recorder,
555
565
  }, [
566
+ ...(appLeads ? [appStep(true)] : []),
556
567
  {
557
568
  id: 'direct',
569
+ ...(appLeads ? { recovers: appRecovers } : {}),
558
570
  run: () => this.apiFetch(trackingNumber, resolvedPostcode, lookup).catch((error) => {
559
571
  // The signal ends the guest tier a moment before the runner counts
560
572
  // the budget as spent: the tier reports the budget itself, so the
@@ -569,11 +581,7 @@ export class DPDTracker {
569
581
  id: 'page',
570
582
  recovers: pageRecovers,
571
583
  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 }),
576
- }] : []),
584
+ }] : app && !appLeads ? [appStep(false)] : []),
577
585
  ]);
578
586
  result.tracking_url = this.country === 'DE'
579
587
  ? `https://tracking.dpd.de/status/en_US/parcel/${trackingNumber}`
@@ -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, then the German app's service. */
2
+ /** Germany reads the German app's service, then the guest protocol with its own business-unit selector. */
3
3
  export declare const adapter: AdapterFactory;
@@ -1,8 +1,9 @@
1
1
  import { CarrierError, carrierErrorKind } from '../../core/errors/index.js';
2
- import { recoverableByDefault } from '../../core/runner/index.js';
3
2
  import { DPDTracker } from '../dpd/adapter.js';
4
3
  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. */
4
+ /** Left to the guest API when the app's session is still opening ahead of it. */
5
+ const GUEST_RESERVE_MS = 20_000;
6
+ /** Germany reads the German app's service, then the guest protocol with its own business-unit selector. */
6
7
  export const adapter = (environment) => {
7
8
  const app = new DpdDeAppClient({ fetcher: environment.fetcher, userAgent: environment.userAgent });
8
9
  const tracker = new DPDTracker({
@@ -12,14 +13,18 @@ export const adapter = (environment) => {
12
13
  recorder: environment.recorder,
13
14
  userAgent: environment.userAgent,
14
15
  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')
16
+ run: (number, { signal, timeoutMs, leads }) => app.track(number, {
17
+ signal, timeoutMs, sessionWaitMs: leads ? Math.max(0, timeoutMs - GUEST_RESERVE_MS) : timeoutMs,
18
+ }),
19
+ // Each service has its own host, limits and outages, so either answers when
20
+ // the other fails. Both read the same parcel: neither can place another
21
+ // country's delivery in Germany, nor find a parcel the guest API does not know.
22
+ recovers: error => carrierErrorKind(error) !== 'not_found'
18
23
  && !(error instanceof CarrierError && error.reason === DPD_DE_OTHER_COUNTRY),
19
24
  },
20
25
  });
21
26
  return {
22
- id: 'dpd-de', recordsSteps: true, steps: ['direct', 'app'],
27
+ id: 'dpd-de', recordsSteps: true, steps: ['app', 'direct'],
23
28
  track: (input, context) => tracker.fetch(input.number, input.postcode ?? '', context),
24
29
  };
25
30
  };
@@ -4,6 +4,8 @@ import { type XmlNode } from '../../core/transport/xml.js';
4
4
  export declare const DPD_DE_APP_API = "https://api.paketnavigator.de/services/v1/Navigator3Service.asmx";
5
5
  /** Activity the service places outside Germany; the app tier would read the same parcel. */
6
6
  export declare const DPD_DE_OTHER_COUNTRY = "other_country";
7
+ /** The lookup stopped waiting for the session, which keeps opening for the next one. */
8
+ export declare const DPD_DE_SESSION_OPENING = "session_opening";
7
9
  export interface DpdDePartner {
8
10
  name: string;
9
11
  token: string;
@@ -18,6 +20,7 @@ export declare function parseDpdDeApp(data: XmlNode, scans: XmlNode, number: str
18
20
  /**
19
21
  * The German app's SOAP service. Tracking needs an anonymous device session, which the
20
22
  * service takes tens of seconds to open and then accepts for hours: one is kept per client.
23
+ * The opening runs on its own clock, so a lookup that stops waiting leaves it to the next.
21
24
  */
22
25
  export declare class DpdDeAppClient {
23
26
  #private;
@@ -27,11 +30,16 @@ export declare class DpdDeAppClient {
27
30
  userAgent?: string;
28
31
  now?: () => number;
29
32
  });
33
+ /** `sessionWaitMs` bounds the wait for a session still opening; the whole `timeoutMs` by default. */
30
34
  track(number: string, options: {
31
35
  signal: AbortSignal;
32
36
  timeoutMs: number;
37
+ sessionWaitMs?: number;
33
38
  }): Promise<CarrierResult>;
34
- /** One session for every lookup; the lookup that opens it lends its signal, the others only wait. */
39
+ /**
40
+ * One session for every lookup. The opening runs while a lookup that asked for it is still
41
+ * running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
42
+ */
35
43
  private session;
36
44
  private call;
37
45
  }
@@ -6,9 +6,13 @@ import { xmlDocument } from '../../core/transport/xml.js';
6
6
  export const DPD_DE_APP_API = 'https://api.paketnavigator.de/services/v1/Navigator3Service.asmx';
7
7
  /** Activity the service places outside Germany; the app tier would read the same parcel. */
8
8
  export const DPD_DE_OTHER_COUNTRY = 'other_country';
9
+ /** The lookup stopped waiting for the session, which keeps opening for the next one. */
10
+ export const DPD_DE_SESSION_OPENING = 'session_opening';
9
11
  const SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
10
12
  const SERVICE = 'https://cloud.dpd.com/';
11
13
  const MAX_BYTES = 2_000_000;
14
+ /** Opening a session has taken up to 42 seconds. */
15
+ const SESSION_OPEN_MS = 75_000;
12
16
  // Shared partner credentials of the public app, distributed with the maintainer's approval.
13
17
  const PARTNER = { name: 'Android Paketnavigator3', token: 'A33363237662F5945576', password: '272 WetFd2mpXrgD' };
14
18
  /** The app's progress rail: one coarse state for the whole parcel. */
@@ -120,6 +124,7 @@ export function parseDpdDeApp(data, scans, number) {
120
124
  /**
121
125
  * The German app's SOAP service. Tracking needs an anonymous device session, which the
122
126
  * service takes tens of seconds to open and then accepts for hours: one is kept per client.
127
+ * The opening runs on its own clock, so a lookup that stops waiting leaves it to the next.
123
128
  */
124
129
  export class DpdDeAppClient {
125
130
  #partner;
@@ -135,6 +140,7 @@ export class DpdDeAppClient {
135
140
  this.#userAgent = userAgentOf(options.userAgent);
136
141
  this.#now = options.now ?? Date.now;
137
142
  }
143
+ /** `sessionWaitMs` bounds the wait for a session still opening; the whole `timeoutMs` by default. */
138
144
  async track(number, options) {
139
145
  if (!/^\d{14}$/.test(number))
140
146
  throw new TypeError('DPD Germany app tracking takes 14 digits');
@@ -142,7 +148,7 @@ export class DpdDeAppClient {
142
148
  const left = () => Math.max(1, Math.floor(deadline - performance.now()));
143
149
  try {
144
150
  for (let attempt = 0;; attempt += 1) {
145
- const session = await this.session(options.signal, left());
151
+ const session = await this.session(options.signal, Math.min(left(), options.sessionWaitMs ?? Infinity));
146
152
  try {
147
153
  const data = await this.call('getTrackingData', { SessionToken: session, ParcelNo: number, DeliveryZipCode: '',
148
154
  UpdateNewDeliveryData: 'false', addParcelIfNoTrackingdataAvailable: 'false', ParcelFlowTypeID: 'receiving' }, options.signal, left());
@@ -168,32 +174,60 @@ export class DpdDeAppClient {
168
174
  throw new TransportError('DPD Germany', 'DPD Germany app request failed');
169
175
  }
170
176
  }
171
- /** One session for every lookup; the lookup that opens it lends its signal, the others only wait. */
172
- async session(signal, timeoutMs) {
177
+ /**
178
+ * One session for every lookup. The opening runs while a lookup that asked for it is still
179
+ * running, even one that stopped waiting; each lookup waits until its signal or `waitMs` ends.
180
+ */
181
+ async session(signal, waitMs) {
173
182
  if (this.#session)
174
183
  return this.#session;
184
+ signal.throwIfAborted();
175
185
  if (!this.#opening) {
176
- const opening = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
186
+ const controller = new AbortController();
187
+ const holders = new Set();
188
+ const token = this.call('getSessionFullState', { SessionToken: '', DeviceData: {
177
189
  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))
190
+ } }, controller.signal, SESSION_OPEN_MS).then(result => {
191
+ const value = scalar(one(result, 'SessionFullState'), 'SessionToken', 600);
192
+ if (!/^[A-Za-z0-9+/=]{16,512}$/.test(value))
181
193
  invalid();
182
- return this.#session = token;
194
+ return this.#session = value;
183
195
  });
196
+ const release = () => {
197
+ for (const holder of holders)
198
+ if (holder.aborted)
199
+ holders.delete(holder);
200
+ if (!holders.size)
201
+ controller.abort(new Error('No lookup is waiting for the DPD Germany session'));
202
+ };
203
+ const opening = { token, holders, release };
184
204
  this.#opening = opening;
185
- void opening.catch(() => undefined).finally(() => { if (this.#opening === opening)
186
- this.#opening = null; });
205
+ void token.catch(() => undefined).finally(() => {
206
+ if (this.#opening === opening)
207
+ this.#opening = null;
208
+ for (const holder of holders)
209
+ holder.removeEventListener('abort', release);
210
+ });
211
+ }
212
+ const { token, holders, release } = this.#opening;
213
+ if (!holders.has(signal)) {
214
+ holders.add(signal);
215
+ signal.addEventListener('abort', release, { once: true });
187
216
  }
188
- const shared = this.#opening;
189
- signal.throwIfAborted();
190
217
  let leave;
191
- const left = new Promise((_resolve, reject) => { leave = () => reject(signal.reason); });
218
+ let timer;
219
+ const left = new Promise((_resolve, reject) => {
220
+ leave = () => reject(signal.reason);
221
+ timer = setTimeout(() => reject(new IndeterminateError('DPD Germany', 'DPD Germany app session is still opening', {
222
+ reason: DPD_DE_SESSION_OPENING,
223
+ })), Math.max(0, Math.min(waitMs, SESSION_OPEN_MS)));
224
+ });
192
225
  signal.addEventListener('abort', leave, { once: true });
193
226
  try {
194
- return await Promise.race([shared, left]);
227
+ return await Promise.race([token, left]);
195
228
  }
196
229
  finally {
230
+ clearTimeout(timer);
197
231
  signal.removeEventListener('abort', leave);
198
232
  }
199
233
  }
@@ -18,8 +18,8 @@
18
18
  "mode": "automatic",
19
19
  "adapter": "dpd-de",
20
20
  "steps": [
21
- "direct",
22
- "app"
21
+ "app",
22
+ "direct"
23
23
  ],
24
24
  "requirements": [
25
25
  {
@@ -2,22 +2,31 @@ import type { AdapterFactory, TrackingContext } from '../../core/adapter/index.j
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
3
  import { type StepRecorder } from '../../core/telemetry/index.js';
4
4
  import { TrawlClient } from '../../core/transport/index.js';
5
+ import { MondialRelayAppClient } from './app.js';
5
6
  interface MondialRelayCredential {
6
7
  shipment: string;
7
8
  postcode: string;
8
9
  canonicalShipment?: string;
10
+ /** Read from a checksummed label barcode, which needs no postcode. */
11
+ barcode?: true;
9
12
  }
10
13
  export declare function normalizeMondialRelayCredential(rawShipment: string, rawPostcode?: string): MondialRelayCredential;
11
14
  export declare function mondialRelayTrackingUrl(rawShipment: string, rawPostcode?: string): string;
12
15
  export declare function parseMondialRelayTrackingResponse(payload: unknown, rawShipment: string, rawPostcode?: string): CarrierResult;
13
16
  export interface MondialRelayTrackerOptions {
14
17
  timeoutMs?: number;
18
+ /** How long the app step may take before the website is tried. */
15
19
  directTimeoutMs?: number;
16
20
  /** Legacy configuration seam; `trawl` is preferred. */
17
21
  trawlUrl?: string;
18
22
  /** The browser service, or null when none is configured. */
19
23
  trawl?: TrawlClient | null;
24
+ /** An InPost account's refresh token for the app step; without one the website answers alone. */
25
+ appRefreshToken?: string;
26
+ /** Test seam for the app step; built from `appRefreshToken` when absent. */
27
+ app?: MondialRelayAppClient | null;
20
28
  fetcher?: typeof fetch;
29
+ userAgent?: string;
21
30
  recorder?: StepRecorder;
22
31
  }
23
32
  export declare class MondialRelayTracker {
@@ -7,6 +7,7 @@ import { NOOP_RECORDER } from '../../core/telemetry/index.js';
7
7
  import { isoTime, zonedTime } from '../../core/time/index.js';
8
8
  import { cleanScalar, TRAWL_TRANSPORT_ALLOWANCE_MS, trawlBody, TrawlClient } from '../../core/transport/index.js';
9
9
  import { isRecord } from '../../core/types.js';
10
+ import { MondialRelayAppClient } from './app.js';
10
11
  import { classifyStatus, milestoneNumberStatus } from './status.js';
11
12
  // Protocol provenance (inspected 2026-08-30):
12
13
  // https://www.mondialrelay.fr/versioned-assets/2nMAiuVI9Rv9J3kZacblPYCfCABwzS-qZ3m7eFBQn4A/Scripts/vue/tracking/js/app.js
@@ -20,7 +21,8 @@ const TRACKING_API = 'https://www.mondialrelay.fr/api/tracking';
20
21
  const MAX_DIRECT_BYTES = 2_000_000;
21
22
  const MAX_TRAWL_BYTES = 10_000_000;
22
23
  const DEFAULT_TIMEOUT_MS = 90_000;
23
- const DEFAULT_DIRECT_TIMEOUT_MS = 20_000;
24
+ /** The app answers in about a second; past this the website gets the rest of the budget. */
25
+ const DEFAULT_DIRECT_TIMEOUT_MS = 10_000;
24
26
  const ZONE = 'Europe/Paris';
25
27
  const CREDENTIAL_MESSAGE = 'Mondial Relay tracking requires an 8-, 10-, or 12-digit shipment number followed by '
26
28
  + 'the 5-digit recipient postcode';
@@ -42,7 +44,7 @@ export function normalizeMondialRelayCredential(rawShipment, rawPostcode = '') {
42
44
  throw new InvalidInputError('Mondial Relay', 'Invalid Mondial Relay barcode or postcode');
43
45
  }
44
46
  // The public alias carries brand/shipment/parcel sequence, not a postcode.
45
- return { shipment: shipment.slice(0, 12), postcode, canonicalShipment: shipment.slice(2, 10) };
47
+ return { shipment: shipment.slice(0, 12), postcode, canonicalShipment: shipment.slice(2, 10), barcode: true };
46
48
  }
47
49
  if (!postcode && /^(?:\d{13}|\d{15}|\d{17})$/.test(shipment)) {
48
50
  postcode = shipment.slice(-5);
@@ -264,6 +266,7 @@ export class MondialRelayTracker {
264
266
  directTimeoutMs;
265
267
  trawlUrl;
266
268
  #trawl;
269
+ #app;
267
270
  #fetcher;
268
271
  #recorder;
269
272
  #serialize = singleFlight();
@@ -272,6 +275,9 @@ export class MondialRelayTracker {
272
275
  this.directTimeoutMs = Math.max(1_000, Math.min(this.timeoutMs, options.directTimeoutMs ?? DEFAULT_DIRECT_TIMEOUT_MS));
273
276
  this.trawlUrl = (options.trawlUrl ?? '').trim();
274
277
  this.#trawl = options.trawl;
278
+ this.#app = options.app !== undefined ? options.app : options.appRefreshToken?.trim()
279
+ ? new MondialRelayAppClient({ refreshToken: options.appRefreshToken, fetcher: options.fetcher, userAgent: options.userAgent })
280
+ : null;
275
281
  this.#fetcher = options.fetcher;
276
282
  this.#recorder = options.recorder ?? NOOP_RECORDER;
277
283
  if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) {
@@ -286,16 +292,16 @@ export class MondialRelayTracker {
286
292
  }
287
293
  async fetch(rawShipment, rawPostcode = '', context = {}) {
288
294
  // One lookup at a time: the page token and the API call have to stay on the
289
- // same solved browser identity.
295
+ // same solved browser identity, and the app's access token is renewed once.
290
296
  return takeTurn(this.#serialize, 'Mondial Relay', context, (left) => this.#lookup(normalizeMondialRelayCredential(rawShipment, rawPostcode), left));
291
297
  }
292
298
  async #lookup(credential, context) {
293
- // Cloudflare blocks every non-browser client with an HTTP 403 WAF block
294
- // (verified from multiple networks, 2026-09-10), so a direct attempt only
295
- // burns time and reports a fallback on each sync. There is one step, and
296
- // it is the browser.
299
+ // Cloudflare blocks every non-browser client of the website with an HTTP
300
+ // 403 WAF block (verified from multiple networks, 2026-09-10). The app's
301
+ // backend answers plain HTTP, but only for a signed-in account.
302
+ const app = this.#app;
297
303
  const trawl = this.#browserService();
298
- if (!trawl) {
304
+ if (!app && !trawl) {
299
305
  throw new ChallengeError('Mondial Relay', 'Mondial Relay challenged direct tracking; configure FLARESOLVERR_URL for browser fallback');
300
306
  }
301
307
  return runSteps({
@@ -304,8 +310,21 @@ export class MondialRelayTracker {
304
310
  carrier: 'mondial-relay', budgetMs: context.budgetMs ?? this.timeoutMs + TRAWL_TRANSPORT_ALLOWANCE_MS, signal: context.signal,
305
311
  recorder: this.#recorder,
306
312
  }, [
313
+ {
314
+ id: 'app',
315
+ enabled: app !== null,
316
+ run: async ({ remainingMs, signal }) => {
317
+ // The app filters on the recipient postcode only; a label barcode is its own proof.
318
+ const query = { shipment: credential.shipment, postcode: credential.barcode ? '' : credential.postcode };
319
+ const result = await app.track(query, { signal, timeoutMs: Math.min(remainingMs, this.directTimeoutMs) });
320
+ return this.#link(result, credential, 'mobile-app-response');
321
+ },
322
+ },
307
323
  {
308
324
  id: 'trawl',
325
+ enabled: trawl !== null,
326
+ // The website is a separate service: no failure of the app settles the lookup.
327
+ recovers: () => true,
309
328
  run: ({ remainingMs, signal }) => this.#trawlResult(trawl, credential, performance.now() + remainingMs, signal),
310
329
  },
311
330
  ]);
@@ -338,7 +357,9 @@ export class MondialRelayTracker {
338
357
  });
339
358
  }
340
359
  #finish(payload, credential, trackingSource) {
341
- const result = parseTrackingResponse(payload, credential);
360
+ return this.#link(parseTrackingResponse(payload, credential), credential, trackingSource);
361
+ }
362
+ #link(result, credential, trackingSource) {
342
363
  const url = new URL(TRACKING_PAGE);
343
364
  url.searchParams.set('numeroExpedition', credential.shipment);
344
365
  result.tracking_url = url.toString();
@@ -349,13 +370,15 @@ export class MondialRelayTracker {
349
370
  export const adapter = (environment) => {
350
371
  const tracker = new MondialRelayTracker({
351
372
  fetcher: environment.fetcher,
373
+ userAgent: environment.userAgent,
352
374
  trawl: environment.trawl,
375
+ appRefreshToken: environment.env.MONDIAL_RELAY_REFRESH_TOKEN,
353
376
  recorder: environment.recorder,
354
377
  });
355
378
  return {
356
379
  id: 'mondial-relay', recordsSteps: true,
357
- // Cloudflare refuses every non-browser client, so there is no direct tier.
358
- steps: ['trawl'],
380
+ // Cloudflare refuses every non-browser client of the website; the app step needs an account.
381
+ steps: ['app', 'trawl'],
359
382
  track: (input, context) => tracker.fetch(input.number, input.postcode ?? '', context),
360
383
  };
361
384
  };
@@ -0,0 +1,42 @@
1
+ import type { CarrierResult } from '../../core/result/index.js';
2
+ export declare const MONDIAL_RELAY_APP_API = "https://mobile-app-bff.mondialrelay.app/api/";
3
+ /**
4
+ * The backend's bot screen refuses Node's built-in fetch, which offers
5
+ * HTTP/2, and answers the same request over plain HTTP/1.1.
6
+ */
7
+ export declare const http1Fetch: typeof fetch;
8
+ /** A normalized credential: 8, 10 or 12 digits, or a barcode's 12-digit alias, and a postcode. */
9
+ export interface MondialRelayAppQuery {
10
+ shipment: string;
11
+ postcode: string;
12
+ }
13
+ /**
14
+ * The app's parcel detail: a headline, milestones and their dated events. It
15
+ * carries no delivery estimate, and its relay, barcode and contact fields are
16
+ * never read.
17
+ */
18
+ export declare function parseMondialRelayApp(payload: unknown, uid: string): CarrierResult;
19
+ /**
20
+ * The consumer app's backend, signed in as one InPost account. Searching
21
+ * needs no link between that account and the parcel. Requests carry the
22
+ * app's shared signature; the account's refresh token renews a two-hour
23
+ * access token, kept per client.
24
+ */
25
+ export declare class MondialRelayAppClient {
26
+ #private;
27
+ constructor(options: {
28
+ refreshToken: string;
29
+ fetcher?: typeof fetch;
30
+ userAgent?: string;
31
+ now?: () => number;
32
+ });
33
+ /**
34
+ * One parcel, found by its shipment and the recipient postcode, or by a
35
+ * barcode's brand and shipment alone. A search that does not single out the
36
+ * parcel is inconclusive: the website also accepts the sender's postcode.
37
+ */
38
+ track(query: MondialRelayAppQuery, options: {
39
+ signal: AbortSignal;
40
+ timeoutMs: number;
41
+ }): Promise<CarrierResult>;
42
+ }
@@ -0,0 +1,259 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { request } from 'node:https';
3
+ import { Readable } from 'node:stream';
4
+ import { DateTime } from 'luxon';
5
+ import { CarrierError, ChallengeError, IndeterminateError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
6
+ import { EXPLICIT_OFFSET_PATTERN } from '../../core/time/index.js';
7
+ import { cleanScalar, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
8
+ import { isRecord } from '../../core/types.js';
9
+ import { classifyStatus, milestoneNumberStatus } from './status.js';
10
+ export const MONDIAL_RELAY_APP_API = 'https://mobile-app-bff.mondialrelay.app/api/';
11
+ const TOKEN_URL = 'https://account.inpost-group.com/oauth2/token';
12
+ const CLIENT_ID = 'mondialrelay-mobile';
13
+ // Shared request-signing secret of the public app, distributed with the maintainer's approval.
14
+ const SIGNING_SECRET = 'VCzt4PzS8ynJE2yy7zNBiQbTE3pkncqEyvrUsCDbXupGn8yqRrPDov2FYiAVfuUx';
15
+ const MAX_BYTES = 2_000_000;
16
+ /** An access token this close to its stated expiry is renewed first. */
17
+ const EXPIRY_MARGIN_MS = 60_000;
18
+ const ZONE = 'Europe/Paris';
19
+ /**
20
+ * The backend's bot screen refuses Node's built-in fetch, which offers
21
+ * HTTP/2, and answers the same request over plain HTTP/1.1.
22
+ */
23
+ export const http1Fetch = (input, init = {}) => new Promise((resolve, reject) => {
24
+ const url = input instanceof Request ? input.url : String(input);
25
+ const sent = init.body ?? undefined;
26
+ if (sent !== undefined && typeof sent !== 'string' && !(sent instanceof URLSearchParams))
27
+ throw new TypeError('Only text bodies are sent');
28
+ const body = sent?.toString();
29
+ const headers = Object.fromEntries(new Headers(init.headers));
30
+ if (body !== undefined)
31
+ headers['content-length'] = String(Buffer.byteLength(body));
32
+ const outgoing = request(url, { method: init.method ?? 'GET', headers, signal: init.signal ?? undefined }, (incoming) => {
33
+ const status = incoming.statusCode ?? 0;
34
+ const replied = new Headers();
35
+ for (let index = 0; index + 1 < incoming.rawHeaders.length; index += 2)
36
+ replied.append(incoming.rawHeaders[index], incoming.rawHeaders[index + 1]);
37
+ try {
38
+ const stream = [204, 205, 304].includes(status) ? null : Readable.toWeb(incoming);
39
+ resolve(new Response(stream, { status, statusText: incoming.statusMessage, headers: replied }));
40
+ }
41
+ catch (error) {
42
+ incoming.destroy();
43
+ reject(error);
44
+ }
45
+ });
46
+ outgoing.on('error', reject);
47
+ outgoing.end(body);
48
+ });
49
+ function invalid(message = 'Mondial Relay app returned invalid tracking data') {
50
+ throw new SchemaError('Mondial Relay', message);
51
+ }
52
+ function json(bytes) {
53
+ try {
54
+ return JSON.parse(decodeText(bytes));
55
+ }
56
+ catch {
57
+ return invalid();
58
+ }
59
+ }
60
+ /** The app sends UTC instants; they are shown on Paris clocks, as the website does. */
61
+ function eventTime(value) {
62
+ const raw = cleanScalar(value, 64);
63
+ if (!EXPLICIT_OFFSET_PATTERN.test(raw))
64
+ return null;
65
+ const parsed = DateTime.fromISO(raw, { setZone: true }).setZone(ZONE);
66
+ const iso = parsed.isValid ? parsed.toISO({ suppressMilliseconds: true }) : null;
67
+ return iso ? { iso, timestamp: parsed.toMillis() } : null;
68
+ }
69
+ /** The highest milestone with a date: its wording, else its position on the app's rail. */
70
+ function milestoneStatus(steps) {
71
+ const reached = steps
72
+ .filter((step) => eventTime(step.date) && Number.isFinite(Number(step.number)))
73
+ .sort((left, right) => Number(right.number) - Number(left.number))[0];
74
+ if (!reached)
75
+ return null;
76
+ const worded = classifyStatus(cleanScalar(reached.status, 200));
77
+ return worded.status !== 'unknown' ? worded : milestoneNumberStatus(Number(reached.number));
78
+ }
79
+ /**
80
+ * The app's parcel detail: a headline, milestones and their dated events. It
81
+ * carries no delivery estimate, and its relay, barcode and contact fields are
82
+ * never read.
83
+ */
84
+ export function parseMondialRelayApp(payload, uid) {
85
+ if (!Array.isArray(payload))
86
+ invalid();
87
+ const parcel = payload.find((entry) => isRecord(entry) && isRecord(entry.expedition)
88
+ && cleanScalar(entry.expedition.shipmentUid, 16) === uid);
89
+ if (!parcel)
90
+ invalid('Mondial Relay returned a different shipment');
91
+ const expedition = parcel.expedition;
92
+ if (!isRecord(parcel.detail) || !Array.isArray(parcel.detail.steps))
93
+ invalid();
94
+ const steps = parcel.detail.steps.filter(isRecord);
95
+ const parsed = [];
96
+ const seen = new Set();
97
+ for (const step of steps) {
98
+ for (const raw of Array.isArray(step.events) ? step.events : []) {
99
+ if (!isRecord(raw))
100
+ continue;
101
+ const time = eventTime(raw.date);
102
+ const description = cleanScalar(raw.label, 500);
103
+ if (!time || !description)
104
+ continue;
105
+ const identity = JSON.stringify([time.iso, description]);
106
+ if (seen.has(identity))
107
+ continue;
108
+ seen.add(identity);
109
+ const classified = classifyStatus(description);
110
+ parsed.push({ event: { time: time.iso, location: '', description, stage: classified.stage }, classified, timestamp: time.timestamp });
111
+ }
112
+ }
113
+ parsed.sort((left, right) => right.timestamp - left.timestamp);
114
+ const events = parsed.slice(0, 100).map(({ event }) => event);
115
+ const hint = cleanScalar(expedition.stepHint, 200);
116
+ const hinted = classifyStatus(hint);
117
+ const current = hinted.status !== 'unknown'
118
+ ? hinted
119
+ : parsed.find(({ classified }) => classified.status !== 'unknown')?.classified ?? milestoneStatus(steps);
120
+ if (!events.length && !current)
121
+ throw new IndeterminateError('Mondial Relay', 'Mondial Relay app returned no shipment activity');
122
+ return {
123
+ status: current?.status ?? 'unknown',
124
+ ...(current ? { current_stage: current.stage } : {}),
125
+ last_status_text: hint || events[0]?.description || 'Tracking information received',
126
+ last_update: events[0]?.time ?? null,
127
+ expected_delivery: null,
128
+ timezone: ZONE,
129
+ events,
130
+ source: 'mondial_relay_app',
131
+ };
132
+ }
133
+ /**
134
+ * The consumer app's backend, signed in as one InPost account. Searching
135
+ * needs no link between that account and the parcel. Requests carry the
136
+ * app's shared signature; the account's refresh token renews a two-hour
137
+ * access token, kept per client.
138
+ */
139
+ export class MondialRelayAppClient {
140
+ #refreshToken;
141
+ #fetcher;
142
+ #userAgent;
143
+ #now;
144
+ #access = null;
145
+ #renewing = null;
146
+ constructor(options) {
147
+ this.#refreshToken = options.refreshToken.trim();
148
+ if (!this.#refreshToken)
149
+ throw new TypeError('Mondial Relay app tracking needs a refresh token');
150
+ this.#fetcher = options.fetcher ?? http1Fetch;
151
+ this.#userAgent = userAgentOf(options.userAgent);
152
+ this.#now = options.now ?? Date.now;
153
+ }
154
+ /**
155
+ * One parcel, found by its shipment and the recipient postcode, or by a
156
+ * barcode's brand and shipment alone. A search that does not single out the
157
+ * parcel is inconclusive: the website also accepts the sender's postcode.
158
+ */
159
+ async track(query, options) {
160
+ const { shipment, postcode } = query;
161
+ if (!/^(?:\d{8}|\d{10}|\d{12})$/.test(shipment))
162
+ throw new TypeError('Mondial Relay app tracking takes 8, 10 or 12 digits');
163
+ const deadline = performance.now() + options.timeoutMs;
164
+ const left = () => Math.max(1, Math.floor(deadline - performance.now()));
165
+ try {
166
+ const search = await this.#get('parcels-search', postcode
167
+ ? { shipmentUid: shipment.length === 8 ? shipment : shipment.slice(2, 10), postcode }
168
+ : { shipmentUid: shipment.slice(0, 10) }, options.signal, left);
169
+ if (!isRecord(search) || !Array.isArray(search.list))
170
+ invalid();
171
+ const matches = search.list.flatMap((entry) => {
172
+ const uid = isRecord(entry) && isRecord(entry.expedition) ? cleanScalar(entry.expedition.shipmentUid, 16) : '';
173
+ // The 10-digit UID is the brand followed by the shipment.
174
+ return /^\d{10}$/.test(uid) && (shipment.length === 8 ? uid.slice(2) === shipment : uid === shipment.slice(0, 10)) ? [uid] : [];
175
+ });
176
+ const uids = [...new Set(matches)];
177
+ if (uids.length !== 1)
178
+ throw new IndeterminateError('Mondial Relay', 'Mondial Relay app could not single out the shipment');
179
+ const detail = await this.#get('parcels-detail', { shipmentUids: uids[0], parcelType: 'received' }, options.signal, left);
180
+ return parseMondialRelayApp(detail, uids[0]);
181
+ }
182
+ catch (error) {
183
+ options.signal.throwIfAborted();
184
+ // Transport diagnostics carry the bearer token and the postcode.
185
+ if (error instanceof CarrierError)
186
+ throw new CarrierError(error.kind, 'Mondial Relay', `Mondial Relay app tracking failed (${error.kind})`, {
187
+ status: error.status, retryAfterMs: error.retryAfterMs, reason: error.reason,
188
+ });
189
+ throw new TransportError('Mondial Relay', 'Mondial Relay app request failed');
190
+ }
191
+ }
192
+ /** A signed request; a rejected access token is renewed once. */
193
+ async #get(path, query, signal, left) {
194
+ const url = new URL(path, MONDIAL_RELAY_APP_API);
195
+ for (const [name, value] of Object.entries(query))
196
+ url.searchParams.set(name, value);
197
+ for (let attempt = 0;; attempt += 1) {
198
+ const token = await this.#accessToken(signal, left);
199
+ const nonce = randomUUID();
200
+ const clock = String(Math.floor(this.#now() / 1000));
201
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
202
+ const { response, bytes } = await fetchBounded(url, { signal, headers: {
203
+ Accept: 'application/json', 'Accept-Language': 'fr-FR', 'User-Agent': this.#userAgent, 'X-OriginApp': 'MR',
204
+ 'X-MR-Param1': nonce, 'X-MR-Param2': clock, 'X-MR-API-KEY': sha256(sha256(SIGNING_SECRET + nonce + clock)),
205
+ Authorization: `Bearer ${token}`,
206
+ } }, { provider: 'Mondial Relay', maxBytes: MAX_BYTES, timeoutMs: left(), fetcher: this.#fetcher, allowHttpStatuses: [401, 403, 404] });
207
+ if (response.status === 401 && !attempt) {
208
+ if (this.#access?.token === token)
209
+ this.#access = null;
210
+ continue;
211
+ }
212
+ if ([401, 403].includes(response.status))
213
+ throw new ChallengeError('Mondial Relay', 'Mondial Relay refused the app request');
214
+ // A missing parcel is an empty search; a 404 is a moved route.
215
+ if (response.status === 404)
216
+ throw new TransportError('Mondial Relay', 'Mondial Relay app service is unavailable', { status: 404 });
217
+ if (response.status !== 200)
218
+ throw new UpstreamHttpError('Mondial Relay', response.status);
219
+ return json(bytes);
220
+ }
221
+ }
222
+ /** The current access token, renewed by one request at a time. */
223
+ async #accessToken(signal, left) {
224
+ if (this.#access && this.#access.expiresAt - EXPIRY_MARGIN_MS > this.#now())
225
+ return this.#access.token;
226
+ signal.throwIfAborted();
227
+ this.#renewing ??= this.#renew(left()).finally(() => { this.#renewing = null; });
228
+ const renewing = this.#renewing;
229
+ let leave;
230
+ const aborted = new Promise((_resolve, reject) => {
231
+ leave = () => reject(signal.reason);
232
+ signal.addEventListener('abort', leave, { once: true });
233
+ });
234
+ try {
235
+ return await Promise.race([renewing, aborted]);
236
+ }
237
+ finally {
238
+ signal.removeEventListener('abort', leave);
239
+ }
240
+ }
241
+ async #renew(timeoutMs) {
242
+ const body = new URLSearchParams({ client_id: CLIENT_ID, grant_type: 'refresh_token', refresh_token: this.#refreshToken });
243
+ const { response, bytes } = await fetchBounded(TOKEN_URL, { method: 'POST', body, headers: {
244
+ Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': this.#userAgent,
245
+ } }, { provider: 'Mondial Relay', maxBytes: 100_000, timeoutMs, fetcher: this.#fetcher, allowHttpStatuses: [400, 401] });
246
+ // An expired or revoked sign-in needs a new one; the website still answers meanwhile.
247
+ if (response.status !== 200)
248
+ throw new ChallengeError('Mondial Relay', 'Mondial Relay refused the account token');
249
+ const reply = json(bytes);
250
+ if (!isRecord(reply))
251
+ invalid();
252
+ const token = cleanScalar(reply.access_token, 8_192);
253
+ const lifetime = Number(reply.expires_in);
254
+ if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || !Number.isFinite(lifetime) || lifetime <= 0)
255
+ invalid();
256
+ this.#access = { token, expiresAt: this.#now() + lifetime * 1000 };
257
+ return token;
258
+ }
259
+ }
@@ -35,6 +35,7 @@
35
35
  }
36
36
  ],
37
37
  "steps": [
38
+ "app",
38
39
  "trawl"
39
40
  ]
40
41
  },
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * PostLogistics tracking.
3
3
  *
4
- * The public tracker at `tracking.postlogistics.ch` posts the identifier to
5
- * `eosapi.postlogistics.ch/api/trackandtrace/public`. The response says what
6
- * the identifier was:
4
+ * PostLogistics now trades as Swiss Post Cargo, and its tracker moved to
5
+ * `apv.swisspost-cargo.com`, which posts the identifier to
6
+ * `eosapi.swisspost-cargo.com/api/trackandtrace/public`. The former
7
+ * `postlogistics.ch` hosts answer 404. The response says what the identifier
8
+ * was:
7
9
  *
8
10
  * - `Type` 1: a barcode. The answer may contain several shipments, so only the
9
11
  * one whose `Identifier` equals the requested barcode is read.
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * PostLogistics tracking.
3
3
  *
4
- * The public tracker at `tracking.postlogistics.ch` posts the identifier to
5
- * `eosapi.postlogistics.ch/api/trackandtrace/public`. The response says what
6
- * the identifier was:
4
+ * PostLogistics now trades as Swiss Post Cargo, and its tracker moved to
5
+ * `apv.swisspost-cargo.com`, which posts the identifier to
6
+ * `eosapi.swisspost-cargo.com/api/trackandtrace/public`. The former
7
+ * `postlogistics.ch` hosts answer 404. The response says what the identifier
8
+ * was:
7
9
  *
8
10
  * - `Type` 1: a barcode. The answer may contain several shipments, so only the
9
11
  * one whose `Identifier` equals the requested barcode is read.
@@ -28,7 +30,7 @@ import { postlogisticsIdentifier } from './number.js';
28
30
  import { POSTLOGISTICS_IMAGE_CODE, postlogisticsStage, postlogisticsStatus } from './status.js';
29
31
  const PROVIDER = 'PostLogistics';
30
32
  const UPSTREAM = 'PostLogistics tracking';
31
- const TRACK_URL = 'https://eosapi.postlogistics.ch/api/trackandtrace/public?culture=fr-FR';
33
+ const TRACK_URL = 'https://eosapi.swisspost-cargo.com/api/trackandtrace/public?culture=fr-FR';
32
34
  const DEFAULT_TIMEOUT_MS = 15_000;
33
35
  const BASE_HEADERS = {
34
36
  Accept: 'application/json, text/plain, */*',
@@ -146,8 +148,8 @@ export class PostlogisticsTracker {
146
148
  ...BASE_HEADERS,
147
149
  'User-Agent': this.userAgent,
148
150
  'Content-Type': 'application/json',
149
- Origin: 'https://tracking.postlogistics.ch',
150
- Referer: 'https://tracking.postlogistics.ch/',
151
+ Origin: 'https://apv.swisspost-cargo.com',
152
+ Referer: 'https://apv.swisspost-cargo.com/',
151
153
  },
152
154
  body: JSON.stringify({ Identifier: identifier }),
153
155
  signal: budget.signal,
@@ -28,8 +28,8 @@
28
28
  "eta"
29
29
  ],
30
30
  "portal": {
31
- "url": "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}",
32
- "canaryUrl": "https://tracking.postlogistics.ch/public/trackandtrace",
31
+ "url": "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}",
32
+ "canaryUrl": "https://apv.swisspost-cargo.com/public/trackandtrace",
33
33
  "shows": [
34
34
  "status",
35
35
  "history",
@@ -354,8 +354,8 @@
354
354
  "adapter": "postlogistics",
355
355
  "recognitionRank": 26
356
356
  },
357
- "canaryUrl": "https://tracking.postlogistics.ch/public/trackandtrace",
358
- "trackingUrlTemplate": "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}",
357
+ "canaryUrl": "https://apv.swisspost-cargo.com/public/trackandtrace",
358
+ "trackingUrlTemplate": "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}",
359
359
  "linkRules": [
360
360
  {
361
361
  "domains": [
@@ -236,8 +236,8 @@ export declare const CARRIER_CATALOG: {
236
236
  readonly adapter: "postlogistics";
237
237
  readonly recognitionRank: 26;
238
238
  };
239
- readonly canaryUrl: "https://tracking.postlogistics.ch/public/trackandtrace";
240
- readonly trackingUrlTemplate: "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}";
239
+ readonly canaryUrl: "https://apv.swisspost-cargo.com/public/trackandtrace";
240
+ readonly trackingUrlTemplate: "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}";
241
241
  readonly linkRules: readonly [{
242
242
  readonly domains: readonly ["tracking.postlogistics.ch"];
243
243
  readonly path: "^/public/trackandtrace/([^/?#]+)/?$";
@@ -355,8 +355,8 @@ export const CARRIER_CATALOG = {
355
355
  "adapter": "postlogistics",
356
356
  "recognitionRank": 26
357
357
  },
358
- "canaryUrl": "https://tracking.postlogistics.ch/public/trackandtrace",
359
- "trackingUrlTemplate": "https://tracking.postlogistics.ch/public/trackandtrace/{trackingNumber}",
358
+ "canaryUrl": "https://apv.swisspost-cargo.com/public/trackandtrace",
359
+ "trackingUrlTemplate": "https://apv.swisspost-cargo.com/public/trackandtrace/{trackingNumber}",
360
360
  "linkRules": [
361
361
  {
362
362
  "domains": [
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.34.0"
5
+ "version": "0.35.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.34.0-main.379",
3
+ "version": "0.35.0-main.384",
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",