@webcrawlerapi/stealth 1.0.1 → 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,14 +21,14 @@ 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
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` |
34
34
 
@@ -189,11 +189,12 @@ Optional launcher. Requires puppeteer via `opts.puppeteer` or resolved lazily
189
189
 
190
190
  | Key | Default | Used by |
191
191
  |-----|---------|---------|
192
- | `webglRenderer` / `renderer` | `ANGLE (Intel, ...)` | all |
193
- | `webglVendor` / `vendor` | `Google Inc. (Intel)` | all |
194
- | `hardwareConcurrency` | `8` | all |
195
- | `deviceMemory` | `8` | all |
196
- | `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 |
197
198
  | `origin` / `permissions` | — | `newPage` |
198
199
  | `puppeteer` | lazily resolved | `launch` |
199
200
  | `executablePath` | `$CHROME_PATH` | `launch` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webcrawlerapi/stealth",
3
- "version": "1.0.1",
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;
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.1';
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