├── README.md ├── bin ├── __init__.py ├── results │ ├── chromium_headfull_areuhead.png │ ├── chromium_headfull_sannysoft.png │ ├── chromium_headfull_stealth_areuhead.png │ ├── chromium_headfull_stealth_sannysoft.png │ ├── chromium_headless_areuhead.png │ ├── chromium_headless_stealth_areuhead.png │ ├── chromium_headless_stealth_sannysoft.png │ ├── firefox_headfull_areuhead.png │ ├── firefox_headfull_sannysoft.png │ ├── firefox_headfull_stealth_areuhead.png │ ├── firefox_headfull_stealth_sannysoft.png │ ├── firefox_headless_areuhead.png │ ├── firefox_headless_sannysoft.png │ ├── firefox_headless_stealth_areuhead.png │ └── firefox_headless_stealth_sannysoft.png └── test.py ├── playwright_stealth ├── __init__.py ├── js │ ├── README.md │ ├── __init__.py │ ├── chrome.app.js │ ├── chrome.csi.js │ ├── chrome.loadtimes.js │ ├── chrome.runtime.js │ ├── hairline.js │ ├── iframe.contentWindow.js │ ├── magic-arrays.js │ ├── media.codecs.js │ ├── navigator.hardwareConcurrency.js │ ├── navigator.languages.js │ ├── navigator.permissions.js │ ├── navigator.platform.js │ ├── navigator.plugins.js │ ├── navigator.userAgent.js │ ├── navigator.vendor.js │ ├── navigator.webdriver.js │ ├── sourceurl.js │ ├── user-agent-override.js │ ├── utils.js │ ├── webgl.vendor.js │ └── window.outerdimensions.js └── stealth.py ├── pyproject.toml └── tests ├── test_navigator_webdriver.py └── utils.py /README.md: -------------------------------------------------------------------------------- 1 | # Playwright-Stealth 2 | 3 | Helper scripts for avoiding bot detection of [playwright-python] controlled browsers. 4 | 5 | Usage: 6 | ``` 7 | from playwright_stealth import stealth_async 8 | 9 | async with async_playwright() as pw: 10 | browser = await pw.chromium.launch(headless=True) 11 | page = await browser.newPage() 12 | await stealth_async(page) 13 | await page.goto('https://bot.sannysoft.com/') 14 | await page.screenshot(path='chrome_headless_stealth.png', fullPage=True) 15 | ``` 16 | 17 | For more see `/bin/test_chrome.py` and `/bin/test_firefox.py` 18 | and original puppeteer repo: https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth -------------------------------------------------------------------------------- /bin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/__init__.py -------------------------------------------------------------------------------- /bin/results/chromium_headfull_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headfull_areuhead.png -------------------------------------------------------------------------------- /bin/results/chromium_headfull_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headfull_sannysoft.png -------------------------------------------------------------------------------- /bin/results/chromium_headfull_stealth_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headfull_stealth_areuhead.png -------------------------------------------------------------------------------- /bin/results/chromium_headfull_stealth_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headfull_stealth_sannysoft.png -------------------------------------------------------------------------------- /bin/results/chromium_headless_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headless_areuhead.png -------------------------------------------------------------------------------- /bin/results/chromium_headless_stealth_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headless_stealth_areuhead.png -------------------------------------------------------------------------------- /bin/results/chromium_headless_stealth_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/chromium_headless_stealth_sannysoft.png -------------------------------------------------------------------------------- /bin/results/firefox_headfull_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headfull_areuhead.png -------------------------------------------------------------------------------- /bin/results/firefox_headfull_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headfull_sannysoft.png -------------------------------------------------------------------------------- /bin/results/firefox_headfull_stealth_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headfull_stealth_areuhead.png -------------------------------------------------------------------------------- /bin/results/firefox_headfull_stealth_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headfull_stealth_sannysoft.png -------------------------------------------------------------------------------- /bin/results/firefox_headless_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headless_areuhead.png -------------------------------------------------------------------------------- /bin/results/firefox_headless_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headless_sannysoft.png -------------------------------------------------------------------------------- /bin/results/firefox_headless_stealth_areuhead.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headless_stealth_areuhead.png -------------------------------------------------------------------------------- /bin/results/firefox_headless_stealth_sannysoft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/bin/results/firefox_headless_stealth_sannysoft.png -------------------------------------------------------------------------------- /bin/test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from itertools import product 3 | 4 | from playwright.async_api import async_playwright, Page 5 | 6 | from playwright_stealth.stealth import stealth_async 7 | 8 | 9 | async def capture(pw, name, url, use_stealth=False, headless=False, browser="chromium"): 10 | print(f"crawling {url} with {browser}, head:{headless}, stealth: {use_stealth}") 11 | browser = await getattr(pw, browser).launch(headless=headless) 12 | page: Page = await browser.new_page() 13 | if use_stealth: 14 | await stealth_async(page) 15 | await page.goto(url) 16 | await page.screenshot(path=f"{name}.png", full_page=True) 17 | await page.close() 18 | 19 | 20 | async def main(): 21 | urls = [ 22 | ("sannysoft", "https://bot.sannysoft.com/"), 23 | # ('areuhead', 'http://arh.antoinevastel.com/bots/areyouheadless') 24 | ] 25 | # mix browser_type, use_head, use_stealth — all combinations 26 | mix = product( 27 | ("chromium", "firefox"), # browser type 28 | (True, False), # to use headless mode or not 29 | (True, False), # to use stealth or not 30 | urls 31 | ) 32 | mix = (('chromium', True, True, urls[0]),) 33 | async with async_playwright() as pw: 34 | for browser, use_head, use_stealth, url in mix: 35 | url_name, url = url 36 | await capture( 37 | pw, 38 | f'results/{browser}_head{"less" if use_head else "full"}_{"stealth_" if use_stealth else ""}{url_name}', 39 | use_stealth=use_stealth, 40 | headless=use_head, 41 | url=url, 42 | ) 43 | 44 | 45 | if __name__ == "__main__": 46 | asyncio.run(main()) 47 | -------------------------------------------------------------------------------- /playwright_stealth/__init__.py: -------------------------------------------------------------------------------- 1 | from playwright_stealth.stealth import stealth_sync, stealth_async, StealthConfig 2 | -------------------------------------------------------------------------------- /playwright_stealth/js/README.md: -------------------------------------------------------------------------------- 1 | # Steal Scripts 2 | 3 | 4 | Javascript stealth scripts are located here. 5 | 6 | To load these scripts with playwright use `Page.addInitScript` method which executes the script on page initiation and every `goto` call. 7 | 8 | Mostly taken from https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth/evasions -------------------------------------------------------------------------------- /playwright_stealth/js/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Granitosaurus/playwright-stealth/fbb50332284751db75728eab6900ebd2b7f56446/playwright_stealth/js/__init__.py -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.app.js: -------------------------------------------------------------------------------- 1 | if (!window.chrome) { 2 | // Use the exact property descriptor found in headful Chrome 3 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 4 | Object.defineProperty(window, 'chrome', { 5 | writable: true, 6 | enumerable: true, 7 | configurable: false, // note! 8 | value: {} // We'll extend that later 9 | }) 10 | } 11 | 12 | // app in window.chrome means we're running headful and don't need to mock anything 13 | if (!('app' in window.chrome)) { 14 | const makeError = { 15 | ErrorInInvocation: fn => { 16 | const err = new TypeError(`Error in invocation of app.${fn}()`) 17 | return utils.stripErrorWithAnchor( 18 | err, 19 | `at ${fn} (eval at ` 20 | ) 21 | } 22 | } 23 | 24 | // There's a some static data in that property which doesn't seem to change, 25 | // we should periodically check for updates: `JSON.stringify(window.app, null, 2)` 26 | const APP_STATIC_DATA = JSON.parse( 27 | ` 28 | { 29 | "isInstalled": false, 30 | "InstallState": { 31 | "DISABLED": "disabled", 32 | "INSTALLED": "installed", 33 | "NOT_INSTALLED": "not_installed" 34 | }, 35 | "RunningState": { 36 | "CANNOT_RUN": "cannot_run", 37 | "READY_TO_RUN": "ready_to_run", 38 | "RUNNING": "running" 39 | } 40 | } 41 | `.trim() 42 | ) 43 | 44 | window.chrome.app = { 45 | ...APP_STATIC_DATA, 46 | 47 | get isInstalled() { 48 | return false 49 | }, 50 | 51 | getDetails: function getDetails() { 52 | if (arguments.length) { 53 | throw makeError.ErrorInInvocation(`getDetails`) 54 | } 55 | return null 56 | }, 57 | getIsInstalled: function getDetails() { 58 | if (arguments.length) { 59 | throw makeError.ErrorInInvocation(`getIsInstalled`) 60 | } 61 | return false 62 | }, 63 | runningState: function getDetails() { 64 | if (arguments.length) { 65 | throw makeError.ErrorInInvocation(`runningState`) 66 | } 67 | return 'cannot_run' 68 | } 69 | } 70 | utils.patchToStringNested(window.chrome.app) 71 | } -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.csi.js: -------------------------------------------------------------------------------- 1 | if (!window.chrome) { 2 | // Use the exact property descriptor found in headful Chrome 3 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 4 | Object.defineProperty(window, 'chrome', { 5 | writable: true, 6 | enumerable: true, 7 | configurable: false, // note! 8 | value: {} // We'll extend that later 9 | }) 10 | } 11 | 12 | // Check if we're running headful and don't need to mock anything 13 | // Check that the Navigation Timing API v1 is available, we need that 14 | if (!('csi' in window.chrome) && (window.performance || window.performance.timing)) { 15 | const {csi_timing} = window.performance 16 | 17 | console.log('loading chrome.csi.js') 18 | window.chrome.csi = function () { 19 | return { 20 | onloadT: csi_timing.domContentLoadedEventEnd, 21 | startE: csi_timing.navigationStart, 22 | pageT: Date.now() - csi_timing.navigationStart, 23 | tran: 15 // Transition type or something 24 | } 25 | } 26 | utils.patchToString(window.chrome.csi) 27 | } -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.loadtimes.js: -------------------------------------------------------------------------------- 1 | if (!window.chrome) { 2 | // Use the exact property descriptor found in headful Chrome 3 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 4 | Object.defineProperty(window, 'chrome', { 5 | writable: true, 6 | enumerable: true, 7 | configurable: false, // note! 8 | value: {} // We'll extend that later 9 | }) 10 | } 11 | 12 | // That means we're running headful and don't need to mock anything 13 | if ('loadTimes' in window.chrome) { 14 | throw new Error('skipping chrome loadtimes update, running in headfull mode') 15 | } 16 | 17 | // Check that the Navigation Timing API v1 + v2 is available, we need that 18 | if ( 19 | window.performance || 20 | window.performance.timing || 21 | window.PerformancePaintTiming 22 | ) { 23 | 24 | console.log('loading chrome.loadtimes.js') 25 | const {performance} = window 26 | 27 | // Some stuff is not available on about:blank as it requires a navigation to occur, 28 | // let's harden the code to not fail then: 29 | const ntEntryFallback = { 30 | nextHopProtocol: 'h2', 31 | type: 'other' 32 | } 33 | 34 | // The API exposes some funky info regarding the connection 35 | const protocolInfo = { 36 | get connectionInfo() { 37 | const ntEntry = 38 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 39 | return ntEntry.nextHopProtocol 40 | }, 41 | get npnNegotiatedProtocol() { 42 | // NPN is deprecated in favor of ALPN, but this implementation returns the 43 | // HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 44 | const ntEntry = 45 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 46 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 47 | ? ntEntry.nextHopProtocol 48 | : 'unknown' 49 | }, 50 | get navigationType() { 51 | const ntEntry = 52 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 53 | return ntEntry.type 54 | }, 55 | get wasAlternateProtocolAvailable() { 56 | // The Alternate-Protocol header is deprecated in favor of Alt-Svc 57 | // (https://www.mnot.net/blog/2016/03/09/alt-svc), so technically this 58 | // should always return false. 59 | return false 60 | }, 61 | get wasFetchedViaSpdy() { 62 | // SPDY is deprecated in favor of HTTP/2, but this implementation returns 63 | // true for HTTP/2 or HTTP2+QUIC/39 as well. 64 | const ntEntry = 65 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 66 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 67 | }, 68 | get wasNpnNegotiated() { 69 | // NPN is deprecated in favor of ALPN, but this implementation returns true 70 | // for HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 71 | const ntEntry = 72 | performance.getEntriesByType('navigation')[0] || ntEntryFallback 73 | return ['h2', 'hq'].includes(ntEntry.nextHopProtocol) 74 | } 75 | } 76 | 77 | const {timing} = window.performance 78 | 79 | // Truncate number to specific number of decimals, most of the `loadTimes` stuff has 3 80 | function toFixed(num, fixed) { 81 | var re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?') 82 | return num.toString().match(re)[0] 83 | } 84 | 85 | const timingInfo = { 86 | get firstPaintAfterLoadTime() { 87 | // This was never actually implemented and always returns 0. 88 | return 0 89 | }, 90 | get requestTime() { 91 | return timing.navigationStart / 1000 92 | }, 93 | get startLoadTime() { 94 | return timing.navigationStart / 1000 95 | }, 96 | get commitLoadTime() { 97 | return timing.responseStart / 1000 98 | }, 99 | get finishDocumentLoadTime() { 100 | return timing.domContentLoadedEventEnd / 1000 101 | }, 102 | get finishLoadTime() { 103 | return timing.loadEventEnd / 1000 104 | }, 105 | get firstPaintTime() { 106 | const fpEntry = performance.getEntriesByType('paint')[0] || { 107 | startTime: timing.loadEventEnd / 1000 // Fallback if no navigation occured (`about:blank`) 108 | } 109 | return toFixed( 110 | (fpEntry.startTime + performance.timeOrigin) / 1000, 111 | 3 112 | ) 113 | } 114 | } 115 | 116 | window.chrome.loadTimes = function () { 117 | return { 118 | ...protocolInfo, 119 | ...timingInfo 120 | } 121 | } 122 | utils.patchToString(window.chrome.loadTimes) 123 | } -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.runtime.js: -------------------------------------------------------------------------------- 1 | const STATIC_DATA = { 2 | "OnInstalledReason": { 3 | "CHROME_UPDATE": "chrome_update", 4 | "INSTALL": "install", 5 | "SHARED_MODULE_UPDATE": "shared_module_update", 6 | "UPDATE": "update" 7 | }, 8 | "OnRestartRequiredReason": { 9 | "APP_UPDATE": "app_update", 10 | "OS_UPDATE": "os_update", 11 | "PERIODIC": "periodic" 12 | }, 13 | "PlatformArch": { 14 | "ARM": "arm", 15 | "ARM64": "arm64", 16 | "MIPS": "mips", 17 | "MIPS64": "mips64", 18 | "X86_32": "x86-32", 19 | "X86_64": "x86-64" 20 | }, 21 | "PlatformNaclArch": { 22 | "ARM": "arm", 23 | "MIPS": "mips", 24 | "MIPS64": "mips64", 25 | "X86_32": "x86-32", 26 | "X86_64": "x86-64" 27 | }, 28 | "PlatformOs": { 29 | "ANDROID": "android", 30 | "CROS": "cros", 31 | "LINUX": "linux", 32 | "MAC": "mac", 33 | "OPENBSD": "openbsd", 34 | "WIN": "win" 35 | }, 36 | "RequestUpdateCheckStatus": { 37 | "NO_UPDATE": "no_update", 38 | "THROTTLED": "throttled", 39 | "UPDATE_AVAILABLE": "update_available" 40 | } 41 | } 42 | 43 | if (!window.chrome) { 44 | // Use the exact property descriptor found in headful Chrome 45 | // fetch it via `Object.getOwnPropertyDescriptor(window, 'chrome')` 46 | Object.defineProperty(window, 'chrome', { 47 | writable: true, 48 | enumerable: true, 49 | configurable: false, // note! 50 | value: {} // We'll extend that later 51 | }) 52 | } 53 | 54 | // That means we're running headfull and don't need to mock anything 55 | const existsAlready = 'runtime' in window.chrome 56 | // `chrome.runtime` is only exposed on secure origins 57 | const isNotSecure = !window.location.protocol.startsWith('https') 58 | if (!(existsAlready || (isNotSecure && !opts.runOnInsecureOrigins))) { 59 | console.log('loading chrome.runtime.js') 60 | window.chrome.runtime = { 61 | // There's a bunch of static data in that property which doesn't seem to change, 62 | // we should periodically check for updates: `JSON.stringify(window.chrome.runtime, null, 2)` 63 | ...STATIC_DATA, 64 | // `chrome.runtime.id` is extension related and returns undefined in Chrome 65 | get id() { 66 | return undefined 67 | }, 68 | // These two require more sophisticated mocks 69 | connect: null, 70 | sendMessage: null 71 | } 72 | 73 | const makeCustomRuntimeErrors = (preamble, method, extensionId) => ({ 74 | NoMatchingSignature: new TypeError( 75 | preamble + `No matching signature.` 76 | ), 77 | MustSpecifyExtensionID: new TypeError( 78 | preamble + 79 | `${method} called from a webpage must specify an Extension ID (string) for its first argument.` 80 | ), 81 | InvalidExtensionID: new TypeError( 82 | preamble + `Invalid extension id: '${extensionId}'` 83 | ) 84 | }) 85 | 86 | // Valid Extension IDs are 32 characters in length and use the letter `a` to `p`: 87 | // https://source.chromium.org/chromium/chromium/src/+/master:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90 88 | const isValidExtensionID = str => 89 | str.length === 32 && str.toLowerCase().match(/^[a-p]+$/) 90 | 91 | /** Mock `chrome.runtime.sendMessage` */ 92 | const sendMessageHandler = { 93 | apply: function (target, ctx, args) { 94 | const [extensionId, options, responseCallback] = args || [] 95 | 96 | // Define custom errors 97 | const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): ` 98 | const Errors = makeCustomRuntimeErrors( 99 | errorPreamble, 100 | `chrome.runtime.sendMessage()`, 101 | extensionId 102 | ) 103 | 104 | // Check if the call signature looks ok 105 | const noArguments = args.length === 0 106 | const tooManyArguments = args.length > 4 107 | const incorrectOptions = options && typeof options !== 'object' 108 | const incorrectResponseCallback = 109 | responseCallback && typeof responseCallback !== 'function' 110 | if ( 111 | noArguments || 112 | tooManyArguments || 113 | incorrectOptions || 114 | incorrectResponseCallback 115 | ) { 116 | throw Errors.NoMatchingSignature 117 | } 118 | 119 | // At least 2 arguments are required before we even validate the extension ID 120 | if (args.length < 2) { 121 | throw Errors.MustSpecifyExtensionID 122 | } 123 | 124 | // Now let's make sure we got a string as extension ID 125 | if (typeof extensionId !== 'string') { 126 | throw Errors.NoMatchingSignature 127 | } 128 | 129 | if (!isValidExtensionID(extensionId)) { 130 | throw Errors.InvalidExtensionID 131 | } 132 | 133 | return undefined // Normal behavior 134 | } 135 | } 136 | utils.mockWithProxy( 137 | window.chrome.runtime, 138 | 'sendMessage', 139 | function sendMessage() { 140 | }, 141 | sendMessageHandler 142 | ) 143 | 144 | /** 145 | * Mock `chrome.runtime.connect` 146 | * 147 | * @see https://developer.chrome.com/apps/runtime#method-connect 148 | */ 149 | const connectHandler = { 150 | apply: function (target, ctx, args) { 151 | const [extensionId, connectInfo] = args || [] 152 | 153 | // Define custom errors 154 | const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): ` 155 | const Errors = makeCustomRuntimeErrors( 156 | errorPreamble, 157 | `chrome.runtime.connect()`, 158 | extensionId 159 | ) 160 | 161 | // Behavior differs a bit from sendMessage: 162 | const noArguments = args.length === 0 163 | const emptyStringArgument = args.length === 1 && extensionId === '' 164 | if (noArguments || emptyStringArgument) { 165 | throw Errors.MustSpecifyExtensionID 166 | } 167 | 168 | const tooManyArguments = args.length > 2 169 | const incorrectConnectInfoType = 170 | connectInfo && typeof connectInfo !== 'object' 171 | 172 | if (tooManyArguments || incorrectConnectInfoType) { 173 | throw Errors.NoMatchingSignature 174 | } 175 | 176 | const extensionIdIsString = typeof extensionId === 'string' 177 | if (extensionIdIsString && extensionId === '') { 178 | throw Errors.MustSpecifyExtensionID 179 | } 180 | if (extensionIdIsString && !isValidExtensionID(extensionId)) { 181 | throw Errors.InvalidExtensionID 182 | } 183 | 184 | // There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate 185 | const validateConnectInfo = ci => { 186 | // More than a first param connectInfo as been provided 187 | if (args.length > 1) { 188 | throw Errors.NoMatchingSignature 189 | } 190 | // An empty connectInfo has been provided 191 | if (Object.keys(ci).length === 0) { 192 | throw Errors.MustSpecifyExtensionID 193 | } 194 | // Loop over all connectInfo props an check them 195 | Object.entries(ci).forEach(([k, v]) => { 196 | const isExpected = ['name', 'includeTlsChannelId'].includes(k) 197 | if (!isExpected) { 198 | throw new TypeError( 199 | errorPreamble + `Unexpected property: '${k}'.` 200 | ) 201 | } 202 | const MismatchError = (propName, expected, found) => 203 | TypeError( 204 | errorPreamble + 205 | `Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.` 206 | ) 207 | if (k === 'name' && typeof v !== 'string') { 208 | throw MismatchError(k, 'string', typeof v) 209 | } 210 | if (k === 'includeTlsChannelId' && typeof v !== 'boolean') { 211 | throw MismatchError(k, 'boolean', typeof v) 212 | } 213 | }) 214 | } 215 | if (typeof extensionId === 'object') { 216 | validateConnectInfo(extensionId) 217 | throw Errors.MustSpecifyExtensionID 218 | } 219 | 220 | // Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well 221 | return utils.patchToStringNested(makeConnectResponse()) 222 | } 223 | } 224 | utils.mockWithProxy( 225 | window.chrome.runtime, 226 | 'connect', 227 | function connect() { 228 | }, 229 | connectHandler 230 | ) 231 | 232 | function makeConnectResponse() { 233 | const onSomething = () => ({ 234 | addListener: function addListener() { 235 | }, 236 | dispatch: function dispatch() { 237 | }, 238 | hasListener: function hasListener() { 239 | }, 240 | hasListeners: function hasListeners() { 241 | return false 242 | }, 243 | removeListener: function removeListener() { 244 | } 245 | }) 246 | 247 | const response = { 248 | name: '', 249 | sender: undefined, 250 | disconnect: function disconnect() { 251 | }, 252 | onDisconnect: onSomething(), 253 | onMessage: onSomething(), 254 | postMessage: function postMessage() { 255 | if (!arguments.length) { 256 | throw new TypeError(`Insufficient number of arguments.`) 257 | } 258 | throw new Error(`Attempting to use a disconnected port object`) 259 | } 260 | } 261 | return response 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /playwright_stealth/js/hairline.js: -------------------------------------------------------------------------------- 1 | // https://intoli.com/blog/making-chrome-headless-undetectable/ 2 | // store the existing descriptor 3 | const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); 4 | 5 | // redefine the property with a patched descriptor 6 | Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { 7 | ...elementDescriptor, 8 | get: function() { 9 | if (this.id === 'modernizr') { 10 | return 1; 11 | } 12 | return elementDescriptor.get.apply(this); 13 | }, 14 | }); -------------------------------------------------------------------------------- /playwright_stealth/js/iframe.contentWindow.js: -------------------------------------------------------------------------------- 1 | // Fix for the HEADCHR_IFRAME detection (iframe.contentWindow.chrome), hopefully this time without breaking iframes. 2 | // Note: Only srcdoc powered iframes cause issues due to a chromium bug: 3 | // https://github.com/puppeteer/puppeteer/issues/1106 4 | 5 | try { 6 | // Adds a contentWindow proxy to the provided iframe element 7 | const addContentWindowProxy = iframe => { 8 | const contentWindowProxy = { 9 | get(target, key) { 10 | // Now to the interesting part: 11 | // We actually make this thing behave like a regular iframe window, 12 | // by intercepting calls to e.g. `.self` and redirect it to the correct thing. :) 13 | // That makes it possible for these assertions to be correct: 14 | // iframe.contentWindow.self === window.top // must be false 15 | if (key === 'self') { 16 | return this 17 | } 18 | // iframe.contentWindow.frameElement === iframe // must be true 19 | if (key === 'frameElement') { 20 | return iframe 21 | } 22 | // Intercept iframe.contentWindow[0] to hide the property 0 added by the proxy. 23 | if (key === '0') { 24 | return undefined 25 | } 26 | return Reflect.get(target, key) 27 | } 28 | } 29 | 30 | if (!iframe.contentWindow) { 31 | const proxy = new Proxy(window, contentWindowProxy) 32 | Object.defineProperty(iframe, 'contentWindow', { 33 | get() { 34 | return proxy 35 | }, 36 | set(newValue) { 37 | return newValue // contentWindow is immutable 38 | }, 39 | enumerable: true, 40 | configurable: false 41 | }) 42 | } 43 | } 44 | 45 | // Handles iframe element creation, augments `srcdoc` property so we can intercept further 46 | const handleIframeCreation = (target, thisArg, args) => { 47 | const iframe = target.apply(thisArg, args) 48 | 49 | // We need to keep the originals around 50 | const _iframe = iframe 51 | const _srcdoc = _iframe.srcdoc 52 | 53 | // Add hook for the srcdoc property 54 | // We need to be very surgical here to not break other iframes by accident 55 | Object.defineProperty(iframe, 'srcdoc', { 56 | configurable: true, // Important, so we can reset this later 57 | get: function () { 58 | return _srcdoc 59 | }, 60 | set: function (newValue) { 61 | addContentWindowProxy(this) 62 | // Reset property, the hook is only needed once 63 | Object.defineProperty(iframe, 'srcdoc', { 64 | configurable: false, 65 | writable: false, 66 | value: _srcdoc 67 | }) 68 | _iframe.srcdoc = newValue 69 | } 70 | }) 71 | return iframe 72 | } 73 | 74 | // Adds a hook to intercept iframe creation events 75 | const addIframeCreationSniffer = () => { 76 | /* global document */ 77 | const createElementHandler = { 78 | // Make toString() native 79 | get(target, key) { 80 | return Reflect.get(target, key) 81 | }, 82 | apply: function (target, thisArg, args) { 83 | const isIframe = 84 | args && args.length && `${args[0]}`.toLowerCase() === 'iframe' 85 | if (!isIframe) { 86 | // Everything as usual 87 | return target.apply(thisArg, args) 88 | } else { 89 | return handleIframeCreation(target, thisArg, args) 90 | } 91 | } 92 | } 93 | // All this just due to iframes with srcdoc bug 94 | utils.replaceWithProxy( 95 | document, 96 | 'createElement', 97 | createElementHandler 98 | ) 99 | } 100 | 101 | // Let's go 102 | addIframeCreationSniffer() 103 | } catch (err) { 104 | // console.warn(err) 105 | } -------------------------------------------------------------------------------- /playwright_stealth/js/magic-arrays.js: -------------------------------------------------------------------------------- 1 | generateFunctionMocks = ( 2 | proto, 3 | itemMainProp, 4 | dataArray 5 | ) => ({ 6 | item: utils.createProxy(proto.item, { 7 | apply(target, ctx, args) { 8 | if (!args.length) { 9 | throw new TypeError( 10 | `Failed to execute 'item' on '${ 11 | proto[Symbol.toStringTag] 12 | }': 1 argument required, but only 0 present.` 13 | ) 14 | } 15 | // Special behavior alert: 16 | // - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup 17 | // - If anything else than an integer (including as string) is provided it will return the first entry 18 | const isInteger = args[0] && Number.isInteger(Number(args[0])) // Cast potential string to number first, then check for integer 19 | // Note: Vanilla never returns `undefined` 20 | return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null 21 | } 22 | }), 23 | /** Returns the MimeType object with the specified name. */ 24 | namedItem: utils.createProxy(proto.namedItem, { 25 | apply(target, ctx, args) { 26 | if (!args.length) { 27 | throw new TypeError( 28 | `Failed to execute 'namedItem' on '${ 29 | proto[Symbol.toStringTag] 30 | }': 1 argument required, but only 0 present.` 31 | ) 32 | } 33 | return dataArray.find(mt => mt[itemMainProp] === args[0]) || null // Not `undefined`! 34 | } 35 | }), 36 | /** Does nothing and shall return nothing */ 37 | refresh: proto.refresh 38 | ? utils.createProxy(proto.refresh, { 39 | apply(target, ctx, args) { 40 | return undefined 41 | } 42 | }) 43 | : undefined 44 | }) 45 | 46 | function generateMagicArray( 47 | dataArray = [], 48 | proto = MimeTypeArray.prototype, 49 | itemProto = MimeType.prototype, 50 | itemMainProp = 'type' 51 | ) { 52 | // Quick helper to set props with the same descriptors vanilla is using 53 | const defineProp = (obj, prop, value) => 54 | Object.defineProperty(obj, prop, { 55 | value, 56 | writable: false, 57 | enumerable: false, // Important for mimeTypes & plugins: `JSON.stringify(navigator.mimeTypes)` 58 | configurable: false 59 | }) 60 | 61 | // Loop over our fake data and construct items 62 | const makeItem = data => { 63 | const item = {} 64 | for (const prop of Object.keys(data)) { 65 | if (prop.startsWith('__')) { 66 | continue 67 | } 68 | defineProp(item, prop, data[prop]) 69 | } 70 | // navigator.plugins[i].length should always be 1 71 | if (itemProto === Plugin.prototype) { 72 | defineProp(item, 'length', 1) 73 | } 74 | // We need to spoof a specific `MimeType` or `Plugin` object 75 | return Object.create(itemProto, Object.getOwnPropertyDescriptors(item)) 76 | } 77 | 78 | const magicArray = [] 79 | 80 | // Loop through our fake data and use that to create convincing entities 81 | dataArray.forEach(data => { 82 | magicArray.push(makeItem(data)) 83 | }) 84 | 85 | // Add direct property access based on types (e.g. `obj['application/pdf']`) afterwards 86 | magicArray.forEach(entry => { 87 | defineProp(magicArray, entry[itemMainProp], entry) 88 | }) 89 | 90 | // This is the best way to fake the type to make sure this is false: `Array.isArray(navigator.mimeTypes)` 91 | const magicArrayObj = Object.create(proto, { 92 | ...Object.getOwnPropertyDescriptors(magicArray), 93 | 94 | // There's one ugly quirk we unfortunately need to take care of: 95 | // The `MimeTypeArray` prototype has an enumerable `length` property, 96 | // but headful Chrome will still skip it when running `Object.getOwnPropertyNames(navigator.mimeTypes)`. 97 | // To strip it we need to make it first `configurable` and can then overlay a Proxy with an `ownKeys` trap. 98 | length: { 99 | value: magicArray.length, 100 | writable: false, 101 | enumerable: false, 102 | configurable: true // Important to be able to use the ownKeys trap in a Proxy to strip `length` 103 | } 104 | }) 105 | 106 | // Generate our functional function mocks :-) 107 | const functionMocks = generateFunctionMocks( 108 | proto, 109 | itemMainProp, 110 | magicArray 111 | ) 112 | 113 | // We need to overlay our custom object with a JS Proxy 114 | const magicArrayObjProxy = new Proxy(magicArrayObj, { 115 | get(target, key = '') { 116 | // Redirect function calls to our custom proxied versions mocking the vanilla behavior 117 | if (key === 'item') { 118 | return functionMocks.item 119 | } 120 | if (key === 'namedItem') { 121 | return functionMocks.namedItem 122 | } 123 | if (proto === PluginArray.prototype && key === 'refresh') { 124 | return functionMocks.refresh 125 | } 126 | // Everything else can pass through as normal 127 | return utils.cache.Reflect.get(...arguments) 128 | }, 129 | ownKeys(target) { 130 | // There are a couple of quirks where the original property demonstrates "magical" behavior that makes no sense 131 | // This can be witnessed when calling `Object.getOwnPropertyNames(navigator.mimeTypes)` and the absense of `length` 132 | // My guess is that it has to do with the recent change of not allowing data enumeration and this being implemented weirdly 133 | // For that reason we just completely fake the available property names based on our data to match what regular Chrome is doing 134 | // Specific issues when not patching this: `length` property is available, direct `types` props (e.g. `obj['application/pdf']`) are missing 135 | const keys = [] 136 | const typeProps = magicArray.map(mt => mt[itemMainProp]) 137 | typeProps.forEach((_, i) => keys.push(`${i}`)) 138 | typeProps.forEach(propName => keys.push(propName)) 139 | return keys 140 | } 141 | }) 142 | 143 | return magicArrayObjProxy 144 | } -------------------------------------------------------------------------------- /playwright_stealth/js/media.codecs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Input might look funky, we need to normalize it so e.g. whitespace isn't an issue for our spoofing. 3 | * 4 | * @example 5 | * video/webm; codecs="vp8, vorbis" 6 | * video/mp4; codecs="avc1.42E01E" 7 | * audio/x-m4a; 8 | * audio/ogg; codecs="vorbis" 9 | * @param {String} arg 10 | */ 11 | const parseInput = arg => { 12 | const [mime, codecStr] = arg.trim().split(';') 13 | let codecs = [] 14 | if (codecStr && codecStr.includes('codecs="')) { 15 | codecs = codecStr 16 | .trim() 17 | .replace(`codecs="`, '') 18 | .replace(`"`, '') 19 | .trim() 20 | .split(',') 21 | .filter(x => !!x) 22 | .map(x => x.trim()) 23 | } 24 | return { 25 | mime, 26 | codecStr, 27 | codecs 28 | } 29 | } 30 | 31 | const canPlayType = { 32 | // Intercept certain requests 33 | apply: function (target, ctx, args) { 34 | if (!args || !args.length) { 35 | return target.apply(ctx, args) 36 | } 37 | const {mime, codecs} = parseInput(args[0]) 38 | // This specific mp4 codec is missing in Chromium 39 | if (mime === 'video/mp4') { 40 | if (codecs.includes('avc1.42E01E')) { 41 | return 'probably' 42 | } 43 | } 44 | // This mimetype is only supported if no codecs are specified 45 | if (mime === 'audio/x-m4a' && !codecs.length) { 46 | return 'maybe' 47 | } 48 | 49 | // This mimetype is only supported if no codecs are specified 50 | if (mime === 'audio/aac' && !codecs.length) { 51 | return 'probably' 52 | } 53 | // Everything else as usual 54 | return target.apply(ctx, args) 55 | } 56 | } 57 | 58 | /* global HTMLMediaElement */ 59 | utils.replaceWithProxy( 60 | HTMLMediaElement.prototype, 61 | 'canPlayType', 62 | canPlayType 63 | ) -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.hardwareConcurrency.js: -------------------------------------------------------------------------------- 1 | // Set the hardwareConcurrency to 4 (optionally configurable with hardwareConcurrency) 2 | (utils) => { 3 | utils.replaceGetterWithProxy( 4 | Object.getPrototypeOf(navigator), 5 | 'hardwareConcurrency', 6 | utils.makeHandler().getterValue(opts.hardwareConcurrency) 7 | ) 8 | }, { 9 | opts: this.opts 10 | } -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.languages.js: -------------------------------------------------------------------------------- 1 | () => { 2 | const languages = opts.languages.length 3 | ? opts.languages 4 | : ['en-US', 'en'] 5 | utils.replaceGetterWithProxy( 6 | Object.getPrototypeOf(navigator), 7 | 'languages', 8 | utils.makeHandler().getterValue(Object.freeze([...languages])) 9 | ) 10 | } -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.permissions.js: -------------------------------------------------------------------------------- 1 | const handler = { 2 | apply: function (target, ctx, args) { 3 | const param = (args || [])[0] 4 | 5 | if (param && param.name && param.name === 'notifications') { 6 | const result = {state: Notification.permission} 7 | Object.setPrototypeOf(result, PermissionStatus.prototype) 8 | return Promise.resolve(result) 9 | } 10 | 11 | return utils.cache.Reflect.apply(...arguments) 12 | } 13 | } 14 | 15 | utils.replaceWithProxy( 16 | window.navigator.permissions.__proto__, // eslint-disable-line no-proto 17 | 'query', 18 | handler 19 | ) 20 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.platform.js: -------------------------------------------------------------------------------- 1 | if (opts.navigator_platform) { 2 | Object.defineProperty(Object.getPrototypeOf(navigator), 'platform', { 3 | get: () => opts.navigator_plaftorm, 4 | }) 5 | } -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.plugins.js: -------------------------------------------------------------------------------- 1 | data = { 2 | "mimeTypes": [ 3 | { 4 | "type": "application/pdf", 5 | "suffixes": "pdf", 6 | "description": "", 7 | "__pluginName": "Chrome PDF Viewer" 8 | }, 9 | { 10 | "type": "application/x-google-chrome-pdf", 11 | "suffixes": "pdf", 12 | "description": "Portable Document Format", 13 | "__pluginName": "Chrome PDF Plugin" 14 | }, 15 | { 16 | "type": "application/x-nacl", 17 | "suffixes": "", 18 | "description": "Native Client Executable", 19 | "__pluginName": "Native Client" 20 | }, 21 | { 22 | "type": "application/x-pnacl", 23 | "suffixes": "", 24 | "description": "Portable Native Client Executable", 25 | "__pluginName": "Native Client" 26 | } 27 | ], 28 | "plugins": [ 29 | { 30 | "name": "Chrome PDF Plugin", 31 | "filename": "internal-pdf-viewer", 32 | "description": "Portable Document Format", 33 | "__mimeTypes": ["application/x-google-chrome-pdf"] 34 | }, 35 | { 36 | "name": "Chrome PDF Viewer", 37 | "filename": "mhjfbmdgcfjbbpaeojofohoefgiehjai", 38 | "description": "", 39 | "__mimeTypes": ["application/pdf"] 40 | }, 41 | { 42 | "name": "Native Client", 43 | "filename": "internal-nacl-plugin", 44 | "description": "", 45 | "__mimeTypes": ["application/x-nacl", "application/x-pnacl"] 46 | } 47 | ] 48 | } 49 | 50 | 51 | // That means we're running headful 52 | const hasPlugins = 'plugins' in navigator && navigator.plugins.length 53 | if (!(hasPlugins)) { 54 | 55 | console.log('loading navigator.plugins.js') 56 | const mimeTypes = generateMagicArray( 57 | data.mimeTypes, 58 | MimeTypeArray.prototype, 59 | MimeType.prototype, 60 | 'type' 61 | ) 62 | const plugins = generateMagicArray( 63 | data.plugins, 64 | PluginArray.prototype, 65 | Plugin.prototype, 66 | 'name' 67 | ) 68 | 69 | // Plugin and MimeType cross-reference each other, let's do that now 70 | // Note: We're looping through `data.plugins` here, not the generated `plugins` 71 | for (const pluginData of data.plugins) { 72 | pluginData.__mimeTypes.forEach((type, index) => { 73 | plugins[pluginData.name][index] = mimeTypes[type] 74 | plugins[type] = mimeTypes[type] 75 | Object.defineProperty(mimeTypes[type], 'enabledPlugin', { 76 | value: JSON.parse(JSON.stringify(plugins[pluginData.name])), 77 | writable: false, 78 | enumerable: false, // Important: `JSON.stringify(navigator.plugins)` 79 | configurable: false 80 | }) 81 | }) 82 | } 83 | 84 | const patchNavigator = (name, value) => 85 | utils.replaceProperty(Object.getPrototypeOf(navigator), name, { 86 | get() { 87 | return value 88 | } 89 | }) 90 | 91 | patchNavigator('mimeTypes', mimeTypes) 92 | patchNavigator('plugins', plugins) 93 | } -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.userAgent.js: -------------------------------------------------------------------------------- 1 | // replace Headless references in default useragent 2 | const current_ua = navigator.userAgent 3 | Object.defineProperty(Object.getPrototypeOf(navigator), 'userAgent', { 4 | get: () => opts.navigator_user_agent || current_ua.replace('HeadlessChrome/', 'Chrome/') 5 | }) 6 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.vendor.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(Object.getPrototypeOf(navigator), 'vendor', { 2 | get: () => opts.navigator_vendor || 'Google Inc.', 3 | }) 4 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.webdriver.js: -------------------------------------------------------------------------------- 1 | if (navigator.webdriver === false) { 2 | // Post Chrome 89.0.4339.0 and already good 3 | } else if (navigator.webdriver === undefined) { 4 | // Pre Chrome 89.0.4339.0 and already good 5 | } else { 6 | // Pre Chrome 88.0.4291.0 and needs patching 7 | delete Object.getPrototypeOf(navigator).webdriver 8 | } -------------------------------------------------------------------------------- /playwright_stealth/js/sourceurl.js: -------------------------------------------------------------------------------- 1 | // TODO https://github.com/berstend/puppeteer-extra/blob/master/packages/puppeteer-extra-plugin-stealth/evasions/sourceurl/index.js 2 | // seems -------------------------------------------------------------------------------- /playwright_stealth/js/user-agent-override.js: -------------------------------------------------------------------------------- 1 | // TODO: this should be python 2 | const override = { 3 | userAgent: 4 | this.opts.userAgent || 5 | (await page.browser().userAgent()).replace( 6 | 'HeadlessChrome/', 7 | 'Chrome/' 8 | ), 9 | acceptLanguage: this.opts.locale || 'en-US,en', 10 | platform: this.opts.platform || 'Win32' 11 | } 12 | 13 | console.log('onPageCreated - Will set these user agent options', { 14 | override, 15 | opts: this.opts 16 | }) 17 | 18 | page._client.send('Network.setUserAgentOverride', override) -------------------------------------------------------------------------------- /playwright_stealth/js/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A set of shared utility functions specifically for the purpose of modifying native browser APIs without leaving traces. 3 | * 4 | * Meant to be passed down in puppeteer and used in the context of the page (everything in here runs in NodeJS as well as a browser). 5 | * 6 | * Note: If for whatever reason you need to use this outside of `puppeteer-extra`: 7 | * Just remove the `module.exports` statement at the very bottom, the rest can be copy pasted into any browser context. 8 | * 9 | * Alternatively take a look at the `extract-stealth-evasions` package to create a finished bundle which includes these utilities. 10 | * 11 | */ 12 | const utils = {} 13 | 14 | utils.init = () => { 15 | utils.preloadCache() 16 | } 17 | 18 | /** 19 | * Wraps a JS Proxy Handler and strips it's presence from error stacks, in case the traps throw. 20 | * 21 | * The presence of a JS Proxy can be revealed as it shows up in error stack traces. 22 | * 23 | * @param {object} handler - The JS Proxy handler to wrap 24 | */ 25 | utils.stripProxyFromErrors = (handler = {}) => { 26 | const newHandler = { 27 | setPrototypeOf: function (target, proto) { 28 | if (proto === null) 29 | throw new TypeError('Cannot convert object to primitive value') 30 | if (Object.getPrototypeOf(target) === Object.getPrototypeOf(proto)) { 31 | throw new TypeError('Cyclic __proto__ value') 32 | } 33 | return Reflect.setPrototypeOf(target, proto) 34 | } 35 | } 36 | // We wrap each trap in the handler in a try/catch and modify the error stack if they throw 37 | const traps = Object.getOwnPropertyNames(handler) 38 | traps.forEach(trap => { 39 | newHandler[trap] = function () { 40 | try { 41 | // Forward the call to the defined proxy handler 42 | return handler[trap].apply(this, arguments || []) 43 | } catch (err) { 44 | // Stack traces differ per browser, we only support chromium based ones currently 45 | if (!err || !err.stack || !err.stack.includes(`at `)) { 46 | throw err 47 | } 48 | 49 | // When something throws within one of our traps the Proxy will show up in error stacks 50 | // An earlier implementation of this code would simply strip lines with a blacklist, 51 | // but it makes sense to be more surgical here and only remove lines related to our Proxy. 52 | // We try to use a known "anchor" line for that and strip it with everything above it. 53 | // If the anchor line cannot be found for some reason we fall back to our blacklist approach. 54 | 55 | const stripWithBlacklist = (stack, stripFirstLine = true) => { 56 | const blacklist = [ 57 | `at Reflect.${trap} `, // e.g. Reflect.get or Reflect.apply 58 | `at Object.${trap} `, // e.g. Object.get or Object.apply 59 | `at Object.newHandler. [as ${trap}] ` // caused by this very wrapper :-) 60 | ] 61 | return ( 62 | err.stack 63 | .split('\n') 64 | // Always remove the first (file) line in the stack (guaranteed to be our proxy) 65 | .filter((line, index) => !(index === 1 && stripFirstLine)) 66 | // Check if the line starts with one of our blacklisted strings 67 | .filter(line => !blacklist.some(bl => line.trim().startsWith(bl))) 68 | .join('\n') 69 | ) 70 | } 71 | 72 | const stripWithAnchor = (stack, anchor) => { 73 | const stackArr = stack.split('\n') 74 | anchor = anchor || `at Object.newHandler. [as ${trap}] ` // Known first Proxy line in chromium 75 | const anchorIndex = stackArr.findIndex(line => 76 | line.trim().startsWith(anchor) 77 | ) 78 | if (anchorIndex === -1) { 79 | return false // 404, anchor not found 80 | } 81 | // Strip everything from the top until we reach the anchor line 82 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 83 | stackArr.splice(1, anchorIndex) 84 | return stackArr.join('\n') 85 | } 86 | 87 | // Special cases due to our nested toString proxies 88 | err.stack = err.stack.replace( 89 | 'at Object.toString (', 90 | 'at Function.toString (' 91 | ) 92 | if ((err.stack || '').includes('at Function.toString (')) { 93 | err.stack = stripWithBlacklist(err.stack, false) 94 | throw err 95 | } 96 | 97 | // Try using the anchor method, fallback to blacklist if necessary 98 | err.stack = stripWithAnchor(err.stack) || stripWithBlacklist(err.stack) 99 | 100 | throw err // Re-throw our now sanitized error 101 | } 102 | } 103 | }) 104 | return newHandler 105 | } 106 | 107 | /** 108 | * Strip error lines from stack traces until (and including) a known line the stack. 109 | * 110 | * @param {object} err - The error to sanitize 111 | * @param {string} anchor - The string the anchor line starts with 112 | */ 113 | utils.stripErrorWithAnchor = (err, anchor) => { 114 | const stackArr = err.stack.split('\n') 115 | const anchorIndex = stackArr.findIndex(line => line.trim().startsWith(anchor)) 116 | if (anchorIndex === -1) { 117 | return err // 404, anchor not found 118 | } 119 | // Strip everything from the top until we reach the anchor line (remove anchor line as well) 120 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 121 | stackArr.splice(1, anchorIndex) 122 | err.stack = stackArr.join('\n') 123 | return err 124 | } 125 | 126 | /** 127 | * Replace the property of an object in a stealthy way. 128 | * 129 | * Note: You also want to work on the prototype of an object most often, 130 | * as you'd otherwise leave traces (e.g. showing up in Object.getOwnPropertyNames(obj)). 131 | * 132 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 133 | * 134 | * @example 135 | * replaceProperty(WebGLRenderingContext.prototype, 'getParameter', { value: "alice" }) 136 | * // or 137 | * replaceProperty(Object.getPrototypeOf(navigator), 'languages', { get: () => ['en-US', 'en'] }) 138 | * 139 | * @param {object} obj - The object which has the property to replace 140 | * @param {string} propName - The property name to replace 141 | * @param {object} descriptorOverrides - e.g. { value: "alice" } 142 | */ 143 | utils.replaceProperty = (obj, propName, descriptorOverrides = {}) => { 144 | return Object.defineProperty(obj, propName, { 145 | // Copy over the existing descriptors (writable, enumerable, configurable, etc) 146 | ...(Object.getOwnPropertyDescriptor(obj, propName) || {}), 147 | // Add our overrides (e.g. value, get()) 148 | ...descriptorOverrides 149 | }) 150 | } 151 | 152 | /** 153 | * Preload a cache of function copies and data. 154 | * 155 | * For a determined enough observer it would be possible to overwrite and sniff usage of functions 156 | * we use in our internal Proxies, to combat that we use a cached copy of those functions. 157 | * 158 | * Note: Whenever we add a `Function.prototype.toString` proxy we should preload the cache before, 159 | * by executing `utils.preloadCache()` before the proxy is applied (so we don't cause recursive lookups). 160 | * 161 | * This is evaluated once per execution context (e.g. window) 162 | */ 163 | utils.preloadCache = () => { 164 | if (utils.cache) { 165 | return 166 | } 167 | utils.cache = { 168 | // Used in our proxies 169 | Reflect: { 170 | get: Reflect.get.bind(Reflect), 171 | apply: Reflect.apply.bind(Reflect) 172 | }, 173 | // Used in `makeNativeString` 174 | nativeToStringStr: Function.toString + '' // => `function toString() { [native code] }` 175 | } 176 | } 177 | 178 | /** 179 | * Utility function to generate a cross-browser `toString` result representing native code. 180 | * 181 | * There's small differences: Chromium uses a single line, whereas FF & Webkit uses multiline strings. 182 | * To future-proof this we use an existing native toString result as the basis. 183 | * 184 | * The only advantage we have over the other team is that our JS runs first, hence we cache the result 185 | * of the native toString result once, so they cannot spoof it afterwards and reveal that we're using it. 186 | * 187 | * @example 188 | * makeNativeString('foobar') // => `function foobar() { [native code] }` 189 | * 190 | * @param {string} [name] - Optional function name 191 | */ 192 | utils.makeNativeString = (name = '') => { 193 | return utils.cache.nativeToStringStr.replace('toString', name || '') 194 | } 195 | 196 | /** 197 | * Helper function to modify the `toString()` result of the provided object. 198 | * 199 | * Note: Use `utils.redirectToString` instead when possible. 200 | * 201 | * There's a quirk in JS Proxies that will cause the `toString()` result to differ from the vanilla Object. 202 | * If no string is provided we will generate a `[native code]` thing based on the name of the property object. 203 | * 204 | * @example 205 | * patchToString(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }') 206 | * 207 | * @param {object} obj - The object for which to modify the `toString()` representation 208 | * @param {string} str - Optional string used as a return value 209 | */ 210 | utils.patchToString = (obj, str = '') => { 211 | const handler = { 212 | apply: function (target, ctx) { 213 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 214 | if (ctx === Function.prototype.toString) { 215 | return utils.makeNativeString('toString') 216 | } 217 | // `toString` targeted at our proxied Object detected 218 | if (ctx === obj) { 219 | // We either return the optional string verbatim or derive the most desired result automatically 220 | return str || utils.makeNativeString(obj.name) 221 | } 222 | // Check if the toString protype of the context is the same as the global prototype, 223 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 224 | const hasSameProto = Object.getPrototypeOf( 225 | Function.prototype.toString 226 | ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins 227 | if (!hasSameProto) { 228 | // Pass the call on to the local Function.prototype.toString instead 229 | return ctx.toString() 230 | } 231 | return target.call(ctx) 232 | } 233 | } 234 | 235 | const toStringProxy = new Proxy( 236 | Function.prototype.toString, 237 | utils.stripProxyFromErrors(handler) 238 | ) 239 | utils.replaceProperty(Function.prototype, 'toString', { 240 | value: toStringProxy 241 | }) 242 | } 243 | 244 | /** 245 | * Make all nested functions of an object native. 246 | * 247 | * @param {object} obj 248 | */ 249 | utils.patchToStringNested = (obj = {}) => { 250 | return utils.execRecursively(obj, ['function'], utils.patchToString) 251 | } 252 | 253 | /** 254 | * Redirect toString requests from one object to another. 255 | * 256 | * @param {object} proxyObj - The object that toString will be called on 257 | * @param {object} originalObj - The object which toString result we wan to return 258 | */ 259 | utils.redirectToString = (proxyObj, originalObj) => { 260 | const handler = { 261 | apply: function (target, ctx) { 262 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 263 | if (ctx === Function.prototype.toString) { 264 | return utils.makeNativeString('toString') 265 | } 266 | 267 | // `toString` targeted at our proxied Object detected 268 | if (ctx === proxyObj) { 269 | const fallback = () => 270 | originalObj && originalObj.name 271 | ? utils.makeNativeString(originalObj.name) 272 | : utils.makeNativeString(proxyObj.name) 273 | 274 | // Return the toString representation of our original object if possible 275 | return originalObj + '' || fallback() 276 | } 277 | 278 | if (typeof ctx === 'undefined' || ctx === null) { 279 | return target.call(ctx) 280 | } 281 | 282 | // Check if the toString protype of the context is the same as the global prototype, 283 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 284 | const hasSameProto = Object.getPrototypeOf( 285 | Function.prototype.toString 286 | ).isPrototypeOf(ctx.toString) // eslint-disable-line no-prototype-builtins 287 | if (!hasSameProto) { 288 | // Pass the call on to the local Function.prototype.toString instead 289 | return ctx.toString() 290 | } 291 | 292 | return target.call(ctx) 293 | } 294 | } 295 | 296 | const toStringProxy = new Proxy( 297 | Function.prototype.toString, 298 | utils.stripProxyFromErrors(handler) 299 | ) 300 | utils.replaceProperty(Function.prototype, 'toString', { 301 | value: toStringProxy 302 | }) 303 | } 304 | 305 | /** 306 | * All-in-one method to replace a property with a JS Proxy using the provided Proxy handler with traps. 307 | * 308 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 309 | * Note: This is meant to modify native Browser APIs and works best with prototype objects. 310 | * 311 | * @example 312 | * replaceWithProxy(WebGLRenderingContext.prototype, 'getParameter', proxyHandler) 313 | * 314 | * @param {object} obj - The object which has the property to replace 315 | * @param {string} propName - The name of the property to replace 316 | * @param {object} handler - The JS Proxy handler to use 317 | */ 318 | utils.replaceWithProxy = (obj, propName, handler) => { 319 | const originalObj = obj[propName] 320 | const proxyObj = new Proxy(obj[propName], utils.stripProxyFromErrors(handler)) 321 | 322 | utils.replaceProperty(obj, propName, { value: proxyObj }) 323 | utils.redirectToString(proxyObj, originalObj) 324 | 325 | return true 326 | } 327 | /** 328 | * All-in-one method to replace a getter with a JS Proxy using the provided Proxy handler with traps. 329 | * 330 | * @example 331 | * replaceGetterWithProxy(Object.getPrototypeOf(navigator), 'vendor', proxyHandler) 332 | * 333 | * @param {object} obj - The object which has the property to replace 334 | * @param {string} propName - The name of the property to replace 335 | * @param {object} handler - The JS Proxy handler to use 336 | */ 337 | utils.replaceGetterWithProxy = (obj, propName, handler) => { 338 | const fn = Object.getOwnPropertyDescriptor(obj, propName).get 339 | const fnStr = fn.toString() // special getter function string 340 | const proxyObj = new Proxy(fn, utils.stripProxyFromErrors(handler)) 341 | 342 | utils.replaceProperty(obj, propName, { get: proxyObj }) 343 | utils.patchToString(proxyObj, fnStr) 344 | 345 | return true 346 | } 347 | 348 | /** 349 | * All-in-one method to mock a non-existing property with a JS Proxy using the provided Proxy handler with traps. 350 | * 351 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 352 | * 353 | * @example 354 | * mockWithProxy(chrome.runtime, 'sendMessage', function sendMessage() {}, proxyHandler) 355 | * 356 | * @param {object} obj - The object which has the property to replace 357 | * @param {string} propName - The name of the property to replace or create 358 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 359 | * @param {object} handler - The JS Proxy handler to use 360 | */ 361 | utils.mockWithProxy = (obj, propName, pseudoTarget, handler) => { 362 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) 363 | 364 | utils.replaceProperty(obj, propName, { value: proxyObj }) 365 | utils.patchToString(proxyObj) 366 | 367 | return true 368 | } 369 | 370 | /** 371 | * All-in-one method to create a new JS Proxy with stealth tweaks. 372 | * 373 | * This is meant to be used whenever we need a JS Proxy but don't want to replace or mock an existing known property. 374 | * 375 | * Will stealthify certain aspects of the Proxy (strip error stack traces, redirect toString, etc). 376 | * 377 | * @example 378 | * createProxy(navigator.mimeTypes.__proto__.namedItem, proxyHandler) // => Proxy 379 | * 380 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 381 | * @param {object} handler - The JS Proxy handler to use 382 | */ 383 | utils.createProxy = (pseudoTarget, handler) => { 384 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)) 385 | utils.patchToString(proxyObj) 386 | 387 | return proxyObj 388 | } 389 | 390 | /** 391 | * Helper function to split a full path to an Object into the first part and property. 392 | * 393 | * @example 394 | * splitObjPath(`HTMLMediaElement.prototype.canPlayType`) 395 | * // => {objName: "HTMLMediaElement.prototype", propName: "canPlayType"} 396 | * 397 | * @param {string} objPath - The full path to an object as dot notation string 398 | */ 399 | utils.splitObjPath = objPath => ({ 400 | // Remove last dot entry (property) ==> `HTMLMediaElement.prototype` 401 | objName: objPath.split('.').slice(0, -1).join('.'), 402 | // Extract last dot entry ==> `canPlayType` 403 | propName: objPath.split('.').slice(-1)[0] 404 | }) 405 | 406 | /** 407 | * Convenience method to replace a property with a JS Proxy using the provided objPath. 408 | * 409 | * Supports a full path (dot notation) to the object as string here, in case that makes it easier. 410 | * 411 | * @example 412 | * replaceObjPathWithProxy('WebGLRenderingContext.prototype.getParameter', proxyHandler) 413 | * 414 | * @param {string} objPath - The full path to an object (dot notation string) to replace 415 | * @param {object} handler - The JS Proxy handler to use 416 | */ 417 | utils.replaceObjPathWithProxy = (objPath, handler) => { 418 | const { objName, propName } = utils.splitObjPath(objPath) 419 | const obj = eval(objName) // eslint-disable-line no-eval 420 | return utils.replaceWithProxy(obj, propName, handler) 421 | } 422 | 423 | /** 424 | * Traverse nested properties of an object recursively and apply the given function on a whitelist of value types. 425 | * 426 | * @param {object} obj 427 | * @param {array} typeFilter - e.g. `['function']` 428 | * @param {Function} fn - e.g. `utils.patchToString` 429 | */ 430 | utils.execRecursively = (obj = {}, typeFilter = [], fn) => { 431 | function recurse(obj) { 432 | for (const key in obj) { 433 | if (obj[key] === undefined) { 434 | continue 435 | } 436 | if (obj[key] && typeof obj[key] === 'object') { 437 | recurse(obj[key]) 438 | } else { 439 | if (obj[key] && typeFilter.includes(typeof obj[key])) { 440 | fn.call(this, obj[key]) 441 | } 442 | } 443 | } 444 | } 445 | recurse(obj) 446 | return obj 447 | } 448 | 449 | /** 450 | * Everything we run through e.g. `page.evaluate` runs in the browser context, not the NodeJS one. 451 | * That means we cannot just use reference variables and functions from outside code, we need to pass everything as a parameter. 452 | * 453 | * Unfortunately the data we can pass is only allowed to be of primitive types, regular functions don't survive the built-in serialization process. 454 | * This utility function will take an object with functions and stringify them, so we can pass them down unharmed as strings. 455 | * 456 | * We use this to pass down our utility functions as well as any other functions (to be able to split up code better). 457 | * 458 | * @see utils.materializeFns 459 | * 460 | * @param {object} fnObj - An object containing functions as properties 461 | */ 462 | utils.stringifyFns = (fnObj = { hello: () => 'world' }) => { 463 | // Object.fromEntries() ponyfill (in 6 lines) - supported only in Node v12+, modern browsers are fine 464 | // https://github.com/feross/fromentries 465 | function fromEntries(iterable) { 466 | return [...iterable].reduce((obj, [key, val]) => { 467 | obj[key] = val 468 | return obj 469 | }, {}) 470 | } 471 | return (Object.fromEntries || fromEntries)( 472 | Object.entries(fnObj) 473 | .filter(([key, value]) => typeof value === 'function') 474 | .map(([key, value]) => [key, value.toString()]) // eslint-disable-line no-eval 475 | ) 476 | } 477 | 478 | /** 479 | * Utility function to reverse the process of `utils.stringifyFns`. 480 | * Will materialize an object with stringified functions (supports classic and fat arrow functions). 481 | * 482 | * @param {object} fnStrObj - An object containing stringified functions as properties 483 | */ 484 | utils.materializeFns = (fnStrObj = { hello: "() => 'world'" }) => { 485 | return Object.fromEntries( 486 | Object.entries(fnStrObj).map(([key, value]) => { 487 | if (value.startsWith('function')) { 488 | // some trickery is needed to make oldschool functions work :-) 489 | return [key, eval(`() => ${value}`)()] // eslint-disable-line no-eval 490 | } else { 491 | // arrow functions just work 492 | return [key, eval(value)] // eslint-disable-line no-eval 493 | } 494 | }) 495 | ) 496 | } 497 | 498 | // Proxy handler templates for re-usability 499 | utils.makeHandler = () => ({ 500 | // Used by simple `navigator` getter evasions 501 | getterValue: value => ({ 502 | apply(target, ctx, args) { 503 | // Let's fetch the value first, to trigger and escalate potential errors 504 | // Illegal invocations like `navigator.__proto__.vendor` will throw here 505 | utils.cache.Reflect.apply(...arguments) 506 | return value 507 | } 508 | }) 509 | }) -------------------------------------------------------------------------------- /playwright_stealth/js/webgl.vendor.js: -------------------------------------------------------------------------------- 1 | console.log(opts) 2 | const getParameterProxyHandler = { 3 | apply: function (target, ctx, args) { 4 | const param = (args || [])[0] 5 | // UNMASKED_VENDOR_WEBGL 6 | if (param === 37445) { 7 | return opts.webgl_vendor || 'Intel Inc.' // default in headless: Google Inc. 8 | } 9 | // UNMASKED_RENDERER_WEBGL 10 | if (param === 37446) { 11 | return opts.webgl_renderer || 'Intel Iris OpenGL Engine' // default in headless: Google SwiftShader 12 | } 13 | return utils.cache.Reflect.apply(target, ctx, args) 14 | } 15 | } 16 | 17 | // There's more than one WebGL rendering context 18 | // https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext#Browser_compatibility 19 | // To find out the original values here: Object.getOwnPropertyDescriptors(WebGLRenderingContext.prototype.getParameter) 20 | const addProxy = (obj, propName) => { 21 | utils.replaceWithProxy(obj, propName, getParameterProxyHandler) 22 | } 23 | // For whatever weird reason loops don't play nice with Object.defineProperty, here's the next best thing: 24 | addProxy(WebGLRenderingContext.prototype, 'getParameter') 25 | addProxy(WebGL2RenderingContext.prototype, 'getParameter') -------------------------------------------------------------------------------- /playwright_stealth/js/window.outerdimensions.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | try { 4 | if (!!window.outerWidth && !!window.outerHeight) { 5 | const windowFrame = 85 // probably OS and WM dependent 6 | window.outerWidth = window.innerWidth 7 | console.log(`current window outer height ${window.outerHeight}`) 8 | window.outerHeight = window.innerHeight + windowFrame 9 | console.log(`new window outer height ${window.outerHeight}`) 10 | } 11 | } catch (err) { 12 | } 13 | -------------------------------------------------------------------------------- /playwright_stealth/stealth.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Tuple, Optional, Dict 4 | 5 | from importlib import resources 6 | from playwright.async_api import Page as AsyncPage 7 | from playwright.sync_api import Page as SyncPage 8 | 9 | 10 | def from_file(name): 11 | """read script from /js data directory""" 12 | return resources.read_text(f'{__package__}.js', name) 13 | 14 | 15 | SCRIPTS: Dict[str, str] = { 16 | 'webdrive': from_file('navigator.webdriver.js'), 17 | 'navigator_vendor': from_file('navigator.vendor.js'), 18 | 'navigator_plugins': from_file('navigator.plugins.js'), 19 | 'navigator_permissions': from_file('navigator.permissions.js'), 20 | 'navigator_languages': from_file('navigator.languages.js'), 21 | 'navigator_platform': from_file('navigator.platform.js'), 22 | 'navigator_user_agent': from_file('navigator.userAgent.js'), 23 | 'navigator_hardware_concurrency': from_file('navigator.hardwareConcurrency.js'), 24 | 'media_codecs': from_file('media.codecs.js'), 25 | 'chrome_runtime': from_file('chrome.runtime.js'), 26 | 'chrome_loadtimes': from_file('chrome.loadtimes.js'), 27 | 'chrome_csi': from_file('chrome.csi.js'), 28 | 'chrome_app': from_file('chrome.app.js'), 29 | 'iframe_content_window': from_file('iframe.contentWindow.js'), 30 | 'outerdimensions': from_file('window.outerdimensions.js'), 31 | 'webgl_vendor': from_file('webgl.vendor.js'), 32 | 'hairline': from_file('hairline.js'), 33 | 'utils': from_file('utils.js'), 34 | 'magic-arrays': from_file('magic-arrays.js'), 35 | } 36 | 37 | 38 | @dataclass 39 | class StealthConfig: 40 | """ 41 | Playwright Stealth Configuration that applies stealth strategies to Playwright Page objects. 42 | 43 | The stealth strategies are contained in /js package and are basic javascript scripts that are executed 44 | on every Page.goto call. 45 | 46 | Note: 47 | All init scripts are combined by playwright into one script and then executed this means 48 | the scripts should not have conflicting constants/variables etc. ! 49 | This also means scripts can be extended by overriding enabled_scripts generator: 50 | 51 | ``` 52 | @property 53 | def enabled_scripts(): 54 | yield 'console.log("first script")' 55 | yield from super().enabled_scripts() 56 | yield 'console.log("last script")' 57 | ``` 58 | """ 59 | # scripts 60 | webdrive: bool = True 61 | webgl_vendor: bool = True 62 | navigator_vendor: bool = True 63 | navigator_plugins: bool = True 64 | navigator_permissions: bool = True 65 | navigator_languages: bool = True 66 | navigator_platform: bool = True 67 | navigator_user_agent: bool = True 68 | navigator_hardware_concurrency: int = 4 69 | media_codecs: bool = True 70 | iframe_content_window: bool = True 71 | chrome_runtime: bool = True 72 | chrome_loadtimes: bool = True 73 | chrome_csi: bool = True 74 | chrome_app: bool = True 75 | outerdimensions: bool = True 76 | hairline: bool = True 77 | 78 | # options 79 | vendor: str = 'Intel Inc.' 80 | renderer: str = 'Intel Iris OpenGL Engine' 81 | nav_vendor: str = 'Google Inc.' 82 | nav_user_agent: str = None 83 | nav_platform: str = None 84 | languages: Tuple[str] = ('en-US', 'en') 85 | runOnInsecureOrigins: Optional[bool] = None 86 | 87 | @property 88 | def enabled_scripts(self): 89 | opts = json.dumps({ 90 | 'webgl_vendor': self.vendor, 91 | 'webgl_renderer': self.renderer, 92 | 'navigator_vendor': self.nav_vendor, 93 | 'navigator_platform': self.nav_platform, 94 | 'navigator_user_agent': self.nav_user_agent, 95 | 'languages': list(self.languages), 96 | 'runOnInsecureOrigins': self.runOnInsecureOrigins, 97 | 'hardwareConcurrency': self.navigator_hardware_concurrency, 98 | }) 99 | # defined options constant 100 | yield f'const opts = {opts}' 101 | # init utils and magic-arrays helper 102 | yield SCRIPTS['utils'] 103 | yield SCRIPTS['magic-arrays'] 104 | 105 | if self.webdrive: 106 | yield SCRIPTS['webdrive'] 107 | if self.outerdimensions: 108 | yield SCRIPTS['outerdimensions'] 109 | if self.webgl_vendor: 110 | yield SCRIPTS['webgl_vendor'] 111 | if self.navigator_vendor: 112 | yield SCRIPTS['navigator_vendor'] 113 | if self.navigator_plugins: 114 | yield SCRIPTS['navigator_plugins'] 115 | if self.navigator_permissions: 116 | yield SCRIPTS['navigator_permissions'] 117 | if self.navigator_languages: 118 | yield SCRIPTS['navigator_languages'] 119 | if self.navigator_platform: 120 | yield SCRIPTS['navigator_platform'] 121 | if self.navigator_user_agent: 122 | yield SCRIPTS['navigator_user_agent'] 123 | if self.media_codecs: 124 | yield SCRIPTS['media_codecs'] 125 | if self.iframe_content_window: 126 | yield SCRIPTS['iframe_content_window'] 127 | if self.chrome_runtime: 128 | yield SCRIPTS['chrome_runtime'] 129 | if self.chrome_loadtimes: 130 | yield SCRIPTS['chrome_loadtimes'] 131 | if self.chrome_csi: 132 | yield SCRIPTS['chrome_csi'] 133 | if self.chrome_app: 134 | yield SCRIPTS['chrome_app'] 135 | if self.hairline: 136 | yield SCRIPTS['hairline'] 137 | 138 | 139 | def stealth_sync(page: SyncPage, config: StealthConfig = None): 140 | """teaches synchronous playwright Page to be stealthy like a ninja!""" 141 | for script in (config or StealthConfig()).enabled_scripts: 142 | page.add_init_script(script) 143 | 144 | 145 | async def stealth_async(page: AsyncPage, config: StealthConfig = None): 146 | """teaches asynchronous playwright Page to be stealthy like a ninja!""" 147 | for script in (config or StealthConfig()).enabled_scripts: 148 | await page.add_init_script(script) 149 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "playwright-stealth" 3 | version = "0.3.0" 4 | description = "stealth helper for playwright browser automation" 5 | license = 'GPL-3.0-or-later' 6 | authors = ["Bernardas Ališauskas "] 7 | include = [ 8 | 'playwright_stealth/js/*.js' 9 | ] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.8" 13 | 14 | [tool.poetry.dev-dependencies] 15 | playwright = "^1.19.0" 16 | pytest-asyncio = "^0.18.1" 17 | 18 | [build-system] 19 | requires = ["poetry-core>=1.0.0"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /tests/test_navigator_webdriver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tests.utils import run_and_eval 3 | 4 | from playwright_stealth.stealth import StealthConfig 5 | 6 | @pytest.mark.asyncio 7 | async def test_navigator_webdriver(): 8 | # no stealth 9 | assert await run_and_eval('navigator.webdriver') is True 10 | # default 11 | conf = StealthConfig() 12 | assert await run_and_eval('navigator.webdriver', conf) is None 13 | # disable 14 | conf.webdrive = False 15 | assert await run_and_eval('navigator.webdriver') is True 16 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from playwright.async_api import async_playwright, Page 4 | 5 | from playwright_stealth.stealth import StealthConfig, stealth_async 6 | 7 | async def run_and_eval(script, stealth_conf: Optional[StealthConfig]=None): 8 | async with async_playwright() as pw: 9 | browser = await pw.chromium.launch(headless=True) 10 | page: Page = await browser.new_page() 11 | if stealth_conf: 12 | await stealth_async(page, stealth_conf) 13 | await page.goto('http://httpbin.org/html') 14 | return await page.evaluate(script) 15 | 16 | --------------------------------------------------------------------------------