@webcrawlerapi/stealth 1.0.0 → 1.0.2

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
@@ -21,15 +21,16 @@ The two functions you normally need are:
21
21
  | `Function.prototype.toString` + anonymous injected globals | native-looking `toString` via a `WeakSet` cloak |
22
22
  | `navigator.webdriver` | deleted + faked to `false` |
23
23
  | WebDriver / Selenium / Playwright residue (`cdc_*`, `__webdriver_*`, `__selenium_*`, `__pw*`, `__playwright_*`) | removed |
24
- | `navigator.languages / platform / hardwareConcurrency / deviceMemory / vendor / onLine` | normalized |
24
+ | `navigator.languages / platform / hardwareConcurrency / deviceMemory` | left as the host reports them; overridable via `opts` |
25
25
  | `window.chrome` (`runtime`, `loadTimes`, `csi`, `app`) | faked when missing |
26
- | `navigator.userAgentData` (`brands`, `getHighEntropyValues`) | faked |
26
+ | `navigator.userAgentData` (`brands`, `getHighEntropyValues`) | faked **only when missing**; a real UA-CH object is left untouched |
27
27
  | `navigator.plugins` / `mimeTypes` (when empty) | faked |
28
28
  | `navigator.permissions.query` | notifications → `default` |
29
- | WebGL `UNMASKED_VENDOR_WEBGL` / `UNMASKED_RENDERER_WEBGL` | spoofed (hides SwiftShader / software GL) |
29
+ | WebGL `UNMASKED_VENDOR_WEBGL` / `UNMASKED_RENDERER_WEBGL` | spoofed with an **OS-consistent** GPU only when software GL (SwiftShader / llvmpipe) is detected, or when `webglRenderer`/`webglVendor` is set. A real GPU is reported as-is |
30
30
  | `matchMedia` pointer / hover | normalized |
31
- | `window.outerWidth` / `outerHeight` | corrected to inner + chrome |
31
+ | `window.outerWidth` / `outerHeight` | corrected only when smaller than the inner size |
32
32
  | `Error.prepareStackTrace` | installed if absent |
33
+ | `User-Agent` header + `navigator.userAgent` (`HeadlessChrome` token) | read from `browser.userAgent()` and `HeadlessChrome` → `Chrome` via `page.setUserAgent`; never hardcoded, so the version always matches the real engine. Skip with `fixUserAgent: false`, or pass `userAgent` |
33
34
 
34
35
  ---
35
36
 
@@ -188,11 +189,12 @@ Optional launcher. Requires puppeteer via `opts.puppeteer` or resolved lazily
188
189
 
189
190
  | Key | Default | Used by |
190
191
  |-----|---------|---------|
191
- | `webglRenderer` / `renderer` | `ANGLE (Intel, ...)` | all |
192
- | `webglVendor` / `vendor` | `Google Inc. (Intel)` | all |
193
- | `hardwareConcurrency` | `8` | all |
194
- | `deviceMemory` | `8` | all |
195
- | `platform` | `Linux x86_64` | all |
192
+ | `webglRenderer` / `renderer` | OS-appropriate GPU (only applied when software GL is detected) | all |
193
+ | `webglVendor` / `vendor` | OS-appropriate vendor (only applied when software GL is detected) | all |
194
+ | `hardwareConcurrency` | host value | all |
195
+ | `deviceMemory` | host value | all |
196
+ | `languages` | host value | all |
197
+ | `platform` | host value | all |
196
198
  | `origin` / `permissions` | — | `newPage` |
197
199
  | `puppeteer` | lazily resolved | `launch` |
198
200
  | `executablePath` | `$CHROME_PATH` | `launch` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webcrawlerapi/stealth",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Zero-dependency stealth layer for Puppeteer / CDP browsers (works with launch() and connect()).",
5
5
  "main": "stealth.js",
6
6
  "types": "stealth.d.ts",
package/stealth.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  export interface StealthOptions {
2
- /** WebGL UNMASKED_RENDERER_WEBGL value to report. */
2
+ /** WebGL UNMASKED_RENDERER_WEBGL to report. Only applied when software GL is detected or this is set; defaults to an OS-appropriate renderer. */
3
3
  webglRenderer?: string;
4
- /** WebGL UNMASKED_VENDOR_WEBGL value to report. */
4
+ /** WebGL UNMASKED_VENDOR_WEBGL to report. Only applied when software GL is detected or this is set; defaults to an OS-appropriate vendor. */
5
5
  webglVendor?: string;
6
- /** navigator.hardwareConcurrency override. */
6
+ /** navigator.hardwareConcurrency override. Left as the host value when omitted. */
7
7
  hardwareConcurrency?: number;
8
- /** navigator.deviceMemory override. */
8
+ /** navigator.deviceMemory override. Left as the host value when omitted. */
9
9
  deviceMemory?: number;
10
- /** navigator.platform override. */
10
+ /** navigator.languages / language override. Left as the host value when omitted. */
11
+ languages?: string[];
12
+ /** navigator.platform override. Left as the host value when omitted. */
11
13
  platform?: string;
12
14
  /** Launch only: injected puppeteer instance. */
13
15
  puppeteer?: unknown;
@@ -19,6 +21,10 @@ export interface StealthOptions {
19
21
  userDataDir?: string;
20
22
  /** Launch only: extra chrome args. */
21
23
  args?: string[];
24
+ /** Explicit User-Agent for the page. Default: browser UA with "HeadlessChrome" replaced by "Chrome". */
25
+ userAgent?: string;
26
+ /** Set to false to skip the User-Agent fix. Default true. */
27
+ fixUserAgent?: boolean;
22
28
  /** newPage only: origin for overridePermissions. */
23
29
  origin?: string;
24
30
  /** newPage only: permissions to grant. */
@@ -47,6 +53,7 @@ export declare const UA: string;
47
53
  export declare function buildInitScript(cfg?: StealthOptions): void;
48
54
  export declare function buildSource(opts?: StealthOptions): string;
49
55
  export declare function installStealth(page: PageLike, opts?: StealthOptions): Promise<PageLike>;
56
+ export declare function fixUserAgent(page: PageLike, opts?: StealthOptions): Promise<PageLike>;
50
57
  export declare function injectNow(page: PageLike, opts?: StealthOptions): Promise<PageLike>;
51
58
  export declare function installOopifHook(browser: BrowserLike, opts?: StealthOptions): BrowserLike;
52
59
  export declare function newPage(browser: BrowserLike, opts?: StealthOptions): Promise<PageLike>;
package/stealth.js CHANGED
@@ -21,11 +21,7 @@ const UA =
21
21
  'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ' +
22
22
  '(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36';
23
23
 
24
- const VERSION = '1.0.0';
25
-
26
- const DEFAULT_RENDERER =
27
- 'ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11)';
28
- const DEFAULT_VENDOR = 'Google Inc. (Intel)';
24
+ const VERSION = '1.0.2';
29
25
 
30
26
  /* ------------------------------------------------------------------ *
31
27
  * The in-page bootstrap. Runs inside every document before page JS. *
@@ -34,8 +30,59 @@ const DEFAULT_VENDOR = 'Google Inc. (Intel)';
34
30
  * ------------------------------------------------------------------ */
35
31
  function buildInitScript(cfg) {
36
32
  cfg = cfg || {};
37
- const renderer = cfg.renderer || 'ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11)';
38
- const vendor = cfg.vendor || 'Google Inc. (Intel)';
33
+
34
+ // One coherent profile per OS family. The values inside a profile are chosen
35
+ // together so the platform string, UA-CH hints, WebGL strings and geometry
36
+ // all agree with each other *and* with the real Chrome build running the page.
37
+ // (A Windows-only renderer like "Direct3D11" on a Linux platform is a
38
+ // contradiction a fingerprinting script can spot, so we never mix families.)
39
+ const PROFILES = {
40
+ windows: {
41
+ platform: 'Win32',
42
+ uaPlatform: 'Windows',
43
+ platformVersion: '10.0.0',
44
+ architecture: 'x86',
45
+ bitness: '64',
46
+ renderer: 'ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11)',
47
+ vendor: 'Google Inc. (Intel)',
48
+ },
49
+ mac: {
50
+ platform: 'MacIntel',
51
+ uaPlatform: 'macOS',
52
+ platformVersion: '15.0.0',
53
+ architecture: 'arm',
54
+ bitness: '64',
55
+ renderer: 'ANGLE (Apple, ANGLE Metal Renderer: Apple M1, Unspecified Version)',
56
+ vendor: 'Google Inc. (Apple)',
57
+ },
58
+ linux: {
59
+ platform: 'Linux x86_64',
60
+ uaPlatform: 'Linux',
61
+ platformVersion: '',
62
+ architecture: 'x86',
63
+ bitness: '64',
64
+ renderer: 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6 (Core Profile) Mesa 22.0.5)',
65
+ vendor: 'Google Inc. (Intel)',
66
+ },
67
+ };
68
+
69
+ // Derive everything from the real browser so the profile can never drift
70
+ // away from the engine that is actually running.
71
+ const realUA = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
72
+ const chromeVer = (realUA.match(/Chrome\/(\d+)/) || [])[1] || '120';
73
+ const fullVersion = chromeVer + '.0.0.0';
74
+ const osFamily = /Windows/i.test(realUA)
75
+ ? 'windows'
76
+ : /Macintosh|Mac OS X/i.test(realUA)
77
+ ? 'mac'
78
+ : 'linux';
79
+ const profile = PROFILES[osFamily];
80
+
81
+ // Optional overrides. Anything not supplied keeps the host's real value, so
82
+ // the page never contradicts the machine it is running on.
83
+ const renderer = cfg.renderer || profile.renderer;
84
+ const vendor = cfg.vendor || profile.vendor;
85
+ const languages = cfg.languages;
39
86
 
40
87
  const patched = new WeakSet();
41
88
 
@@ -117,17 +164,27 @@ function buildInitScript(cfg) {
117
164
  };
118
165
  }
119
166
 
120
- // --- navigator.languages / language ---
121
- try { getter(Navigator.prototype, 'languages', Object.freeze(['en-US', 'en']), 'languages'); } catch (_) {}
122
- try { getter(Navigator.prototype, 'language', 'en-US', 'language'); } catch (_) {}
167
+ // --- navigator.languages / language (only when the caller asked for it) ---
168
+ // Overriding these while leaving the Accept-Language request header alone
169
+ // would make the JS view disagree with the network view.
170
+ if (Array.isArray(languages) && languages.length) {
171
+ try { getter(Navigator.prototype, 'languages', Object.freeze(languages.slice()), 'languages'); } catch (_) {}
172
+ try { getter(Navigator.prototype, 'language', languages[0], 'language'); } catch (_) {}
173
+ }
123
174
 
124
- // --- hardware / platform ---
125
- try { getter(Navigator.prototype, 'hardwareConcurrency', cfg.hardwareConcurrency || 8, 'hardwareConcurrency'); } catch (_) {}
126
- try { getter(Navigator.prototype, 'deviceMemory', cfg.deviceMemory || 8, 'deviceMemory'); } catch (_) {}
127
- try { getter(Navigator.prototype, 'platform', cfg.platform || 'Linux x86_64', 'platform'); } catch (_) {}
128
- try { getter(Navigator.prototype, 'maxTouchPoints', 0, 'maxTouchPoints'); } catch (_) {}
129
- try { getter(Navigator.prototype, 'vendor', 'Google Inc.', 'vendor'); } catch (_) {}
130
- try { getter(Navigator.prototype, 'onLine', true, 'onLine'); } catch (_) {}
175
+ // --- platform / vendor / hardware ---
176
+ // These already match the real engine, so they are left untouched unless an
177
+ // explicit override is supplied. Forcing a value the host does not actually
178
+ // have (e.g. hardwareConcurrency 8 on a 4-core box) is itself a tell.
179
+ if (cfg.platform) {
180
+ try { getter(Navigator.prototype, 'platform', cfg.platform, 'platform'); } catch (_) {}
181
+ }
182
+ if (cfg.hardwareConcurrency) {
183
+ try { getter(Navigator.prototype, 'hardwareConcurrency', cfg.hardwareConcurrency, 'hardwareConcurrency'); } catch (_) {}
184
+ }
185
+ if (cfg.deviceMemory) {
186
+ try { getter(Navigator.prototype, 'deviceMemory', cfg.deviceMemory, 'deviceMemory'); } catch (_) {}
187
+ }
131
188
 
132
189
  // --- plugins / mimeTypes (only when empty) ---
133
190
  try {
@@ -195,55 +252,86 @@ function buildInitScript(cfg) {
195
252
  } catch (_) {}
196
253
 
197
254
  // --- userAgentData ---
255
+ // When the engine exposes UA-CH it is already consistent with the UA string
256
+ // and the sec-ch-ua headers, so it is left alone. The synthetic object is only
257
+ // built when it is genuinely missing, and then from the same profile.
198
258
  try {
199
- const brands = [
200
- { brand: 'Chromium', version: '151' },
201
- { brand: 'Not?A_Brand', version: '24' },
202
- { brand: 'Google Chrome', version: '151' },
203
- ];
204
- const fullVersionList = brands.map((b) => ({ brand: b.brand, version: b.brand === 'Not?A_Brand' ? '24.0.0.0' : '151.0.7922.108' }));
205
- const highEntropy = {
206
- architecture: 'x86', bitness: '64', brands, fullVersionList, mobile: false, model: '',
207
- platform: 'Linux', platformVersion: '6.8.0', uaFullVersion: '151.0.7922.108', wow64: false,
208
- };
209
- const data = {
210
- brands, mobile: false, platform: 'Linux',
211
- getHighEntropyValues: cloak(function getHighEntropyValues(hints) {
212
- const out = { brands, mobile: false, platform: 'Linux' };
213
- (hints || []).forEach((h) => { if (h in highEntropy) out[h] = highEntropy[h]; });
214
- return Promise.resolve(out);
215
- }, 'getHighEntropyValues'),
216
- toJSON: cloak(function toJSON() { return { brands, mobile: false, platform: 'Linux' }; }, 'toJSON'),
217
- };
218
- if (navigator.userAgentData) {
219
- try {
220
- Object.defineProperty(Navigator.prototype, 'userAgentData', { get: cloak(function () { return data; }, 'get userAgentData'), configurable: true });
221
- } catch (_) {}
222
- } else {
259
+ if (!navigator.userAgentData) {
260
+ const grease = '24';
261
+ const brands = [
262
+ { brand: 'Chromium', version: chromeVer },
263
+ { brand: 'Google Chrome', version: chromeVer },
264
+ { brand: 'Not?A_Brand', version: grease },
265
+ ];
266
+ const fullVersionList = [
267
+ { brand: 'Chromium', version: fullVersion },
268
+ { brand: 'Google Chrome', version: fullVersion },
269
+ { brand: 'Not?A_Brand', version: grease + '.0.0.0' },
270
+ ];
271
+ const highEntropy = {
272
+ architecture: profile.architecture,
273
+ bitness: profile.bitness,
274
+ brands,
275
+ fullVersionList,
276
+ mobile: false,
277
+ model: '',
278
+ platform: profile.uaPlatform,
279
+ platformVersion: profile.platformVersion,
280
+ uaFullVersion: fullVersion,
281
+ wow64: false,
282
+ };
283
+ const data = {
284
+ brands, mobile: false, platform: profile.uaPlatform,
285
+ getHighEntropyValues: cloak(function getHighEntropyValues(hints) {
286
+ const out = { brands, mobile: false, platform: profile.uaPlatform };
287
+ (hints || []).forEach((h) => { if (h in highEntropy) out[h] = highEntropy[h]; });
288
+ return Promise.resolve(out);
289
+ }, 'getHighEntropyValues'),
290
+ toJSON: cloak(function toJSON() { return { brands, mobile: false, platform: profile.uaPlatform }; }, 'toJSON'),
291
+ };
223
292
  getter(Navigator.prototype, 'userAgentData', data, 'userAgentData');
224
293
  }
225
294
  } catch (_) {}
226
295
 
227
296
  // --- WebGL vendor / renderer ---
297
+ // Spoofing is applied only when software rendering is detected (SwiftShader /
298
+ // llvmpipe / Mesa OffScreen) or the caller explicitly asked for it. When the
299
+ // engine already reports a real GPU we leave it alone: swapping a real, coherent
300
+ // renderer for a different one only creates contradictions.
228
301
  try {
229
- for (const name of ['WebGLRenderingContext', 'WebGL2RenderingContext']) {
230
- const Ctor = window[name];
231
- if (!Ctor || !Ctor.prototype) continue;
232
- const origGetParameter = Ctor.prototype.getParameter;
233
- const origGetExtension = Ctor.prototype.getExtension;
234
- const getParameter = cloak(function getParameter(p) {
235
- if (p === 37445) return vendor;
236
- if (p === 37446) return renderer;
237
- return origGetParameter.call(this, p);
238
- }, 'getParameter');
239
- const getExtension = cloak(function getExtension(n) {
240
- if (n === 'WEBGL_debug_renderer_info') {
241
- return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
242
- }
243
- return origGetExtension.call(this, n);
244
- }, 'getExtension');
245
- Object.defineProperty(Ctor.prototype, 'getParameter', { value: getParameter, writable: true, configurable: true });
246
- Object.defineProperty(Ctor.prototype, 'getExtension', { value: getExtension, writable: true, configurable: true });
302
+ let softwareGL = false;
303
+ try {
304
+ const probe = document.createElement('canvas');
305
+ const gl = probe.getContext('webgl') || probe.getContext('experimental-webgl');
306
+ if (gl) {
307
+ const dbg = gl.getExtension('WEBGL_debug_renderer_info');
308
+ const real = dbg
309
+ ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) || '')
310
+ : String(gl.getParameter(gl.RENDERER) || '');
311
+ softwareGL = /swiftshader|llvmpipe|software|mesa offscreen|angle \(google/i.test(real);
312
+ }
313
+ } catch (_) {}
314
+
315
+ if (softwareGL || cfg.renderer || cfg.vendor) {
316
+ for (const name of ['WebGLRenderingContext', 'WebGL2RenderingContext']) {
317
+ const Ctor = window[name];
318
+ if (!Ctor || !Ctor.prototype) continue;
319
+ const origGetParameter = Ctor.prototype.getParameter;
320
+ const origGetExtension = Ctor.prototype.getExtension;
321
+ const getParameter = cloak(function getParameter(p) {
322
+ if (p === 37445) return vendor;
323
+ if (p === 37446) return renderer;
324
+ return origGetParameter.call(this, p);
325
+ }, 'getParameter');
326
+ const getExtension = cloak(function getExtension(n) {
327
+ if (n === 'WEBGL_debug_renderer_info') {
328
+ return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
329
+ }
330
+ return origGetExtension.call(this, n);
331
+ }, 'getExtension');
332
+ Object.defineProperty(Ctor.prototype, 'getParameter', { value: getParameter, writable: true, configurable: true });
333
+ Object.defineProperty(Ctor.prototype, 'getExtension', { value: getExtension, writable: true, configurable: true });
334
+ }
247
335
  }
248
336
  } catch (_) {}
249
337
 
@@ -307,11 +395,14 @@ function buildInitScript(cfg) {
307
395
  } catch (_) {}
308
396
 
309
397
  // --- window geometry drift ---
398
+ // outerWidth/outerHeight must be >= the inner ones. Only correct the cases
399
+ // that are actually broken instead of forcing a value that may contradict the
400
+ // real window manager.
310
401
  try {
311
402
  const innerH = window.innerHeight || 900;
312
403
  const innerW = window.innerWidth || 1366;
313
- getter(window, 'outerHeight', innerH + 20, 'outerHeight');
314
- getter(window, 'outerWidth', innerW, 'outerWidth');
404
+ if (!window.outerWidth || window.outerWidth < innerW) getter(window, 'outerWidth', innerW + 16, 'outerWidth');
405
+ if (!window.outerHeight || window.outerHeight < innerH) getter(window, 'outerHeight', innerH + 88, 'outerHeight');
315
406
  } catch (_) {}
316
407
  }
317
408
 
@@ -321,12 +412,15 @@ function buildInitScript(cfg) {
321
412
 
322
413
  function resolveConfig(opts) {
323
414
  opts = opts || {};
415
+ // Only carry explicit overrides. Defaults are resolved in-page from the real
416
+ // browser (see buildInitScript) so the profile cannot contradict the engine.
324
417
  return {
325
- renderer: opts.webglRenderer || opts.renderer || DEFAULT_RENDERER,
326
- vendor: opts.webglVendor || opts.vendor || DEFAULT_VENDOR,
418
+ renderer: opts.webglRenderer || opts.renderer,
419
+ vendor: opts.webglVendor || opts.vendor,
327
420
  hardwareConcurrency: opts.hardwareConcurrency,
328
421
  deviceMemory: opts.deviceMemory,
329
422
  platform: opts.platform,
423
+ languages: opts.languages,
330
424
  };
331
425
  }
332
426
 
@@ -352,10 +446,43 @@ async function installStealth(page, opts) {
352
446
  opts = opts || {};
353
447
  const cfg = resolveConfig(opts);
354
448
  await page.evaluateOnNewDocument(buildInitScript, cfg);
449
+ await fixUserAgent(page, opts);
355
450
  try { page[INSTALLED] = true; } catch (_) {}
356
451
  return page;
357
452
  }
358
453
 
454
+ /**
455
+ * Headless Chrome announces itself as "HeadlessChrome/<ver>" in both the
456
+ * `User-Agent` request header and navigator.userAgent. The header is set by the
457
+ * network stack, so the in-page patches can't touch it; it has to go through
458
+ * the CDP UA override (page.setUserAgent).
459
+ *
460
+ * The UA is read from the running browser (browser.userAgent()) instead of
461
+ * being hardcoded, so the Chrome version always matches the real engine and
462
+ * never goes stale when the bundled Chrome is upgraded.
463
+ *
464
+ * opts.userAgent explicit UA, used as is
465
+ * opts.fixUserAgent set to false to skip
466
+ *
467
+ * No-op when the browser UA has no "HeadlessChrome" token (headful, connect()).
468
+ */
469
+ async function fixUserAgent(page, opts) {
470
+ opts = opts || {};
471
+ if (opts.fixUserAgent === false || typeof page.setUserAgent !== 'function') return page;
472
+ try {
473
+ let ua = opts.userAgent;
474
+ if (!ua) {
475
+ const browser = typeof page.browser === 'function' ? page.browser() : null;
476
+ if (!browser || typeof browser.userAgent !== 'function') return page;
477
+ const current = await browser.userAgent();
478
+ if (!/HeadlessChrome/.test(current)) return page;
479
+ ua = current.replace('HeadlessChrome', 'Chrome');
480
+ }
481
+ await page.setUserAgent(ua);
482
+ } catch (_) {}
483
+ return page;
484
+ }
485
+
359
486
  /**
360
487
  * Also inject the bootstrap into documents that are ALREADY loaded (useful when
361
488
  * you attach with connect() to a page that is already on the target URL).
@@ -468,6 +595,7 @@ module.exports = {
468
595
  buildInitScript,
469
596
  buildSource,
470
597
  installStealth,
598
+ fixUserAgent,
471
599
  injectNow,
472
600
  installOopifHook,
473
601
  newPage,