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.
- package/dist/carriers/royal-mail/adapter.d.ts +3 -1
- package/dist/carriers/royal-mail/adapter.js +10 -7
- package/dist/carriers/royal-mail/browser-service.d.ts +3 -0
- package/dist/carriers/royal-mail/browser-service.js +19 -0
- package/dist/carriers/royal-mail/browser.d.ts +3 -2
- package/dist/carriers/royal-mail/browser.js +25 -7
- package/dist/cli/index.js +11 -0
- package/dist/core/transport/localBrowser.d.ts +5 -1
- package/dist/core/transport/localBrowser.js +33 -4
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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