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 CHANGED
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
13
13
  [![License: Apache-2.0](https://img.shields.io/badge/core-Apache--2.0-blue.svg)](LICENSE)
14
14
 
15
15
  <!-- GENERATED:summary -->
16
- **3,500+ carriers** through **98 dedicated adapters** and **5 universal fallbacks**
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 **71**, rising to **84** with all fallbacks enabled. The best single aggregator returns 53.
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 71, ParcelsApp 53, Postal Ninja 43, 17TRACK 40, Ship24 42, UPU 12." width="760">
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": "universal"
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}",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "catalog": 119,
3
- "activeAdapters": 98,
3
+ "activeAdapters": 99,
4
4
  "countries": 58,
5
5
  "referenceCarriers": 113,
6
6
  "comparison": {
7
7
  "combined": 84,
8
- "direct": 71,
8
+ "direct": 72,
9
9
  "providers": {
10
10
  "ParcelsApp": 53,
11
11
  "Postal Ninja": 43,
@@ -1407,7 +1407,7 @@ var ce = {
1407
1407
  timezone: "UTC",
1408
1408
  tracking: {
1409
1409
  mode: "automatic",
1410
- adapter: "universal"
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: "Disabled",
8285
- sample: "Error",
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: "Alternate public reference returned eight identity-bound full-history scans in an interactive official session. Fresh automated Chromium obtained the matching microsummary, but its separate events request failed; automated full history remains unverified: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/",
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 declare function normalizeRoyalMailNumber(raw: string): string;
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 { DateTime } from 'luxon';
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 { clean, cleanScalar, TRAWL_TRANSPORT_ALLOWANCE_MS, TrawlClient } from '../../core/transport/index.js';
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 { royalMailEventStage, royalMailStage, royalMailSummaryStage, statusForStage } from './status.js';
10
- /** Read the response produced by Royal Mail's form and hCaptcha callback. */
11
- const TRACKING_BASE = 'https://www.royalmail.com/track-your-item';
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
- // Akamai refuses every non-browser client, so a direct attempt only burns
176
- // time. There is one step, and it is the browser.
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 = (environment) => {
299
- const tracker = new RoyalMailTracker({
300
- fetcher: environment.fetcher,
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
- // Akamai refuses every non-browser client, so there is no direct tier.
308
- steps: ['trawl'],
309
- track: (input, context) => tracker.fetch(input.number, context),
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": "universal",
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
+ }
@@ -1741,7 +1741,7 @@
1741
1741
  "timezone": "UTC",
1742
1742
  "tracking": {
1743
1743
  "mode": "automatic",
1744
- "adapter": "universal"
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: "universal";
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": "universal"
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": "universal",
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": "Disabled",
143
- "sample": "Error",
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": "Alternate public reference returned eight identity-bound full-history scans in an interactive official session. Fresh automated Chromium obtained the matching microsummary, but its separate events request failed; automated full history remains unverified: https://www.reddit.com/r/royalmail/comments/1wh27pw/cant_track_my_shipment/",
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
  ]
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.13.1"
5
+ "version": "0.14.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.13.1",
3
+ "version": "0.14.0-main.340",
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",