universal-parcel-scraper 0.13.1 → 0.14.0-main.340
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/data/catalog.json +1 -1
- package/data/coverage-summary.json +2 -2
- package/dist/browser/scraper.js +4 -4
- package/dist/carriers/royal-mail/adapter.d.ts +3 -12
- package/dist/carriers/royal-mail/adapter.js +25 -155
- package/dist/carriers/royal-mail/browser.d.ts +5 -0
- package/dist/carriers/royal-mail/browser.js +198 -0
- package/dist/carriers/royal-mail/carrier.json +10 -3
- package/dist/carriers/royal-mail/parser.d.ts +13 -0
- package/dist/carriers/royal-mail/parser.js +140 -0
- package/dist/data/catalog.json +1 -1
- package/dist/generated/catalog.d.ts +1 -1
- package/dist/generated/catalog.js +1 -1
- package/dist/generated/registry.js +1 -1
- package/dist/providers/coverage.json +3 -3
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **99 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>119 catalog entries · 58 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
|
@@ -33,9 +33,9 @@ its own as a command, a Node library or an HTTP server.
|
|
|
33
33
|
## Benchmark
|
|
34
34
|
|
|
35
35
|
<!-- GENERATED:coverage -->
|
|
36
|
-
In a curated benchmark of 113 carriers, dedicated adapters return tracking history for **
|
|
36
|
+
In a curated benchmark of 113 carriers, dedicated adapters return tracking history for **72**, rising to **84** with all fallbacks enabled. The best single aggregator returns 53.
|
|
37
37
|
|
|
38
|
-
<img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled 84, This project, dedicated adapters alone
|
|
38
|
+
<img src="docs/assets/coverage.svg" alt="Carriers with tracking history: This project, all fallbacks enabled 84, This project, dedicated adapters alone 72, ParcelsApp 53, Postal Ninja 43, 17TRACK 40, Ship24 42, UPU 12." width="760">
|
|
39
39
|
<!-- /GENERATED:coverage -->
|
|
40
40
|
|
|
41
41
|
[Method and results per carrier](providers/COVERAGE.md)
|
package/data/catalog.json
CHANGED
|
@@ -1741,7 +1741,7 @@
|
|
|
1741
1741
|
"timezone": "UTC",
|
|
1742
1742
|
"tracking": {
|
|
1743
1743
|
"mode": "automatic",
|
|
1744
|
-
"adapter": "
|
|
1744
|
+
"adapter": "royal-mail"
|
|
1745
1745
|
},
|
|
1746
1746
|
"canaryUrl": "https://www.royalmail.com/track-your-item",
|
|
1747
1747
|
"trackingUrlTemplate": "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
|
package/dist/browser/scraper.js
CHANGED
|
@@ -1407,7 +1407,7 @@ var ce = {
|
|
|
1407
1407
|
timezone: "UTC",
|
|
1408
1408
|
tracking: {
|
|
1409
1409
|
mode: "automatic",
|
|
1410
|
-
adapter: "
|
|
1410
|
+
adapter: "royal-mail"
|
|
1411
1411
|
},
|
|
1412
1412
|
canaryUrl: "https://www.royalmail.com/track-your-item",
|
|
1413
1413
|
trackingUrlTemplate: "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
|
|
@@ -8281,8 +8281,8 @@ var gs = { carriers: [
|
|
|
8281
8281
|
},
|
|
8282
8282
|
{
|
|
8283
8283
|
carrier: "royal-mail",
|
|
8284
|
-
direct: "
|
|
8285
|
-
sample: "
|
|
8284
|
+
direct: "Yes (local Chromium)",
|
|
8285
|
+
sample: "✓ 8",
|
|
8286
8286
|
references: [{ results: {
|
|
8287
8287
|
Ship24: "no_history",
|
|
8288
8288
|
ParcelsApp: 1,
|
|
@@ -8290,7 +8290,7 @@ var gs = { carriers: [
|
|
|
8290
8290
|
"Postal Ninja": 4,
|
|
8291
8291
|
UPU: "no_history"
|
|
8292
8292
|
} }, {
|
|
8293
|
-
note: "
|
|
8293
|
+
note: "Public references returned eight and six identity-bound history scans in fresh automated Chromium on both local and Linux server hosts: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/ and https://www.reddit.com/r/Sugargoo/comments/1we8q17/where_is_my_parcel/. The official documentation example returned E1142, which remains inconclusive. Anonymous requests can still stall before the API request or be reset; browser and runtime compatibility matter. TRAWL deployment compatibility is unverified.",
|
|
8294
8294
|
results: {}
|
|
8295
8295
|
}]
|
|
8296
8296
|
},
|
|
@@ -2,19 +2,10 @@ 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
|
-
export
|
|
6
|
-
export declare function royalMailTrackingUrl(trackingNumber: string): string;
|
|
7
|
-
export declare function royalMailSummaryApiUrl(trackingNumber: string): string;
|
|
8
|
-
export declare function royalMailEventsApiUrl(trackingNumber: string): string;
|
|
9
|
-
/** Only the field vocabulary consumed by the public tracking application. */
|
|
10
|
-
export declare function parseRoyalMailTrackingResponse(payload: unknown, trackingNumber: string): CarrierResult;
|
|
11
|
-
/**
|
|
12
|
-
* The page the browser rendered, read only to tell a bot challenge from an
|
|
13
|
-
* inconclusive load. Rendered text never decides not-found: only the
|
|
14
|
-
* structured reply does.
|
|
15
|
-
*/
|
|
16
|
-
export declare function parseRoyalMailTrackingHtml(page: string): 'challenged' | 'inconclusive';
|
|
5
|
+
export { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
|
|
17
6
|
export interface RoyalMailTrackerOptions {
|
|
7
|
+
/** Fresh local Chromium; preferred when supplied. */
|
|
8
|
+
executablePath?: string | null;
|
|
18
9
|
timeoutMs?: number;
|
|
19
10
|
/** Legacy configuration seam; `trawl` is preferred. */
|
|
20
11
|
trawlUrl?: string;
|
|
@@ -1,151 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { load } from 'cheerio';
|
|
3
|
-
import { ChallengeError, IndeterminateError, InvalidInputError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
1
|
+
import { ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
4
2
|
import { runSteps } from '../../core/runner/index.js';
|
|
5
3
|
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
-
import {
|
|
7
|
-
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
4
|
+
import { cleanScalar, TRAWL_TRANSPORT_ALLOWANCE_MS, TrawlClient } from '../../core/transport/index.js';
|
|
8
5
|
import { isRecord } from '../../core/types.js';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const SUMMARY_API_PREFIX = 'https://api-web.royalmail.com/mailpieces/microsummary/v1/summary/';
|
|
13
|
-
const EVENTS_API_PREFIX = 'https://api-web.royalmail.com/mailpieces/v3/';
|
|
6
|
+
import { fetchRoyalMailInBrowser } from './browser.js';
|
|
7
|
+
import { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
|
|
8
|
+
export { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
|
|
14
9
|
const MAX_BYTES = 10_000_000;
|
|
15
10
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
16
|
-
// How long the browser may wait for the page's own summary call after the page settles.
|
|
17
11
|
const SETTLE_TIMEOUT_MS = 15_000;
|
|
18
|
-
// Captured replies beyond this many are noise, never the answer.
|
|
19
12
|
const MAX_CAPTURED = 20;
|
|
20
|
-
const MAX_EVENTS_TO_INSPECT = 500;
|
|
21
|
-
const MAX_EVENTS_TO_RETURN = 100;
|
|
22
|
-
export function normalizeRoyalMailNumber(raw) {
|
|
23
|
-
const value = raw.toLocaleUpperCase('en-US').replace(/[\s.-]/g, '');
|
|
24
|
-
if (!/^[A-Z]{2}\d{9}GB$/.test(value)) {
|
|
25
|
-
throw new InvalidInputError('Royal Mail', 'Royal Mail tracking numbers must match the UPU S10 format');
|
|
26
|
-
}
|
|
27
|
-
return value;
|
|
28
|
-
}
|
|
29
|
-
export function royalMailTrackingUrl(trackingNumber) {
|
|
30
|
-
return `${TRACKING_BASE}#/tracking-results/${normalizeRoyalMailNumber(trackingNumber)}`;
|
|
31
|
-
}
|
|
32
|
-
export function royalMailSummaryApiUrl(trackingNumber) {
|
|
33
|
-
return `${SUMMARY_API_PREFIX}${normalizeRoyalMailNumber(trackingNumber)}`;
|
|
34
|
-
}
|
|
35
|
-
export function royalMailEventsApiUrl(trackingNumber) {
|
|
36
|
-
return `${EVENTS_API_PREFIX}${normalizeRoyalMailNumber(trackingNumber)}/events`;
|
|
37
|
-
}
|
|
38
|
-
/** Preserve offset-free wall time instead of assigning a zone to overseas scans. */
|
|
39
|
-
function eventTime(value) {
|
|
40
|
-
const raw = cleanScalar(value, 64);
|
|
41
|
-
// A date or wall clock must never acquire the carrier's default timezone.
|
|
42
|
-
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):?[0-5]\d|14:?00))$/i.test(raw))
|
|
43
|
-
return null;
|
|
44
|
-
return explicitOffsetTime(raw)?.iso ?? null;
|
|
45
|
-
}
|
|
46
|
-
/** A delivery estimate reduced to its calendar day. */
|
|
47
|
-
function expectedDelivery(value) {
|
|
48
|
-
const raw = cleanScalar(value, 64);
|
|
49
|
-
const day = /^\d{4}-\d{2}-\d{2}/.exec(raw)?.[0];
|
|
50
|
-
return day && DateTime.fromISO(day).isValid ? day : null;
|
|
51
|
-
}
|
|
52
|
-
/** Only the field vocabulary consumed by the public tracking application. */
|
|
53
|
-
export function parseRoyalMailTrackingResponse(payload, trackingNumber) {
|
|
54
|
-
const number = normalizeRoyalMailNumber(trackingNumber);
|
|
55
|
-
if (!isRecord(payload))
|
|
56
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned an invalid tracking response');
|
|
57
|
-
const errors = Array.isArray(payload.errors) ? payload.errors.filter(isRecord) : [];
|
|
58
|
-
if (errors.some(error => error.errorCode === 'E0015' || error.code === 'E0015')) {
|
|
59
|
-
throw new ChallengeError('Royal Mail', 'Royal Mail denied the tracking session');
|
|
60
|
-
}
|
|
61
|
-
if (String(payload.httpCode) === '429')
|
|
62
|
-
throw new RateLimitedError('Royal Mail');
|
|
63
|
-
if (errors.length)
|
|
64
|
-
throw new IndeterminateError('Royal Mail', 'Royal Mail could not confirm the tracking status');
|
|
65
|
-
const mailpiece = payload.mailPieces;
|
|
66
|
-
// Both summary and events endpoints return one object, not the array used by the
|
|
67
|
-
// recent-items endpoint. Never treat a gateway 404 or schema drift as not-found.
|
|
68
|
-
if (!isRecord(mailpiece) || !isRecord(mailpiece.summary)) {
|
|
69
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned an invalid tracking response');
|
|
70
|
-
}
|
|
71
|
-
if (cleanScalar(mailpiece.mailPieceId).toUpperCase() !== number) {
|
|
72
|
-
throw new SchemaError('Royal Mail', 'Royal Mail did not return the requested parcel');
|
|
73
|
-
}
|
|
74
|
-
const summary = mailpiece.summary;
|
|
75
|
-
const summaryText = cleanScalar(summary.statusDescription);
|
|
76
|
-
if (mailpiece.events !== undefined && !Array.isArray(mailpiece.events)) {
|
|
77
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
78
|
-
}
|
|
79
|
-
const scans = Array.isArray(mailpiece.events) ? mailpiece.events : [];
|
|
80
|
-
const events = [];
|
|
81
|
-
const seen = new Set();
|
|
82
|
-
for (const raw of scans.slice(0, MAX_EVENTS_TO_INSPECT)) {
|
|
83
|
-
if (!isRecord(raw))
|
|
84
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
85
|
-
// The website returns Markdown emphasis, including "**Delivered by**".
|
|
86
|
-
// Normalize it before classification so delivery prose is still redacted.
|
|
87
|
-
const description = cleanScalar(raw.eventName).replace(/\*\*/g, '').trim();
|
|
88
|
-
if (!description)
|
|
89
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
90
|
-
const code = cleanScalar(raw.eventCode, 64);
|
|
91
|
-
const mapped = royalMailEventStage(code);
|
|
92
|
-
const stage = mapped ?? royalMailStage(description) ?? undefined;
|
|
93
|
-
const time = eventTime(raw.eventDateTime);
|
|
94
|
-
const timeText = cleanScalar(raw.eventDateTime, 64);
|
|
95
|
-
const location = cleanScalar(raw.locationName, 250);
|
|
96
|
-
const event = {
|
|
97
|
-
...(time ? { time } : {}),
|
|
98
|
-
...(!time && timeText ? { provider_time_text: timeText } : {}),
|
|
99
|
-
...(location ? { location } : {}),
|
|
100
|
-
description: stage === 'delivered' ? 'Delivered' : description,
|
|
101
|
-
...(stage ? { stage } : {}),
|
|
102
|
-
...(mapped ? { stage_source: 'carrier_map' } : {}),
|
|
103
|
-
...(/^[A-Za-z0-9_-]+$/.test(code) ? { provider_code: code } : {}),
|
|
104
|
-
};
|
|
105
|
-
const identity = JSON.stringify([time ?? timeText, location, event.description]);
|
|
106
|
-
if (seen.has(identity))
|
|
107
|
-
continue;
|
|
108
|
-
seen.add(identity);
|
|
109
|
-
events.push(event);
|
|
110
|
-
}
|
|
111
|
-
// Royal Mail also sorts the history; upstream order is not guaranteed.
|
|
112
|
-
// A partial comparator can move resolved scans across unresolved overseas
|
|
113
|
-
// clocks. Preserve provider order unless every scan denotes an instant.
|
|
114
|
-
if (events.every(event => event.time)) {
|
|
115
|
-
events.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
|
|
116
|
-
}
|
|
117
|
-
const trimmed = events.slice(0, MAX_EVENTS_TO_RETURN);
|
|
118
|
-
const summaryCategory = cleanScalar(summary.statusCategory);
|
|
119
|
-
const statusText = summaryText || summaryCategory || trimmed[0]?.description;
|
|
120
|
-
if (!statusText)
|
|
121
|
-
throw new SchemaError('Royal Mail', 'Royal Mail returned no usable tracking status');
|
|
122
|
-
const stage = royalMailSummaryStage(summaryCategory, statusText) ?? undefined;
|
|
123
|
-
const delivered = stage === 'delivered';
|
|
124
|
-
return {
|
|
125
|
-
status: stage ? statusForStage(stage) : 'unknown',
|
|
126
|
-
...(stage ? { current_stage: stage } : {}),
|
|
127
|
-
last_status_text: delivered ? 'Delivered' : statusText,
|
|
128
|
-
last_update: eventTime(summary.lastEventDateTime) || trimmed[0]?.time || null,
|
|
129
|
-
expected_delivery: delivered || !isRecord(mailpiece.estimatedDelivery)
|
|
130
|
-
? null : expectedDelivery(mailpiece.estimatedDelivery.date),
|
|
131
|
-
events: trimmed,
|
|
132
|
-
...(trimmed.length ? {} : { summary_only: true }),
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* The page the browser rendered, read only to tell a bot challenge from an
|
|
137
|
-
* inconclusive load. Rendered text never decides not-found: only the
|
|
138
|
-
* structured reply does.
|
|
139
|
-
*/
|
|
140
|
-
export function parseRoyalMailTrackingHtml(page) {
|
|
141
|
-
const $ = load(page);
|
|
142
|
-
$('script, style, noscript').remove();
|
|
143
|
-
const visible = clean($('body').text(), 20_000);
|
|
144
|
-
if (/access denied|just a moment|attention required|are you a robot|verify you are human|before you proceed|unusual traffic/i.test(visible)) {
|
|
145
|
-
return 'challenged';
|
|
146
|
-
}
|
|
147
|
-
return 'inconclusive';
|
|
148
|
-
}
|
|
149
13
|
export class RoyalMailTracker {
|
|
150
14
|
timeoutMs;
|
|
151
15
|
trawlUrl;
|
|
@@ -153,6 +17,7 @@ export class RoyalMailTracker {
|
|
|
153
17
|
#fetcher;
|
|
154
18
|
#recorder;
|
|
155
19
|
#fullHistory;
|
|
20
|
+
#executablePath;
|
|
156
21
|
constructor(options = {}) {
|
|
157
22
|
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
|
158
23
|
throw new TypeError('Royal Mail timeout must be positive');
|
|
@@ -163,6 +28,7 @@ export class RoyalMailTracker {
|
|
|
163
28
|
this.#fetcher = options.fetcher;
|
|
164
29
|
this.#recorder = options.recorder ?? NOOP_RECORDER;
|
|
165
30
|
this.#fullHistory = options.fullHistory ?? false;
|
|
31
|
+
this.#executablePath = options.executablePath ?? null;
|
|
166
32
|
}
|
|
167
33
|
/** The injected browser service, or one built from the configured URL. */
|
|
168
34
|
#browserService() {
|
|
@@ -172,8 +38,11 @@ export class RoyalMailTracker {
|
|
|
172
38
|
}
|
|
173
39
|
async fetch(trackingNumber, context = {}) {
|
|
174
40
|
const number = normalizeRoyalMailNumber(trackingNumber);
|
|
175
|
-
|
|
176
|
-
|
|
41
|
+
if (this.#executablePath) {
|
|
42
|
+
return runSteps({ carrier: 'royal-mail', budgetMs: context.budgetMs ?? this.timeoutMs,
|
|
43
|
+
signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath, signal, Math.min(60_000, remainingMs), this.#fullHistory) }]);
|
|
44
|
+
}
|
|
45
|
+
// The legacy service remains available through explicit tracker instances.
|
|
177
46
|
const trawl = this.#browserService();
|
|
178
47
|
if (!trawl) {
|
|
179
48
|
throw new ChallengeError('Royal Mail', 'Royal Mail challenged direct tracking; configure FLARESOLVERR_URL for browser fallback');
|
|
@@ -241,6 +110,8 @@ export class RoyalMailTracker {
|
|
|
241
110
|
throw new IndeterminateError('Royal Mail', 'Royal Mail could not confirm the tracking status');
|
|
242
111
|
}
|
|
243
112
|
}
|
|
113
|
+
if ([404, 410].includes(entry.status))
|
|
114
|
+
throw new TransportError('Royal Mail', 'Royal Mail tracking endpoint is unavailable');
|
|
244
115
|
if (entry.status >= 400)
|
|
245
116
|
throw new UpstreamHttpError('Royal Mail', entry.status);
|
|
246
117
|
if (entry.status !== 200 || entry.truncated || entry.base64Encoded || entry.error || entry.body === null)
|
|
@@ -279,7 +150,7 @@ export class RoyalMailTracker {
|
|
|
279
150
|
const summaryPiece = summaryPayload.mailPieces;
|
|
280
151
|
return this.#structuredResult(number, { mailPieces: {
|
|
281
152
|
mailPieceId: number, summary: summaryPiece.summary,
|
|
282
|
-
estimatedDelivery: summaryPiece.estimatedDelivery, events: historyPiece.events,
|
|
153
|
+
estimatedDelivery: historyPiece.estimatedDelivery ?? summaryPiece.estimatedDelivery, events: historyPiece.events,
|
|
283
154
|
} });
|
|
284
155
|
}
|
|
285
156
|
}
|
|
@@ -295,17 +166,16 @@ export class RoyalMailTracker {
|
|
|
295
166
|
return result;
|
|
296
167
|
}
|
|
297
168
|
}
|
|
298
|
-
export const adapter =
|
|
299
|
-
const tracker = new RoyalMailTracker({
|
|
300
|
-
|
|
301
|
-
trawl: environment.trawl,
|
|
302
|
-
recorder: environment.recorder,
|
|
303
|
-
fullHistory: true,
|
|
304
|
-
});
|
|
169
|
+
export const adapter = environment => {
|
|
170
|
+
const tracker = new RoyalMailTracker({ executablePath: environment.browserExecutablePath,
|
|
171
|
+
recorder: environment.recorder, fullHistory: true });
|
|
305
172
|
return {
|
|
306
|
-
id: 'royal-mail', recordsSteps: true,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
173
|
+
id: 'royal-mail', recordsSteps: true, steps: ['browser'],
|
|
174
|
+
track: (input, context) => {
|
|
175
|
+
normalizeRoyalMailNumber(input.number);
|
|
176
|
+
if (!environment.browserExecutablePath)
|
|
177
|
+
throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH');
|
|
178
|
+
return tracker.fetch(input.number, context);
|
|
179
|
+
},
|
|
310
180
|
};
|
|
311
181
|
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
/** Observe the application's request; its issued session stays in the browser. */
|
|
4
|
+
export declare function captureRoyalMailReply(page: Page, url: string, number: string, phase: 'summary' | 'events', signal: AbortSignal, timeoutMs: number, trigger: () => Promise<void>): Promise<Record<string, unknown>>;
|
|
5
|
+
export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean): Promise<CarrierResult>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
|
|
2
|
+
import { withLocalBrowser } from '../../core/transport/localBrowser.js';
|
|
3
|
+
import { isRecord } from '../../core/types.js';
|
|
4
|
+
import { parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
|
|
5
|
+
const PROVIDER = 'Royal Mail';
|
|
6
|
+
const ORIGIN = 'https://www.royalmail.com';
|
|
7
|
+
const MAX_BYTES = 2_000_000;
|
|
8
|
+
/** Observe the application's request; its issued session stays in the browser. */
|
|
9
|
+
export async function captureRoyalMailReply(page, url, number, phase, signal, timeoutMs, trigger) {
|
|
10
|
+
const deadline = AbortSignal.any([signal, AbortSignal.timeout(Math.max(1, Math.floor(timeoutMs)))]);
|
|
11
|
+
deadline.throwIfAborted();
|
|
12
|
+
let active = true;
|
|
13
|
+
let refresh = false;
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
let count = 0;
|
|
16
|
+
let resolve;
|
|
17
|
+
let reject;
|
|
18
|
+
const reply = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
19
|
+
void reply.catch(() => { });
|
|
20
|
+
const matches = (request) => active && request.url() === url && request.method() === 'GET'
|
|
21
|
+
&& ['xhr', 'fetch'].includes(request.resourceType()) && request.frame() === page.mainFrame();
|
|
22
|
+
const failed = (request) => { if (matches(request))
|
|
23
|
+
reject(new TransportError(PROVIDER, 'Royal Mail tracking request failed')); };
|
|
24
|
+
const aborted = () => reject(signal.aborted ? signal.reason : new TransportError(PROVIDER, 'Royal Mail tracking response timed out'));
|
|
25
|
+
const received = (response) => {
|
|
26
|
+
if (!matches(response.request()))
|
|
27
|
+
return;
|
|
28
|
+
void (async () => {
|
|
29
|
+
if (++count > 3)
|
|
30
|
+
throw new SchemaError(PROVIDER, 'Royal Mail returned too many tracking replies');
|
|
31
|
+
const status = response.status();
|
|
32
|
+
const headers = response.headers();
|
|
33
|
+
if (status === 429) {
|
|
34
|
+
const retry = headers['retry-after'];
|
|
35
|
+
throw new RateLimitedError(PROVIDER, retry && /^\d+$/.test(retry) ? Number(retry) * 1000 : undefined);
|
|
36
|
+
}
|
|
37
|
+
if (status === 403)
|
|
38
|
+
throw new ChallengeError(PROVIDER);
|
|
39
|
+
if (status === 410)
|
|
40
|
+
throw new TransportError(PROVIDER, 'Royal Mail tracking endpoint is unavailable');
|
|
41
|
+
if (![200, 401, 404].includes(status))
|
|
42
|
+
throw new UpstreamHttpError(PROVIDER, status);
|
|
43
|
+
const invalid = () => status === 401 ? new ChallengeError(PROVIDER)
|
|
44
|
+
: status === 404 ? new TransportError(PROVIDER, 'Royal Mail tracking endpoint is unavailable') : new SchemaError(PROVIDER);
|
|
45
|
+
const length = headers['content-length'] === undefined ? 0 : Number(headers['content-length']);
|
|
46
|
+
if (!(headers['content-type'] ?? '').includes('application/json') || !Number.isSafeInteger(length)
|
|
47
|
+
|| length < 0 || length > MAX_BYTES)
|
|
48
|
+
throw invalid();
|
|
49
|
+
// The page already buffers its XHR. Bound both the declared and decoded
|
|
50
|
+
// capture, plus repeated replies, before parsing anything for the result.
|
|
51
|
+
const body = await response.body();
|
|
52
|
+
if (!active)
|
|
53
|
+
return;
|
|
54
|
+
bytes += body.byteLength;
|
|
55
|
+
if (body.byteLength > MAX_BYTES || bytes > 2 * MAX_BYTES)
|
|
56
|
+
throw new SchemaError(PROVIDER);
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(body.toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw invalid();
|
|
63
|
+
}
|
|
64
|
+
if (!isRecord(data))
|
|
65
|
+
throw new SchemaError(PROVIDER);
|
|
66
|
+
const errors = Array.isArray(data.errors) ? data.errors.filter(isRecord) : [];
|
|
67
|
+
const denied = errors.some(error => error.errorCode === 'E0015' || error.code === 'E0015');
|
|
68
|
+
if (status === 401 && denied && !refresh) {
|
|
69
|
+
refresh = true;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (status === 401 || denied)
|
|
73
|
+
throw new ChallengeError(PROVIDER);
|
|
74
|
+
if (errors.some(error => error.errorCode === 'E1142' || error.code === 'E1142')) {
|
|
75
|
+
throw new IndeterminateError(PROVIDER, 'Royal Mail could not confirm the tracking status');
|
|
76
|
+
}
|
|
77
|
+
if (status === 404)
|
|
78
|
+
throw new TransportError(PROVIDER, 'Royal Mail tracking endpoint is unavailable');
|
|
79
|
+
if (status !== 200)
|
|
80
|
+
throw new UpstreamHttpError(PROVIDER, status);
|
|
81
|
+
if (data.errors !== undefined || data.error !== undefined || data.httpCode !== undefined)
|
|
82
|
+
throw new IndeterminateError(PROVIDER);
|
|
83
|
+
const piece = data.mailPieces;
|
|
84
|
+
if (!isRecord(piece) || piece.mailPieceId !== number
|
|
85
|
+
|| (phase === 'summary' ? !isRecord(piece.summary) : !Array.isArray(piece.events) || piece.events.length > 500)) {
|
|
86
|
+
throw new SchemaError(PROVIDER, 'Royal Mail did not return the requested parcel history');
|
|
87
|
+
}
|
|
88
|
+
// Drop proof, recipient and credential fields before merging the replies.
|
|
89
|
+
resolve(phase === 'summary' ? { mailPieceId: number, summary: piece.summary, estimatedDelivery: piece.estimatedDelivery }
|
|
90
|
+
: { mailPieceId: number, events: piece.events,
|
|
91
|
+
...(isRecord(piece.estimatedDelivery) ? { estimatedDelivery: { date: piece.estimatedDelivery.date } } : {}) });
|
|
92
|
+
})().catch(error => { if (active)
|
|
93
|
+
reject(carrierErrorKind(error) ? error : new TransportError(PROVIDER)); });
|
|
94
|
+
};
|
|
95
|
+
page.on('response', received);
|
|
96
|
+
page.on('requestfailed', failed);
|
|
97
|
+
deadline.addEventListener('abort', aborted, { once: true });
|
|
98
|
+
try {
|
|
99
|
+
deadline.throwIfAborted();
|
|
100
|
+
await Promise.race([trigger(), reply.then(() => { })]);
|
|
101
|
+
return await reply;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
active = false;
|
|
105
|
+
page.off('response', received);
|
|
106
|
+
page.off('requestfailed', failed);
|
|
107
|
+
deadline.removeEventListener('abort', aborted);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory) {
|
|
111
|
+
return withLocalBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs,
|
|
112
|
+
args: ['--disable-blink-features=AutomationControlled'] }, async ({ browser, signal: session, remainingMs }) => {
|
|
113
|
+
try {
|
|
114
|
+
const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
|
|
115
|
+
: process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
|
|
116
|
+
const major = browser.version().split('.')[0];
|
|
117
|
+
const context = await browser.newContext({ locale: 'en-GB', acceptDownloads: false, serviceWorkers: 'block',
|
|
118
|
+
userAgent: `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36` });
|
|
119
|
+
session.throwIfAborted();
|
|
120
|
+
const page = await context.newPage();
|
|
121
|
+
const timeout = () => Math.min(15_000, remainingMs());
|
|
122
|
+
const decline = async () => {
|
|
123
|
+
const button = page.getByText('Decline all', { exact: true });
|
|
124
|
+
await button.waitFor({ state: 'visible', timeout: Math.min(3_000, remainingMs()) }).catch(() => { });
|
|
125
|
+
session.throwIfAborted();
|
|
126
|
+
if (await button.isVisible()) {
|
|
127
|
+
// Consent may reload the document and clear a number already typed.
|
|
128
|
+
const reload = page.waitForEvent('domcontentloaded', { timeout: Math.min(4_000, remainingMs()) }).catch(() => { });
|
|
129
|
+
await button.click({ timeout: timeout() });
|
|
130
|
+
await reload;
|
|
131
|
+
session.throwIfAborted();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
// Ordinary navigation lets the public site initialize its anonymous edge
|
|
135
|
+
// session before the tracking application starts.
|
|
136
|
+
const response = await page.goto(`${ORIGIN}/`, { waitUntil: 'domcontentloaded', timeout: timeout() });
|
|
137
|
+
session.throwIfAborted();
|
|
138
|
+
if (!response)
|
|
139
|
+
throw new TransportError(PROVIDER);
|
|
140
|
+
if ([401, 403].includes(response.status()))
|
|
141
|
+
throw new ChallengeError(PROVIDER);
|
|
142
|
+
if ([404, 410].includes(response.status()))
|
|
143
|
+
throw new TransportError(PROVIDER, 'Royal Mail tracking page is unavailable');
|
|
144
|
+
if (response.status() !== 200)
|
|
145
|
+
throw new UpstreamHttpError(PROVIDER, response.status());
|
|
146
|
+
if (new URL(page.url()).origin !== ORIGIN)
|
|
147
|
+
throw new SchemaError(PROVIDER);
|
|
148
|
+
await decline();
|
|
149
|
+
const link = page.locator('a').filter({ hasText: /^\s*Track your item\s*$/i }).first();
|
|
150
|
+
await link.waitFor({ state: 'visible', timeout: timeout() });
|
|
151
|
+
const href = await link.getAttribute('href');
|
|
152
|
+
const target = new URL(href ?? '', ORIGIN);
|
|
153
|
+
if (target.origin !== ORIGIN || target.pathname !== '/track-your-item')
|
|
154
|
+
throw new SchemaError(PROVIDER);
|
|
155
|
+
// The site's fixed navigation can cover this link. Follow its validated
|
|
156
|
+
// public destination with the same browser context instead of waiting on an overlay.
|
|
157
|
+
const trackingPage = await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: timeout() });
|
|
158
|
+
session.throwIfAborted();
|
|
159
|
+
if (!trackingPage)
|
|
160
|
+
throw new TransportError(PROVIDER);
|
|
161
|
+
if ([401, 403].includes(trackingPage.status()))
|
|
162
|
+
throw new ChallengeError(PROVIDER);
|
|
163
|
+
if ([404, 410].includes(trackingPage.status()))
|
|
164
|
+
throw new TransportError(PROVIDER, 'Royal Mail tracking page is unavailable');
|
|
165
|
+
if (trackingPage.status() !== 200)
|
|
166
|
+
throw new UpstreamHttpError(PROVIDER, trackingPage.status());
|
|
167
|
+
const input = page.locator('#barcode-input');
|
|
168
|
+
await input.waitFor({ state: 'visible', timeout: timeout() });
|
|
169
|
+
await decline();
|
|
170
|
+
await input.waitFor({ state: 'visible', timeout: timeout() });
|
|
171
|
+
const landed = new URL(page.url());
|
|
172
|
+
if (landed.origin !== ORIGIN || landed.pathname !== '/track-your-item')
|
|
173
|
+
throw new SchemaError(PROVIDER);
|
|
174
|
+
await input.click({ timeout: timeout() });
|
|
175
|
+
await input.pressSequentially(number, { delay: 80, timeout: timeout() });
|
|
176
|
+
session.throwIfAborted();
|
|
177
|
+
const summary = await captureRoyalMailReply(page, royalMailSummaryApiUrl(number), number, 'summary', session, Math.min(25_000, remainingMs()), async () => {
|
|
178
|
+
if (await input.inputValue() !== number)
|
|
179
|
+
throw new TransportError(PROVIDER, 'Royal Mail tracking form was reset');
|
|
180
|
+
await page.locator('#submit:not(:disabled)').click({ timeout: timeout() });
|
|
181
|
+
});
|
|
182
|
+
// The summary issues an API session. The application's details handler
|
|
183
|
+
// uses it, or performs its own CAPTCHA refresh when the session expires.
|
|
184
|
+
parseRoyalMailTrackingResponse({ mailPieces: summary }, number);
|
|
185
|
+
const history = fullHistory ? await captureRoyalMailReply(page, royalMailEventsApiUrl(number), number, 'events', session, Math.min(25_000, remainingMs()), () => page.locator('#btn-more-details').click({ timeout: timeout() })) : undefined;
|
|
186
|
+
session.throwIfAborted();
|
|
187
|
+
return { ...parseRoyalMailTrackingResponse({ mailPieces: { ...summary, ...(history ?? {}) } }, number),
|
|
188
|
+
tracking_url: royalMailTrackingUrl(number), tracking_source: 'structured-web-response' };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
session.throwIfAborted();
|
|
192
|
+
if (carrierErrorKind(error))
|
|
193
|
+
throw error;
|
|
194
|
+
// Native browser diagnostics can contain request URLs or page contents.
|
|
195
|
+
throw new TransportError(PROVIDER, 'Royal Mail browser tracking failed');
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
"timezone": "UTC",
|
|
15
15
|
"tracking": {
|
|
16
16
|
"mode": "automatic",
|
|
17
|
-
"adapter": "
|
|
18
|
-
"steps": [
|
|
17
|
+
"adapter": "royal-mail",
|
|
18
|
+
"steps": [
|
|
19
|
+
"browser"
|
|
20
|
+
]
|
|
19
21
|
},
|
|
20
22
|
"capabilities": [
|
|
21
23
|
"history",
|
|
@@ -31,7 +33,12 @@
|
|
|
31
33
|
"eta",
|
|
32
34
|
"location"
|
|
33
35
|
],
|
|
34
|
-
"scraped": [
|
|
36
|
+
"scraped": [
|
|
37
|
+
"status",
|
|
38
|
+
"history",
|
|
39
|
+
"eta",
|
|
40
|
+
"location"
|
|
41
|
+
],
|
|
35
42
|
"discarded": [
|
|
36
43
|
"signatory",
|
|
37
44
|
"proof of delivery extras",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare function normalizeRoyalMailNumber(raw: string): string;
|
|
3
|
+
export declare function royalMailTrackingUrl(trackingNumber: string): string;
|
|
4
|
+
export declare function royalMailSummaryApiUrl(trackingNumber: string): string;
|
|
5
|
+
export declare function royalMailEventsApiUrl(trackingNumber: string): string;
|
|
6
|
+
/** Only the field vocabulary consumed by the public tracking application. */
|
|
7
|
+
export declare function parseRoyalMailTrackingResponse(payload: unknown, trackingNumber: string): CarrierResult;
|
|
8
|
+
/**
|
|
9
|
+
* The page the browser rendered, read only to tell a bot challenge from an
|
|
10
|
+
* inconclusive load. Rendered text never decides not-found: only the
|
|
11
|
+
* structured reply does.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseRoyalMailTrackingHtml(page: string): 'challenged' | 'inconclusive';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { load } from 'cheerio';
|
|
3
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, RateLimitedError, SchemaError } from '../../core/errors/index.js';
|
|
4
|
+
import { clean, cleanScalar } from '../../core/transport/index.js';
|
|
5
|
+
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
6
|
+
import { isRecord } from '../../core/types.js';
|
|
7
|
+
import { royalMailEventStage, royalMailStage, royalMailSummaryStage, statusForStage } from './status.js';
|
|
8
|
+
/** Read the response produced by Royal Mail's form and hCaptcha callback. */
|
|
9
|
+
const TRACKING_BASE = 'https://www.royalmail.com/track-your-item';
|
|
10
|
+
const SUMMARY_API_PREFIX = 'https://api-web.royalmail.com/mailpieces/microsummary/v1/summary/';
|
|
11
|
+
const EVENTS_API_PREFIX = 'https://api-web.royalmail.com/mailpieces/v3/';
|
|
12
|
+
const MAX_EVENTS_TO_INSPECT = 500;
|
|
13
|
+
const MAX_EVENTS_TO_RETURN = 100;
|
|
14
|
+
export function normalizeRoyalMailNumber(raw) {
|
|
15
|
+
const value = raw.toLocaleUpperCase('en-US').replace(/[\s.-]/g, '');
|
|
16
|
+
if (!/^[A-Z]{2}\d{9}GB$/.test(value)) {
|
|
17
|
+
throw new InvalidInputError('Royal Mail', 'Royal Mail tracking numbers must match the UPU S10 format');
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
export function royalMailTrackingUrl(trackingNumber) {
|
|
22
|
+
return `${TRACKING_BASE}#/tracking-results/${normalizeRoyalMailNumber(trackingNumber)}`;
|
|
23
|
+
}
|
|
24
|
+
export function royalMailSummaryApiUrl(trackingNumber) {
|
|
25
|
+
return `${SUMMARY_API_PREFIX}${normalizeRoyalMailNumber(trackingNumber)}`;
|
|
26
|
+
}
|
|
27
|
+
export function royalMailEventsApiUrl(trackingNumber) {
|
|
28
|
+
return `${EVENTS_API_PREFIX}${normalizeRoyalMailNumber(trackingNumber)}/events`;
|
|
29
|
+
}
|
|
30
|
+
/** Preserve offset-free wall time instead of assigning a zone to overseas scans. */
|
|
31
|
+
function eventTime(value) {
|
|
32
|
+
const raw = cleanScalar(value, 64);
|
|
33
|
+
// A date or wall clock must never acquire the carrier's default timezone.
|
|
34
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-](?:(?:0\d|1[0-3]):?[0-5]\d|14:?00))$/i.test(raw))
|
|
35
|
+
return null;
|
|
36
|
+
return explicitOffsetTime(raw)?.iso ?? null;
|
|
37
|
+
}
|
|
38
|
+
/** A delivery estimate reduced to its calendar day. */
|
|
39
|
+
function expectedDelivery(value) {
|
|
40
|
+
const raw = cleanScalar(value, 64);
|
|
41
|
+
const day = /^\d{4}-\d{2}-\d{2}/.exec(raw)?.[0];
|
|
42
|
+
return day && DateTime.fromISO(day).isValid ? day : null;
|
|
43
|
+
}
|
|
44
|
+
/** Only the field vocabulary consumed by the public tracking application. */
|
|
45
|
+
export function parseRoyalMailTrackingResponse(payload, trackingNumber) {
|
|
46
|
+
const number = normalizeRoyalMailNumber(trackingNumber);
|
|
47
|
+
if (!isRecord(payload))
|
|
48
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned an invalid tracking response');
|
|
49
|
+
const errors = Array.isArray(payload.errors) ? payload.errors.filter(isRecord) : [];
|
|
50
|
+
if (errors.some(error => error.errorCode === 'E0015' || error.code === 'E0015')) {
|
|
51
|
+
throw new ChallengeError('Royal Mail', 'Royal Mail denied the tracking session');
|
|
52
|
+
}
|
|
53
|
+
if (String(payload.httpCode) === '429')
|
|
54
|
+
throw new RateLimitedError('Royal Mail');
|
|
55
|
+
if (errors.length)
|
|
56
|
+
throw new IndeterminateError('Royal Mail', 'Royal Mail could not confirm the tracking status');
|
|
57
|
+
const mailpiece = payload.mailPieces;
|
|
58
|
+
// Both summary and events endpoints return one object, not the array used by the
|
|
59
|
+
// recent-items endpoint. Never treat a gateway 404 or schema drift as not-found.
|
|
60
|
+
if (!isRecord(mailpiece) || !isRecord(mailpiece.summary)) {
|
|
61
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned an invalid tracking response');
|
|
62
|
+
}
|
|
63
|
+
if (cleanScalar(mailpiece.mailPieceId).toUpperCase() !== number) {
|
|
64
|
+
throw new SchemaError('Royal Mail', 'Royal Mail did not return the requested parcel');
|
|
65
|
+
}
|
|
66
|
+
const summary = mailpiece.summary;
|
|
67
|
+
const summaryText = cleanScalar(summary.statusDescription);
|
|
68
|
+
if (mailpiece.events !== undefined && !Array.isArray(mailpiece.events)) {
|
|
69
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
70
|
+
}
|
|
71
|
+
const scans = Array.isArray(mailpiece.events) ? mailpiece.events : [];
|
|
72
|
+
const events = [];
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const raw of scans.slice(0, MAX_EVENTS_TO_INSPECT)) {
|
|
75
|
+
if (!isRecord(raw))
|
|
76
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
77
|
+
// The website returns Markdown emphasis, including "**Delivered by**".
|
|
78
|
+
// Normalize it before classification so delivery prose is still redacted.
|
|
79
|
+
const description = cleanScalar(raw.eventName).replace(/\*\*/g, '').trim();
|
|
80
|
+
if (!description)
|
|
81
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned invalid tracking events');
|
|
82
|
+
const code = cleanScalar(raw.eventCode, 64);
|
|
83
|
+
const mapped = royalMailEventStage(code);
|
|
84
|
+
const stage = mapped ?? royalMailStage(description) ?? undefined;
|
|
85
|
+
const time = eventTime(raw.eventDateTime);
|
|
86
|
+
const timeText = cleanScalar(raw.eventDateTime, 64);
|
|
87
|
+
const location = cleanScalar(raw.locationName, 250);
|
|
88
|
+
const event = {
|
|
89
|
+
...(time ? { time } : {}),
|
|
90
|
+
...(!time && timeText ? { provider_time_text: timeText } : {}),
|
|
91
|
+
...(location ? { location } : {}),
|
|
92
|
+
description: stage === 'delivered' ? 'Delivered' : description,
|
|
93
|
+
...(stage ? { stage } : {}),
|
|
94
|
+
...(mapped ? { stage_source: 'carrier_map' } : {}),
|
|
95
|
+
...(/^[A-Za-z0-9_-]+$/.test(code) ? { provider_code: code } : {}),
|
|
96
|
+
};
|
|
97
|
+
const identity = JSON.stringify([time ?? timeText, location, event.description]);
|
|
98
|
+
if (seen.has(identity))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(identity);
|
|
101
|
+
events.push(event);
|
|
102
|
+
}
|
|
103
|
+
// Royal Mail also sorts the history; upstream order is not guaranteed.
|
|
104
|
+
// A partial comparator can move resolved scans across unresolved overseas
|
|
105
|
+
// clocks. Preserve provider order unless every scan denotes an instant.
|
|
106
|
+
if (events.every(event => event.time)) {
|
|
107
|
+
events.sort((a, b) => Date.parse(b.time) - Date.parse(a.time));
|
|
108
|
+
}
|
|
109
|
+
const trimmed = events.slice(0, MAX_EVENTS_TO_RETURN);
|
|
110
|
+
const summaryCategory = cleanScalar(summary.statusCategory);
|
|
111
|
+
const statusText = summaryText || summaryCategory || trimmed[0]?.description;
|
|
112
|
+
if (!statusText)
|
|
113
|
+
throw new SchemaError('Royal Mail', 'Royal Mail returned no usable tracking status');
|
|
114
|
+
const stage = royalMailSummaryStage(summaryCategory, statusText) ?? undefined;
|
|
115
|
+
const delivered = stage === 'delivered';
|
|
116
|
+
return {
|
|
117
|
+
status: stage ? statusForStage(stage) : 'unknown',
|
|
118
|
+
...(stage ? { current_stage: stage } : {}),
|
|
119
|
+
last_status_text: delivered ? 'Delivered' : statusText,
|
|
120
|
+
last_update: eventTime(summary.lastEventDateTime) || trimmed[0]?.time || null,
|
|
121
|
+
expected_delivery: delivered || !isRecord(mailpiece.estimatedDelivery)
|
|
122
|
+
? null : expectedDelivery(mailpiece.estimatedDelivery.date),
|
|
123
|
+
events: trimmed,
|
|
124
|
+
...(trimmed.length ? {} : { summary_only: true }),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The page the browser rendered, read only to tell a bot challenge from an
|
|
129
|
+
* inconclusive load. Rendered text never decides not-found: only the
|
|
130
|
+
* structured reply does.
|
|
131
|
+
*/
|
|
132
|
+
export function parseRoyalMailTrackingHtml(page) {
|
|
133
|
+
const $ = load(page);
|
|
134
|
+
$('script, style, noscript').remove();
|
|
135
|
+
const visible = clean($('body').text(), 20_000);
|
|
136
|
+
if (/access denied|just a moment|attention required|are you a robot|verify you are human|before you proceed|unusual traffic/i.test(visible)) {
|
|
137
|
+
return 'challenged';
|
|
138
|
+
}
|
|
139
|
+
return 'inconclusive';
|
|
140
|
+
}
|
package/dist/data/catalog.json
CHANGED
|
@@ -1741,7 +1741,7 @@
|
|
|
1741
1741
|
"timezone": "UTC",
|
|
1742
1742
|
"tracking": {
|
|
1743
1743
|
"mode": "automatic",
|
|
1744
|
-
"adapter": "
|
|
1744
|
+
"adapter": "royal-mail"
|
|
1745
1745
|
},
|
|
1746
1746
|
"canaryUrl": "https://www.royalmail.com/track-your-item",
|
|
1747
1747
|
"trackingUrlTemplate": "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
|
|
@@ -1121,7 +1121,7 @@ export declare const CARRIER_CATALOG: {
|
|
|
1121
1121
|
readonly timezone: "UTC";
|
|
1122
1122
|
readonly tracking: {
|
|
1123
1123
|
readonly mode: "automatic";
|
|
1124
|
-
readonly adapter: "
|
|
1124
|
+
readonly adapter: "royal-mail";
|
|
1125
1125
|
};
|
|
1126
1126
|
readonly canaryUrl: "https://www.royalmail.com/track-your-item";
|
|
1127
1127
|
readonly trackingUrlTemplate: "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}";
|
|
@@ -1742,7 +1742,7 @@ export const CARRIER_CATALOG = {
|
|
|
1742
1742
|
"timezone": "UTC",
|
|
1743
1743
|
"tracking": {
|
|
1744
1744
|
"mode": "automatic",
|
|
1745
|
-
"adapter": "
|
|
1745
|
+
"adapter": "royal-mail"
|
|
1746
1746
|
},
|
|
1747
1747
|
"canaryUrl": "https://www.royalmail.com/track-your-item",
|
|
1748
1748
|
"trackingUrlTemplate": "https://www.royalmail.com/portal/rm/track?trackNumber={trackingNumber}",
|
|
@@ -289,7 +289,7 @@ export const REGISTRY = {
|
|
|
289
289
|
"purolator": "purolator",
|
|
290
290
|
"quickpac": "planzer",
|
|
291
291
|
"relais-colis": "relais-colis",
|
|
292
|
-
"royal-mail": "
|
|
292
|
+
"royal-mail": "royal-mail",
|
|
293
293
|
"sagawa": "universal",
|
|
294
294
|
"seur": "seur",
|
|
295
295
|
"sf-express": "sf-express",
|
|
@@ -139,8 +139,8 @@
|
|
|
139
139
|
},
|
|
140
140
|
{
|
|
141
141
|
"carrier": "royal-mail",
|
|
142
|
-
"direct": "
|
|
143
|
-
"sample": "
|
|
142
|
+
"direct": "Yes (local Chromium)",
|
|
143
|
+
"sample": "\u2713 8",
|
|
144
144
|
"references": [
|
|
145
145
|
{
|
|
146
146
|
"results": {
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
}
|
|
153
153
|
},
|
|
154
154
|
{
|
|
155
|
-
"note": "
|
|
155
|
+
"note": "Public references returned eight and six identity-bound history scans in fresh automated Chromium on both local and Linux server hosts: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/ and https://www.reddit.com/r/Sugargoo/comments/1we8q17/where_is_my_parcel/. The official documentation example returned E1142, which remains inconclusive. Anonymous requests can still stall before the API request or be reset; browser and runtime compatibility matter. TRAWL deployment compatibility is unverified.",
|
|
156
156
|
"results": {}
|
|
157
157
|
}
|
|
158
158
|
]
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED