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.
- package/dist/carriers/mondial-relay/adapter.d.ts +9 -0
- package/dist/carriers/mondial-relay/adapter.js +34 -11
- package/dist/carriers/mondial-relay/app.d.ts +42 -0
- package/dist/carriers/mondial-relay/app.js +259 -0
- package/dist/carriers/mondial-relay/carrier.json +1 -0
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
294
|
-
// (verified from multiple networks, 2026-09-10)
|
|
295
|
-
//
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.
|
|
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",
|