generated from kgod/ai-review-template
588 lines
27 KiB
Python
588 lines
27 KiB
Python
# -*- coding: utf-8 -*-
|
|
import inspect
|
|
import json
|
|
import random
|
|
import re
|
|
import warnings
|
|
from collections.abc import Callable
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Dict, List, Union, Any, Tuple, Optional
|
|
|
|
from playwright import async_api, sync_api
|
|
from playwright_stealth.case_insensitive_dict import CaseInsensitiveDict
|
|
from playwright_stealth.context_managers import (
|
|
AsyncWrappingContextManager,
|
|
SyncWrappingContextManager,
|
|
)
|
|
|
|
|
|
def from_file(name) -> str:
|
|
return (Path(__file__).parent / "js" / name).read_text()
|
|
|
|
|
|
SCRIPTS: Dict[str, str] = {
|
|
"generate_magic_arrays": from_file("generate.magic.arrays.js"),
|
|
"utils": from_file("utils.js"),
|
|
"chrome_app": from_file("evasions/chrome.app.js"),
|
|
"chrome_csi": from_file("evasions/chrome.csi.js"),
|
|
"chrome_hairline": from_file("evasions/chrome.hairline.js"),
|
|
"chrome_load_times": from_file("evasions/chrome.load.times.js"),
|
|
"chrome_runtime": from_file("evasions/chrome.runtime.js"),
|
|
"iframe_content_window": from_file("evasions/iframe.contentWindow.js"),
|
|
"media_codecs": from_file("evasions/media.codecs.js"),
|
|
"navigator_hardware_concurrency": from_file("evasions/navigator.hardwareConcurrency.js"),
|
|
"navigator_languages": from_file("evasions/navigator.languages.js"),
|
|
"navigator_permissions": from_file("evasions/navigator.permissions.js"),
|
|
"navigator_platform": from_file("evasions/navigator.platform.js"),
|
|
"navigator_plugins": from_file("evasions/navigator.plugins.js"),
|
|
"navigator_user_agent": from_file("evasions/navigator.userAgent.js"),
|
|
"navigator_user_agent_data": from_file("evasions/navigator.userAgentData.js"),
|
|
"navigator_vendor": from_file("evasions/navigator.vendor.js"),
|
|
"navigator_webdriver": from_file("evasions/navigator.webdriver.js"),
|
|
"error_prototype": from_file("evasions/error.prototype.js"),
|
|
"webgl_vendor": from_file("evasions/webgl.vendor.js"),
|
|
}
|
|
|
|
|
|
class Stealth:
|
|
"""
|
|
Playwright stealth configuration that applies stealth strategies to Playwright.
|
|
The stealth strategies are contained in ./js package and are basic javascript scripts that are executed
|
|
on every page.goto() called.
|
|
Note:
|
|
All init scripts are combined by playwright into one script and then executed this means
|
|
the scripts should not have conflicting constants/variables etc. !
|
|
This also means scripts can be extended by overriding enabled_scripts generator:
|
|
```
|
|
@property
|
|
def enabled_scripts():
|
|
yield 'console.log("first script")'
|
|
yield from super().enabled_scripts()
|
|
yield 'console.log("last script")'
|
|
```
|
|
"""
|
|
|
|
_USER_AGENT_OVERRIDE_PIGGYBACK_KEY = "_stealth_user_agent"
|
|
_SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY = "_stealth_sec_ch_ua"
|
|
_STEALTH_APPLIED_KEY = "_playwright_stealth_applied"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
chrome_app: bool = True,
|
|
chrome_csi: bool = True,
|
|
chrome_load_times: bool = True,
|
|
chrome_runtime: bool = False,
|
|
hairline: bool = True,
|
|
iframe_content_window: bool = True,
|
|
media_codecs: bool = True,
|
|
navigator_hardware_concurrency: bool = True,
|
|
navigator_languages: bool = True,
|
|
navigator_permissions: bool = True,
|
|
navigator_platform: bool = True,
|
|
navigator_plugins: bool = True,
|
|
navigator_user_agent: bool = True,
|
|
navigator_user_agent_data: bool = True,
|
|
navigator_vendor: bool = True,
|
|
navigator_webdriver: bool = True,
|
|
error_prototype: bool = True,
|
|
sec_ch_ua: bool = True,
|
|
webgl_vendor: bool = True,
|
|
navigator_languages_override: Tuple[str, str] = ("en-US", "en"),
|
|
navigator_platform_override: str = "Win32",
|
|
navigator_user_agent_override: Optional[str] = None,
|
|
navigator_vendor_override: str = None,
|
|
sec_ch_ua_override: Optional[str] = None,
|
|
webgl_renderer_override: str = None,
|
|
webgl_vendor_override: str = None,
|
|
init_scripts_only: bool = False,
|
|
script_logging: bool = False,
|
|
):
|
|
# scripts to load
|
|
self.chrome_app: bool = chrome_app
|
|
self.chrome_csi: bool = chrome_csi
|
|
self.chrome_load_times: bool = chrome_load_times
|
|
self.chrome_runtime: bool = chrome_runtime
|
|
self.hairline: bool = hairline
|
|
self.iframe_content_window: bool = iframe_content_window
|
|
self.media_codecs: bool = media_codecs
|
|
self.navigator_hardware_concurrency: int = navigator_hardware_concurrency
|
|
self.navigator_languages: bool = navigator_languages
|
|
self.navigator_permissions: bool = navigator_permissions
|
|
self.navigator_platform: bool = navigator_platform
|
|
self.navigator_plugins: bool = navigator_plugins
|
|
self.navigator_user_agent: bool = navigator_user_agent
|
|
self.navigator_user_agent_data: bool = navigator_user_agent_data
|
|
self.navigator_vendor: bool = navigator_vendor
|
|
self.navigator_webdriver: bool = navigator_webdriver
|
|
self.error_prototype: bool = error_prototype
|
|
self.sec_ch_ua: bool = sec_ch_ua
|
|
self.webgl_vendor: bool = webgl_vendor
|
|
|
|
# warn if an override was provided for a disabled option
|
|
self._check_for_disabled_options_overridden(locals())
|
|
# evasion options
|
|
self.navigator_languages_override: Tuple[str, str] = navigator_languages_override or ("en-US", "en")
|
|
self.navigator_platform_override: Optional[str] = navigator_platform_override
|
|
self.navigator_user_agent_override: Optional[str] = navigator_user_agent_override
|
|
self.navigator_vendor_override: str = navigator_vendor_override or None
|
|
if sec_ch_ua_override is None and self.navigator_user_agent_override is not None:
|
|
# we can get sec_ch_ua override for "free" here if we can parse the Chrome version string
|
|
self.sec_ch_ua_override = self._get_greased_chrome_sec_ua_ch(self.navigator_user_agent_override)
|
|
else:
|
|
self.sec_ch_ua_override: Optional[str] = sec_ch_ua_override
|
|
self.webgl_renderer_override: str = webgl_renderer_override or "Intel Iris OpenGL Engine"
|
|
self.webgl_vendor_override: str = webgl_vendor_override or "Intel Inc."
|
|
# other options
|
|
self.init_scripts_only: bool = init_scripts_only
|
|
self.script_logging = script_logging
|
|
|
|
@property
|
|
def script_payload(self) -> str:
|
|
"""
|
|
Generates an immediately invoked function expression for all enabled scripts
|
|
Returns: string of enabled scripts in IIFE
|
|
"""
|
|
scripts_block = "\n".join(self.enabled_scripts)
|
|
if len(scripts_block) == 0:
|
|
return ""
|
|
return "(() => {\n" + scripts_block + "\n})();"
|
|
|
|
@property
|
|
def options_payload(self) -> str:
|
|
opts = {
|
|
"navigator_hardware_concurrency": self.navigator_hardware_concurrency,
|
|
"navigator_languages_override": self.navigator_languages_override,
|
|
"navigator_platform": self.navigator_platform_override,
|
|
"navigator_user_agent": self.navigator_user_agent_override,
|
|
"navigator_vendor": self.navigator_vendor_override,
|
|
"webgl_renderer": self.webgl_renderer_override,
|
|
"webgl_vendor": self.webgl_vendor_override,
|
|
"script_logging": self.script_logging,
|
|
}
|
|
return f"const opts = {json.dumps(opts)};"
|
|
|
|
@property
|
|
def enabled_scripts(self):
|
|
evasion_script_block = "\n".join(self._evasion_scripts)
|
|
if len(evasion_script_block) == 0:
|
|
return ""
|
|
|
|
yield self.options_payload
|
|
yield SCRIPTS["utils"]
|
|
yield SCRIPTS["generate_magic_arrays"]
|
|
yield evasion_script_block
|
|
|
|
@property
|
|
def _evasion_scripts(self) -> str:
|
|
if self.chrome_app:
|
|
yield SCRIPTS["chrome_app"]
|
|
if self.chrome_csi:
|
|
yield SCRIPTS["chrome_csi"]
|
|
if self.hairline:
|
|
yield SCRIPTS["chrome_hairline"]
|
|
if self.chrome_load_times:
|
|
yield SCRIPTS["chrome_load_times"]
|
|
if self.chrome_runtime:
|
|
yield SCRIPTS["chrome_runtime"]
|
|
if self.iframe_content_window:
|
|
yield SCRIPTS["iframe_content_window"]
|
|
if self.media_codecs:
|
|
yield SCRIPTS["media_codecs"]
|
|
if self.navigator_languages:
|
|
yield SCRIPTS["navigator_languages"]
|
|
if self.navigator_permissions:
|
|
yield SCRIPTS["navigator_permissions"]
|
|
if self.navigator_platform:
|
|
yield SCRIPTS["navigator_platform"]
|
|
if self.navigator_plugins:
|
|
yield SCRIPTS["navigator_plugins"]
|
|
if self.navigator_user_agent:
|
|
yield SCRIPTS["navigator_user_agent"]
|
|
if self.navigator_user_agent_data:
|
|
yield SCRIPTS["navigator_user_agent_data"]
|
|
if self.navigator_vendor:
|
|
yield SCRIPTS["navigator_vendor"]
|
|
if self.navigator_webdriver:
|
|
yield SCRIPTS["navigator_webdriver"]
|
|
if self.error_prototype:
|
|
yield SCRIPTS["error_prototype"]
|
|
if self.webgl_vendor:
|
|
yield SCRIPTS["webgl_vendor"]
|
|
|
|
def warn_if_stealth_applied(self, obj: Any) -> bool:
|
|
if hasattr(obj, self._STEALTH_APPLIED_KEY):
|
|
warnings.warn(
|
|
"Stealth has already been applied to this page or context. Skipping duplicate application.",
|
|
category=UserWarning,
|
|
)
|
|
return True
|
|
return False
|
|
|
|
def use_async(self, ctx: async_api.PlaywrightContextManager) -> AsyncWrappingContextManager:
|
|
"""
|
|
Instruments the playwright context manager.
|
|
Any browser connected to or any page created with any method from
|
|
the patched context should have stealth evasions applied automatically.
|
|
|
|
async with Stealth().use_async(async_playwright()) as p:
|
|
...
|
|
"""
|
|
return AsyncWrappingContextManager(self, ctx)
|
|
|
|
def use_sync(self, ctx: sync_api.PlaywrightContextManager) -> SyncWrappingContextManager:
|
|
"""
|
|
Instruments the playwright context manager.
|
|
Any browser connected to or any page created with any method from
|
|
the patched context should have stealth evasions applied automatically.
|
|
|
|
with Stealth().use_sync(sync_playwright()) as p:
|
|
...
|
|
"""
|
|
return SyncWrappingContextManager(self, ctx)
|
|
|
|
async def apply_stealth_async(self, page_or_context: Union[async_api.Page, async_api.BrowserContext]) -> None:
|
|
if len(self.script_payload) > 0 and not self.warn_if_stealth_applied(page_or_context):
|
|
await page_or_context.add_init_script(self.script_payload)
|
|
setattr(page_or_context, self._STEALTH_APPLIED_KEY, True)
|
|
|
|
def apply_stealth_sync(self, page_or_context: Union[sync_api.Page, sync_api.BrowserContext]) -> None:
|
|
if len(self.script_payload) > 0 and not self.warn_if_stealth_applied(page_or_context):
|
|
page_or_context.add_init_script(self.script_payload)
|
|
setattr(page_or_context, self._STEALTH_APPLIED_KEY, True)
|
|
|
|
def hook_playwright_context(self, ctx: Union[async_api.Playwright, sync_api.Playwright]) -> None:
|
|
"""
|
|
Given a Playwright context object, hooks all the browser type object methods that return a Browser object.
|
|
Can be used with sync and async methods contexts
|
|
"""
|
|
browser_class_name = sync_api.Browser.__name__
|
|
for browser_type in (ctx.chromium, ctx.firefox, ctx.webkit):
|
|
chromium_mode = browser_type.name == "chromium"
|
|
for name, hooked_method in inspect.getmembers(browser_type, predicate=inspect.ismethod):
|
|
# todo: ctx.browser.launch_persistent_context
|
|
if hooked_method.__annotations__.get("return") == browser_class_name:
|
|
hooked_method = self._generate_hooked_method_that_returns_browser(hooked_method, chromium_mode)
|
|
setattr(browser_type, name, hooked_method)
|
|
|
|
def _kwargs_with_patched_cli_arg(
|
|
self, method: Callable, packed_kwargs: Dict[str, Any], chromium_mode: bool
|
|
) -> Dict[str, Any]:
|
|
signature = inspect.signature(method).parameters
|
|
args_parameter = signature.get("args")
|
|
|
|
# deep just in case
|
|
new_kwargs = deepcopy(packed_kwargs)
|
|
if args_parameter is not None:
|
|
if chromium_mode and not self.init_scripts_only:
|
|
new_cli_args = new_kwargs.get("args", args_parameter.default)
|
|
if self.navigator_webdriver:
|
|
new_cli_args = self._patch_blink_features_cli_args(new_cli_args or [])
|
|
if self.navigator_languages:
|
|
languages_cli_flag = f"--accept-lang={','.join(self.navigator_languages_override)}"
|
|
new_cli_args = self._patch_cli_arg(new_cli_args or [], languages_cli_flag)
|
|
new_kwargs["args"] = new_cli_args
|
|
return new_kwargs
|
|
|
|
def _generate_hooked_method_that_returns_browser(self, method: Callable, chromium_mode: bool):
|
|
async def async_hooked_method(*args, **kwargs) -> async_api.Browser:
|
|
browser = await method(
|
|
*args,
|
|
**self._kwargs_with_patched_cli_arg(method, kwargs, chromium_mode),
|
|
)
|
|
self._reassign_new_page_new_context(browser)
|
|
return browser
|
|
|
|
def sync_hooked_method(*args, **kwargs) -> sync_api.Browser:
|
|
browser = method(
|
|
*args,
|
|
**self._kwargs_with_patched_cli_arg(method, kwargs, chromium_mode),
|
|
)
|
|
self._reassign_new_page_new_context(browser)
|
|
return browser
|
|
|
|
if inspect.iscoroutinefunction(method):
|
|
return async_hooked_method
|
|
return sync_hooked_method
|
|
|
|
def _generate_hooked_new_context(self, new_context_method: Callable, new_page_method: Callable) -> Callable:
|
|
async def hooked_new_context_async(*args, **kwargs):
|
|
context = await new_context_method(
|
|
*args,
|
|
**(await self._kwargs_new_page_context_with_patches_async(new_page_method, kwargs)),
|
|
)
|
|
await self.apply_stealth_async(context)
|
|
return context
|
|
|
|
def hooked_browser_method_sync(*args, **kwargs):
|
|
context = new_context_method(
|
|
*args,
|
|
**(self._kwargs_new_page_context_with_patches_sync(new_page_method, kwargs)),
|
|
)
|
|
self.apply_stealth_sync(context)
|
|
return context
|
|
|
|
if inspect.iscoroutinefunction(new_context_method):
|
|
return hooked_new_context_async
|
|
return hooked_browser_method_sync
|
|
|
|
def _generate_hooked_new_page(self, new_page_method: Callable, patch_kwargs: bool) -> Callable:
|
|
"""
|
|
Returns a hooked method (async or sync) for new_page.
|
|
*args and **kwargs even though these methods may not take any number of arguments,
|
|
we want to preserve accurate stack traces when caller passes args improperly.
|
|
|
|
If patch_kwargs is true, we patch kwargs the caller passes to enhance evasions. This only applies
|
|
to Browser.new_page, so we pass false when passing in a BrowserContext.new_page method
|
|
"""
|
|
|
|
async def hooked_new_page_async(*args, **kwargs):
|
|
kwargs = (
|
|
await self._kwargs_new_page_context_with_patches_async(new_page_method, kwargs)
|
|
if patch_kwargs
|
|
else kwargs
|
|
)
|
|
page = await new_page_method(*args, **kwargs)
|
|
await self.apply_stealth_async(page)
|
|
return page
|
|
|
|
def hooked_new_page_sync(*args, **kwargs):
|
|
kwargs = (
|
|
self._kwargs_new_page_context_with_patches_sync(new_page_method, kwargs) if patch_kwargs else kwargs
|
|
)
|
|
page = new_page_method(*args, **kwargs)
|
|
self.apply_stealth_sync(page)
|
|
return page
|
|
|
|
if inspect.iscoroutinefunction(new_page_method):
|
|
return hooked_new_page_async
|
|
return hooked_new_page_sync
|
|
|
|
async def _kwargs_new_page_context_with_patches_async(
|
|
self, unpatched_new_page: Callable, packed_kwargs: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
This returns kwargs with arguments added based on enabled evasions, while respecting any kwargs the caller
|
|
has passed in. If enabled and overrides aren't set for either navigator_user_agent or sec_ch_ua,
|
|
and we're dealing with a Chromium browser, we create a temporary page to get up-to-date UA information.
|
|
|
|
This function is suitable for patching kwargs for new_context and new_page, though in either case, an unpatched
|
|
new_page method must be provided.
|
|
Args:
|
|
unpatched_new_page: new_page
|
|
packed_kwargs: kwargs the caller has passed in
|
|
Returns:
|
|
patched kwargs
|
|
"""
|
|
browser_or_context = unpatched_new_page.__self__
|
|
if isinstance(browser_or_context, async_api.BrowserContext):
|
|
browser_instance = browser_or_context.browser
|
|
else:
|
|
browser_instance = browser_or_context
|
|
is_chromium = browser_instance.browser_type.name == "chromium"
|
|
|
|
async def get_user_agent_and_sec_ch_ua_async() -> Tuple[str, str]:
|
|
temp_page: Optional[async_api.Page]
|
|
stealth_user_agent = getattr(browser_instance, self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY, None)
|
|
sec_ch_ua = getattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, None)
|
|
if stealth_user_agent is None or sec_ch_ua is None:
|
|
temp_page = await unpatched_new_page()
|
|
stealth_user_agent = (await temp_page.evaluate("navigator.userAgent")).replace(
|
|
"HeadlessChrome", "Chrome"
|
|
)
|
|
await temp_page.close(reason="playwright_stealth internal temp utility page")
|
|
sec_ch_ua = self._get_greased_chrome_sec_ua_ch(stealth_user_agent)
|
|
setattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, sec_ch_ua)
|
|
setattr(
|
|
browser_instance,
|
|
self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY,
|
|
stealth_user_agent,
|
|
)
|
|
return stealth_user_agent, sec_ch_ua
|
|
|
|
new_kwargs = deepcopy(packed_kwargs)
|
|
if self.navigator_user_agent and packed_kwargs.get("user_agent") is None:
|
|
resolved_user_agent_override = self.navigator_user_agent_override
|
|
if resolved_user_agent_override is None and is_chromium:
|
|
(
|
|
resolved_user_agent_override,
|
|
_,
|
|
) = await get_user_agent_and_sec_ch_ua_async()
|
|
new_kwargs["user_agent"] = resolved_user_agent_override
|
|
extra_http_headers = packed_kwargs.get("extra_http_headers", {})
|
|
if self.sec_ch_ua and CaseInsensitiveDict(extra_http_headers).get("sec-ch-ua") is None:
|
|
resolved_sec_ch_ua_override = self.sec_ch_ua_override
|
|
if resolved_sec_ch_ua_override is None and is_chromium:
|
|
(
|
|
_,
|
|
resolved_sec_ch_ua_override,
|
|
) = await get_user_agent_and_sec_ch_ua_async()
|
|
if resolved_sec_ch_ua_override is not None:
|
|
extra_http_headers["sec-ch-ua"] = resolved_sec_ch_ua_override
|
|
new_kwargs["extra_http_headers"] = extra_http_headers
|
|
|
|
return new_kwargs
|
|
|
|
def _kwargs_new_page_context_with_patches_sync(
|
|
self, unpatched_new_page: Callable, packed_kwargs: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""see self._kwargs_new_page_context_with_patches_async for docs."""
|
|
browser_or_context = unpatched_new_page.__self__
|
|
if isinstance(browser_or_context, sync_api.BrowserContext):
|
|
browser_instance = browser_or_context.browser
|
|
else:
|
|
browser_instance = browser_or_context
|
|
is_chromium = browser_instance.browser_type.name == "chromium"
|
|
|
|
def get_user_agent_and_sec_ch_ua_sync() -> Tuple[str, str]:
|
|
temp_page: Optional[sync_api.Page]
|
|
stealth_user_agent = getattr(browser_instance, self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY, None)
|
|
sec_ch_ua = getattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, None)
|
|
if stealth_user_agent is None or sec_ch_ua is None:
|
|
temp_page = unpatched_new_page()
|
|
stealth_user_agent = temp_page.evaluate("navigator.userAgent").replace("HeadlessChrome", "Chrome")
|
|
sec_ch_ua = self._get_greased_chrome_sec_ua_ch(stealth_user_agent)
|
|
temp_page.close(reason="playwright_stealth internal temp utility page")
|
|
setattr(browser_instance, self._SEC_CH_UA_OVERRIDE_PIGGYBACK_KEY, sec_ch_ua)
|
|
setattr(
|
|
browser_instance,
|
|
self._USER_AGENT_OVERRIDE_PIGGYBACK_KEY,
|
|
stealth_user_agent,
|
|
)
|
|
return stealth_user_agent, sec_ch_ua
|
|
|
|
new_kwargs = deepcopy(packed_kwargs)
|
|
if self.navigator_user_agent and packed_kwargs.get("user_agent") is None:
|
|
resolved_user_agent_override = self.navigator_user_agent_override
|
|
if resolved_user_agent_override is None and is_chromium:
|
|
resolved_user_agent_override, _ = get_user_agent_and_sec_ch_ua_sync()
|
|
new_kwargs["user_agent"] = resolved_user_agent_override
|
|
extra_http_headers = packed_kwargs.get("extra_http_headers", {})
|
|
if self.sec_ch_ua and CaseInsensitiveDict(extra_http_headers).get("sec-ch-ua") is None:
|
|
resolved_sec_ch_ua_override = self.sec_ch_ua_override
|
|
if resolved_sec_ch_ua_override is None and is_chromium:
|
|
_, resolved_sec_ch_ua_override = get_user_agent_and_sec_ch_ua_sync()
|
|
if resolved_sec_ch_ua_override is not None:
|
|
extra_http_headers["sec-ch-ua"] = resolved_sec_ch_ua_override
|
|
new_kwargs["extra_http_headers"] = extra_http_headers
|
|
|
|
return new_kwargs
|
|
|
|
def _reassign_new_page_new_context(self, browser: Union[async_api.Browser, sync_api.Browser]) -> None:
|
|
if isinstance(browser, (async_api.Browser, sync_api.Browser)):
|
|
browser.new_context = self._generate_hooked_new_context(browser.new_context, browser.new_page)
|
|
browser.new_page = self._generate_hooked_new_page(browser.new_page, patch_kwargs=True)
|
|
else:
|
|
raise TypeError(f"unexpected type from function (bug): returned {browser}")
|
|
|
|
@staticmethod
|
|
def _get_greased_chrome_sec_ua_ch(user_agent: str) -> Optional[str]:
|
|
"""
|
|
From the major version in user_agent, generate a Sec-CH-UA header value. An example of the data in this
|
|
header can be generated from navigator.userAgentData.brands (requires secure context). We could query that
|
|
ourselves, but since it requires a secure context, there's no performant way to do that, so instead we
|
|
re-implement the greasing algorithm from Chrome.
|
|
|
|
See Also:
|
|
https://wicg.github.io/ua-client-hints/#grease
|
|
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA
|
|
https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc
|
|
Args:
|
|
user_agent: Chrome UA
|
|
|
|
Returns:
|
|
greased Sec-CH-UA header value, None if the Chrome version cannot be parsed
|
|
"""
|
|
greased_versions = [8, 99, 24]
|
|
greasy_chars = " ():-./;=?_"
|
|
greasy_brand = f"Not{random.choice(greasy_chars)}A{random.choice(greasy_chars)}Brand"
|
|
version = re.search(r"Chrome/(\d+)[\d.]+", user_agent, re.IGNORECASE)
|
|
if len(version.groups()) == 0:
|
|
return None
|
|
major_version = version.group(1)
|
|
brands = [
|
|
("Chromium", major_version),
|
|
("Chrome", major_version),
|
|
(greasy_brand, random.choice(greased_versions)),
|
|
]
|
|
random.shuffle(brands)
|
|
return ", ".join(f'"{brand}";v="{version}"' for brand, version in brands)
|
|
|
|
@staticmethod
|
|
def _patch_blink_features_cli_args(existing_args: Optional[List[str]]) -> List[str]:
|
|
"""Patches CLI args list to disable AutomationControlled blink feature, while preserving other args"""
|
|
new_args = []
|
|
disable_blink_features_prefix = "--disable-blink-features="
|
|
automation_controlled_feature_name = "AutomationControlled"
|
|
for arg in existing_args or []:
|
|
stripped_arg = arg.strip()
|
|
if stripped_arg.startswith(disable_blink_features_prefix):
|
|
if automation_controlled_feature_name not in stripped_arg:
|
|
stripped_arg += f",{automation_controlled_feature_name}"
|
|
new_args.append(stripped_arg)
|
|
else:
|
|
new_args.append(arg)
|
|
else: # no break
|
|
# the user has specified no extra blink features disabled,
|
|
# so no need to be careful how we modify the command line
|
|
new_args.append(f"{disable_blink_features_prefix}{automation_controlled_feature_name}")
|
|
return new_args
|
|
|
|
@staticmethod
|
|
def _patch_cli_arg(existing_args: List[str], flag: str) -> List[str]:
|
|
"""Patches CLI args list with any arg, warns if the user passed their own value in themselves"""
|
|
new_args = []
|
|
switch_name = re.search("(.*)=?", flag).group(1)
|
|
for arg in existing_args:
|
|
stripped_arg = arg.strip()
|
|
if stripped_arg.startswith(switch_name):
|
|
warnings.warn(
|
|
"playwright-stealth is trying to modify a flag you have set yourself already."
|
|
f"Either disable the mitigation or don't specify this flag manually {flag=}"
|
|
f"to avoid this warning. playwright-stealth has overridden your flag",
|
|
category=UserWarning,
|
|
)
|
|
new_args.append(flag)
|
|
break
|
|
else:
|
|
new_args.append(arg)
|
|
else: # no break
|
|
# none of the existing switches overlap with the one we're trying to set
|
|
new_args.append(flag)
|
|
return new_args
|
|
|
|
@staticmethod
|
|
def _check_for_disabled_options_overridden(packed_kwargs: Dict[str, Any]) -> None:
|
|
for key in ALL_EVASIONS_DISABLED_KWARGS.keys():
|
|
if not packed_kwargs.get(key) and packed_kwargs.get(f"{key}_override") is not None:
|
|
warnings.warn(
|
|
f"{key} is False, but an override ({key}_override) was provided, "
|
|
f"which is probably not what you intended to do",
|
|
stacklevel=3,
|
|
category=UserWarning,
|
|
)
|
|
|
|
|
|
ALL_EVASIONS_DISABLED_KWARGS = {
|
|
"chrome_app": False,
|
|
"chrome_csi": False,
|
|
"chrome_load_times": False,
|
|
"chrome_runtime": False,
|
|
"hairline": False,
|
|
"iframe_content_window": False,
|
|
"media_codecs": False,
|
|
"navigator_hardware_concurrency": False,
|
|
"navigator_languages": False,
|
|
"navigator_permissions": False,
|
|
"navigator_platform": False,
|
|
"navigator_plugins": False,
|
|
"navigator_user_agent": False,
|
|
"navigator_user_agent_data": False,
|
|
"navigator_vendor": False,
|
|
"navigator_webdriver": False,
|
|
"error_prototype": False,
|
|
"sec_ch_ua": False,
|
|
"webgl_vendor": False,
|
|
}
|