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.
- package/README.md +88 -0
- package/core.d.ts +18 -0
- package/core.js +152 -1
- 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.
|
|
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.
|
|
56
|
-
"@types/mocha": "^10.0.
|
|
57
|
-
"@types/node": "^20.
|
|
58
|
-
"@types/sinon": "^17.0.
|
|
59
|
-
"chai": "4",
|
|
60
|
-
"express": "^4.
|
|
61
|
-
"mocha": "^11.
|
|
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.
|
|
64
|
-
"rimraf": "^6.
|
|
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": "^
|
|
68
|
+
"typescript": "^6.0.3"
|
|
69
69
|
}
|
|
70
70
|
}
|