universal-parcel-scraper 0.34.1-main.381 → 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.
@@ -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
  },
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.34.1"
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.1-main.381",
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",