graceful-playwright 1.5.1 → 1.7.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.
Files changed (4) hide show
  1. package/README.md +88 -0
  2. package/core.d.ts +18 -0
  3. package/core.js +152 -1
  4. package/package.json +12 -12
package/README.md CHANGED
@@ -20,6 +20,8 @@ Gracefully handle timeout and network error with auto retry.
20
20
 
21
21
  - create `Page` instance lazily (on-demand)
22
22
 
23
+ - stealth helpers to reduce common Playwright automation signals (`navigator.webdriver`, `HeadlessChrome` user agent, missing `window.chrome`)
24
+
23
25
  ## Installation
24
26
 
25
27
  ```bash
@@ -33,6 +35,7 @@ You can install the package with yarn, pnpm or slnpm as well.
33
35
  More usage examples see: [example.ts](./example.ts) and [core.spec.ts](./core.spec.ts)
34
36
 
35
37
  ```typescript
38
+ import { chromium } from 'playwright'
36
39
  import { GracefulPage } from 'graceful-playwright'
37
40
 
38
41
  let browser = await chromium.launch()
@@ -122,6 +125,91 @@ export type GotoErrorDetails = {
122
125
  }
123
126
  ```
124
127
 
128
+ Stealth Helpers
129
+
130
+ ```typescript
131
+ /** build a non-HeadlessChrome user agent from bundled chromium version */
132
+ export function getStealthUserAgent(): string
133
+
134
+ /** launch args for chromium.launch() and chromium.launchPersistentContext() */
135
+ export function getStealthChromiumArgs(options?: {
136
+ /** opt in for Docker/CI/root; sandbox is enabled by default */
137
+ noSandbox?: boolean
138
+ /** @default getStealthUserAgent() */
139
+ userAgent?: string
140
+ }): string[]
141
+
142
+ /** pass to context.addInitScript(stealthChromeInitScript) */
143
+ export function stealthChromeInitScript(): void
144
+ ```
145
+
146
+ `getStealthChromiumArgs()` returns:
147
+
148
+ - `--no-sandbox` (only when `noSandbox: true`)
149
+ - `--disable-setuid-sandbox` (only when `noSandbox: true`)
150
+ - `--disable-dev-shm-usage`
151
+ - `--user-agent=...` (from `options.userAgent` or `getStealthUserAgent()`)
152
+ - `--disable-blink-features=AutomationControlled`
153
+
154
+ `getStealthUserAgent()` builds a `Chrome/${major}.0.0.0` user agent from the bundled chromium binary (`chrome --product-version`) without launching a browser.
155
+
156
+ `stealthChromeInitScript` patches missing `window.chrome` fields (`app`, `loadTimes`, `csi`) before page scripts run. Pass the function reference to `addInitScript` — do not call it yourself.
157
+
158
+ Example (see also [example.ts](./example.ts)):
159
+
160
+ ```typescript
161
+ import { chromium } from 'playwright'
162
+ import {
163
+ getStealthChromiumArgs,
164
+ stealthChromeInitScript,
165
+ GracefulPage,
166
+ } from 'graceful-playwright'
167
+
168
+ let context = await chromium.launchPersistentContext('.chromium', {
169
+ args: getStealthChromiumArgs(),
170
+ // channel: 'chromium', // optional: new headless mode (full chromium engine)
171
+ // channel: 'chrome', // optional: installed Google Chrome
172
+ // headless: false,
173
+ })
174
+ await context.addInitScript(stealthChromeInitScript)
175
+ let page = new GracefulPage({ from: context })
176
+
177
+ await page.goto('https://example.net')
178
+ await context.close()
179
+ ```
180
+
181
+ Notes:
182
+
183
+ - use `noSandbox: true` only in Docker, CI, or when running as root
184
+ - use `channel: 'chromium'` for new headless mode — closer to real Chrome than the default headless shell
185
+ - use `channel: 'chrome'` when you need installed Google Chrome locally (e.g. codecs, `window.chrome`)
186
+ - default (no `channel`) uses bundled headless shell — best for CI; add `stealthChromeInitScript` if sites check `window.chrome`
187
+
188
+ Helper Functions: `sleep` and `sleepUntil`
189
+
190
+ ```typescript
191
+ /**
192
+ * Resolves after the given number of milliseconds.
193
+ * @param options.extraRandom when true, adds up to `ms` of random jitter;
194
+ * when a number, adds up to that many ms of jitter; default off
195
+ */
196
+ export function sleep(
197
+ ms: number,
198
+ options?: { extraRandom?: number | false | true },
199
+ ): Promise<void>
200
+
201
+ /**
202
+ * Polls `conditionFn` until it returns true, or throws on timeout.
203
+ * @param options.interval polling interval in ms (default: ~33ms, 30 fps)
204
+ * @param options.timeout overall timeout in ms (default: 30000)
205
+ * @param options.extraRandom passed to each `sleep(interval)` poll wait
206
+ */
207
+ export function sleepUntil(
208
+ conditionFn: () => boolean | Promise<boolean>,
209
+ options?: { interval?: number; timeout?: number; extraRandom?: number },
210
+ ): Promise<void>
211
+ ```
212
+
125
213
  ## License
126
214
 
127
215
  This project is licensed with [BSD-2-Clause](./LICENSE)
package/core.d.ts CHANGED
@@ -1,4 +1,14 @@
1
1
  import { Browser, BrowserContext, Page } from 'playwright';
2
+ /** build a non-HeadlessChrome user agent without launching a browser */
3
+ export declare function getStealthUserAgent(): string;
4
+ /** can be used in args by `chromium.launch()` and `chromium.launchPersistentContext()` */
5
+ export declare function getStealthChromiumArgs(options?: {
6
+ noSandbox?: boolean;
7
+ /** @default getStealthUserAgent() */
8
+ userAgent?: string;
9
+ }): string[];
10
+ /** run via `context.addInitScript(stealthChromeInitScript)` */
11
+ export declare function stealthChromeInitScript(): void;
2
12
  export declare class GracefulPage {
3
13
  options: {
4
14
  from: Browser | BrowserContext;
@@ -67,4 +77,12 @@ export declare class GotoError extends Error {
67
77
  details: GotoErrorDetails;
68
78
  constructor(message: string, details: GotoErrorDetails);
69
79
  }
80
+ export declare function sleep(ms: number, options?: {
81
+ extraRandom?: number | false | true;
82
+ }): Promise<unknown>;
83
+ export declare function sleepUntil(conditionFn: () => boolean | Promise<boolean>, options?: {
84
+ interval?: number;
85
+ timeout?: number;
86
+ extraRandom?: number;
87
+ }): Promise<void>;
70
88
  export declare function parseRetryAfter(headerValue: string | null): number | null;
package/core.js CHANGED
@@ -1,7 +1,140 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GotoError = exports.GracefulPage = void 0;
4
+ exports.getStealthUserAgent = getStealthUserAgent;
5
+ exports.getStealthChromiumArgs = getStealthChromiumArgs;
6
+ exports.stealthChromeInitScript = stealthChromeInitScript;
7
+ exports.sleep = sleep;
8
+ exports.sleepUntil = sleepUntil;
4
9
  exports.parseRetryAfter = parseRetryAfter;
10
+ const child_process_1 = require("child_process");
11
+ const playwright_1 = require("playwright");
12
+ let cachedChromiumMajorVersion;
13
+ /** read from bundled chromium via `chrome --product-version` */
14
+ function getChromiumMajorVersion() {
15
+ if (!cachedChromiumMajorVersion) {
16
+ let output = (0, child_process_1.execSync)(`"${playwright_1.chromium.executablePath()}" --product-version`, {
17
+ encoding: 'utf8',
18
+ }).trim();
19
+ cachedChromiumMajorVersion = output.match(/^(\d+)/)?.[1] ?? '120';
20
+ }
21
+ return cachedChromiumMajorVersion;
22
+ }
23
+ function getPlatformToken() {
24
+ if (process.platform === 'darwin') {
25
+ return 'Macintosh; Intel Mac OS X 10_15_7';
26
+ }
27
+ if (process.platform === 'win32') {
28
+ return 'Windows NT 10.0; Win64; x64';
29
+ }
30
+ let arch = process.arch === 'arm64' ? 'aarch64' : 'x86_64';
31
+ return `X11; Linux ${arch}`;
32
+ }
33
+ /** build a non-HeadlessChrome user agent without launching a browser */
34
+ function getStealthUserAgent() {
35
+ let major = getChromiumMajorVersion();
36
+ let platform = getPlatformToken();
37
+ return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
38
+ }
39
+ /** can be used in args by `chromium.launch()` and `chromium.launchPersistentContext()` */
40
+ function getStealthChromiumArgs(options = {}) {
41
+ let args = [];
42
+ if (options.noSandbox) {
43
+ args.push('--no-sandbox', '--disable-setuid-sandbox');
44
+ }
45
+ args.push('--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled');
46
+ let userAgent = options.userAgent || getStealthUserAgent();
47
+ args.push(`--user-agent=${userAgent}`);
48
+ return args;
49
+ }
50
+ /** run via `context.addInitScript(stealthChromeInitScript)` */
51
+ function stealthChromeInitScript() {
52
+ let win = window;
53
+ win.chrome = win.chrome || {};
54
+ let chrome = win.chrome;
55
+ function getNavigationTiming() {
56
+ let navigation = performance.getEntriesByType('navigation')[0];
57
+ if (navigation) {
58
+ let timeOrigin = performance.timeOrigin;
59
+ let domContentLoadedEventEnd = navigation.domContentLoadedEventEnd ||
60
+ navigation.domContentLoadedEventStart ||
61
+ 0;
62
+ let loadEventEnd = navigation.loadEventEnd || domContentLoadedEventEnd || 0;
63
+ let responseStart = navigation.responseStart || 0;
64
+ return {
65
+ navigationStartMs: timeOrigin,
66
+ navigationStartSec: timeOrigin / 1000,
67
+ domContentLoadedMs: timeOrigin + domContentLoadedEventEnd,
68
+ domContentLoadedSec: (timeOrigin + domContentLoadedEventEnd) / 1000,
69
+ loadEventEndMs: timeOrigin + loadEventEnd,
70
+ loadEventEndSec: (timeOrigin + loadEventEnd) / 1000,
71
+ responseStartSec: (timeOrigin + responseStart) / 1000,
72
+ protocol: navigation.nextHopProtocol || 'http/1.1',
73
+ };
74
+ }
75
+ // fallback for very early init script execution
76
+ let timing = performance.timing;
77
+ let navigationStartMs = timing.navigationStart;
78
+ let domContentLoadedMs = timing.domContentLoadedEventEnd || navigationStartMs;
79
+ let loadEventEndMs = timing.loadEventEnd ||
80
+ timing.domContentLoadedEventEnd ||
81
+ navigationStartMs;
82
+ return {
83
+ navigationStartMs,
84
+ navigationStartSec: navigationStartMs / 1000,
85
+ domContentLoadedMs,
86
+ domContentLoadedSec: domContentLoadedMs / 1000,
87
+ loadEventEndMs,
88
+ loadEventEndSec: loadEventEndMs / 1000,
89
+ responseStartSec: timing.responseStart / 1000,
90
+ protocol: 'http/1.1',
91
+ };
92
+ }
93
+ chrome.app ||= {
94
+ isInstalled: false,
95
+ InstallState: {
96
+ DISABLED: 'disabled',
97
+ INSTALLED: 'installed',
98
+ NOT_INSTALLED: 'not_installed',
99
+ },
100
+ RunningState: {
101
+ CANNOT_RUN: 'cannot_run',
102
+ READY_TO_RUN: 'ready_to_run',
103
+ RUNNING: 'running',
104
+ },
105
+ };
106
+ chrome.loadTimes ||= () => {
107
+ let timing = getNavigationTiming();
108
+ let protocol = timing.protocol;
109
+ let usesHttp2Or3 = protocol === 'h2' || protocol === 'h3';
110
+ return {
111
+ requestTime: timing.navigationStartSec,
112
+ startLoadTime: timing.navigationStartSec,
113
+ commitLoadTime: timing.domContentLoadedSec,
114
+ finishDocumentLoadTime: timing.domContentLoadedSec,
115
+ finishLoadTime: timing.loadEventEndSec,
116
+ firstPaintTime: timing.responseStartSec,
117
+ firstPaintAfterLoadTime: 0,
118
+ navigationType: 'Other',
119
+ wasFetchedViaSpdy: usesHttp2Or3,
120
+ wasNpnNegotiated: usesHttp2Or3,
121
+ npnNegotiatedProtocol: usesHttp2Or3 ? protocol : 'unknown',
122
+ wasAlternateProtocolAvailable: false,
123
+ connectionInfo: protocol,
124
+ };
125
+ };
126
+ chrome.csi ||= () => {
127
+ let timing = getNavigationTiming();
128
+ let startE = timing.navigationStartMs;
129
+ let onloadT = timing.domContentLoadedMs || startE;
130
+ return {
131
+ startE,
132
+ onloadT,
133
+ pageT: performance.now(),
134
+ tran: 15,
135
+ };
136
+ };
137
+ }
5
138
  class GracefulPage {
6
139
  options;
7
140
  constructor(options) {
@@ -159,9 +292,27 @@ class GotoError extends Error {
159
292
  }
160
293
  }
161
294
  exports.GotoError = GotoError;
162
- function sleep(ms) {
295
+ function sleep(ms, options) {
296
+ if (options?.extraRandom) {
297
+ let extraRandom = typeof options.extraRandom === 'number' ? options.extraRandom : ms;
298
+ ms += Math.random() * extraRandom;
299
+ }
163
300
  return new Promise(resolve => setTimeout(resolve, ms));
164
301
  }
302
+ async function sleepUntil(conditionFn, options) {
303
+ let sleepOptions = options && 'extraRandom' in options
304
+ ? { extraRandom: options.extraRandom }
305
+ : undefined;
306
+ let interval = options?.interval || 1000 / 30; // 30 fps
307
+ let timeout = options?.timeout || 30_000; // 30 seconds
308
+ let endTime = Date.now() + timeout;
309
+ while (Date.now() < endTime) {
310
+ if (await conditionFn())
311
+ return;
312
+ await sleep(interval, sleepOptions);
313
+ }
314
+ throw new Error('Timeout');
315
+ }
165
316
  function parseRetryAfter(headerValue) {
166
317
  if (!headerValue)
167
318
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "graceful-playwright",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Gracefully handle timeout and network error with auto retry.",
5
5
  "keywords": [
6
6
  "graceful",
@@ -51,20 +51,20 @@
51
51
  "playwright": "^1.41.2"
52
52
  },
53
53
  "devDependencies": {
54
- "@types/chai": "4",
55
- "@types/express": "^4.17.21",
56
- "@types/mocha": "^10.0.6",
57
- "@types/node": "^20.11.17",
58
- "@types/sinon": "^17.0.1",
59
- "chai": "4",
60
- "express": "^4.18.2",
61
- "mocha": "^11.2.2",
54
+ "@types/chai": "^4.3.20",
55
+ "@types/express": "^4.17.25",
56
+ "@types/mocha": "^10.0.10",
57
+ "@types/node": "^20.19.43",
58
+ "@types/sinon": "^17.0.4",
59
+ "chai": "^4.5.0",
60
+ "express": "^4.22.2",
61
+ "mocha": "^11.7.6",
62
62
  "npm-run-all": "^4.1.5",
63
- "playwright": "^1.52.0",
64
- "rimraf": "^6.0.1",
63
+ "playwright": "^1.61.1",
64
+ "rimraf": "^6.1.3",
65
65
  "sinon": "^20.0.0",
66
66
  "ts-mocha": "^11.1.0",
67
67
  "ts-node": "^10.9.2",
68
- "typescript": "^5.3.3"
68
+ "typescript": "^6.0.3"
69
69
  }
70
70
  }