universal-parcel-scraper 0.191.0-main.1349 → 0.191.0

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.
@@ -4,7 +4,9 @@ import { type StepRecorder } from '../../core/telemetry/index.js';
4
4
  import { TrawlClient } from '../../core/transport/index.js';
5
5
  export { normalizeRoyalMailNumber, parseRoyalMailTrackingHtml, parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
6
6
  export interface RoyalMailTrackerOptions {
7
- /** Fresh local Chromium; preferred when supplied. */
7
+ /** Private Playwright WebSocket service; preferred when supplied. Keep its capability URL outside logs and Git. */
8
+ browserWsEndpoint?: string | null;
9
+ /** Fresh local Chromium, used when no private browser endpoint is supplied. */
8
10
  executablePath?: string | null;
9
11
  /** An `http://` or `socks5://` proxy for the local Chromium's traffic. */
10
12
  browserProxy?: string | null;
@@ -20,6 +20,7 @@ export class RoyalMailTracker {
20
20
  #fullHistory;
21
21
  #executablePath;
22
22
  #browserProxy;
23
+ #browserWsEndpoint;
23
24
  constructor(options = {}) {
24
25
  if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
25
26
  throw new TypeError('Royal Mail timeout must be positive');
@@ -32,6 +33,7 @@ export class RoyalMailTracker {
32
33
  this.#fullHistory = options.fullHistory ?? false;
33
34
  this.#executablePath = options.executablePath ?? null;
34
35
  this.#browserProxy = options.browserProxy?.trim() || null;
36
+ this.#browserWsEndpoint = options.browserWsEndpoint?.trim() || null;
35
37
  }
36
38
  /** The injected browser service, or one built from the configured URL. */
37
39
  #browserService() {
@@ -41,9 +43,9 @@ export class RoyalMailTracker {
41
43
  }
42
44
  async fetch(trackingNumber, context = {}) {
43
45
  const number = normalizeRoyalMailNumber(trackingNumber);
44
- if (this.#executablePath) {
46
+ if (this.#browserWsEndpoint || this.#executablePath) {
45
47
  return runSteps({ carrier: 'royal-mail', budgetMs: context.budgetMs ?? this.timeoutMs,
46
- signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath, signal, Math.min(60_000, remainingMs), this.#fullHistory, this.#browserProxy) }]);
48
+ signal: context.signal, recorder: this.#recorder }, [{ id: 'browser', run: ({ remainingMs, signal }) => fetchRoyalMailInBrowser(number, this.#executablePath ?? '', signal, Math.min(60_000, remainingMs), this.#fullHistory, this.#browserProxy, this.#browserWsEndpoint) }]);
47
49
  }
48
50
  // The legacy service remains available through explicit tracker instances.
49
51
  const trawl = this.#browserService();
@@ -170,20 +172,21 @@ export class RoyalMailTracker {
170
172
  }
171
173
  }
172
174
  export const adapter = environment => {
175
+ const endpoint = environment.env.ROYAL_MAIL_BROWSER_WS_ENDPOINT?.trim() || null;
173
176
  const tracker = new RoyalMailTracker({ executablePath: environment.browserExecutablePath,
174
- browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
177
+ browserWsEndpoint: endpoint, browserProxy: environment.env.ROYAL_MAIL_BROWSER_PROXY, recorder: environment.recorder, fullHistory: true });
175
178
  return {
176
179
  id: 'royal-mail', recordsSteps: true, steps: ['browser'],
177
180
  track: (input, context) => {
178
181
  normalizeRoyalMailNumber(input.number);
179
- if (!environment.browserExecutablePath)
180
- throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH');
182
+ if (!endpoint && !environment.browserExecutablePath)
183
+ throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH or ROYAL_MAIL_BROWSER_WS_ENDPOINT');
181
184
  return tracker.fetch(input.number, context);
182
185
  },
183
186
  recognizeWithBrowser: (number, context) => recognizeFromBrowserLookup(() => {
184
187
  normalizeRoyalMailNumber(number);
185
- if (!environment.browserExecutablePath)
186
- throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH');
188
+ if (!endpoint && !environment.browserExecutablePath)
189
+ throw new ChallengeError('Royal Mail', 'Royal Mail requires TRACKING_CHROMIUM_PATH or ROYAL_MAIL_BROWSER_WS_ENDPOINT');
187
190
  return tracker.fetch(number, context);
188
191
  }),
189
192
  };
@@ -0,0 +1,3 @@
1
+ import type { BrowserServer } from 'playwright-core';
2
+ /** A private Playwright service. Its capability path and process environment never enter browser pages. */
3
+ export declare function startRoyalMailBrowserService(env: Readonly<Record<string, string | undefined>>): Promise<BrowserServer>;
@@ -0,0 +1,19 @@
1
+ import { loadChromium } from '../../core/transport/optional.js';
2
+ /** A private Playwright service. Its capability path and process environment never enter browser pages. */
3
+ export async function startRoyalMailBrowserService(env) {
4
+ const executablePath = env.TRACKING_CHROMIUM_PATH?.trim();
5
+ if (!executablePath)
6
+ throw new TypeError('Royal Mail browser service requires TRACKING_CHROMIUM_PATH');
7
+ const wsPath = env.ROYAL_MAIL_BROWSER_WS_PATH ?? '';
8
+ if (!/^\/[A-Za-z0-9_-]{32,128}$/.test(wsPath)) {
9
+ throw new TypeError('ROYAL_MAIL_BROWSER_WS_PATH must be an unguessable path of 32 to 128 URL-safe characters');
10
+ }
11
+ const rawPort = env.ROYAL_MAIL_BROWSER_PORT ?? '3001';
12
+ const port = /^\d{1,5}$/.test(rawPort) ? Number(rawPort) : 0;
13
+ if (port < 1 || port > 65535)
14
+ throw new TypeError('Invalid Royal Mail browser service port');
15
+ const chromium = await loadChromium('Royal Mail');
16
+ return chromium.launchServer({ executablePath, headless: true, host: '127.0.0.1', port, wsPath,
17
+ args: ['--disable-blink-features=AutomationControlled'], timeout: 20_000,
18
+ env: { PATH: env.PATH ?? '', HOME: env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
19
+ }
@@ -1,5 +1,6 @@
1
- import type { Page } from 'playwright-core';
1
+ import type { Browser, Page } from 'playwright-core';
2
2
  import type { CarrierResult } from '../../core/result/index.js';
3
+ export declare function royalMailBrowserUserAgent(browser: Browser, remote: boolean): Promise<string>;
3
4
  /** The destination the full-history reply adds to the summary, and nothing else from it. */
4
5
  export declare function royalMailDestination(summary: unknown): {
5
6
  summary?: {
@@ -8,4 +9,4 @@ export declare function royalMailDestination(summary: unknown): {
8
9
  };
9
10
  /** Observe the application's request; its issued session stays in the browser. */
10
11
  export declare function captureRoyalMailReply(page: Page, url: string, number: string, phase: 'summary' | 'events', signal: AbortSignal, timeoutMs: number, trigger: () => Promise<void>): Promise<Record<string, unknown>>;
11
- export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean, proxy?: string | null): Promise<CarrierResult>;
12
+ export declare function fetchRoyalMailInBrowser(number: string, executablePath: string, signal: AbortSignal, timeoutMs: number, fullHistory: boolean, proxy?: string | null, endpoint?: string | null): Promise<CarrierResult>;
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
3
- import { withLocalBrowser } from '../../core/transport/localBrowser.js';
3
+ import { withLocalBrowser, withRemoteBrowser } from '../../core/transport/localBrowser.js';
4
4
  import { isRecord } from '../../core/types.js';
5
5
  import { parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummaryApiUrl, royalMailTrackingUrl } from './parser.js';
6
6
  const PROVIDER = 'Royal Mail';
@@ -8,6 +8,25 @@ const ORIGIN = 'https://www.royalmail.com';
8
8
  const MAX_BYTES = 2_000_000;
9
9
  // The page keeps this frame hidden off-screen until hCaptcha wants a person to solve it.
10
10
  const CAPTCHA_CHALLENGE = 'iframe[src*="hcaptcha.com"][src*="frame=challenge"]:visible';
11
+ export async function royalMailBrowserUserAgent(browser, remote) {
12
+ const major = browser.version().split('.')[0];
13
+ if (remote) {
14
+ // Use the browser's OS, rather than the calling server's platform.
15
+ const cdp = await browser.newBrowserCDPSession();
16
+ try {
17
+ const version = await cdp.send('Browser.getVersion');
18
+ if (!/^Mozilla\/5\.0 [\x20-\x7E]{20,300}$/.test(version.userAgent))
19
+ throw new SchemaError(PROVIDER);
20
+ return version.userAgent.replace(/HeadlessChrome\/[\d.]+/, `Chrome/${major}.0.0.0`);
21
+ }
22
+ finally {
23
+ await cdp.detach();
24
+ }
25
+ }
26
+ const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
27
+ : process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
28
+ return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
29
+ }
11
30
  // The consent manager parks the address of a frame it blocks in `data-ta-src`.
12
31
  const CAPTCHA_WITHHELD = 'iframe[data-hcaptcha-widget-id][data-ta-src]';
13
32
  /** The destination the full-history reply adds to the summary, and nothing else from it. */
@@ -142,15 +161,14 @@ export async function captureRoyalMailReply(page, url, number, phase, signal, ti
142
161
  deadline.removeEventListener('abort', aborted);
143
162
  }
144
163
  }
145
- export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory, proxy = null) {
146
- return withLocalBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs, proxy,
164
+ export function fetchRoyalMailInBrowser(number, executablePath, signal, timeoutMs, fullHistory, proxy = null, endpoint = null) {
165
+ const withBrowser = endpoint ? withRemoteBrowser : withLocalBrowser;
166
+ return withBrowser({ provider: PROVIDER, executablePath, signal, timeoutMs, proxy, endpoint: endpoint ?? '',
147
167
  args: ['--disable-blink-features=AutomationControlled'] }, async ({ browser, signal: session, remainingMs }) => {
148
168
  try {
149
- const platform = process.platform === 'darwin' ? 'Macintosh; Intel Mac OS X 10_15_7'
150
- : process.platform === 'win32' ? 'Windows NT 10.0; Win64; x64' : 'X11; Linux x86_64';
151
- const major = browser.version().split('.')[0];
169
+ const userAgent = await royalMailBrowserUserAgent(browser, endpoint !== null);
152
170
  const context = await browser.newContext({ locale: 'en-GB', acceptDownloads: false, serviceWorkers: 'block',
153
- userAgent: `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36` });
171
+ userAgent });
154
172
  session.throwIfAborted();
155
173
  const page = await context.newPage();
156
174
  const timeout = () => Math.min(15_000, remainingMs());
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { InputRequiredError } from '../core/errors/index.js';
5
5
  import { createTracker, TrackingError } from '../facade/index.js';
6
6
  import { CARRIER_CATALOG } from '../generated/catalog.js';
7
7
  import { createTrackingServer } from '../server/index.js';
8
+ import { startRoyalMailBrowserService } from '../carriers/royal-mail/browser-service.js';
8
9
  const help = `Universal Parcel Scraper
9
10
 
10
11
  parcel-scraper detect <number, link or text>
@@ -12,6 +13,7 @@ const help = `Universal Parcel Scraper
12
13
  parcel-scraper track <number> [--carrier <id>] [--postcode <value>] [--tracking-url <url>]
13
14
  parcel-scraper carriers
14
15
  parcel-scraper serve [--host <address>] [--port <port>]
16
+ parcel-scraper royal-mail-browser
15
17
 
16
18
  SCRAPER_PROVIDERS selects comma-separated providers; default: UPU.
17
19
  FLARESOLVERR_URL and TRACKING_CHROMIUM_PATH enable browser transports.
@@ -34,6 +36,15 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
34
36
  console.log(help);
35
37
  return 0;
36
38
  }
39
+ if (command === 'royal-mail-browser') {
40
+ if (args.length)
41
+ throw new TypeError('Unexpected argument; configure the Royal Mail browser service through its environment');
42
+ const server = await startRoyalMailBrowserService(env);
43
+ console.error(JSON.stringify({ service: 'royal-mail-browser', state: 'ready' }));
44
+ for (const signal of ['SIGINT', 'SIGTERM'])
45
+ process.once(signal, () => { void server.close(); });
46
+ return 0;
47
+ }
37
48
  if (!['detect', 'recognize', 'track', 'carriers', 'serve'].includes(command))
38
49
  throw new TypeError('Unknown command; use --help');
39
50
  const values = {};
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The one local Chromium a process runs. A carrier outage can send a whole
2
+ * The one browser session a process runs. A carrier outage can send a whole
3
3
  * batch to the browser tier, so lookups take turns instead of each spawning a
4
4
  * browser: a lookup waits for the browser inside its own deadline and fails
5
5
  * with a budget error when that deadline passes first.
@@ -24,3 +24,7 @@ export interface LocalBrowserSession {
24
24
  remainingMs: () => number;
25
25
  }
26
26
  export declare function withLocalBrowser<T>(options: LocalBrowserOptions, run: (session: LocalBrowserSession) => Promise<T>): Promise<T>;
27
+ /** Connects to a private Playwright browser; closing the client disposes its contexts, not the service. */
28
+ export declare function withRemoteBrowser<T>(options: Pick<LocalBrowserOptions, 'provider' | 'signal' | 'timeoutMs'> & {
29
+ endpoint: string;
30
+ }, run: (session: LocalBrowserSession) => Promise<T>): Promise<T>;
@@ -1,4 +1,4 @@
1
- import { BudgetExceededError, carrierErrorKind } from '../errors/index.js';
1
+ import { BudgetExceededError, carrierErrorKind, TransportError } from '../errors/index.js';
2
2
  import { loadChromium } from './optional.js';
3
3
  /** Covers the rounding of the timeouts handed to Playwright and the granularity of timers. */
4
4
  const DEADLINE_SLACK_MS = 5;
@@ -17,6 +17,33 @@ function proxyServer(value) {
17
17
  return `${url.protocol}//${url.host}`;
18
18
  }
19
19
  export async function withLocalBrowser(options, run) {
20
+ return withBrowser(options, run);
21
+ }
22
+ /** Connects to a private Playwright browser; closing the client disposes its contexts, not the service. */
23
+ export async function withRemoteBrowser(options, run) {
24
+ let url;
25
+ try {
26
+ url = new URL(options.endpoint);
27
+ }
28
+ catch {
29
+ throw new TypeError('Browser endpoint must be a WebSocket URL');
30
+ }
31
+ if (!['ws:', 'wss:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.hash) {
32
+ throw new TypeError('Browser endpoint must be a WebSocket URL');
33
+ }
34
+ try {
35
+ return await withBrowser({ provider: options.provider, timeoutMs: options.timeoutMs, signal: options.signal, executablePath: '' }, run, url.href);
36
+ }
37
+ catch (error) {
38
+ if (options.signal?.aborted)
39
+ throw options.signal.reason;
40
+ if (carrierErrorKind(error))
41
+ throw error;
42
+ // Connection diagnostics can contain the endpoint's capability path.
43
+ throw new TransportError(options.provider, `${options.provider} browser service is unavailable`);
44
+ }
45
+ }
46
+ async function withBrowser(options, run, endpoint) {
20
47
  const { provider, timeoutMs } = options;
21
48
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
22
49
  throw new TypeError('Browser tracking timeout must be between 1 and 60000 ms');
@@ -69,9 +96,11 @@ export async function withLocalBrowser(options, run) {
69
96
  if (signal.aborted)
70
97
  throw stopped();
71
98
  // Do not pass the application environment (database or API secrets) to Chromium.
72
- launching = chromium.launch({ executablePath: options.executablePath, headless: true, args: [...(options.args ?? [])],
73
- timeout: Math.min(remainingMs(), 10_000), ...(proxy ? { proxy: { server: proxy } } : {}),
74
- env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
99
+ // The private service binds to loopback; its Host check must survive a tunnel's different address.
100
+ launching = endpoint ? chromium.connect(endpoint, { timeout: Math.min(remainingMs(), 10_000), headers: { Host: 'localhost' } })
101
+ : chromium.launch({ executablePath: options.executablePath, headless: true, args: [...(options.args ?? [])],
102
+ timeout: Math.min(remainingMs(), 10_000), ...(proxy ? { proxy: { server: proxy } } : {}),
103
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '/tmp', LANG: 'en_US.UTF-8' } });
75
104
  browser = await unlessStopped(launching);
76
105
  return await unlessStopped(run({ browser, signal, remainingMs }));
77
106
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.191.0-main.1349",
3
+ "version": "0.191.0",
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",