├── .gitattributes ├── .github ├── CODEOWNERS ├── config │ └── .pre-commit-config-template.yaml └── workflows │ ├── precommits.yml │ ├── secrets-scanner.yml │ └── vuln-scanner-pr.yml ├── .gitignore ├── .pylintrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── .yamllint ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── examples └── stealth_mode.py ├── images ├── example_with_stealth_headful.png └── example_with_stealth_headless.png ├── playwright_stealth ├── __init__.py ├── core │ ├── __init__.py │ └── _stealth_config.py ├── js │ ├── chrome.app.js │ ├── chrome.csi.js │ ├── chrome.load.times.js │ ├── chrome.plugin.js │ ├── chrome.runtime.js │ ├── generate.magic.arrays.js │ ├── iframe.contentWindow.js │ ├── media.codecs.js │ ├── navigator.hardwareConcurrency.js │ ├── navigator.languages.js │ ├── navigator.permissions.js │ ├── navigator.plugins.js │ ├── navigator.userAgent.js │ ├── navigator.vendor.js │ ├── navigator.webdriver.js │ ├── utils.js │ ├── webgl.vendor.js │ └── window.outerdimensions.js ├── properties │ ├── __init__.py │ ├── _header_properties.py │ ├── _navigator_properties.py │ ├── _properties.py │ ├── _viewport_properties.py │ └── _webgl_properties.py └── stealth.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── e2e ├── __init__.py ├── configs.py ├── demo_with_stealth_test.py ├── test_all_scripts.py ├── test_multiple_scripts.py └── test_one_script.py ├── unit ├── __init__.py ├── header_properties_test.py ├── navigator_properties_test.py ├── properties_test.py ├── viewport_properties_test.py └── webgl_properties_test.py └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * linguist-language=python -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This file is managed by Terraform in github-control repository 2 | # Do not edit this file, all changes will be overwritten 3 | # If you need to change this file, create a pull request in 4 | # https://github.com/tinyfish-io/github-control 5 | 6 | .github/workflows/** @tinyfish-io/security_team 7 | osv-scanner.toml @tinyfish-io/security_team 8 | 9 | 10 | * @tinyfish-io/open-source-team 11 | -------------------------------------------------------------------------------- /.github/config/.pre-commit-config-template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: "local" 4 | hooks: 5 | - id: "trufflehog" 6 | name: "TruffleHog" 7 | description: Detect secrets in your data. 8 | entry: bash -c 'trufflehog git file://. --since-commit HEAD --no-verification --fail --no-update' 9 | language: system 10 | stages: ["pre-commit", "pre-push"] 11 | -------------------------------------------------------------------------------- /.github/workflows/precommits.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit checks 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | env: 8 | POETRY_VERSION: "2.0.1" 9 | 10 | jobs: 11 | python-pre-commit: 12 | name: Pre-commit checks 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 18 | 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Install Python 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install Poetry 29 | run: | 30 | pipx install poetry==${{ env.POETRY_VERSION }} 31 | 32 | - name: Check Poetry File 33 | run: | 34 | poetry check 35 | 36 | - name: Install Package Dependencies 37 | run: | 38 | poetry env use $pythonLocation/bin/python 39 | poetry install -n 40 | echo "$(poetry env info --path)/bin" >> $GITHUB_PATH 41 | 42 | - name: Run unit tests 43 | run: | 44 | poetry run pytest tests/unit 45 | 46 | - name: Lint check 47 | run: | 48 | poetry run pylint --disable=R,C playwright_stealth 49 | 50 | - name: Code style check 51 | run: | 52 | poetry run black playwright_stealth --check --diff 53 | -------------------------------------------------------------------------------- /.github/workflows/secrets-scanner.yml: -------------------------------------------------------------------------------- 1 | # This file is managed by Terraform in github-control repository 2 | # Do not edit this file, all changes will be overwritten 3 | # If you need to change this file, create a pull request in 4 | # https://github.com/tinyfish-io/github-control 5 | --- 6 | name: Leaked Secrets Scan 7 | on: # yamllint disable-line rule:truthy 8 | pull_request: 9 | merge_group: 10 | branches: [main] 11 | 12 | jobs: 13 | TruffleHog: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: TruffleHog OSS 21 | uses: trufflesecurity/trufflehog@main 22 | with: 23 | path: ./ 24 | base: ${{ github.event.repository.default_branch }} 25 | head: HEAD 26 | extra_args: --only-verified 27 | -------------------------------------------------------------------------------- /.github/workflows/vuln-scanner-pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: OSV-Scanner PR Scan 3 | 4 | on: # yamllint disable-line rule:truthy 5 | pull_request: 6 | branches: [main] 7 | 8 | permissions: 9 | # Required to upload SARIF file to CodeQL. See: https://github.com/github/codeql-action/issues/2117 10 | actions: read 11 | # Require writing security events to upload SARIF file to security tab 12 | security-events: write 13 | # Only need to read contents 14 | contents: read 15 | 16 | jobs: 17 | vulnerability-check: 18 | uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@v1.9.2" 19 | with: 20 | upload-sarif: false 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | tests/e2e/screenshots 107 | .DS_Store -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=no 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list= 29 | 30 | # A comma-separated list of package or module names from where C extensions may 31 | # be loaded. Extensions are loading into the active Python interpreter and may 32 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 33 | # for backward compatibility.) 34 | extension-pkg-whitelist= 35 | 36 | # Return non-zero exit code if any of these messages/categories are detected, 37 | # even if score is above --fail-under value. Syntax same as enable. Messages 38 | # specified are enabled, while categories only check already-enabled messages. 39 | fail-on= 40 | 41 | # Specify a score threshold under which the program will exit with error. 42 | fail-under=10 43 | 44 | # Interpret the stdin as a python script, whose filename needs to be passed as 45 | # the module_or_package argument. 46 | #from-stdin= 47 | 48 | # Files or directories to be skipped. They should be base names, not paths. 49 | ignore=CVS 50 | 51 | # Add files or directories matching the regular expressions patterns to the 52 | # ignore-list. The regex matches against paths and can be in Posix or Windows 53 | # format. Because '\\' represents the directory delimiter on Windows systems, 54 | # it can't be used as an escape character. 55 | ignore-paths= 56 | 57 | # Files or directories matching the regular expression patterns are skipped. 58 | # The regex matches against base names, not paths. The default value ignores 59 | # Emacs file locks 60 | ignore-patterns=^\.# 61 | 62 | # List of module names for which member attributes should not be checked 63 | # (useful for modules/projects where namespaces are manipulated during runtime 64 | # and thus existing member attributes cannot be deduced by static analysis). It 65 | # supports qualified module names, as well as Unix pattern matching. 66 | ignored-modules= 67 | 68 | # Python code to execute, usually for sys.path manipulation such as 69 | # pygtk.require(). 70 | init-hook='import sys; sys.path.append(".")' 71 | 72 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 73 | # number of processors available to use, and will cap the count on Windows to 74 | # avoid hangs. 75 | jobs=1 76 | 77 | # Control the amount of potential inferred values when inferring a single 78 | # object. This can help the performance when dealing with large functions or 79 | # complex, nested conditions. 80 | limit-inference-results=100 81 | 82 | # List of plugins (as comma separated values of python module names) to load, 83 | # usually to register additional checkers. 84 | load-plugins= 85 | 86 | # Pickle collected data for later comparisons. 87 | persistent=yes 88 | 89 | # Minimum Python version to use for version dependent checks. Will default to 90 | # the version used to run pylint. 91 | py-version=3.11 92 | 93 | # Discover python modules and packages in the file system subtree. 94 | recursive=no 95 | 96 | # Add paths to the list of the source roots. Supports globbing patterns. The 97 | # source root is an absolute path or a path relative to the current working 98 | # directory used to determine a package namespace for modules located under the 99 | # source root. 100 | source-roots= 101 | 102 | # When enabled, pylint would attempt to guess common misconfiguration and emit 103 | # user-friendly hints instead of false-positive error messages. 104 | suggestion-mode=yes 105 | 106 | # Allow loading of arbitrary C extensions. Extensions are imported into the 107 | # active Python interpreter and may run arbitrary code. 108 | unsafe-load-any-extension=no 109 | 110 | # In verbose mode, extra non-checker-related info will be displayed. 111 | #verbose= 112 | 113 | 114 | [BASIC] 115 | 116 | # Naming style matching correct argument names. 117 | argument-naming-style=snake_case 118 | 119 | # Regular expression matching correct argument names. Overrides argument- 120 | # naming-style. If left empty, argument names will be checked with the set 121 | # naming style. 122 | #argument-rgx= 123 | 124 | # Naming style matching correct attribute names. 125 | attr-naming-style=snake_case 126 | 127 | # Regular expression matching correct attribute names. Overrides attr-naming- 128 | # style. If left empty, attribute names will be checked with the set naming 129 | # style. 130 | #attr-rgx= 131 | 132 | # Bad variable names which should always be refused, separated by a comma. 133 | bad-names=foo, 134 | bar, 135 | baz, 136 | toto, 137 | tutu, 138 | tata 139 | 140 | # Bad variable names regexes, separated by a comma. If names match any regex, 141 | # they will always be refused 142 | bad-names-rgxs= 143 | 144 | # Naming style matching correct class attribute names. 145 | class-attribute-naming-style=any 146 | 147 | # Regular expression matching correct class attribute names. Overrides class- 148 | # attribute-naming-style. If left empty, class attribute names will be checked 149 | # with the set naming style. 150 | #class-attribute-rgx= 151 | 152 | # Naming style matching correct class constant names. 153 | class-const-naming-style=UPPER_CASE 154 | 155 | # Regular expression matching correct class constant names. Overrides class- 156 | # const-naming-style. If left empty, class constant names will be checked with 157 | # the set naming style. 158 | #class-const-rgx= 159 | 160 | # Naming style matching correct class names. 161 | class-naming-style=PascalCase 162 | 163 | # Regular expression matching correct class names. Overrides class-naming- 164 | # style. If left empty, class names will be checked with the set naming style. 165 | #class-rgx= 166 | 167 | # Naming style matching correct constant names. 168 | const-naming-style=UPPER_CASE 169 | 170 | # Regular expression matching correct constant names. Overrides const-naming- 171 | # style. If left empty, constant names will be checked with the set naming 172 | # style. 173 | #const-rgx= 174 | 175 | # Minimum line length for functions/classes that require docstrings, shorter 176 | # ones are exempt. 177 | docstring-min-length=-1 178 | 179 | # Naming style matching correct function names. 180 | function-naming-style=snake_case 181 | 182 | # Regular expression matching correct function names. Overrides function- 183 | # naming-style. If left empty, function names will be checked with the set 184 | # naming style. 185 | #function-rgx= 186 | 187 | # Good variable names which should always be accepted, separated by a comma. 188 | good-names=i, 189 | j, 190 | k, 191 | ex, 192 | Run, 193 | _ 194 | 195 | # Good variable names regexes, separated by a comma. If names match any regex, 196 | # they will always be accepted 197 | good-names-rgxs= 198 | 199 | # Include a hint for the correct naming format with invalid-name. 200 | include-naming-hint=no 201 | 202 | # Naming style matching correct inline iteration names. 203 | inlinevar-naming-style=any 204 | 205 | # Regular expression matching correct inline iteration names. Overrides 206 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 207 | # with the set naming style. 208 | #inlinevar-rgx= 209 | 210 | # Naming style matching correct method names. 211 | method-naming-style=snake_case 212 | 213 | # Regular expression matching correct method names. Overrides method-naming- 214 | # style. If left empty, method names will be checked with the set naming style. 215 | #method-rgx= 216 | 217 | # Naming style matching correct module names. 218 | module-naming-style=snake_case 219 | 220 | # Regular expression matching correct module names. Overrides module-naming- 221 | # style. If left empty, module names will be checked with the set naming style. 222 | #module-rgx= 223 | 224 | # Colon-delimited sets of names that determine each other's naming style when 225 | # the name regexes allow several styles. 226 | name-group= 227 | 228 | # Regular expression which should only match function or class names that do 229 | # not require a docstring. 230 | no-docstring-rgx=^_ 231 | 232 | # List of decorators that produce properties, such as abc.abstractproperty. Add 233 | # to this list to register other decorators that produce valid properties. 234 | # These decorators are taken in consideration only for invalid-name. 235 | property-classes=abc.abstractproperty 236 | 237 | # Regular expression matching correct type alias names. If left empty, type 238 | # alias names will be checked with the set naming style. 239 | #typealias-rgx= 240 | 241 | # Regular expression matching correct type variable names. If left empty, type 242 | # variable names will be checked with the set naming style. 243 | #typevar-rgx= 244 | 245 | # Naming style matching correct variable names. 246 | variable-naming-style=snake_case 247 | 248 | # Regular expression matching correct variable names. Overrides variable- 249 | # naming-style. If left empty, variable names will be checked with the set 250 | # naming style. 251 | #variable-rgx= 252 | 253 | 254 | [CLASSES] 255 | 256 | # Warn about protected attribute access inside special methods 257 | check-protected-access-in-special-methods=no 258 | 259 | # List of method names used to declare (i.e. assign) instance attributes. 260 | defining-attr-methods=__init__, 261 | __new__, 262 | setUp, 263 | asyncSetUp, 264 | __post_init__ 265 | 266 | # List of member names, which should be excluded from the protected access 267 | # warning. 268 | exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit 269 | 270 | # List of valid names for the first argument in a class method. 271 | valid-classmethod-first-arg=cls 272 | 273 | # List of valid names for the first argument in a metaclass class method. 274 | valid-metaclass-classmethod-first-arg=mcs 275 | 276 | 277 | [DESIGN] 278 | 279 | # List of regular expressions of class ancestor names to ignore when counting 280 | # public methods (see R0903) 281 | exclude-too-few-public-methods= 282 | 283 | # List of qualified class names to ignore when counting class parents (see 284 | # R0901) 285 | ignored-parents= 286 | 287 | # Maximum number of arguments for function / method. 288 | max-args=5 289 | 290 | # Maximum number of attributes for a class (see R0902). 291 | max-attributes=7 292 | 293 | # Maximum number of boolean expressions in an if statement (see R0916). 294 | max-bool-expr=5 295 | 296 | # Maximum number of branch for function / method body. 297 | max-branches=12 298 | 299 | # Maximum number of locals for function / method body. 300 | max-locals=15 301 | 302 | # Maximum number of parents for a class (see R0901). 303 | max-parents=7 304 | 305 | # Maximum number of public methods for a class (see R0904). 306 | max-public-methods=20 307 | 308 | # Maximum number of return / yield for function / method body. 309 | max-returns=6 310 | 311 | # Maximum number of statements in function / method body. 312 | max-statements=50 313 | 314 | # Minimum number of public methods for a class (see R0903). 315 | min-public-methods=2 316 | 317 | 318 | [EXCEPTIONS] 319 | 320 | # Exceptions that will emit a warning when caught. 321 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 322 | 323 | 324 | [FORMAT] 325 | 326 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 327 | expected-line-ending-format= 328 | 329 | # Regexp for a line that is allowed to be longer than the limit. 330 | ignore-long-lines=^\s*(# )??$ 331 | 332 | # Number of spaces of indent required inside a hanging or continued line. 333 | indent-after-paren=4 334 | 335 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 336 | # tab). 337 | indent-string=' ' 338 | 339 | # Maximum number of characters on a single line. 340 | max-line-length=1000 341 | 342 | # Maximum number of lines in a module. 343 | max-module-lines=1000 344 | 345 | # Allow the body of a class to be on the same line as the declaration if body 346 | # contains single statement. 347 | single-line-class-stmt=no 348 | 349 | # Allow the body of an if to be on the same line as the test if there is no 350 | # else. 351 | single-line-if-stmt=no 352 | 353 | 354 | [IMPORTS] 355 | 356 | # List of modules that can be imported at any level, not just the top level 357 | # one. 358 | allow-any-import-level= 359 | 360 | # Allow explicit reexports by alias from a package __init__. 361 | allow-reexport-from-package=no 362 | 363 | # Allow wildcard imports from modules that define __all__. 364 | allow-wildcard-with-all=no 365 | 366 | # Deprecated modules which should not be used, separated by a comma. 367 | deprecated-modules= 368 | 369 | # Output a graph (.gv or any supported image format) of external dependencies 370 | # to the given file (report RP0402 must not be disabled). 371 | ext-import-graph= 372 | 373 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 374 | # external) dependencies to the given file (report RP0402 must not be 375 | # disabled). 376 | import-graph= 377 | 378 | # Output a graph (.gv or any supported image format) of internal dependencies 379 | # to the given file (report RP0402 must not be disabled). 380 | int-import-graph= 381 | 382 | # Force import order to recognize a module as part of the standard 383 | # compatibility libraries. 384 | known-standard-library= 385 | 386 | # Force import order to recognize a module as part of a third party library. 387 | known-third-party=enchant 388 | 389 | # Couples of modules and preferred modules, separated by a comma. 390 | preferred-modules= 391 | 392 | 393 | [LOGGING] 394 | 395 | # The type of string formatting that logging methods do. `old` means using % 396 | # formatting, `new` is for `{}` formatting. 397 | logging-format-style=new 398 | 399 | # Logging modules to check that the string format arguments are in logging 400 | # function parameter format. 401 | logging-modules=logging 402 | 403 | 404 | [MESSAGES CONTROL] 405 | 406 | # Only show warnings with the listed confidence levels. Leave empty to show 407 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 408 | # UNDEFINED. 409 | confidence=HIGH, 410 | CONTROL_FLOW, 411 | INFERENCE, 412 | INFERENCE_FAILURE, 413 | UNDEFINED 414 | 415 | # Disable the message, report, category or checker with the given id(s). You 416 | # can either give multiple identifiers separated by comma (,) or put this 417 | # option multiple times (only on the command line, not in the configuration 418 | # file where it should appear only once). You can also use "--disable=all" to 419 | # disable everything first and then re-enable specific checks. For example, if 420 | # you want to run only the similarities checker, you can use "--disable=all 421 | # --enable=similarities". If you want to run only the classes checker, but have 422 | # no Warning level messages displayed, use "--disable=all --enable=classes 423 | # --disable=W". 424 | disable=raw-checker-failed, 425 | bad-inline-option, 426 | locally-disabled, 427 | file-ignored, 428 | suppressed-message, 429 | useless-suppression, 430 | deprecated-pragma, 431 | use-symbolic-message-instead, 432 | pointless-string-statement, 433 | redefined-outer-name, 434 | wrong-import-position, 435 | logging-fstring-interpolation, 436 | missing-class-docstring, 437 | no-name-in-module, 438 | missing-module-docstring, 439 | fixme 440 | 441 | # Enable the message, report, category or checker with the given id(s). You can 442 | # either give multiple identifier separated by comma (,) or put this option 443 | # multiple time (only on the command line, not in the configuration file where 444 | # it should appear only once). See also the "--disable" option for examples. 445 | enable=c-extension-no-member 446 | 447 | 448 | [METHOD_ARGS] 449 | 450 | # List of qualified names (i.e., library.method) which require a timeout 451 | # parameter e.g. 'requests.api.get,requests.api.post' 452 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 453 | 454 | 455 | [MISCELLANEOUS] 456 | 457 | # List of note tags to take in consideration, separated by a comma. 458 | notes=FIXME, 459 | XXX, 460 | TODO 461 | 462 | # Regular expression of note tags to take in consideration. 463 | notes-rgx= 464 | 465 | 466 | [REFACTORING] 467 | 468 | # Maximum number of nested blocks for function / method body 469 | max-nested-blocks=5 470 | 471 | # Complete name of functions that never returns. When checking for 472 | # inconsistent-return-statements if a never returning function is called then 473 | # it will be considered as an explicit return statement and no message will be 474 | # printed. 475 | never-returning-functions=sys.exit,argparse.parse_error 476 | 477 | 478 | [REPORTS] 479 | 480 | # Python expression which should return a score less than or equal to 10. You 481 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 482 | # 'convention', and 'info' which contain the number of messages in each 483 | # category, as well as 'statement' which is the total number of statements 484 | # analyzed. This score is used by the global evaluation report (RP0004). 485 | evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) 486 | 487 | # Template used to display messages. This is a python new-style format string 488 | # used to format the message information. See doc for all details. 489 | msg-template= 490 | 491 | # Set the output format. Available formats are text, parseable, colorized, json 492 | # and msvs (visual studio). You can also give a reporter class, e.g. 493 | # mypackage.mymodule.MyReporterClass. 494 | #output-format= 495 | 496 | # Tells whether to display a full report or only the messages. 497 | reports=no 498 | 499 | # Activate the evaluation score. 500 | score=yes 501 | 502 | 503 | [SIMILARITIES] 504 | 505 | # Comments are removed from the similarity computation 506 | ignore-comments=yes 507 | 508 | # Docstrings are removed from the similarity computation 509 | ignore-docstrings=yes 510 | 511 | # Imports are removed from the similarity computation 512 | ignore-imports=yes 513 | 514 | # Signatures are removed from the similarity computation 515 | ignore-signatures=yes 516 | 517 | # Minimum lines number of a similarity. 518 | min-similarity-lines=4 519 | 520 | 521 | [SPELLING] 522 | 523 | # Limits count of emitted suggestions for spelling mistakes. 524 | max-spelling-suggestions=4 525 | 526 | # Spelling dictionary name. No available dictionaries : You need to install 527 | # both the python package and the system dependency for enchant to work.. 528 | spelling-dict= 529 | 530 | # List of comma separated words that should be considered directives if they 531 | # appear at the beginning of a comment and should not be checked. 532 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 533 | 534 | # List of comma separated words that should not be checked. 535 | spelling-ignore-words= 536 | 537 | # A path to a file that contains the private dictionary; one word per line. 538 | spelling-private-dict-file= 539 | 540 | # Tells whether to store unknown words to the private dictionary (see the 541 | # --spelling-private-dict-file option) instead of raising a message. 542 | spelling-store-unknown-words=no 543 | 544 | 545 | [STRING] 546 | 547 | # This flag controls whether inconsistent-quotes generates a warning when the 548 | # character used as a quote delimiter is used inconsistently within a module. 549 | check-quote-consistency=no 550 | 551 | # This flag controls whether the implicit-str-concat should generate a warning 552 | # on implicit string concatenation in sequences defined over several lines. 553 | check-str-concat-over-line-jumps=no 554 | 555 | 556 | [TYPECHECK] 557 | 558 | # List of decorators that produce context managers, such as 559 | # contextlib.contextmanager. Add to this list to register other decorators that 560 | # produce valid context managers. 561 | contextmanager-decorators=contextlib.contextmanager 562 | 563 | # List of members which are set dynamically and missed by pylint inference 564 | # system, and so shouldn't trigger E1101 when accessed. Python regular 565 | # expressions are accepted. 566 | generated-members= 567 | 568 | # Tells whether to warn about missing members when the owner of the attribute 569 | # is inferred to be None. 570 | ignore-none=yes 571 | 572 | # This flag controls whether pylint should warn about no-member and similar 573 | # checks whenever an opaque object is returned when inferring. The inference 574 | # can return multiple potential results while evaluating a Python object, but 575 | # some branches might not be evaluated, which results in partial inference. In 576 | # that case, it might be useful to still emit no-member and other checks for 577 | # the rest of the inferred objects. 578 | ignore-on-opaque-inference=yes 579 | 580 | # List of symbolic message names to ignore for Mixin members. 581 | ignored-checks-for-mixins=no-member, 582 | not-async-context-manager, 583 | not-context-manager, 584 | attribute-defined-outside-init 585 | 586 | # List of class names for which member attributes should not be checked (useful 587 | # for classes with dynamically set attributes). This supports the use of 588 | # qualified names. 589 | ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace 590 | 591 | # Show a hint with possible names when a member name was not found. The aspect 592 | # of finding the hint is based on edit distance. 593 | missing-member-hint=yes 594 | 595 | # The minimum edit distance a name should have in order to be considered a 596 | # similar match for a missing member name. 597 | missing-member-hint-distance=1 598 | 599 | # The total number of similar names that should be taken in consideration when 600 | # showing a hint for a missing member. 601 | missing-member-max-choices=1 602 | 603 | # Regex pattern to define which classes are considered mixins. 604 | mixin-class-rgx=.*[Mm]ixin 605 | 606 | # List of decorators that change the signature of a decorated function. 607 | signature-mutators= 608 | 609 | 610 | [VARIABLES] 611 | 612 | # List of additional names supposed to be defined in builtins. Remember that 613 | # you should avoid defining new builtins when possible. 614 | additional-builtins= 615 | 616 | # Tells whether unused global variables should be treated as a violation. 617 | allow-global-unused-variables=yes 618 | 619 | # List of names allowed to shadow builtins 620 | allowed-redefined-builtins= 621 | 622 | # List of strings which can identify a callback function by name. A callback 623 | # name must start or end with one of those strings. 624 | callbacks=cb_, 625 | _cb 626 | 627 | # A regular expression matching the name of dummy variables (i.e. expected to 628 | # not be used). 629 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 630 | 631 | # Argument names that match this expression will be ignored. 632 | ignored-argument-names=_.*|^ignored_|^unused_ 633 | 634 | # Tells whether we should check for unused import in __init__ files. 635 | init-import=no 636 | 637 | # List of qualified module names which can have objects that can redefine 638 | # builtins. 639 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 640 | 641 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.black-formatter", 4 | "ms-python.isort", 5 | "ms-python.pylint", 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Example", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "./examples/stealth_mode.py", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": ["tests"], 3 | "python.testing.unittestEnabled": false, 4 | "python.testing.pytestEnabled": true 5 | } 6 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | # This file is managed by Terraform in github-control repository 2 | # Do not edit this file, all changes will be overwritten 3 | # If you need to change this file, create a pull request in 4 | # https://github.com/tinyfish-io/github-control 5 | --- 6 | extends: default 7 | 8 | rules: 9 | line-length: 10 | max: 120 11 | level: warning 12 | comments: 13 | min-spaces-from-content: 1 14 | require-starting-space: false 15 | truthy: disable 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## 1.1.3 5 | - Updated dependencies to fix security vulnerabilities 6 | 7 | ## 1.1.2 8 | - Fixed stealth support for Firefox (@mikebgrep #35) 9 | 10 | ## 1.1.1 11 | - Fixed a bug when custom config was not respected (@mikebgrep #31) 12 | 13 | ## 1.1.0 14 | - Add ability to have custom headers depending on the browser type. When set, browser-specific headers will be used instead of the default headers. 15 | ``` 16 | await stealth_async(page, config=StealthConfig(browser_type=BrowserType.FIREFOX)) 17 | ``` 18 | 19 | ## 1.0.3 20 | - Fix when custom headers are not properly applied for async version 21 | 22 | ## 1.0.2 23 | - Fix compatibility issues with Python 3.8 and above 24 | 25 | ## 1.0.1 26 | - Fix compatibility issues with Python 3.9 and above 27 | 28 | ## 1.0.0 29 | - Add stealth headers with randomized properties making it harder to detect through network requests 30 | - Fix inconsistent browser stealth properties 31 | 32 | ## 0.0.6 33 | - Fix browser properties not being applied correctly 34 | 35 | ## 0.0.5 36 | - Fix plugins length test failing 37 | - Fix plugins of type PluginArray test failing 38 | - Fix `CHR_MEMORY` test failing when headless is `False` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ASAS1314 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include playwright_stealth/js/*.js 2 | global-exclude *_test.py 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | 3 | include $(wildcard makefiles/*) 4 | 5 | .PHONY: check-trufflehog 6 | check-trufflehog: 7 | @if ! which trufflehog > /dev/null 2>&1; then \ 8 | echo "TruffleHog is not installed."; \ 9 | echo "MacOS users can install it with:"; \ 10 | echo " brew install trufflehog"; \ 11 | echo ""; \ 12 | echo "Linux users can install it with:"; \ 13 | echo " curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin"; \ 14 | echo ""; \ 15 | echo "For more details, go to https://github.com/trufflesecurity/trufflehog"; \ 16 | exit 1; \ 17 | fi 18 | 19 | .PHONY: setup-pre-commit 20 | setup-pre-commit: 21 | @if [ ! -f .pre-commit-config.yaml ]; then \ 22 | echo ".pre-commit-config.yaml not found. Copying template..."; \ 23 | cp .github/config/.pre-commit-config-template.yaml .pre-commit-config.yaml; \ 24 | echo ".pre-commit-config.yaml created from template."; \ 25 | else \ 26 | echo ".pre-commit-config.yaml already exists."; \ 27 | fi 28 | 29 | .PHONY: init 30 | init: setup-pre-commit check-trufflehog 31 | pip install pre-commit 32 | pre-commit install 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🥷🏻 tf-playwright-stealth [![PyPI version](https://img.shields.io/pypi/v/tf-playwright-stealth)](https://pypi.org/project/tf-playwright-stealth/) [![AgentQL](https://img.shields.io/badge/AgentQL-AgentQL-informational)](https://agentql.com) [![Join Discord](https://img.shields.io/badge/join-discord-infomational)](https://discord.gg/agentql) 2 | 3 | 4 | 5 | > Transplanted from [puppeteer-extra-plugin-stealth](https://github.com/berstend/puppeteer-extra/tree/master/packages/puppeteer-extra-plugin-stealth). 6 | 7 | This is a package that makes playwright stealthy like a ninja by spoofing browser features in order to reduce the chance of detection. 8 | 9 | ## Install 10 | 11 | ``` 12 | pip install tf-playwright-stealth 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### sync 18 | ```python 19 | from playwright.sync_api import sync_playwright 20 | from playwright_stealth import stealth_sync 21 | 22 | with sync_playwright() as p: 23 | browser = p.chromium.launch( 24 | headless=True, 25 | ) 26 | page = browser.new_page() 27 | stealth_sync(page) 28 | page.goto("https://bot.sannysoft.com/") 29 | page.screenshot(path=f"example_with_stealth.png", full_page=True) 30 | browser.close() 31 | ``` 32 | 33 | 34 | ### async 35 | 36 | ```python 37 | from playwright.async_api import async_playwright 38 | from playwright_stealth import stealth_async 39 | 40 | with async_playwright() as p: 41 | browser = await p.chromium.launch( 42 | headless=True, 43 | ) 44 | page = await browser.new_page() 45 | await stealth_async(page) 46 | await page.goto("https://bot.sannysoft.com/") 47 | await page.screenshot(path=f"example_with_stealth_async.png", full_page=True) 48 | await browser.close() 49 | ``` 50 | ## Results 51 | From [bot.sannysoft.com](https://bot.sannysoft.com/) 52 | | Headless | Headful | 53 | |----------|---------| 54 | | ![Headless](./images/example_with_stealth_headless.png) | ![Headful](./images/example_with_stealth_headful.png) | 55 | -------------------------------------------------------------------------------- /examples/stealth_mode.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import random 4 | 5 | from playwright.async_api import ProxySettings, async_playwright 6 | from playwright_stealth import stealth_async 7 | from playwright_stealth.core import StealthConfig, BrowserType 8 | 9 | 10 | logging.basicConfig(level=logging.DEBUG) 11 | log = logging.getLogger(__name__) 12 | 13 | PROXIES: list[ProxySettings] = [ 14 | # TODO: replace with your own proxies 15 | # { 16 | # "server": "...", 17 | # "username": "...", 18 | # "password": "...", 19 | # }, 20 | ] 21 | 22 | 23 | async def main(): 24 | proxy: ProxySettings | None = random.choice(PROXIES) if PROXIES else None 25 | 26 | async with async_playwright() as playwright, await playwright.chromium.launch( 27 | headless=False, 28 | ) as browser: 29 | context = await browser.new_context(proxy=proxy) 30 | page = await context.new_page() 31 | await stealth_async(page, config=StealthConfig(browser_type=BrowserType.CHROME)) 32 | 33 | await page.goto("https://bot.sannysoft.com/") 34 | 35 | await page.wait_for_timeout(30000) 36 | 37 | 38 | if __name__ == "__main__": 39 | asyncio.run(main()) 40 | -------------------------------------------------------------------------------- /images/example_with_stealth_headful.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyfish-io/tf-playwright-stealth/cea0fd5e40a647166fc9bd0617776aa480424e1d/images/example_with_stealth_headful.png -------------------------------------------------------------------------------- /images/example_with_stealth_headless.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyfish-io/tf-playwright-stealth/cea0fd5e40a647166fc9bd0617776aa480424e1d/images/example_with_stealth_headless.png -------------------------------------------------------------------------------- /playwright_stealth/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from playwright_stealth.stealth import stealth_sync, stealth_async, StealthConfig 3 | -------------------------------------------------------------------------------- /playwright_stealth/core/__init__.py: -------------------------------------------------------------------------------- 1 | from ._stealth_config import StealthConfig, BrowserType 2 | 3 | __ALL__ = ["StealthConfig", "BrowserType"] 4 | -------------------------------------------------------------------------------- /playwright_stealth/core/_stealth_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Dict, Tuple, Optional 4 | import os 5 | from playwright_stealth.properties import Properties, BrowserType 6 | 7 | 8 | def from_file(name) -> str: 9 | """Read script from ./js directory""" 10 | filename = os.path.join(os.path.dirname(os.path.dirname(__file__)), "js", name) 11 | with open(filename, encoding="utf-8") as f: 12 | return f.read() 13 | 14 | 15 | SCRIPTS: Dict[str, str] = { 16 | "chrome_csi": from_file("chrome.csi.js"), 17 | "chrome_app": from_file("chrome.app.js"), 18 | "chrome_runtime": from_file("chrome.runtime.js"), 19 | "chrome_load_times": from_file("chrome.load.times.js"), 20 | "generate_magic_arrays": from_file("generate.magic.arrays.js"), 21 | "iframe_content_window": from_file("iframe.contentWindow.js"), 22 | "media_codecs": from_file("media.codecs.js"), 23 | "navigator_vendor": from_file("navigator.vendor.js"), 24 | "navigator_plugins": from_file("navigator.plugins.js"), 25 | "navigator_permissions": from_file("navigator.permissions.js"), 26 | "navigator_languages": from_file("navigator.languages.js"), 27 | "navigator_user_agent": from_file("navigator.userAgent.js"), 28 | "navigator_hardware_concurrency": from_file("navigator.hardwareConcurrency.js"), 29 | "outerdimensions": from_file("window.outerdimensions.js"), 30 | "utils": from_file("utils.js"), 31 | "webdriver": from_file("navigator.webdriver.js"), 32 | "webgl_vendor": from_file("webgl.vendor.js"), 33 | } 34 | 35 | 36 | @dataclass 37 | class StealthConfig: 38 | """ 39 | Playwright stealth configuration that applies stealth strategies to playwright page objects. 40 | The stealth strategies are contained in ./js package and are basic javascript scripts that are executed 41 | on every page.goto() called. 42 | Note: 43 | All init scripts are combined by playwright into one script and then executed this means 44 | the scripts should not have conflicting constants/variables etc. ! 45 | This also means scripts can be extended by overriding enabled_scripts generator: 46 | ``` 47 | @property 48 | def enabled_scripts(): 49 | yield 'console.log("first script")' 50 | yield from super().enabled_scripts() 51 | yield 'console.log("last script")' 52 | ``` 53 | """ 54 | 55 | # load script options 56 | webdriver: bool = True 57 | webgl_vendor: bool = True 58 | chrome_app: bool = True 59 | chrome_csi: bool = True 60 | chrome_load_times: bool = True 61 | chrome_runtime: bool = True 62 | iframe_content_window: bool = True 63 | media_codecs: bool = True 64 | navigator_hardware_concurrency: int = 4 65 | navigator_languages: bool = True 66 | navigator_permissions: bool = True 67 | navigator_plugins: bool = True 68 | navigator_user_agent: bool = True 69 | navigator_vendor: bool = True 70 | outerdimensions: bool = True 71 | browser_type: BrowserType = BrowserType.CHROME 72 | 73 | # options 74 | vendor: str = "Intel Inc." 75 | renderer: str = "Intel Iris OpenGL Engine" 76 | nav_vendor: str = "Google Inc." 77 | nav_user_agent: str = None 78 | nav_platform: str = None 79 | languages: Tuple[str, str] = ("en-US", "en") 80 | run_on_insecure_origins: Optional[bool] = None 81 | 82 | def enabled_scripts(self, properties: Properties): 83 | """ 84 | Generate the scripts to be executed. 85 | """ 86 | 87 | opts = json.dumps(properties.as_dict()) 88 | 89 | # defined options constant 90 | yield f"const opts = {opts}" 91 | # init utils and generate_magic_arrays helper 92 | yield SCRIPTS["utils"] 93 | yield SCRIPTS["generate_magic_arrays"] 94 | 95 | if self.chrome_app: 96 | yield SCRIPTS["chrome_app"] 97 | if self.chrome_csi: 98 | yield SCRIPTS["chrome_csi"] 99 | if self.chrome_load_times: 100 | yield SCRIPTS["chrome_load_times"] 101 | if self.chrome_runtime: 102 | yield SCRIPTS["chrome_runtime"] 103 | if self.iframe_content_window: 104 | yield SCRIPTS["iframe_content_window"] 105 | if self.media_codecs: 106 | yield SCRIPTS["media_codecs"] 107 | if self.navigator_languages: 108 | yield SCRIPTS["navigator_languages"] 109 | if self.navigator_permissions: 110 | yield SCRIPTS["navigator_permissions"] 111 | if self.navigator_plugins: 112 | yield SCRIPTS["navigator_plugins"] 113 | if self.navigator_user_agent: 114 | yield SCRIPTS["navigator_user_agent"] 115 | if self.navigator_vendor: 116 | yield SCRIPTS["navigator_vendor"] 117 | if self.webdriver: 118 | yield SCRIPTS["webdriver"] 119 | if self.outerdimensions: 120 | yield SCRIPTS["outerdimensions"] 121 | if self.webgl_vendor: 122 | yield SCRIPTS["webgl_vendor"] 123 | -------------------------------------------------------------------------------- /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 | // Ensures that the chrome object is not undefined in headless mode 13 | window.chrome = { 14 | runtime: {}, 15 | }; 16 | 17 | // That means we're running headful and don't need to mock anything 18 | if (!("app" in window.chrome)) { 19 | 20 | const makeError = { 21 | ErrorInInvocation: (fn) => { 22 | const err = new TypeError(`Error in invocation of app.${fn}()`); 23 | return utils.stripErrorWithAnchor(err, `at ${fn} (eval at `); 24 | }, 25 | }; 26 | 27 | // There's a some static data in that property which doesn't seem to change, 28 | // we should periodically check for updates: `JSON.stringify(window.app, null, 2)` 29 | const STATIC_DATA = JSON.parse( 30 | ` 31 | { 32 | "isInstalled": false, 33 | "InstallState": { 34 | "DISABLED": "disabled", 35 | "INSTALLED": "installed", 36 | "NOT_INSTALLED": "not_installed" 37 | }, 38 | "RunningState": { 39 | "CANNOT_RUN": "cannot_run", 40 | "READY_TO_RUN": "ready_to_run", 41 | "RUNNING": "running" 42 | } 43 | } 44 | `.trim() 45 | ); 46 | 47 | window.chrome.app = { 48 | ...STATIC_DATA, 49 | 50 | get isInstalled() { 51 | return false; 52 | }, 53 | 54 | getDetails: function getDetails() { 55 | if (arguments.length) { 56 | throw makeError.ErrorInInvocation(`getDetails`); 57 | } 58 | return null; 59 | }, 60 | getIsInstalled: function getDetails() { 61 | if (arguments.length) { 62 | throw makeError.ErrorInInvocation(`getIsInstalled`); 63 | } 64 | return false; 65 | }, 66 | runningState: function getDetails() { 67 | if (arguments.length) { 68 | throw makeError.ErrorInInvocation(`runningState`); 69 | } 70 | return "cannot_run"; 71 | }, 72 | }; 73 | utils.patchToStringNested(window.chrome.app); 74 | } 75 | -------------------------------------------------------------------------------- /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 | // That means we're running headful and don't need to mock anything 13 | if (!("csi" in window.chrome)) { 14 | // Check that the Navigation Timing API v1 is available, we need that 15 | if (window.performance && window.performance.timing) { 16 | const { timing } = window.performance; 17 | 18 | window.chrome.csi = () => ({ 19 | onloadT: timing.domContentLoadedEventEnd, 20 | startE: timing.navigationStart, 21 | pageT: Date.now() - timing.navigationStart, 22 | tran: 15, // Transition type or something 23 | }); 24 | utils.patchToString(window.chrome.csi); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.load.times.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 | 15 | 16 | // Check that the Navigation Timing API v1 + v2 is available, we need that 17 | if (window.performance && window.performance.timing && window.PerformancePaintTiming) { 18 | 19 | const { performance } = window; 20 | 21 | // Some stuff is not available on about:blank as it requires a navigation to occur, 22 | // let's harden the code to not fail then: 23 | const ntEntryFallback = { 24 | nextHopProtocol: "h2", 25 | type: "other", 26 | }; 27 | 28 | // The API exposes some funky info regarding the connection 29 | const protocolInfo = { 30 | get connectionInfo() { 31 | const ntEntry = 32 | performance.getEntriesByType("navigation")[0] || ntEntryFallback; 33 | return ntEntry.nextHopProtocol; 34 | }, 35 | get npnNegotiatedProtocol() { 36 | // NPN is deprecated in favor of ALPN, but this implementation returns the 37 | // HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 38 | const ntEntry = 39 | performance.getEntriesByType("navigation")[0] || ntEntryFallback; 40 | return ["h2", "hq"].includes(ntEntry.nextHopProtocol) 41 | ? ntEntry.nextHopProtocol 42 | : "unknown"; 43 | }, 44 | get navigationType() { 45 | const ntEntry = 46 | performance.getEntriesByType("navigation")[0] || ntEntryFallback; 47 | return ntEntry.type; 48 | }, 49 | get wasAlternateProtocolAvailable() { 50 | // The Alternate-Protocol header is deprecated in favor of Alt-Svc 51 | // (https://www.mnot.net/blog/2016/03/09/alt-svc), so technically this 52 | // should always return false. 53 | return false; 54 | }, 55 | get wasFetchedViaSpdy() { 56 | // SPDY is deprecated in favor of HTTP/2, but this implementation returns 57 | // true for HTTP/2 or HTTP2+QUIC/39 as well. 58 | const ntEntry = 59 | performance.getEntriesByType("navigation")[0] || ntEntryFallback; 60 | return ["h2", "hq"].includes(ntEntry.nextHopProtocol); 61 | }, 62 | get wasNpnNegotiated() { 63 | // NPN is deprecated in favor of ALPN, but this implementation returns true 64 | // for HTTP/2 or HTTP2+QUIC/39 requests negotiated via ALPN. 65 | const ntEntry = 66 | performance.getEntriesByType("navigation")[0] || ntEntryFallback; 67 | return ["h2", "hq"].includes(ntEntry.nextHopProtocol); 68 | }, 69 | }; 70 | 71 | const { timing } = window.performance; 72 | 73 | // Truncate number to specific number of decimals, most of the `loadTimes` stuff has 3 74 | function toFixed(num, fixed) { 75 | var re = new RegExp("^-?\\d+(?:.\\d{0," + (fixed || -1) + "})?"); 76 | return num.toString().match(re)[0]; 77 | } 78 | 79 | const timingInfo = { 80 | get firstPaintAfterLoadTime() { 81 | // This was never actually implemented and always returns 0. 82 | return 0; 83 | }, 84 | get requestTime() { 85 | return timing.navigationStart / 1000; 86 | }, 87 | get startLoadTime() { 88 | return timing.navigationStart / 1000; 89 | }, 90 | get commitLoadTime() { 91 | return timing.responseStart / 1000; 92 | }, 93 | get finishDocumentLoadTime() { 94 | return timing.domContentLoadedEventEnd / 1000; 95 | }, 96 | get finishLoadTime() { 97 | return timing.loadEventEnd / 1000; 98 | }, 99 | get firstPaintTime() { 100 | const fpEntry = performance.getEntriesByType("paint")[0] || { 101 | startTime: timing.loadEventEnd / 1000, // Fallback if no navigation occured (`about:blank`) 102 | }; 103 | return toFixed((fpEntry.startTime + performance.timeOrigin) / 1000, 3); 104 | }, 105 | }; 106 | 107 | window.chrome.loadTimes = function () { 108 | return { 109 | ...protocolInfo, 110 | ...timingInfo, 111 | }; 112 | }; 113 | utils.patchToString(window.chrome.loadTimes); 114 | } 115 | } -------------------------------------------------------------------------------- /playwright_stealth/js/chrome.plugin.js: -------------------------------------------------------------------------------- 1 | // Ensures that plugins is not empty and is of PluginArray type 2 | Object.defineProperty(Object.getPrototypeOf(navigator), 'plugins', {get() { 3 | 4 | var ChromiumPDFPlugin = {}; 5 | ChromiumPDFPlugin.__proto__ = Plugin.prototype; 6 | var plugins = { 7 | 0: ChromiumPDFPlugin, 8 | description: 'Portable Document Format', 9 | filename: 'internal-pdf-viewer', 10 | length: 1, 11 | name: 'Chromium PDF Plugin', 12 | __proto__: PluginArray.prototype, 13 | }; 14 | return plugins; 15 | }}) -------------------------------------------------------------------------------- /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 headful 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 | 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(preamble + `No matching signature.`), 75 | MustSpecifyExtensionID: new TypeError( 76 | preamble + 77 | `${method} called from a webpage must specify an Extension ID (string) for its first argument.` 78 | ), 79 | InvalidExtensionID: new TypeError( 80 | preamble + `Invalid extension id: '${extensionId}'` 81 | ), 82 | }); 83 | 84 | // Valid Extension IDs are 32 characters in length and use the letter `a` to `p`: 85 | // https://source.chromium.org/chromium/chromium/src/+/master:components/crx_file/id_util.cc;drc=14a055ccb17e8c8d5d437fe080faba4c6f07beac;l=90 86 | const isValidExtensionID = (str) => 87 | str.length === 32 && str.toLowerCase().match(/^[a-p]+$/); 88 | 89 | /** Mock `chrome.runtime.sendMessage` */ 90 | const sendMessageHandler = { 91 | apply: function (target, ctx, args) { 92 | const [extensionId, options, responseCallback] = args || []; 93 | 94 | // Define custom errors 95 | const errorPreamble = `Error in invocation of runtime.sendMessage(optional string extensionId, any message, optional object options, optional function responseCallback): `; 96 | const Errors = makeCustomRuntimeErrors( 97 | errorPreamble, 98 | `chrome.runtime.sendMessage()`, 99 | extensionId 100 | ); 101 | 102 | // Check if the call signature looks ok 103 | const noArguments = args.length === 0; 104 | const tooManyArguments = args.length > 4; 105 | const incorrectOptions = options && typeof options !== "object"; 106 | const incorrectResponseCallback = 107 | responseCallback && typeof responseCallback !== "function"; 108 | if ( 109 | noArguments || 110 | tooManyArguments || 111 | incorrectOptions || 112 | incorrectResponseCallback 113 | ) { 114 | throw Errors.NoMatchingSignature; 115 | } 116 | 117 | // At least 2 arguments are required before we even validate the extension ID 118 | if (args.length < 2) { 119 | throw Errors.MustSpecifyExtensionID; 120 | } 121 | 122 | // Now let's make sure we got a string as extension ID 123 | if (typeof extensionId !== "string") { 124 | throw Errors.NoMatchingSignature; 125 | } 126 | 127 | if (!isValidExtensionID(extensionId)) { 128 | throw Errors.InvalidExtensionID; 129 | } 130 | 131 | return undefined; // Normal behavior 132 | }, 133 | }; 134 | utils.mockWithProxy( 135 | window.chrome.runtime, 136 | "sendMessage", 137 | function sendMessage() {}, 138 | sendMessageHandler 139 | ); 140 | 141 | /** 142 | * Mock `chrome.runtime.connect` 143 | * 144 | * @see https://developer.chrome.com/apps/runtime#method-connect 145 | */ 146 | const connectHandler = { 147 | apply: function (target, ctx, args) { 148 | const [extensionId, connectInfo] = args || []; 149 | 150 | // Define custom errors 151 | const errorPreamble = `Error in invocation of runtime.connect(optional string extensionId, optional object connectInfo): `; 152 | const Errors = makeCustomRuntimeErrors( 153 | errorPreamble, 154 | `chrome.runtime.connect()`, 155 | extensionId 156 | ); 157 | 158 | // Behavior differs a bit from sendMessage: 159 | const noArguments = args.length === 0; 160 | const emptyStringArgument = args.length === 1 && extensionId === ""; 161 | if (noArguments || emptyStringArgument) { 162 | throw Errors.MustSpecifyExtensionID; 163 | } 164 | 165 | const tooManyArguments = args.length > 2; 166 | const incorrectConnectInfoType = 167 | connectInfo && typeof connectInfo !== "object"; 168 | 169 | if (tooManyArguments || incorrectConnectInfoType) { 170 | throw Errors.NoMatchingSignature; 171 | } 172 | 173 | const extensionIdIsString = typeof extensionId === "string"; 174 | if (extensionIdIsString && extensionId === "") { 175 | throw Errors.MustSpecifyExtensionID; 176 | } 177 | if (extensionIdIsString && !isValidExtensionID(extensionId)) { 178 | throw Errors.InvalidExtensionID; 179 | } 180 | 181 | // There's another edge-case here: extensionId is optional so we might find a connectInfo object as first param, which we need to validate 182 | const validateConnectInfo = (ci) => { 183 | // More than a first param connectInfo as been provided 184 | if (args.length > 1) { 185 | throw Errors.NoMatchingSignature; 186 | } 187 | // An empty connectInfo has been provided 188 | if (Object.keys(ci).length === 0) { 189 | throw Errors.MustSpecifyExtensionID; 190 | } 191 | // Loop over all connectInfo props an check them 192 | Object.entries(ci).forEach(([k, v]) => { 193 | const isExpected = ["name", "includeTlsChannelId"].includes(k); 194 | if (!isExpected) { 195 | throw new TypeError(errorPreamble + `Unexpected property: '${k}'.`); 196 | } 197 | const MismatchError = (propName, expected, found) => 198 | TypeError( 199 | errorPreamble + 200 | `Error at property '${propName}': Invalid type: expected ${expected}, found ${found}.` 201 | ); 202 | if (k === "name" && typeof v !== "string") { 203 | throw MismatchError(k, "string", typeof v); 204 | } 205 | if (k === "includeTlsChannelId" && typeof v !== "boolean") { 206 | throw MismatchError(k, "boolean", typeof v); 207 | } 208 | }); 209 | }; 210 | if (typeof extensionId === "object") { 211 | validateConnectInfo(extensionId); 212 | throw Errors.MustSpecifyExtensionID; 213 | } 214 | 215 | // Unfortunately even when the connect fails Chrome will return an object with methods we need to mock as well 216 | return utils.patchToStringNested(makeConnectResponse()); 217 | }, 218 | }; 219 | utils.mockWithProxy( 220 | window.chrome.runtime, 221 | "connect", 222 | function connect() {}, 223 | connectHandler 224 | ); 225 | 226 | function makeConnectResponse() { 227 | const onSomething = () => ({ 228 | addListener: function addListener() {}, 229 | dispatch: function dispatch() {}, 230 | hasListener: function hasListener() {}, 231 | hasListeners: function hasListeners() { 232 | return false; 233 | }, 234 | removeListener: function removeListener() {}, 235 | }); 236 | 237 | const response = { 238 | name: "", 239 | sender: undefined, 240 | disconnect: function disconnect() {}, 241 | onDisconnect: onSomething(), 242 | onMessage: onSomething(), 243 | postMessage: function postMessage() { 244 | if (!arguments.length) { 245 | throw new TypeError(`Insufficient number of arguments.`); 246 | } 247 | throw new Error(`Attempting to use a disconnected port object`); 248 | }, 249 | }; 250 | return response; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /playwright_stealth/js/generate.magic.arrays.js: -------------------------------------------------------------------------------- 1 | generateFunctionMocks = (proto, itemMainProp, dataArray) => ({ 2 | /** Returns the MimeType object with the specified index. */ 3 | item: utils.createProxy(proto.item, { 4 | apply(target, ctx, args) { 5 | if (!args.length) { 6 | throw new TypeError( 7 | `Failed to execute 'item' on '${ 8 | proto[Symbol.toStringTag] 9 | }': 1 argument required, but only 0 present.` 10 | ); 11 | } 12 | // Special behavior alert: 13 | // - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup 14 | // - If anything else than an integer (including as string) is provided it will return the first entry 15 | const isInteger = args[0] && Number.isInteger(Number(args[0])); // Cast potential string to number first, then check for integer 16 | // Note: Vanilla never returns `undefined` 17 | return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null; 18 | }, 19 | }), 20 | /** Returns the MimeType object with the specified name. */ 21 | namedItem: utils.createProxy(proto.namedItem, { 22 | apply(target, ctx, args) { 23 | if (!args.length) { 24 | throw new TypeError( 25 | `Failed to execute 'namedItem' on '${ 26 | proto[Symbol.toStringTag] 27 | }': 1 argument required, but only 0 present.` 28 | ); 29 | } 30 | return dataArray.find((mt) => mt[itemMainProp] === args[0]) || null; // Not `undefined`! 31 | }, 32 | }), 33 | /** Does nothing and shall return nothing */ 34 | refresh: proto.refresh 35 | ? utils.createProxy(proto.refresh, { 36 | apply(target, ctx, args) { 37 | return undefined; 38 | }, 39 | }) 40 | : undefined, 41 | }); 42 | 43 | function generateMagicArray( 44 | dataArray = [], 45 | proto = MimeTypeArray.prototype, 46 | itemProto = MimeType.prototype, 47 | itemMainProp = "type" 48 | ) { 49 | // Quick helper to set props with the same descriptors vanilla is using 50 | const defineProp = (obj, prop, value) => 51 | Object.defineProperty(obj, prop, { 52 | value, 53 | writable: false, 54 | enumerable: false, // Important for mimeTypes & plugins: `JSON.stringify(navigator.mimeTypes)` 55 | configurable: true, 56 | }); 57 | 58 | // Loop over our fake data and construct items 59 | const makeItem = (data) => { 60 | const item = {}; 61 | for (const prop of Object.keys(data)) { 62 | if (prop.startsWith("__")) { 63 | continue; 64 | } 65 | defineProp(item, prop, data[prop]); 66 | } 67 | return patchItem(item, data); 68 | }; 69 | 70 | const patchItem = (item, data) => { 71 | let descriptor = Object.getOwnPropertyDescriptors(item); 72 | 73 | // Special case: Plugins have a magic length property which is not enumerable 74 | // e.g. `navigator.plugins[i].length` should always be the length of the assigned mimeTypes 75 | if (itemProto === Plugin.prototype) { 76 | descriptor = { 77 | ...descriptor, 78 | length: { 79 | value: data.__mimeTypes.length, 80 | writable: false, 81 | enumerable: false, 82 | configurable: true, // Important to be able to use the ownKeys trap in a Proxy to strip `length` 83 | }, 84 | }; 85 | } 86 | 87 | // We need to spoof a specific `MimeType` or `Plugin` object 88 | const obj = Object.create(itemProto, descriptor); 89 | 90 | // Virtually all property keys are not enumerable in vanilla 91 | const blacklist = [...Object.keys(data), "length", "enabledPlugin"]; 92 | return new Proxy(obj, { 93 | ownKeys(target) { 94 | return Reflect.ownKeys(target).filter((k) => !blacklist.includes(k)); 95 | }, 96 | getOwnPropertyDescriptor(target, prop) { 97 | if (blacklist.includes(prop)) { 98 | return undefined; 99 | } 100 | return Reflect.getOwnPropertyDescriptor(target, prop); 101 | }, 102 | }); 103 | }; 104 | 105 | const magicArray = []; 106 | 107 | // Loop through our fake data and use that to create convincing entities 108 | dataArray.forEach((data) => { 109 | magicArray.push(makeItem(data)); 110 | }); 111 | 112 | // Add direct property access based on types (e.g. `obj['application/pdf']`) afterwards 113 | magicArray.forEach((entry) => { 114 | defineProp(magicArray, entry[itemMainProp], entry); 115 | }); 116 | 117 | // This is the best way to fake the type to make sure this is false: `Array.isArray(navigator.mimeTypes)` 118 | const magicArrayObj = Object.create(proto, { 119 | ...Object.getOwnPropertyDescriptors(magicArray), 120 | 121 | // There's one ugly quirk we unfortunately need to take care of: 122 | // The `MimeTypeArray` prototype has an enumerable `length` property, 123 | // but headful Chrome will still skip it when running `Object.getOwnPropertyNames(navigator.mimeTypes)`. 124 | // To strip it we need to make it first `configurable` and can then overlay a Proxy with an `ownKeys` trap. 125 | length: { 126 | value: magicArray.length, 127 | writable: false, 128 | enumerable: false, 129 | configurable: true, // Important to be able to use the ownKeys trap in a Proxy to strip `length` 130 | }, 131 | }); 132 | 133 | const generateFunctionMocks = utils => ( 134 | proto, 135 | itemMainProp, 136 | dataArray 137 | ) => ({ 138 | /** Returns the MimeType object with the specified index. */ 139 | item: utils.createProxy(proto.item, { 140 | apply(target, ctx, args) { 141 | if (!args.length) { 142 | throw new TypeError( 143 | `Failed to execute 'item' on '${ 144 | proto[Symbol.toStringTag] 145 | }': 1 argument required, but only 0 present.` 146 | ) 147 | } 148 | // Special behavior alert: 149 | // - Vanilla tries to cast strings to Numbers (only integers!) and use them as property index lookup 150 | // - If anything else than an integer (including as string) is provided it will return the first entry 151 | const isInteger = args[0] && Number.isInteger(Number(args[0])) // Cast potential string to number first, then check for integer 152 | // Note: Vanilla never returns `undefined` 153 | return (isInteger ? dataArray[Number(args[0])] : dataArray[0]) || null 154 | } 155 | }), 156 | /** Returns the MimeType object with the specified name. */ 157 | namedItem: utils.createProxy(proto.namedItem, { 158 | apply(target, ctx, args) { 159 | if (!args.length) { 160 | throw new TypeError( 161 | `Failed to execute 'namedItem' on '${ 162 | proto[Symbol.toStringTag] 163 | }': 1 argument required, but only 0 present.` 164 | ) 165 | } 166 | return dataArray.find(mt => mt[itemMainProp] === args[0]) || null // Not `undefined`! 167 | } 168 | }), 169 | /** Does nothing and shall return nothing */ 170 | refresh: proto.refresh 171 | ? utils.createProxy(proto.refresh, { 172 | apply(target, ctx, args) { 173 | return undefined 174 | } 175 | }) 176 | : undefined 177 | }) 178 | 179 | // Generate our functional function mocks :-) 180 | const functionMocks = generateFunctionMocks(utils)( 181 | proto, 182 | itemMainProp, 183 | magicArray 184 | ); 185 | 186 | // We need to overlay our custom object with a JS Proxy 187 | const magicArrayObjProxy = new Proxy(magicArrayObj, { 188 | get(target, key = "") { 189 | // Redirect function calls to our custom proxied versions mocking the vanilla behavior 190 | if (key === "item") { 191 | return functionMocks.item; 192 | } 193 | if (key === "namedItem") { 194 | return functionMocks.namedItem; 195 | } 196 | if (proto === PluginArray.prototype && key === "refresh") { 197 | return functionMocks.refresh; 198 | } 199 | // Everything else can pass through as normal 200 | return utils.cache.Reflect.get(...arguments); 201 | }, 202 | ownKeys(target) { 203 | // There are a couple of quirks where the original property demonstrates "magical" behavior that makes no sense 204 | // This can be witnessed when calling `Object.getOwnPropertyNames(navigator.mimeTypes)` and the absense of `length` 205 | // My guess is that it has to do with the recent change of not allowing data enumeration and this being implemented weirdly 206 | // For that reason we just completely fake the available property names based on our data to match what regular Chrome is doing 207 | // Specific issues when not patching this: `length` property is available, direct `types` props (e.g. `obj['application/pdf']`) are missing 208 | const keys = []; 209 | const typeProps = magicArray.map((mt) => mt[itemMainProp]); 210 | typeProps.forEach((_, i) => keys.push(`${i}`)); 211 | typeProps.forEach((propName) => keys.push(propName)); 212 | return keys; 213 | }, 214 | getOwnPropertyDescriptor(target, prop) { 215 | if (prop === "length") { 216 | return undefined; 217 | } 218 | return Reflect.getOwnPropertyDescriptor(target, prop); 219 | }, 220 | }); 221 | 222 | return magicArrayObjProxy; 223 | } 224 | -------------------------------------------------------------------------------- /playwright_stealth/js/iframe.contentWindow.js: -------------------------------------------------------------------------------- 1 | try { 2 | // Adds a contentWindow proxy to the provided iframe element 3 | const addContentWindowProxy = (iframe) => { 4 | const contentWindowProxy = { 5 | get(target, key) { 6 | // Now to the interesting part: 7 | // We actually make this thing behave like a regular iframe window, 8 | // by intercepting calls to e.g. `.self` and redirect it to the correct thing. :) 9 | // That makes it possible for these assertions to be correct: 10 | // iframe.contentWindow.self === window.top // must be false 11 | if (key === "self") { 12 | return this; 13 | } 14 | // iframe.contentWindow.frameElement === iframe // must be true 15 | if (key === "frameElement") { 16 | return iframe; 17 | } 18 | // Intercept iframe.contentWindow[0] to hide the property 0 added by the proxy. 19 | if (key === "0") { 20 | return undefined; 21 | } 22 | return Reflect.get(target, key); 23 | }, 24 | }; 25 | 26 | if (!iframe.contentWindow) { 27 | const proxy = new Proxy(window, contentWindowProxy); 28 | Object.defineProperty(iframe, "contentWindow", { 29 | get() { 30 | return proxy; 31 | }, 32 | set(newValue) { 33 | return newValue; // contentWindow is immutable 34 | }, 35 | enumerable: true, 36 | configurable: false, 37 | }); 38 | } 39 | }; 40 | 41 | // Handles iframe element creation, augments `srcdoc` property so we can intercept further 42 | const handleIframeCreation = (target, thisArg, args) => { 43 | const iframe = target.apply(thisArg, args); 44 | 45 | // We need to keep the originals around 46 | const _iframe = iframe; 47 | const _srcdoc = _iframe.srcdoc; 48 | 49 | // Add hook for the srcdoc property 50 | // We need to be very surgical here to not break other iframes by accident 51 | Object.defineProperty(iframe, "srcdoc", { 52 | configurable: true, // Important, so we can reset this later 53 | get: function () { 54 | return _srcdoc; 55 | }, 56 | set: function (newValue) { 57 | addContentWindowProxy(this); 58 | // Reset property, the hook is only needed once 59 | Object.defineProperty(iframe, "srcdoc", { 60 | configurable: false, 61 | writable: false, 62 | value: _srcdoc, 63 | }); 64 | _iframe.srcdoc = newValue; 65 | }, 66 | }); 67 | return iframe; 68 | }; 69 | 70 | // Adds a hook to intercept iframe creation events 71 | const addIframeCreationSniffer = () => { 72 | /* global document */ 73 | const createElementHandler = { 74 | // Make toString() native 75 | get(target, key) { 76 | return Reflect.get(target, key); 77 | }, 78 | apply: function (target, thisArg, args) { 79 | const isIframe = 80 | args && args.length && `${args[0]}`.toLowerCase() === "iframe"; 81 | if (!isIframe) { 82 | // Everything as usual 83 | return target.apply(thisArg, args); 84 | } else { 85 | return handleIframeCreation(target, thisArg, args); 86 | } 87 | }, 88 | }; 89 | // All this just due to iframes with srcdoc bug 90 | utils.replaceWithProxy(document, "createElement", createElementHandler); 91 | }; 92 | 93 | // Let's go 94 | addIframeCreationSniffer(); 95 | } catch (err) { 96 | // console.warn(err) 97 | } 98 | -------------------------------------------------------------------------------- /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(HTMLMediaElement.prototype, "canPlayType", canPlayType); 60 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.hardwareConcurrency.js: -------------------------------------------------------------------------------- 1 | utils.replaceGetterWithProxy( 2 | Object.getPrototypeOf(navigator), 3 | "hardwareConcurrency", 4 | utils.makeHandler().getterValue(opts.navigator.hardwareConcurrency) 5 | ); 6 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.languages.js: -------------------------------------------------------------------------------- 1 | const languages = opts.navigator.languages.length ? opts.navigator.languages : ["en-US", "en"]; 2 | utils.replaceGetterWithProxy( 3 | Object.getPrototypeOf(navigator), 4 | "languages", 5 | utils.makeHandler().getterValue(Object.freeze([...languages])) 6 | ); 7 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.permissions.js: -------------------------------------------------------------------------------- 1 | const isSecure = document.location.protocol.startsWith("https"); 2 | 3 | // In headful on secure origins the permission should be "default", not "denied" 4 | if (isSecure) { 5 | utils.replaceGetterWithProxy(Notification, "permission", { 6 | apply() { 7 | return "default"; 8 | }, 9 | }); 10 | } 11 | 12 | // Another weird behavior: 13 | // On insecure origins in headful the state is "denied", 14 | // whereas in headless it's "prompt" 15 | if (!isSecure) { 16 | const handler = { 17 | apply(target, ctx, args) { 18 | const param = (args || [])[0]; 19 | 20 | const isNotifications = 21 | param && param.name && param.name === "notifications"; 22 | if (!isNotifications) { 23 | return utils.cache.Reflect.apply(...arguments); 24 | } 25 | 26 | return Promise.resolve( 27 | Object.setPrototypeOf( 28 | { 29 | state: "denied", 30 | onchange: null, 31 | }, 32 | PermissionStatus.prototype 33 | ) 34 | ); 35 | }, 36 | }; 37 | // Note: Don't use `Object.getPrototypeOf` here 38 | utils.replaceWithProxy(Permissions.prototype, "query", handler); 39 | } 40 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.plugins.js: -------------------------------------------------------------------------------- 1 | 2 | data = { 3 | mimeTypes: [ 4 | { 5 | type: "application/pdf", 6 | suffixes: "pdf", 7 | description: "", 8 | __pluginName: "Chrome PDF Viewer", 9 | }, 10 | { 11 | type: "application/x-google-chrome-pdf", 12 | suffixes: "pdf", 13 | description: "Portable Document Format", 14 | __pluginName: "Chrome PDF Plugin", 15 | }, 16 | { 17 | type: "application/x-nacl", 18 | suffixes: "", 19 | description: "Native Client Executable", 20 | __pluginName: "Native Client", 21 | }, 22 | { 23 | type: "application/x-pnacl", 24 | suffixes: "", 25 | description: "Portable Native Client Executable", 26 | __pluginName: "Native Client", 27 | }, 28 | ], 29 | plugins: [ 30 | { 31 | name: "Chrome PDF Plugin", 32 | filename: "internal-pdf-viewer", 33 | description: "Portable Document Format", 34 | __mimeTypes: ["application/x-google-chrome-pdf"], 35 | }, 36 | { 37 | name: "Chrome PDF Viewer", 38 | filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", 39 | description: "", 40 | __mimeTypes: ["application/pdf"], 41 | }, 42 | { 43 | name: "Native Client", 44 | filename: "internal-nacl-plugin", 45 | description: "", 46 | __mimeTypes: ["application/x-nacl", "application/x-pnacl"], 47 | }, 48 | ], 49 | }; 50 | 51 | const generateMimeTypeArray = mimeTypesData => { 52 | return generateMagicArray( 53 | mimeTypesData, 54 | MimeTypeArray.prototype, 55 | MimeType.prototype, 56 | 'type' 57 | ) 58 | } 59 | 60 | const generatePluginArray = pluginsData => { 61 | return generateMagicArray( 62 | pluginsData, 63 | PluginArray.prototype, 64 | Plugin.prototype, 65 | 'name' 66 | ) 67 | } 68 | 69 | // That means we're running headful 70 | let hasPlugins = "plugins" in navigator && navigator.plugins.length; 71 | hasPlugins = false 72 | if (!hasPlugins) { 73 | 74 | 75 | 76 | 77 | const mimeTypes = generateMimeTypeArray(data.mimeTypes); 78 | const plugins = generatePluginArray(data.plugins); 79 | // Plugin and MimeType cross-reference each other, let's do that now 80 | // Note: We're looping through `data.plugins` here, not the generated `plugins` 81 | for (const pluginData of data.plugins) { 82 | 83 | pluginData.__mimeTypes.forEach((type, index) => { 84 | plugins[pluginData.name][index] = mimeTypes[type]; 85 | 86 | Object.defineProperty(plugins[pluginData.name], type, { 87 | value: mimeTypes[type], 88 | writable: false, 89 | enumerable: false, // Not enumerable 90 | configurable: true, 91 | }); 92 | Object.defineProperty(mimeTypes[type], "enabledPlugin", { 93 | value: 94 | type === "application/x-pnacl" 95 | ? mimeTypes["application/x-nacl"].enabledPlugin // these reference the same plugin, so we need to re-use the Proxy in order to avoid leaks 96 | : new Proxy(plugins[pluginData.name], {}), // Prevent circular references 97 | writable: false, 98 | enumerable: false, // Important: `JSON.stringify(navigator.plugins)` 99 | configurable: true, 100 | }); 101 | }); 102 | } 103 | 104 | const patchNavigator = (name, value) => 105 | utils.replaceProperty(Object.getPrototypeOf(navigator), name, { 106 | get() { 107 | return value; 108 | }, 109 | }); 110 | 111 | patchNavigator("mimeTypes", mimeTypes); 112 | patchNavigator("plugins", plugins); 113 | } -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.userAgent.js: -------------------------------------------------------------------------------- 1 | const override = { 2 | userAgent: opts.navigator.userAgent, 3 | platform: opts.navigator.platform, 4 | doNotTrack: opts.navigator.doNotTrack, 5 | deviceMemory: opts.navigator.deviceMemory, 6 | mobile: opts.navigator.mobile, 7 | hardwareConcurrency: opts.navigator.hardwareConcurrency, 8 | maxTouchPoints: opts.navigator.maxTouchPoints, 9 | appVersion: opts.navigator.appVersion, 10 | productSub: opts.navigator.productSub, 11 | userAgentData: { 12 | brands: opts.navigator.brands, 13 | fullVersion: opts.navigator.userAgent, 14 | platform: opts.navigator.platform, 15 | mobile: false, 16 | }, 17 | }; 18 | 19 | utils.replaceGetterWithProxy( 20 | Object.getPrototypeOf(navigator), 21 | "userAgent", 22 | utils.makeHandler().getterValue(override.userAgent) 23 | ); 24 | 25 | utils.replaceGetterWithProxy( 26 | Object.getPrototypeOf(navigator), 27 | "platform", 28 | utils.makeHandler().getterValue(override.platform) 29 | ); 30 | 31 | utils.replaceGetterWithProxy( 32 | Object.getPrototypeOf(navigator), 33 | "doNotTrack", 34 | utils.makeHandler().getterValue(override.doNotTrack) 35 | ); 36 | 37 | 38 | utils.replaceGetterWithProxy( 39 | Object.getPrototypeOf(navigator), 40 | "deviceMemory", 41 | utils.makeHandler().getterValue(override.deviceMemory) 42 | ); 43 | 44 | utils.replaceGetterWithProxy( 45 | Object.getPrototypeOf(navigator), 46 | "hardwareConcurrency", 47 | utils.makeHandler().getterValue(override.hardwareConcurrency) 48 | ); 49 | 50 | utils.replaceGetterWithProxy( 51 | Object.getPrototypeOf(navigator), 52 | "maxTouchPoints", 53 | utils.makeHandler().getterValue(override.maxTouchPoints) 54 | ); 55 | 56 | utils.replaceGetterWithProxy( 57 | Object.getPrototypeOf(navigator), 58 | "userAgentData", 59 | utils.makeHandler().getterValue(override.userAgentData) 60 | ); 61 | 62 | utils.replaceGetterWithProxy( 63 | Object.getPrototypeOf(navigator), 64 | "appVersion", 65 | utils.makeHandler().getterValue(override.appVersion) 66 | ); 67 | 68 | utils.replaceGetterWithProxy( 69 | Object.getPrototypeOf(navigator), 70 | "productSub", 71 | utils.makeHandler().getterValue(override.productSub) 72 | ); 73 | -------------------------------------------------------------------------------- /playwright_stealth/js/navigator.vendor.js: -------------------------------------------------------------------------------- 1 | utils.replaceGetterWithProxy( 2 | Object.getPrototypeOf(navigator), 3 | "vendor", 4 | utils.makeHandler().getterValue(opts.navigator.vendor) 5 | ); 6 | -------------------------------------------------------------------------------- /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 | } 9 | -------------------------------------------------------------------------------- /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( 68 | (line) => !blacklist.some((bl) => line.trim().startsWith(bl)) 69 | ) 70 | .join("\n") 71 | ); 72 | }; 73 | 74 | const stripWithAnchor = (stack, anchor) => { 75 | const stackArr = stack.split("\n"); 76 | anchor = anchor || `at Object.newHandler. [as ${trap}] `; // Known first Proxy line in chromium 77 | const anchorIndex = stackArr.findIndex((line) => 78 | line.trim().startsWith(anchor) 79 | ); 80 | if (anchorIndex === -1) { 81 | return false; // 404, anchor not found 82 | } 83 | // Strip everything from the top until we reach the anchor line 84 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 85 | stackArr.splice(1, anchorIndex); 86 | return stackArr.join("\n"); 87 | }; 88 | 89 | // Special cases due to our nested toString proxies 90 | err.stack = err.stack.replace( 91 | "at Object.toString (", 92 | "at Function.toString (" 93 | ); 94 | if ((err.stack || "").includes("at Function.toString (")) { 95 | err.stack = stripWithBlacklist(err.stack, false); 96 | throw err; 97 | } 98 | 99 | // Try using the anchor method, fallback to blacklist if necessary 100 | err.stack = stripWithAnchor(err.stack) || stripWithBlacklist(err.stack); 101 | 102 | throw err; // Re-throw our now sanitized error 103 | } 104 | }; 105 | }); 106 | return newHandler; 107 | }; 108 | 109 | /** 110 | * Strip error lines from stack traces until (and including) a known line the stack. 111 | * 112 | * @param {object} err - The error to sanitize 113 | * @param {string} anchor - The string the anchor line starts with 114 | */ 115 | utils.stripErrorWithAnchor = (err, anchor) => { 116 | const stackArr = err.stack.split("\n"); 117 | const anchorIndex = stackArr.findIndex((line) => 118 | line.trim().startsWith(anchor) 119 | ); 120 | if (anchorIndex === -1) { 121 | return err; // 404, anchor not found 122 | } 123 | // Strip everything from the top until we reach the anchor line (remove anchor line as well) 124 | // Note: We're keeping the 1st line (zero index) as it's unrelated (e.g. `TypeError`) 125 | stackArr.splice(1, anchorIndex); 126 | err.stack = stackArr.join("\n"); 127 | return err; 128 | }; 129 | 130 | /** 131 | * Replace the property of an object in a stealthy way. 132 | * 133 | * Note: You also want to work on the prototype of an object most often, 134 | * as you'd otherwise leave traces (e.g. showing up in Object.getOwnPropertyNames(obj)). 135 | * 136 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty 137 | * 138 | * @example 139 | * replaceProperty(WebGLRenderingContext.prototype, 'getParameter', { value: "alice" }) 140 | * // or 141 | * replaceProperty(Object.getPrototypeOf(navigator), 'languages', { get: () => ['en-US', 'en'] }) 142 | * 143 | * @param {object} obj - The object which has the property to replace 144 | * @param {string} propName - The property name to replace 145 | * @param {object} descriptorOverrides - e.g. { value: "alice" } 146 | */ 147 | utils.replaceProperty = (obj, propName, descriptorOverrides = {}) => { 148 | return Object.defineProperty(obj, propName, { 149 | // Copy over the existing descriptors (writable, enumerable, configurable, etc) 150 | ...(Object.getOwnPropertyDescriptor(obj, propName) || {}), 151 | // Add our overrides (e.g. value, get()) 152 | ...descriptorOverrides, 153 | }); 154 | }; 155 | 156 | /** 157 | * Preload a cache of function copies and data. 158 | * 159 | * For a determined enough observer it would be possible to overwrite and sniff usage of functions 160 | * we use in our internal Proxies, to combat that we use a cached copy of those functions. 161 | * 162 | * Note: Whenever we add a `Function.prototype.toString` proxy we should preload the cache before, 163 | * by executing `utils.preloadCache()` before the proxy is applied (so we don't cause recursive lookups). 164 | * 165 | * This is evaluated once per execution context (e.g. window) 166 | */ 167 | utils.preloadCache = () => { 168 | if (!utils.cache) { 169 | utils.cache = { 170 | // Used in our proxies 171 | Reflect: { 172 | get: Reflect.get.bind(Reflect), 173 | apply: Reflect.apply.bind(Reflect), 174 | }, 175 | // Used in `makeNativeString` 176 | nativeToStringStr: Function.toString + "", // => `function toString() { [native code] }` 177 | }; 178 | } 179 | }; 180 | 181 | /** 182 | * Utility function to generate a cross-browser `toString` result representing native code. 183 | * 184 | * There's small differences: Chromium uses a single line, whereas FF & Webkit uses multiline strings. 185 | * To future-proof this we use an existing native toString result as the basis. 186 | * 187 | * The only advantage we have over the other team is that our JS runs first, hence we cache the result 188 | * of the native toString result once, so they cannot spoof it afterwards and reveal that we're using it. 189 | * 190 | * @example 191 | * makeNativeString('foobar') // => `function foobar() { [native code] }` 192 | * 193 | * @param {string} [name] - Optional function name 194 | */ 195 | utils.makeNativeString = (name = "") => { 196 | return utils.cache.nativeToStringStr.replace("toString", name || ""); 197 | }; 198 | 199 | /** 200 | * Helper function to modify the `toString()` result of the provided object. 201 | * 202 | * Note: Use `utils.redirectToString` instead when possible. 203 | * 204 | * There's a quirk in JS Proxies that will cause the `toString()` result to differ from the vanilla Object. 205 | * If no string is provided we will generate a `[native code]` thing based on the name of the property object. 206 | * 207 | * @example 208 | * patchToString(WebGLRenderingContext.prototype.getParameter, 'function getParameter() { [native code] }') 209 | * 210 | * @param {object} obj - The object for which to modify the `toString()` representation 211 | * @param {string} str - Optional string used as a return value 212 | */ 213 | utils.patchToString = (obj, str = "") => { 214 | const handler = { 215 | apply: function (target, ctx) { 216 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 217 | if (ctx === Function.prototype.toString) { 218 | return utils.makeNativeString("toString"); 219 | } 220 | // `toString` targeted at our proxied Object detected 221 | if (ctx === obj) { 222 | // We either return the optional string verbatim or derive the most desired result automatically 223 | return str || utils.makeNativeString(obj.name); 224 | } 225 | // Check if the toString protype of the context is the same as the global prototype, 226 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 227 | const hasSameProto = Object.getPrototypeOf( 228 | Function.prototype.toString 229 | ).isPrototypeOf(ctx.toString); // eslint-disable-line no-prototype-builtins 230 | if (!hasSameProto) { 231 | // Pass the call on to the local Function.prototype.toString instead 232 | return ctx.toString(); 233 | } 234 | return target.call(ctx); 235 | }, 236 | }; 237 | 238 | const toStringProxy = new Proxy( 239 | Function.prototype.toString, 240 | utils.stripProxyFromErrors(handler) 241 | ); 242 | utils.replaceProperty(Function.prototype, "toString", { 243 | value: toStringProxy, 244 | }); 245 | }; 246 | 247 | /** 248 | * Make all nested functions of an object native. 249 | * 250 | * @param {object} obj 251 | */ 252 | utils.patchToStringNested = (obj = {}) => { 253 | return utils.execRecursively(obj, ["function"], utils.patchToString); 254 | }; 255 | 256 | /** 257 | * Redirect toString requests from one object to another. 258 | * 259 | * @param {object} proxyObj - The object that toString will be called on 260 | * @param {object} originalObj - The object which toString result we wan to return 261 | */ 262 | utils.redirectToString = (proxyObj, originalObj) => { 263 | const handler = { 264 | apply: function (target, ctx) { 265 | // This fixes e.g. `HTMLMediaElement.prototype.canPlayType.toString + ""` 266 | if (ctx === Function.prototype.toString) { 267 | return utils.makeNativeString("toString"); 268 | } 269 | 270 | // `toString` targeted at our proxied Object detected 271 | if (ctx === proxyObj) { 272 | const fallback = () => 273 | originalObj && originalObj.name 274 | ? utils.makeNativeString(originalObj.name) 275 | : utils.makeNativeString(proxyObj.name); 276 | 277 | // Return the toString representation of our original object if possible 278 | return originalObj + "" || fallback(); 279 | } 280 | 281 | if (typeof ctx === "undefined" || ctx === null) { 282 | return target.call(ctx); 283 | } 284 | 285 | // Check if the toString protype of the context is the same as the global prototype, 286 | // if not indicates that we are doing a check across different windows., e.g. the iframeWithdirect` test case 287 | const hasSameProto = Object.getPrototypeOf( 288 | Function.prototype.toString 289 | ).isPrototypeOf(ctx.toString); // eslint-disable-line no-prototype-builtins 290 | if (!hasSameProto) { 291 | // Pass the call on to the local Function.prototype.toString instead 292 | return ctx.toString(); 293 | } 294 | 295 | return target.call(ctx); 296 | }, 297 | }; 298 | 299 | const toStringProxy = new Proxy( 300 | Function.prototype.toString, 301 | utils.stripProxyFromErrors(handler) 302 | ); 303 | utils.replaceProperty(Function.prototype, "toString", { 304 | value: toStringProxy, 305 | }); 306 | }; 307 | 308 | /** 309 | * All-in-one method to replace a property with a JS Proxy using the provided Proxy handler with traps. 310 | * 311 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 312 | * Note: This is meant to modify native Browser APIs and works best with prototype objects. 313 | * 314 | * @example 315 | * replaceWithProxy(WebGLRenderingContext.prototype, 'getParameter', proxyHandler) 316 | * 317 | * @param {object} obj - The object which has the property to replace 318 | * @param {string} propName - The name of the property to replace 319 | * @param {object} handler - The JS Proxy handler to use 320 | */ 321 | utils.replaceWithProxy = (obj, propName, handler) => { 322 | const originalObj = obj[propName]; 323 | const proxyObj = new Proxy( 324 | obj[propName], 325 | utils.stripProxyFromErrors(handler) 326 | ); 327 | 328 | utils.replaceProperty(obj, propName, { value: proxyObj }); 329 | utils.redirectToString(proxyObj, originalObj); 330 | 331 | return true; 332 | }; 333 | /** 334 | * All-in-one method to replace a getter with a JS Proxy using the provided Proxy handler with traps. 335 | * 336 | * @example 337 | * replaceGetterWithProxy(Object.getPrototypeOf(navigator), 'vendor', proxyHandler) 338 | * 339 | * @param {object} obj - The object which has the property to replace 340 | * @param {string} propName - The name of the property to replace 341 | * @param {object} handler - The JS Proxy handler to use 342 | */ 343 | utils.replaceGetterWithProxy = (obj, propName, handler) => { 344 | const fn = Object.getOwnPropertyDescriptor(obj, propName).get; 345 | const fnStr = fn.toString(); // special getter function string 346 | const proxyObj = new Proxy(fn, utils.stripProxyFromErrors(handler)); 347 | 348 | utils.replaceProperty(obj, propName, { get: proxyObj }); 349 | utils.patchToString(proxyObj, fnStr); 350 | 351 | return true; 352 | }; 353 | 354 | /** 355 | * All-in-one method to replace a getter and/or setter. Functions get and set 356 | * of handler have one more argument that contains the native function. 357 | * 358 | * @example 359 | * replaceGetterSetter(HTMLIFrameElement.prototype, 'contentWindow', handler) 360 | * 361 | * @param {object} obj - The object which has the property to replace 362 | * @param {string} propName - The name of the property to replace 363 | * @param {object} handlerGetterSetter - The handler with get and/or set 364 | * functions 365 | * @see https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty#description 366 | */ 367 | utils.replaceGetterSetter = (obj, propName, handlerGetterSetter) => { 368 | const ownPropertyDescriptor = Object.getOwnPropertyDescriptor(obj, propName); 369 | const handler = { ...ownPropertyDescriptor }; 370 | 371 | if (handlerGetterSetter.get !== undefined) { 372 | const nativeFn = ownPropertyDescriptor.get; 373 | handler.get = function () { 374 | return handlerGetterSetter.get.call(this, nativeFn.bind(this)); 375 | }; 376 | utils.redirectToString(handler.get, nativeFn); 377 | } 378 | 379 | if (handlerGetterSetter.set !== undefined) { 380 | const nativeFn = ownPropertyDescriptor.set; 381 | handler.set = function (newValue) { 382 | handlerGetterSetter.set.call(this, newValue, nativeFn.bind(this)); 383 | }; 384 | utils.redirectToString(handler.set, nativeFn); 385 | } 386 | 387 | Object.defineProperty(obj, propName, handler); 388 | }; 389 | 390 | /** 391 | * All-in-one method to mock a non-existing property with a JS Proxy using the provided Proxy handler with traps. 392 | * 393 | * Will stealthify these aspects (strip error stack traces, redirect toString, etc). 394 | * 395 | * @example 396 | * mockWithProxy(chrome.runtime, 'sendMessage', function sendMessage() {}, proxyHandler) 397 | * 398 | * @param {object} obj - The object which has the property to replace 399 | * @param {string} propName - The name of the property to replace or create 400 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 401 | * @param {object} handler - The JS Proxy handler to use 402 | */ 403 | utils.mockWithProxy = (obj, propName, pseudoTarget, handler) => { 404 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)); 405 | 406 | utils.replaceProperty(obj, propName, { value: proxyObj }); 407 | utils.patchToString(proxyObj); 408 | 409 | return true; 410 | }; 411 | 412 | /** 413 | * All-in-one method to create a new JS Proxy with stealth tweaks. 414 | * 415 | * This is meant to be used whenever we need a JS Proxy but don't want to replace or mock an existing known property. 416 | * 417 | * Will stealthify certain aspects of the Proxy (strip error stack traces, redirect toString, etc). 418 | * 419 | * @example 420 | * createProxy(navigator.mimeTypes.__proto__.namedItem, proxyHandler) // => Proxy 421 | * 422 | * @param {object} pseudoTarget - The JS Proxy target to use as a basis 423 | * @param {object} handler - The JS Proxy handler to use 424 | */ 425 | utils.createProxy = (pseudoTarget, handler) => { 426 | const proxyObj = new Proxy(pseudoTarget, utils.stripProxyFromErrors(handler)); 427 | utils.patchToString(proxyObj); 428 | 429 | return proxyObj; 430 | }; 431 | 432 | /** 433 | * Helper function to split a full path to an Object into the first part and property. 434 | * 435 | * @example 436 | * splitObjPath(`HTMLMediaElement.prototype.canPlayType`) 437 | * // => {objName: "HTMLMediaElement.prototype", propName: "canPlayType"} 438 | * 439 | * @param {string} objPath - The full path to an object as dot notation string 440 | */ 441 | utils.splitObjPath = (objPath) => ({ 442 | // Remove last dot entry (property) ==> `HTMLMediaElement.prototype` 443 | objName: objPath.split(".").slice(0, -1).join("."), 444 | // Extract last dot entry ==> `canPlayType` 445 | propName: objPath.split(".").slice(-1)[0], 446 | }); 447 | 448 | /** 449 | * Convenience method to replace a property with a JS Proxy using the provided objPath. 450 | * 451 | * Supports a full path (dot notation) to the object as string here, in case that makes it easier. 452 | * 453 | * @example 454 | * replaceObjPathWithProxy('WebGLRenderingContext.prototype.getParameter', proxyHandler) 455 | * 456 | * @param {string} objPath - The full path to an object (dot notation string) to replace 457 | * @param {object} handler - The JS Proxy handler to use 458 | */ 459 | utils.replaceObjPathWithProxy = (objPath, handler) => { 460 | const { objName, propName } = utils.splitObjPath(objPath); 461 | const obj = eval(objName); // eslint-disable-line no-eval 462 | return utils.replaceWithProxy(obj, propName, handler); 463 | }; 464 | 465 | /** 466 | * Traverse nested properties of an object recursively and apply the given function on a whitelist of value types. 467 | * 468 | * @param {object} obj 469 | * @param {array} typeFilter - e.g. `['function']` 470 | * @param {Function} fn - e.g. `utils.patchToString` 471 | */ 472 | utils.execRecursively = (obj = {}, typeFilter = [], fn) => { 473 | function recurse(obj) { 474 | for (const key in obj) { 475 | if (obj[key] === undefined) { 476 | continue; 477 | } 478 | if (obj[key] && typeof obj[key] === "object") { 479 | recurse(obj[key]); 480 | } else { 481 | if (obj[key] && typeFilter.includes(typeof obj[key])) { 482 | fn.call(this, obj[key]); 483 | } 484 | } 485 | } 486 | } 487 | recurse(obj); 488 | return obj; 489 | }; 490 | 491 | /** 492 | * Everything we run through e.g. `page.evaluate` runs in the browser context, not the NodeJS one. 493 | * That means we cannot just use reference variables and functions from outside code, we need to pass everything as a parameter. 494 | * 495 | * Unfortunately the data we can pass is only allowed to be of primitive types, regular functions don't survive the built-in serialization process. 496 | * This utility function will take an object with functions and stringify them, so we can pass them down unharmed as strings. 497 | * 498 | * We use this to pass down our utility functions as well as any other functions (to be able to split up code better). 499 | * 500 | * @see utils.materializeFns 501 | * 502 | * @param {object} fnObj - An object containing functions as properties 503 | */ 504 | utils.stringifyFns = (fnObj = { hello: () => "world" }) => { 505 | // Object.fromEntries() ponyfill (in 6 lines) - supported only in Node v12+, modern browsers are fine 506 | // https://github.com/feross/fromentries 507 | function fromEntries(iterable) { 508 | return [...iterable].reduce((obj, [key, val]) => { 509 | obj[key] = val; 510 | return obj; 511 | }, {}); 512 | } 513 | return (Object.fromEntries || fromEntries)( 514 | Object.entries(fnObj) 515 | .filter(([key, value]) => typeof value === "function") 516 | .map(([key, value]) => [key, value.toString()]) // eslint-disable-line no-eval 517 | ); 518 | }; 519 | 520 | /** 521 | * Utility function to reverse the process of `utils.stringifyFns`. 522 | * Will materialize an object with stringified functions (supports classic and fat arrow functions). 523 | * 524 | * @param {object} fnStrObj - An object containing stringified functions as properties 525 | */ 526 | utils.materializeFns = (fnStrObj = { hello: "() => 'world'" }) => { 527 | return Object.fromEntries( 528 | Object.entries(fnStrObj).map(([key, value]) => { 529 | if (value.startsWith("function")) { 530 | // some trickery is needed to make oldschool functions work :-) 531 | return [key, eval(`() => ${value}`)()]; // eslint-disable-line no-eval 532 | } else { 533 | // arrow functions just work 534 | return [key, eval(value)]; // eslint-disable-line no-eval 535 | } 536 | }) 537 | ); 538 | }; 539 | 540 | // Proxy handler templates for re-usability 541 | utils.makeHandler = () => ({ 542 | // Used by simple `navigator` getter evasions 543 | getterValue: (value) => ({ 544 | apply(target, ctx, args) { 545 | // Let's fetch the value first, to trigger and escalate potential errors 546 | // Illegal invocations like `navigator.__proto__.vendor` will throw here 547 | utils.cache.Reflect.apply(...arguments); 548 | return value; 549 | }, 550 | }), 551 | }); 552 | 553 | /** 554 | * Compare two arrays. 555 | * 556 | * @param {array} array1 - First array 557 | * @param {array} array2 - Second array 558 | */ 559 | utils.arrayEquals = (array1, array2) => { 560 | if (array1.length !== array2.length) { 561 | return false; 562 | } 563 | for (let i = 0; i < array1.length; ++i) { 564 | if (array1[i] !== array2[i]) { 565 | return false; 566 | } 567 | } 568 | return true; 569 | }; 570 | 571 | /** 572 | * Cache the method return according to its arguments. 573 | * 574 | * @param {Function} fn - A function that will be cached 575 | */ 576 | utils.memoize = (fn) => { 577 | const cache = []; 578 | return function (...args) { 579 | if (!cache.some((c) => utils.arrayEquals(c.key, args))) { 580 | cache.push({ key: args, value: fn.apply(this, args) }); 581 | } 582 | return cache.find((c) => utils.arrayEquals(c.key, args)).value; 583 | }; 584 | }; 585 | 586 | // -- 587 | // Stuff starting below this line is NodeJS specific. 588 | // -- 589 | // module.exports = utils 590 | 591 | utils.init() -------------------------------------------------------------------------------- /playwright_stealth/js/webgl.vendor.js: -------------------------------------------------------------------------------- 1 | const getParameterProxyHandler = { 2 | apply: function (target, ctx, args) { 3 | const param = (args || [])[0]; 4 | const result = utils.cache.Reflect.apply(target, ctx, args); 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 result; 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"); 26 | -------------------------------------------------------------------------------- /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 | window.outerHeight = window.innerHeight + windowFrame; 8 | } 9 | } catch (err) {} 10 | -------------------------------------------------------------------------------- /playwright_stealth/properties/__init__.py: -------------------------------------------------------------------------------- 1 | from ._properties import Properties, BrowserType 2 | 3 | __ALL__ = ["Properties", "BrowserType"] 4 | -------------------------------------------------------------------------------- /playwright_stealth/properties/_header_properties.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | 5 | @dataclass 6 | class HeaderProperties: 7 | """Class for the header properties. We will take the Sec_Fetch_Site, Sec_Fetch_Mode and Sec_Fetch_Dest from the original headers.""" 8 | 9 | # Headers passed by library 10 | user_agent: str 11 | accept_language: str 12 | accept_encoding: str 13 | accept: str 14 | referer: str 15 | 16 | # Self generated headers 17 | origin: str 18 | sec_ch_ua: str 19 | sec_ch_ua_mobile: str 20 | sec_ch_ua_platform: str 21 | sec_ch_ua_form_factors: str 22 | dnt: str 23 | 24 | def __init__( 25 | self, 26 | brands: List[dict], 27 | dnt: str, 28 | client_hint_headers_enabled: bool = True, 29 | **kwargs, 30 | ): 31 | # Passed by library 32 | self.user_agent = kwargs["User-Agent"] 33 | self.accept_language = kwargs["Accept-language"] 34 | self.accept_encoding = kwargs["Accept-encoding"] 35 | self.accept = kwargs["Accept"] 36 | self.referer = kwargs["Referer"] 37 | 38 | # # Shared properties 39 | self.dnt = dnt 40 | 41 | # # Self generated headers 42 | if client_hint_headers_enabled: 43 | self.sec_ch_ua = self._generate_sec_ch_ua(brands) 44 | self.sec_ch_ua_mobile = self._generate_sec_ch_ua_mobile() 45 | self.sec_ch_ua_platform = self._generate_sec_ch_ua_platform() 46 | self.sec_ch_ua_form_factors = self._generate_sec_ch_ua_form_factors() 47 | 48 | def _generate_sec_ch_ua_platform(self) -> str: 49 | """Generates the Sec_Ch_Ua_Platform based on the user agent platform.""" 50 | 51 | is_mac = "Macintosh" in self.user_agent 52 | is_windows = "Windows" in self.user_agent 53 | is_linux = "Linux" in self.user_agent 54 | 55 | if is_mac: 56 | return "macOS" 57 | elif is_windows: 58 | return "Windows" 59 | elif is_linux: 60 | return "Linux" 61 | else: 62 | return "Unknown" 63 | 64 | def _generate_sec_ch_ua(self, brands: List[dict]) -> str: 65 | """Generates the Sec_Ch_Ua based brands generated""" 66 | merged_brands = "".join([f'"{brand["brand"]}";v="{brand["version"]}",' for brand in brands]) 67 | return merged_brands 68 | 69 | def _generate_sec_ch_ua_form_factors(self) -> str: 70 | """Generates the Sec_Ch_Ua_Form_Factors based on the user agent.""" 71 | 72 | return "desktop" 73 | 74 | def _generate_sec_ch_ua_mobile(self) -> str: 75 | """Generates the Sec_Ch_Ua_Mobile based on the user agent.""" 76 | 77 | return "?0" 78 | 79 | def as_dict(self) -> dict: 80 | # Convert all keys to kebab case and return a new dictionary 81 | return {key.replace("_", "-").lower(): value for key, value in self.__dict__.items()} 82 | -------------------------------------------------------------------------------- /playwright_stealth/properties/_navigator_properties.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | 4 | 5 | @dataclass 6 | class NavigatorProperties: 7 | """Class for the navigator properties.""" 8 | 9 | userAgent: str 10 | platform: str 11 | language: str 12 | languages: List[str] 13 | appVersion: str 14 | vendor: str 15 | deviceMemory: int 16 | hardwareConcurrency: int 17 | maxTouchPoints: int 18 | doNotTrack: str 19 | brands: List[dict] 20 | mobile: bool 21 | 22 | def __init__(self, brands: List[dict], dnt: str, **kwargs): 23 | self.userAgent = kwargs["User-Agent"] 24 | 25 | # Shared properties 26 | self.brands = brands 27 | self.doNotTrack = dnt 28 | 29 | # Generate properties 30 | self.platform = self._generate_platform(kwargs["User-Agent"]) 31 | self.language = self._generate_language() 32 | self.languages = self._generate_languages(kwargs["Accept-language"]) 33 | self.appVersion = self._generate_app_version(kwargs["User-Agent"]) 34 | self.vendor = self._generate_vendor(kwargs["User-Agent"]) 35 | self.deviceMemory = self._generate_device_memory(kwargs["User-Agent"]) 36 | self.hardwareConcurrency = self._generate_hardware_concurrency(self.deviceMemory) 37 | self.maxTouchPoints = self._generate_max_touch_points() 38 | self.mobile = self._generate_mobile() 39 | self.productSub = self._generate_product_sub(kwargs["User-Agent"]) 40 | 41 | def _generate_platform(self, user_agent: str) -> str: 42 | """Generates the platform based on the user agent.""" 43 | 44 | # Change regarding this article: \ 45 | # https://filipvitas.medium.com/how-to-set-user-agent-header-with-puppeteer-js-and-not-fail-28c7a02165da 46 | if "Macintosh" in user_agent: 47 | return "MacIntel" 48 | elif "Linux" in user_agent: 49 | return "Linux x86_x64" 50 | else: 51 | return "Win64" 52 | 53 | def _generate_language(self) -> str: 54 | """Generates the language based on the accept language.""" 55 | 56 | return "en-US" 57 | 58 | def _generate_languages(self, accept_language: str) -> List[str]: 59 | """Generates the languages based on the accept language.""" 60 | 61 | languages_with_quality = accept_language.split(",") 62 | languages = [language.split(";")[0] for language in languages_with_quality] 63 | return languages 64 | 65 | def _generate_app_version(self, user_agent: str) -> str: 66 | """Generates the app version based on the user agent.""" 67 | 68 | version_part = user_agent.split("/", 1)[1] 69 | return version_part 70 | 71 | def _generate_vendor(self, user_agent: str) -> str: 72 | """Generates the vendor based on the user agent.""" 73 | 74 | if "Chrome" in user_agent: 75 | return "Google Inc." 76 | elif "Firefox" in user_agent: 77 | return "" 78 | 79 | return "Google Inc." 80 | 81 | def _generate_device_memory(self, user_agent: str) -> int: 82 | """Generates the device memory.""" 83 | 84 | return None if "Firefox" in user_agent else 8 85 | 86 | def _generate_hardware_concurrency(self, device_memory: int) -> int: 87 | """Generates the hardware concurrency.""" 88 | 89 | return device_memory 90 | 91 | def _generate_max_touch_points(self) -> int: 92 | """Generates the max touch points. Default is 0 since this is a desktop browser.""" 93 | 94 | return 0 95 | 96 | def _generate_mobile(self) -> bool: 97 | """Generates the mobile flag.""" 98 | 99 | return False 100 | 101 | def _generate_product_sub(self, user_agent: str) -> int: 102 | """Generate product sub depending on the Browser""" 103 | return 20100101 if "Firefox" in user_agent else 20030107 104 | 105 | def as_dict(self) -> dict: 106 | return self.__dict__ 107 | -------------------------------------------------------------------------------- /playwright_stealth/properties/_properties.py: -------------------------------------------------------------------------------- 1 | import re 2 | import random 3 | from dataclasses import dataclass 4 | from fake_http_header import FakeHttpHeader 5 | from ._header_properties import HeaderProperties 6 | from ._navigator_properties import NavigatorProperties 7 | from ._viewport_properties import ViewportProperties 8 | from ._webgl_properties import WebGlProperties 9 | from enum import Enum 10 | 11 | 12 | class BrowserType(Enum): 13 | CHROME = "chrome" 14 | FIREFOX = "firefox" 15 | SAFARI = "safari" 16 | 17 | 18 | @dataclass 19 | class Properties: 20 | 21 | header: HeaderProperties 22 | navigator: NavigatorProperties 23 | viewport: ViewportProperties 24 | webgl: WebGlProperties 25 | runOnInsecureOrigins: bool 26 | 27 | def __init__(self, browser_type: BrowserType = BrowserType.CHROME): 28 | spoofed_headers = FakeHttpHeader(domain_code="com", browser=browser_type.value) 29 | 30 | # Generate shared properties 31 | brands = self._generate_brands(spoofed_headers.user_agent, browser=browser_type.value) 32 | dnt = self._generate_dnt() 33 | 34 | # Generate properties 35 | self.header = HeaderProperties( 36 | brands=brands, 37 | dnt=dnt, 38 | client_hint_headers_enabled=browser_type 39 | is not BrowserType.FIREFOX, # Firefox does not support client hints 40 | **spoofed_headers.as_header_dict(), 41 | ) 42 | self.navigator = NavigatorProperties( 43 | brands=brands, dnt=dnt, **spoofed_headers.as_header_dict() 44 | ) 45 | self.viewport = ViewportProperties() 46 | self.webgl = WebGlProperties() 47 | self.runOnInsecureOrigins = None 48 | 49 | def _generate_brands(self, user_agent: str, browser: str = "chrome") -> str: 50 | """Generates the brands based on the referer.""" 51 | 52 | configs = { 53 | "chrome": { 54 | "regex": r"Chrome/(\d+)", 55 | "brands": ["Chromium", "Google Chrome"], 56 | }, 57 | "firefox": { 58 | "regex": r"Firefox/(\d+)", 59 | "brands": ["Firefox", "Firefox"], 60 | }, 61 | "safari": { 62 | "regex": r"Safari/(\d+)", 63 | "brands": ["Safari", "Apple WebKit"], 64 | }, 65 | } 66 | 67 | config = configs[browser] 68 | pattern = config["regex"] 69 | 70 | browser_with_version = re.search(pattern, user_agent) 71 | version = browser_with_version.group(1) 72 | seed = int(version.split(".")[0]) 73 | 74 | order = [ 75 | [0, 1, 2], 76 | [0, 2, 1], 77 | [1, 0, 2], 78 | [1, 2, 0], 79 | [2, 0, 1], 80 | [2, 1, 0], 81 | ][seed % 6] 82 | 83 | escaped_chars = [" ", " ", ";"] 84 | 85 | greasey_brand = ( 86 | f"{escaped_chars[order[0]]}Not{escaped_chars[order[1]]}A{escaped_chars[order[2]]}Brand" 87 | ) 88 | 89 | greased_brand_version_list = [{}, {}, {}] 90 | 91 | greased_brand_version_list[order[0]] = { 92 | "brand": greasey_brand, 93 | "version": "99", 94 | } 95 | 96 | greased_brand_version_list[order[1]] = { 97 | "brand": config["brands"][0], 98 | "version": seed, 99 | } 100 | 101 | greased_brand_version_list[order[2]] = { 102 | "brand": config["brands"][1], 103 | "version": seed, 104 | } 105 | 106 | return greased_brand_version_list 107 | 108 | def _generate_dnt(self) -> str: 109 | """Randomly generates a 0 or 1.""" 110 | 111 | return str(random.randint(0, 1)) 112 | 113 | def as_dict(self) -> dict: 114 | """Returns the properties as a dictionary.""" 115 | 116 | return { 117 | "header": self.header.as_dict(), 118 | "viewport": self.viewport.as_dict(), 119 | "navigator": self.navigator.as_dict(), 120 | "webgl": self.webgl.as_dict(), 121 | } 122 | -------------------------------------------------------------------------------- /playwright_stealth/properties/_viewport_properties.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Tuple 3 | import random 4 | 5 | 6 | @dataclass 7 | class ViewportProperties: 8 | width: int 9 | height: int 10 | outerWidth: int 11 | outerHeight: int 12 | innerWidth: int 13 | innerHeight: int 14 | 15 | def __init__(self, **kwargs): 16 | self.__dict__.update(kwargs) 17 | 18 | self.width, self.height = self._generate_viewport_dimensions() 19 | self.outerWidth, self.outerHeight = self._generate_outer_dimensions() 20 | self.innerWidth, self.innerHeight = self._generate_inner_dimensions() 21 | 22 | def _generate_viewport_dimensions(self) -> Tuple[int, int]: 23 | return 1920 + random.randint(-100, 100), 1080 + random.randint(-100, 100) 24 | 25 | def _generate_outer_dimensions(self) -> Tuple[int, int]: 26 | return self.width, self.height 27 | 28 | def _generate_inner_dimensions(self) -> Tuple[int, int]: 29 | return ( 30 | self.width - random.randint(0, 20), 31 | self.height - random.randint(0, 20), 32 | ) 33 | 34 | def as_dict(self) -> dict: 35 | return self.__dict__ 36 | -------------------------------------------------------------------------------- /playwright_stealth/properties/_webgl_properties.py: -------------------------------------------------------------------------------- 1 | import random 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass 6 | class WebGlProperties: 7 | vendor: str 8 | renderer: str 9 | 10 | webgl_properties = [ 11 | {"vendor": "Intel Inc.", "renderer": "Intel Iris OpenGL Engine"}, 12 | {"vendor": "AMD", "renderer": "AMD Radeon Pro 5600M OpenGL Engine"}, 13 | { 14 | "vendor": "NVIDIA", 15 | "renderer": "NVIDIA GeForce GTX 1660 Ti OpenGL Engine", 16 | }, 17 | {"vendor": "Apple Inc.", "renderer": "Apple M1 OpenGL Engine"}, 18 | {"vendor": "Qualcomm Inc.", "renderer": "Qualcomm Adreno OpenGL Engine"}, 19 | ] 20 | 21 | def __init__(self): 22 | webgl_prop = self._generate_webgl_prop() 23 | self.vendor = webgl_prop["vendor"] 24 | self.renderer = webgl_prop["renderer"] 25 | 26 | def _generate_webgl_prop(self): 27 | """Generates a WebGL property containing both vendor and renderer.""" 28 | return random.choice(self.webgl_properties) 29 | 30 | def as_dict(self): 31 | return self.__dict__ 32 | -------------------------------------------------------------------------------- /playwright_stealth/stealth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from playwright.async_api import Page as AsyncPage 3 | from playwright.sync_api import Page as SyncPage 4 | from playwright_stealth.core import StealthConfig 5 | from playwright_stealth.properties import Properties, BrowserType 6 | 7 | 8 | def combine_scripts(properties: Properties, config: StealthConfig): 9 | """Combines the scripts for the page based on the properties and config.""" 10 | 11 | scripts = [] 12 | 13 | for script in (config or StealthConfig()).enabled_scripts(properties): 14 | scripts.append(script) 15 | return "\n".join(scripts) 16 | 17 | 18 | def generate_stealth_headers_sync(properties: Properties, page: SyncPage): 19 | """Generates the stealth headers for the page by replacing the original headers with the spoofed ones for every request.""" 20 | page.set_extra_http_headers(properties.as_dict()["header"]) 21 | 22 | 23 | async def generate_stealth_headers_async(properties: Properties, page: AsyncPage): 24 | """Generates the stealth headers for the page by replacing the original headers with the spoofed ones for every request.""" 25 | await page.set_extra_http_headers(properties.as_dict()["header"]) 26 | 27 | 28 | def stealth_sync(page: SyncPage, config: StealthConfig = None): 29 | """teaches synchronous playwright Page to be stealthy like a ninja!""" 30 | properties = Properties(browser_type=config.browser_type if config else BrowserType.CHROME) 31 | combined_script = combine_scripts(properties, config) 32 | generate_stealth_headers_sync(properties, page) 33 | 34 | page.add_init_script(combined_script) 35 | 36 | 37 | async def stealth_async(page: AsyncPage, config: StealthConfig = None): 38 | """teaches asynchronous playwright Page to be stealthy like a ninja!""" 39 | properties = Properties(browser_type=config.browser_type if config else BrowserType.CHROME) 40 | combined_script = combine_scripts(properties, config) 41 | await generate_stealth_headers_async(properties, page) 42 | 43 | await page.add_init_script(combined_script) 44 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "tf-playwright-stealth" 3 | packages = [{ include = "playwright_stealth" }] 4 | version = "1.1.3" 5 | description = "Makes playwright stealthy like a ninja!" 6 | authors = [] 7 | homepage = "https://www.agentql.com/" 8 | repository = "https://github.com/tinyfish-io/tf-playwright-stealth" 9 | license = "MIT" 10 | readme = "README.md" 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.8" 14 | playwright = "^1" 15 | fake-http-header = "^0.3.5" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | agentql = "^1.3.0" 19 | pytest = "^8.3.3" 20 | pylint = "^3.2.7" 21 | pytest-mockito = "^0.0.4" 22 | dill = "*" 23 | black = "*" 24 | 25 | [build-system] 26 | requires = ["poetry-core"] 27 | build-backend = "poetry.core.masonry.api" 28 | 29 | [tool.black] 30 | line-length = 100 31 | 32 | [tool.isort] 33 | profile = "black" 34 | line_length = 100 35 | wrap_length = 100 36 | multi_line_output = 3 37 | include_trailing_comma = true 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyfish-io/tf-playwright-stealth/cea0fd5e40a647166fc9bd0617776aa480424e1d/tests/__init__.py -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyfish-io/tf-playwright-stealth/cea0fd5e40a647166fc9bd0617776aa480424e1d/tests/e2e/__init__.py -------------------------------------------------------------------------------- /tests/e2e/configs.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from pydantic import BaseModel 3 | 4 | from tests.utils import from_file 5 | 6 | 7 | class ScriptConfig(BaseModel): 8 | name: str 9 | script: str 10 | query: str 11 | url: str 12 | 13 | # Need this method to compare ScriptConfig objects for removing duplicates 14 | def __eq__(self, other): 15 | if isinstance(other, ScriptConfig): 16 | return (self.name, self.script, self.query, self.url) == ( 17 | other.name, 18 | other.script, 19 | other.query, 20 | other.url, 21 | ) 22 | return False 23 | 24 | # Need this method to use ScriptConfig objects as keys in a dictionary 25 | def __hash__(self): 26 | return hash((self.name, self.script, self.query, self.url)) 27 | 28 | 29 | class MultipleScriptConfig(BaseModel): 30 | name: str 31 | script: List[str] 32 | query: str 33 | url: str 34 | 35 | 36 | chromeAppConfig = ScriptConfig( 37 | name="chrome_app_test", 38 | script=from_file("chrome.app.js"), 39 | query=""" 40 | { 41 | chrome_new_test_result (return 'True' if test result is 'passed') 42 | headchr_chrome_obj_test_result (return 'True' if test result is 'ok') 43 | headchr_iframe_test_result (return 'True' if test result is 'ok') 44 | fp_collect_info_has_chrome (return 'True' if value is 'true') 45 | fp_collect_info_detail_chrome (return 'True' if value is not 'unknown') 46 | fp_collect_info_iframe_chrome (return 'True' if value is 'object') 47 | } 48 | """, 49 | url="https://bot.sannysoft.com/", 50 | ) 51 | 52 | chromeCsiConfig = ScriptConfig( 53 | name="chrome_csi_test", 54 | script=from_file("chrome.csi.js"), 55 | query=""" 56 | { 57 | chrome_new_test_result (return 'True' if test result is 'passed') 58 | headchr_chrome_obj_test_result (return 'True' if test result is 'ok') 59 | headchr_iframe_test_result (return 'True' if test result is 'ok') 60 | fp_collect_info_has_chrome (return 'True' if value is 'true') 61 | fp_collect_info_detail_chrome (return 'True' if value is not 'unknown') 62 | fp_collect_info_iframe_chrome (return 'True' if value is 'object') 63 | } 64 | """, 65 | url="https://bot.sannysoft.com/", 66 | ) 67 | 68 | chromeLoadTimesConfig = ScriptConfig( 69 | name="chrome_loadTimes_test", 70 | script=from_file("chrome.load.times.js"), 71 | query=""" 72 | { 73 | chrome_new_test_result (return 'True' if test result is 'passed') 74 | headchr_chrome_obj_test_result (return 'True' if test result is 'ok') 75 | headchr_iframe_test_result (return 'True' if test result is 'ok') 76 | fp_collect_info_has_chrome (return 'True' if value is 'true') 77 | fp_collect_info_detail_chrome (return 'True' if value is not 'unknown') 78 | fp_collect_info_iframe_chrome (return 'True' if value is 'object') 79 | } 80 | """, 81 | url="https://bot.sannysoft.com/", 82 | ) 83 | 84 | chromePluginConfig = ScriptConfig( 85 | name="chrome_plugin_test", 86 | script=from_file("chrome.plugin.js"), 87 | query=""" 88 | { 89 | plugins_length_old_test_result (return 'True' if test result is not '0') 90 | plugins_is_of_type_plugin_array_test_result (return 'True' if test result is 'passed') 91 | headchr_plugins_test_result (return 'True' if test result is 'ok') 92 | navigator_plugins (return 'True' if test result is an not empty object) 93 | fp_collect_info_plugins (return 'True' if test result is not an empty object) 94 | } 95 | """, 96 | url="https://bot.sannysoft.com/", 97 | ) 98 | 99 | chromeRuntimeConfig = ScriptConfig( 100 | name="chrome_runtime_test", 101 | script=from_file("chrome.runtime.js"), 102 | query=""" 103 | { 104 | chrome_new_test_result (return 'True' if test result is 'passed') 105 | headchr_chrome_obj_test_result (return 'True' if test result is 'ok') 106 | headchr_iframe_test_result (return 'True' if test result is 'ok') 107 | fp_collect_info_has_chrome (return 'True' if value is 'true') 108 | fp_collect_info_detail_chrome (return 'True' if value is not 'unknown') 109 | fp_collect_info_iframe_chrome (return 'True' if value is 'object') 110 | } 111 | """, 112 | url="https://bot.sannysoft.com/", 113 | ) 114 | 115 | # The following scripts with 'null' query values are currently broken or needs a better testing strategy 116 | generateMagicArraysConfig = ScriptConfig( 117 | name="generate_magic_arrays_test", 118 | script=from_file("generate.magic.arrays.js"), 119 | query=""" 120 | { 121 | null 122 | } 123 | """, 124 | url="https://bot.sannysoft.com/", 125 | ) 126 | 127 | iFrameContentWindowConfig = ScriptConfig( 128 | name="iframe_contentWindow_test", 129 | script=from_file("iframe.contentWindow.js"), 130 | query=""" 131 | { 132 | null 133 | } 134 | """, 135 | url="https://bot.sannysoft.com/", 136 | ) 137 | 138 | mediaCodecsConfig = ScriptConfig( 139 | name="media_codecs_test", 140 | script=from_file("media.codecs.js"), 141 | query=""" 142 | { 143 | null 144 | } 145 | """, 146 | url="https://bot.sannysoft.com/", 147 | ) 148 | 149 | navigatorHardWareConcurrencyConfig = ScriptConfig( 150 | name="navigator_hardwareConcurrency_test", 151 | script=from_file("navigator.hardwareConcurrency.js"), 152 | query=""" 153 | { 154 | null 155 | } 156 | """, 157 | url="https://bot.sannysoft.com/", 158 | ) 159 | 160 | navigatorLanguagesConfig = ScriptConfig( 161 | name="navigator_languages_test", 162 | script=from_file("navigator.languages.js"), 163 | query=""" 164 | { 165 | null 166 | } 167 | """, 168 | url="https://bot.sannysoft.com/", 169 | ) 170 | 171 | navigatorPermissionsConfig = ScriptConfig( 172 | name="navigator_permissions_test", 173 | script=from_file("navigator.permissions.js"), 174 | query=""" 175 | { 176 | null 177 | } 178 | """, 179 | url="https://bot.sannysoft.com/", 180 | ) 181 | 182 | navigatorPluginsConfig = ScriptConfig( 183 | name="navigator_plugins_test", 184 | script=from_file("navigator.plugins.js"), 185 | query=""" 186 | { 187 | null 188 | } 189 | """, 190 | url="https://bot.sannysoft.com/", 191 | ) 192 | 193 | navigatorUserAgentConfig = ScriptConfig( 194 | name="navigator_userAgent_test", 195 | script=from_file("navigator.userAgent.js"), 196 | query=""" 197 | { 198 | phantom_ua_test_result (return 'True' if test result is 'ok') 199 | headchr_ua_test_result (return 'True' if test result is 'ok') 200 | } 201 | """, 202 | url="https://bot.sannysoft.com/", 203 | ) 204 | 205 | navigatorVendorConfig = ScriptConfig( 206 | name="navigator_vendor_test", 207 | script=from_file("navigator.vendor.js"), 208 | query=""" 209 | { 210 | headchr_vendor_test_result (return 'True' if test result is 'ok') 211 | } 212 | """, 213 | url="https://bot.sannysoft.com/", 214 | ) 215 | 216 | navigatorWebDriverConfig = ScriptConfig( 217 | name="navigator_webdriver_test", 218 | script=from_file("navigator.webdriver.js"), 219 | query=""" 220 | { 221 | webdriver_new_test_result (return 'True' if test result is 'passed') 222 | webdriver_advanced_test_result (return 'True' if test result is 'passed') 223 | headchr_chrome_obj_test_result (return 'True' if test result is 'ok') 224 | } 225 | """, 226 | url="https://bot.sannysoft.com/", 227 | ) 228 | 229 | webGLVendorConfig = ScriptConfig( 230 | name="webGL_vendor_test", 231 | script=from_file("webgl.vendor.js"), 232 | query=""" 233 | { 234 | null 235 | } 236 | """, 237 | url="https://bot.sannysoft.com/", 238 | ) 239 | -------------------------------------------------------------------------------- /tests/e2e/demo_with_stealth_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from playwright.sync_api import sync_playwright 3 | from playwright_stealth import stealth_sync 4 | 5 | 6 | @pytest.mark.skip( 7 | "This test is meant to be ran manually, wrapped under a pytest test so it is not automatically ran." 8 | ) 9 | def test_demo_with_stealth(): 10 | """This test demonstrates how to use playwright-stealth with playwright""" 11 | 12 | executablePath = "C:\\Google\\Chrome\\Application\\chrome.exe" 13 | ipAndPort = "221.1.90.67:9000" 14 | args = [ 15 | "--no-sandbox", 16 | "--disable-infobars", 17 | "--lang=zh-CN", 18 | "--start-maximized", 19 | "--window-position=-10,0", 20 | # '--proxy-server=http=' + ipAndPort 21 | ] 22 | 23 | ignoreDefaultArgs = ["--enable-automation"] 24 | headless = False 25 | 26 | with sync_playwright() as p: 27 | browser = p.chromium.launch( 28 | executable_path=executablePath, 29 | args=args, 30 | ignore_default_args=ignoreDefaultArgs, 31 | headless=headless, 32 | ) 33 | page = browser.new_page() 34 | stealth_sync(page) 35 | page.goto("https://bot.sannysoft.com/") 36 | 37 | webdriver_flag = page.evaluate( 38 | """() => { 39 | return window.navigator.webdriver 40 | }""" 41 | ) 42 | 43 | # return None 44 | print(f"window navigator webdriver value: {webdriver_flag}") 45 | 46 | page.screenshot(path=f"example_with_stealth.png", full_page=True) 47 | browser.close() 48 | -------------------------------------------------------------------------------- /tests/e2e/test_all_scripts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import agentql 3 | import logging 4 | from playwright.sync_api import sync_playwright, Page as SyncPage 5 | from playwright.async_api import async_playwright, Page as AsyncPage 6 | from tests.utils import from_file 7 | import json 8 | from playwright_stealth.properties import Properties 9 | from .configs import ( 10 | ScriptConfig, 11 | chromeAppConfig, 12 | chromeCsiConfig, 13 | chromeLoadTimesConfig, 14 | chromePluginConfig, 15 | chromeRuntimeConfig, 16 | generateMagicArraysConfig, 17 | iFrameContentWindowConfig, 18 | mediaCodecsConfig, 19 | navigatorHardWareConcurrencyConfig, 20 | navigatorLanguagesConfig, 21 | navigatorPermissionsConfig, 22 | navigatorPluginsConfig, 23 | navigatorUserAgentConfig, 24 | navigatorVendorConfig, 25 | navigatorWebDriverConfig, 26 | webGLVendorConfig, 27 | ) 28 | 29 | log = logging.getLogger(__name__) 30 | 31 | 32 | # The goal for these tests is to run them all, not comment them out. 33 | # Some scripts don't work yet so we will wait until there are fixes for them. 34 | test_configs = [ 35 | chromeAppConfig, 36 | chromeCsiConfig, 37 | chromeLoadTimesConfig, 38 | chromePluginConfig, 39 | chromeRuntimeConfig, 40 | # generateMagicArraysConfig, # this script does nothing visible in bot.sannysoft.com 41 | # iFrameContentWindowConfig, # this script does nothing visible in bot.sannysoft.com 42 | # mediaCodecsConfig, # this script does nothing visible in bot.sannysoft.com 43 | # navigatorHardWareConcurrencyConfig, # this script does nothing visible in bot.sannysoft.com 44 | # navigatorLanguagesConfig, # this script does nothing visible in bot.sannysoft.com 45 | # navigatorPermissionsConfig, # this script does nothing visible in bot.sannysoft.com 46 | # navigatorPluginsConfig, # this script does nothing visible in bot.sannysoft.com 47 | navigatorUserAgentConfig, 48 | # navigatorVendorConfig, # this script does nothing visible in bot.sannysoft.com 49 | # navigatorWebDriverConfig, # this script does nothing visible in bot.sannysoft.com 50 | # webGLVendorConfig, # this script does visible in bot.sannysoft.com 51 | ] 52 | 53 | 54 | def get_test_id(config: ScriptConfig) -> str: 55 | """This function is used to generate test ids for pytest""" 56 | return config.name 57 | 58 | 59 | @pytest.mark.parametrize("config", test_configs, ids=get_test_id) 60 | def test_all_scripts_sync(config: ScriptConfig): 61 | """This test runs all the scripts in a synchronous manner""" 62 | with sync_playwright() as p: 63 | browser = p.chromium.launch(headless=True) 64 | page: SyncPage = agentql.wrap(browser.new_page()) 65 | 66 | utils_script = from_file("utils.js") 67 | magic_arrays_script = from_file("generate.magic.arrays.js") 68 | 69 | properties = Properties() 70 | opts = json.dumps(properties.as_dict()) 71 | opts = f"const opts = {opts}" 72 | 73 | combined_script = ( 74 | opts + "\n" + utils_script + "\n" + magic_arrays_script + "\n" + config.script 75 | ) 76 | 77 | page.add_init_script(combined_script) 78 | page.goto(config.url) 79 | response = page.query_data(config.query) 80 | 81 | try: 82 | for key, value in response.items(): 83 | assert value == "True" 84 | except AssertionError: 85 | page.screenshot( 86 | path=f"tests/e2e/screenshots/{config.name}.png", full_page=True 87 | ) 88 | raise AssertionError(f"Test failed: {key} is {value}") 89 | finally: 90 | page.close() 91 | browser.close() 92 | 93 | 94 | @pytest.mark.asyncio 95 | @pytest.mark.parametrize("config", test_configs, ids=get_test_id) 96 | async def test_all_scripts_async(config: ScriptConfig): 97 | """This test runs all the scripts in an asynchronous manner""" 98 | async with async_playwright() as p: 99 | browser = await p.chromium.launch(headless=True) 100 | page: AsyncPage = await agentql.wrap_async(browser.new_page()) 101 | 102 | utils_script = from_file("utils.js") 103 | magic_arrays_script = from_file("generate.magic.arrays.js") 104 | 105 | properties = Properties() 106 | opts = json.dumps(properties.as_dict()) 107 | opts = f"const opts = {opts}" 108 | 109 | combined_script = ( 110 | opts + "\n" + utils_script + "\n" + magic_arrays_script + "\n" + config.script 111 | ) 112 | 113 | await page.add_init_script(combined_script) 114 | await page.goto(config.url) 115 | response = await page.query_data(config.query) 116 | 117 | try: 118 | for key, value in response.items(): 119 | assert value == "True" 120 | except AssertionError: 121 | await page.screenshot( 122 | path=f"tests/e2e/screenshots/{config.name}.png", full_page=True 123 | ) 124 | raise AssertionError(f"Test failed: {key} is {value}") 125 | finally: 126 | await page.close() 127 | await browser.close() 128 | -------------------------------------------------------------------------------- /tests/e2e/test_multiple_scripts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import agentql 3 | import logging 4 | from typing import List 5 | from playwright.sync_api import sync_playwright, Page as SyncPage 6 | from playwright.async_api import async_playwright, Page as AsyncPage 7 | from .configs import ( 8 | MultipleScriptConfig, 9 | chromeAppConfig, 10 | chromeCsiConfig, 11 | chromeLoadTimesConfig, 12 | chromePluginConfig, 13 | chromeRuntimeConfig, 14 | generateMagicArraysConfig, 15 | iFrameContentWindowConfig, 16 | mediaCodecsConfig, 17 | navigatorHardWareConcurrencyConfig, 18 | navigatorLanguagesConfig, 19 | navigatorPermissionsConfig, 20 | navigatorPluginsConfig, 21 | navigatorUserAgentConfig, 22 | navigatorVendorConfig, 23 | navigatorWebDriverConfig, 24 | webGLVendorConfig, 25 | ) 26 | 27 | log = logging.getLogger(__name__) 28 | 29 | 30 | def extract_query_lines(query_str) -> List[str]: 31 | """This function is used to extract the query lines from the query string""" 32 | lines = query_str.strip().splitlines() 33 | return [ 34 | line.strip() 35 | for line in lines 36 | if line.strip() and not line.startswith("{") and not line.endswith("}") 37 | ] 38 | 39 | 40 | def join_query_lines(query_lines) -> str: 41 | """This function is used to join the query lines into a single query""" 42 | combined_lines = list(dict.fromkeys(query_lines)) # remove duplicates 43 | combined_query = "{\n\t" + "\n\t".join(combined_lines) + "\n}" 44 | return combined_query 45 | 46 | 47 | @pytest.fixture 48 | def multiple_configs(): 49 | """This fixture is used to test multiple scripts in a single test""" 50 | config_options = { 51 | chromeAppConfig: True, 52 | chromeCsiConfig: True, 53 | chromeLoadTimesConfig: True, 54 | chromePluginConfig: True, 55 | chromeRuntimeConfig: False, 56 | generateMagicArraysConfig: False, # this script does nothing visible in bot.sannysoft.com 57 | iFrameContentWindowConfig: False, # this script does nothing visible in bot.sannysoft.com 58 | mediaCodecsConfig: False, # this script does nothing visible in bot.sannysoft.com 59 | navigatorHardWareConcurrencyConfig: False, # this script does nothing visible in bot.sannysoft.com 60 | navigatorLanguagesConfig: False, # this script does nothing visible in bot.sannysoft.com 61 | navigatorPermissionsConfig: False, # this script does nothing visible in bot.sannysoft.com 62 | navigatorPluginsConfig: False, 63 | navigatorUserAgentConfig: False, 64 | navigatorVendorConfig: False, # this script does nothing visible in bot.sannysoft.com 65 | navigatorWebDriverConfig: False, # this script does nothing visible in bot.sannysoft.com 66 | webGLVendorConfig: False, # this script does visible in bot.sannysoft.com 67 | } 68 | query_lines = [] 69 | scripts = [] 70 | for config, use_config in config_options.items(): 71 | if use_config: 72 | scripts.append(config.script) 73 | query_lines += extract_query_lines(config.query) 74 | 75 | combined_query = join_query_lines(query_lines) 76 | 77 | log.debug(f"Combined query: {combined_query}") 78 | return MultipleScriptConfig( 79 | name="multiple_scripts_test", 80 | script=scripts, 81 | query=combined_query, 82 | url="https://bot.sannysoft.com/", 83 | ) 84 | 85 | 86 | def test_multiple_scripts(multiple_configs: MultipleScriptConfig): 87 | with sync_playwright() as p: 88 | browser = p.chromium.launch(headless=True) 89 | page: AsyncPage = agentql.wrap(browser.new_page()) 90 | 91 | for script in multiple_configs.script: 92 | page.add_init_script(script) 93 | page.goto(multiple_configs.url) 94 | response = page.query_data(multiple_configs.query) 95 | 96 | try: 97 | for key, value in response.items(): 98 | assert value == "True" 99 | except AssertionError: 100 | page.screenshot( 101 | path=f"tests/e2e/screenshots/{multiple_configs.name}_sync.png", 102 | full_page=True, 103 | ) 104 | raise AssertionError(f"Test failed: {key} is {value}") 105 | 106 | page.close() 107 | browser.close() 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_multiple_scripts(multiple_configs: MultipleScriptConfig): 112 | async with async_playwright() as p: 113 | browser = await p.chromium.launch(headless=True) 114 | page: AsyncPage = await agentql.wrap_async(browser.new_page()) 115 | 116 | for script in multiple_configs.script: 117 | await page.add_init_script(script) 118 | await page.goto(multiple_configs.url) 119 | response = await page.query_data(multiple_configs.query) 120 | 121 | try: 122 | for key, value in response.items(): 123 | assert value == "True" 124 | except AssertionError: 125 | await page.screenshot( 126 | path=f"tests/e2e/screenshots/{multiple_configs.name}_async.png", 127 | full_page=True, 128 | ) 129 | raise AssertionError(f"Test failed: {key} is {value}") 130 | 131 | await page.close() 132 | await browser.close() 133 | -------------------------------------------------------------------------------- /tests/e2e/test_one_script.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import agentql 3 | import logging 4 | from playwright.sync_api import sync_playwright, Page as SyncPage 5 | from playwright.async_api import async_playwright, Page as AsyncPage 6 | from .configs import ( 7 | ScriptConfig, 8 | chromeAppConfig, 9 | chromeCsiConfig, 10 | chromeLoadTimesConfig, 11 | chromePluginConfig, 12 | chromeRuntimeConfig, 13 | generateMagicArraysConfig, 14 | iFrameContentWindowConfig, 15 | mediaCodecsConfig, 16 | navigatorHardWareConcurrencyConfig, 17 | navigatorLanguagesConfig, 18 | navigatorPermissionsConfig, 19 | navigatorPluginsConfig, 20 | navigatorUserAgentConfig, 21 | navigatorVendorConfig, 22 | navigatorWebDriverConfig, 23 | webGLVendorConfig, 24 | ) 25 | 26 | log = logging.getLogger(__name__) 27 | 28 | 29 | def get_test_id(config: ScriptConfig) -> str: 30 | """This function is used to generate test ids for pytest""" 31 | return config.name 32 | 33 | 34 | @pytest.fixture 35 | def individual_config() -> ScriptConfig: 36 | """This fixture is used to test a single script in a single test""" 37 | # any script can be used here 38 | return chromeAppConfig 39 | 40 | 41 | def test_individual_script_sync( 42 | individual_config: ScriptConfig, 43 | ): 44 | """This test runs a single script""" 45 | with sync_playwright() as p: 46 | browser = p.chromium.launch(headless=True) 47 | page: AsyncPage = agentql.wrap(browser.new_page()) 48 | 49 | page.add_init_script(individual_config.script) 50 | page.goto(individual_config.url) 51 | response = page.query_data(individual_config.query) 52 | 53 | try: 54 | for key, value in response.items(): 55 | assert value == "True" 56 | except AssertionError: 57 | page.screenshot( 58 | path=f"tests/e2e/screenshots/{individual_config.name}_sync.png", 59 | full_page=True, 60 | ) 61 | raise AssertionError(f"Test failed: {key} is {value}") 62 | finally: 63 | page.close() 64 | browser.close() 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_individual_script_async(individual_config: ScriptConfig): 69 | """This test runs a single script""" 70 | async with async_playwright() as p: 71 | browser = await p.chromium.launch(headless=True) 72 | page: AsyncPage = await agentql.wrap_async(browser.new_page()) 73 | 74 | await page.add_init_script(individual_config.script) 75 | await page.goto(individual_config.url) 76 | response = await page.query_data(individual_config.query) 77 | 78 | try: 79 | for key, value in response.items(): 80 | assert value == "True" 81 | except AssertionError: 82 | await page.screenshot( 83 | path=f"tests/e2e/screenshots/{individual_config.name}_async.png", 84 | full_page=True, 85 | ) 86 | raise AssertionError(f"Test failed: {key} is {value}") 87 | finally: 88 | await page.close() 89 | await browser.close() 90 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tinyfish-io/tf-playwright-stealth/cea0fd5e40a647166fc9bd0617776aa480424e1d/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/header_properties_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fake_http_header import FakeHttpHeader 3 | from playwright_stealth.properties._header_properties import HeaderProperties 4 | 5 | 6 | @pytest.fixture 7 | def fake_headers(): 8 | """Fixture to generate fake headers with an additional 'origin' key if missing.""" 9 | return FakeHttpHeader(domain_code="com", browser="chrome").as_header_dict() 10 | 11 | 12 | @pytest.fixture 13 | def brands(): 14 | """Fixture for sample brand data.""" 15 | return [{"brand": "BrandA", "version": "1"}, {"brand": "BrandB", "version": "2"}] 16 | 17 | 18 | @pytest.fixture 19 | def header_properties(fake_headers, brands): 20 | """Fixture to initialize HeaderProperties with fake headers and brands.""" 21 | return HeaderProperties(brands=brands, dnt="1", **fake_headers) 22 | 23 | 24 | def test_initialization(header_properties, fake_headers, brands): 25 | """Test that HeaderProperties initializes with correct attributes and values.""" 26 | assert header_properties.user_agent == fake_headers["User-Agent"] 27 | assert header_properties.accept_language == fake_headers["Accept-language"] 28 | assert header_properties.accept_encoding == fake_headers["Accept-encoding"] 29 | assert header_properties.accept == fake_headers["Accept"] 30 | assert header_properties.referer == fake_headers["Referer"] 31 | assert header_properties.dnt == "1" 32 | assert header_properties.sec_ch_ua == header_properties._generate_sec_ch_ua(brands) 33 | assert ( 34 | header_properties.sec_ch_ua_mobile 35 | == header_properties._generate_sec_ch_ua_mobile() 36 | ) 37 | assert ( 38 | header_properties.sec_ch_ua_platform 39 | == header_properties._generate_sec_ch_ua_platform() 40 | ) 41 | assert ( 42 | header_properties.sec_ch_ua_form_factors 43 | == header_properties._generate_sec_ch_ua_form_factors() 44 | ) 45 | 46 | 47 | def test_generate_sec_ch_ua_platform(header_properties): 48 | """Test _generate_sec_ch_ua_platform with various user agents.""" 49 | test_cases = [ 50 | ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "macOS"), 51 | ("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Windows"), 52 | ("Mozilla/5.0 (X11; Linux x86_64)", "Linux"), 53 | ("Mozilla/5.0 (Unknown OS)", "Unknown"), 54 | ] 55 | for user_agent, expected_platform in test_cases: 56 | header_properties.user_agent = user_agent 57 | assert header_properties._generate_sec_ch_ua_platform() == expected_platform 58 | 59 | 60 | def test_generate_sec_ch_ua(header_properties, brands): 61 | """Test _generate_sec_ch_ua generates correct string format.""" 62 | ua_string = header_properties._generate_sec_ch_ua(brands) 63 | expected_string = '"BrandA";v="1","BrandB";v="2",' 64 | assert ua_string == expected_string 65 | 66 | 67 | def test_generate_sec_ch_ua_mobile(header_properties): 68 | """Test _generate_sec_ch_ua_mobile returns expected value.""" 69 | assert header_properties._generate_sec_ch_ua_mobile() == "?0" 70 | 71 | 72 | def test_generate_sec_ch_ua_form_factors(header_properties): 73 | """Test _generate_sec_ch_ua_form_factors returns expected value.""" 74 | assert header_properties._generate_sec_ch_ua_form_factors() == "desktop" 75 | 76 | 77 | def test_as_dict(header_properties): 78 | """Test as_dict converts headers to kebab-case correctly and includes all keys.""" 79 | headers_dict = header_properties.as_dict() 80 | expected_keys = { 81 | "user-agent", 82 | "accept-language", 83 | "accept-encoding", 84 | "accept", 85 | "referer", 86 | "dnt", 87 | "sec-ch-ua", 88 | "sec-ch-ua-mobile", 89 | "sec-ch-ua-platform", 90 | "sec-ch-ua-form-factors", 91 | } 92 | assert set(headers_dict.keys()) == expected_keys 93 | assert headers_dict["user-agent"] == header_properties.user_agent 94 | assert headers_dict["accept-language"] == header_properties.accept_language 95 | assert headers_dict["accept-encoding"] == header_properties.accept_encoding 96 | assert headers_dict["accept"] == header_properties.accept 97 | assert headers_dict["referer"] == header_properties.referer 98 | assert headers_dict["dnt"] == header_properties.dnt 99 | assert headers_dict["sec-ch-ua"] == header_properties.sec_ch_ua 100 | assert headers_dict["sec-ch-ua-mobile"] == header_properties.sec_ch_ua_mobile 101 | assert headers_dict["sec-ch-ua-platform"] == header_properties.sec_ch_ua_platform 102 | assert ( 103 | headers_dict["sec-ch-ua-form-factors"] 104 | == header_properties.sec_ch_ua_form_factors 105 | ) 106 | 107 | 108 | @pytest.mark.parametrize( 109 | "user_agent,expected_platform", 110 | [ 111 | ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "macOS"), 112 | ("Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Windows"), 113 | ("Mozilla/5.0 (X11; Linux x86_64)", "Linux"), 114 | ("Mozilla/5.0 (Unknown OS)", "Unknown"), 115 | ], 116 | ) 117 | def test_generate_sec_ch_ua_platform_parametrized(user_agent, expected_platform): 118 | """Parametrized test for _generate_sec_ch_ua_platform.""" 119 | header_properties = HeaderProperties( 120 | brands=[], 121 | dnt="1", 122 | **{ 123 | "User-Agent": user_agent, 124 | "Accept-language": "", 125 | "Accept-encoding": "", 126 | "Accept": "", 127 | "Referer": "", 128 | } 129 | ) 130 | assert header_properties._generate_sec_ch_ua_platform() == expected_platform 131 | 132 | 133 | def test_missing_headers(): 134 | """Test that HeaderProperties raises an error when required headers are missing.""" 135 | with pytest.raises(KeyError): 136 | HeaderProperties(brands=[], dnt="1") 137 | -------------------------------------------------------------------------------- /tests/unit/navigator_properties_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from fake_http_header import FakeHttpHeader 3 | from playwright_stealth.properties._navigator_properties import NavigatorProperties 4 | 5 | 6 | @pytest.fixture 7 | def fake_headers(): 8 | """Fixture to generate fake headers using FakeHttpHeader.""" 9 | return FakeHttpHeader(domain_code="com", browser="chrome").as_header_dict() 10 | 11 | 12 | @pytest.fixture 13 | def brands(): 14 | """Fixture for sample brand data.""" 15 | return [ 16 | {"brand": "Chromium", "version": "95"}, 17 | {"brand": "Google Chrome", "version": "95"}, 18 | ] 19 | 20 | 21 | @pytest.fixture 22 | def navigator_properties(fake_headers, brands): 23 | """Fixture to initialize NavigatorProperties with fake headers and brands.""" 24 | return NavigatorProperties(brands=brands, dnt="1", **fake_headers) 25 | 26 | 27 | def test_initialization(navigator_properties, fake_headers, brands): 28 | """Test that NavigatorProperties initializes with correct attributes.""" 29 | assert navigator_properties.userAgent == fake_headers["User-Agent"] 30 | assert navigator_properties.platform in ["MacIntel", "Win64", "Linux x86_x64"] 31 | assert navigator_properties.language == "en-US" 32 | assert isinstance(navigator_properties.languages, list) 33 | assert ( 34 | navigator_properties.appVersion 35 | == navigator_properties._generate_app_version(fake_headers["User-Agent"]) 36 | ) 37 | assert navigator_properties.vendor in ["Google Inc.", ""] 38 | assert navigator_properties.deviceMemory == 8 39 | assert navigator_properties.hardwareConcurrency == 8 40 | assert navigator_properties.maxTouchPoints == 0 41 | assert navigator_properties.doNotTrack == "1" 42 | assert navigator_properties.brands == brands 43 | assert navigator_properties.mobile is False 44 | 45 | 46 | def test_generate_platform(navigator_properties): 47 | """Test the _generate_platform method with various user agents.""" 48 | ua_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" 49 | ua_windows = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" 50 | ua_linux = "Mozilla/5.0 (X11; Linux x86_64)" 51 | assert navigator_properties._generate_platform(ua_mac) == "MacIntel" 52 | assert navigator_properties._generate_platform(ua_windows) == "Win64" 53 | assert navigator_properties._generate_platform(ua_linux) == "Linux x86_x64" 54 | 55 | 56 | def test_generate_languages(navigator_properties): 57 | """Test the _generate_languages method.""" 58 | accept_language = "en-US,en;q=0.9,fr;q=0.8" 59 | expected_languages = ["en-US", "en", "fr"] 60 | assert ( 61 | navigator_properties._generate_languages(accept_language) == expected_languages 62 | ) 63 | 64 | 65 | def test_generate_app_version(navigator_properties): 66 | """Test the _generate_app_version method.""" 67 | user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" 68 | expected_version = "5.0 (Windows NT 10.0; Win64; x64)" 69 | assert navigator_properties._generate_app_version(user_agent) == expected_version 70 | 71 | 72 | def test_generate_vendor(navigator_properties): 73 | """Test the _generate_vendor method.""" 74 | ua_chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/85.0.4183.102" 75 | ua_firefox = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/80.0" 76 | ua_safari = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15" 77 | assert navigator_properties._generate_vendor(ua_chrome) == "Google Inc." 78 | assert navigator_properties._generate_vendor(ua_firefox) == "" 79 | assert navigator_properties._generate_vendor(ua_safari) == "Google Inc." 80 | 81 | 82 | def test_as_dict(navigator_properties): 83 | """Test that as_dict converts the navigator properties to a dictionary correctly.""" 84 | navigator_dict = navigator_properties.as_dict() 85 | assert navigator_dict["userAgent"] == navigator_properties.userAgent 86 | assert navigator_dict["platform"] == navigator_properties.platform 87 | assert navigator_dict["language"] == navigator_properties.language 88 | assert navigator_dict["languages"] == navigator_properties.languages 89 | assert navigator_dict["appVersion"] == navigator_properties.appVersion 90 | assert navigator_dict["vendor"] == navigator_properties.vendor 91 | assert navigator_dict["deviceMemory"] == navigator_properties.deviceMemory 92 | assert ( 93 | navigator_dict["hardwareConcurrency"] 94 | == navigator_properties.hardwareConcurrency 95 | ) 96 | assert navigator_dict["maxTouchPoints"] == navigator_properties.maxTouchPoints 97 | assert navigator_dict["doNotTrack"] == navigator_properties.doNotTrack 98 | assert navigator_dict["brands"] == navigator_properties.brands 99 | assert navigator_dict["mobile"] == navigator_properties.mobile 100 | -------------------------------------------------------------------------------- /tests/unit/properties_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pytest 3 | from mockito import when, when2, unstub, mock 4 | from fake_http_header import FakeHttpHeader 5 | from playwright_stealth.properties._navigator_properties import NavigatorProperties 6 | from playwright_stealth.properties._header_properties import HeaderProperties 7 | from playwright_stealth.properties._viewport_properties import ViewportProperties 8 | from playwright_stealth.properties._webgl_properties import WebGlProperties 9 | from playwright_stealth.properties._properties import Properties 10 | import playwright_stealth.properties._properties as properties 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | @pytest.fixture 16 | def fake_headers(): 17 | """Provides fake headers for mocking.""" 18 | return { 19 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/85.0.4183.102", 20 | "Accept-language": "en-US,en;q=0.9", 21 | "Accept-encoding": "gzip, deflate, br", 22 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 23 | "Referer": "https://www.example.com", 24 | } 25 | 26 | 27 | @pytest.fixture 28 | def mock_fake_http_header(fake_headers): 29 | """Mocks FakeHttpHeader used within the Properties class.""" 30 | mock_instance = mock(FakeHttpHeader) 31 | when2(properties.FakeHttpHeader, domain_code="com", browser="chrome").thenReturn( 32 | mock_instance 33 | ) 34 | mock_instance.user_agent = fake_headers["User-Agent"] 35 | when(mock_instance).as_header_dict().thenReturn(fake_headers) 36 | yield 37 | unstub() 38 | 39 | 40 | @pytest.fixture 41 | def mock_properties_dependencies(mock_fake_http_header): 42 | """Mocks methods in Properties and its dependencies to control randomness.""" 43 | # Mock Properties._generate_dnt 44 | when(properties.Properties)._generate_dnt().thenReturn("1") 45 | 46 | # Mock ViewportProperties methods 47 | when(ViewportProperties)._generate_viewport_dimensions().thenReturn((1920, 1080)) 48 | when(ViewportProperties)._generate_outer_dimensions().thenReturn((1920, 1080)) 49 | when(ViewportProperties)._generate_inner_dimensions().thenReturn((1920, 1080)) 50 | 51 | # Mock WebGlProperties 52 | test_webgl_prop = {"vendor": "Test Vendor", "renderer": "Test Renderer"} 53 | when(WebGlProperties)._generate_webgl_prop().thenReturn(test_webgl_prop) 54 | 55 | yield 56 | unstub() 57 | 58 | 59 | def test_initialization(mock_properties_dependencies, fake_headers): 60 | """Test that Properties initializes all components correctly.""" 61 | # Create an instance of Properties 62 | props = Properties() 63 | 64 | # Assertions for header properties 65 | assert isinstance(props.header, HeaderProperties) 66 | assert props.header.user_agent == fake_headers["User-Agent"] 67 | assert props.header.accept_language == fake_headers["Accept-language"] 68 | assert props.header.dnt == "1" 69 | 70 | # Assertions for navigator properties 71 | assert isinstance(props.navigator, NavigatorProperties) 72 | assert props.navigator.userAgent == fake_headers["User-Agent"] 73 | assert props.navigator.doNotTrack == "1" 74 | 75 | # Assertions for viewport properties 76 | assert isinstance(props.viewport, ViewportProperties) 77 | assert props.viewport.width == 1920 78 | assert props.viewport.height == 1080 79 | 80 | # Assertions for webgl properties 81 | assert isinstance(props.webgl, WebGlProperties) 82 | assert props.webgl.vendor == "Test Vendor" 83 | assert props.webgl.renderer == "Test Renderer" 84 | 85 | # Assertion for runOnInsecureOrigins 86 | assert props.runOnInsecureOrigins is None 87 | 88 | 89 | def test_generate_brands(mock_fake_http_header, fake_headers): 90 | """Test the _generate_brands method.""" 91 | # Create Properties instance 92 | props = Properties() 93 | brands = props._generate_brands(fake_headers["User-Agent"]) 94 | 95 | # Verify that brands is a list of dictionaries with 'brand' and 'version' 96 | assert isinstance(brands, list) 97 | assert len(brands) == 3 98 | for brand_info in brands: 99 | assert "brand" in brand_info 100 | assert "version" in brand_info 101 | 102 | 103 | def test_generate_dnt(): 104 | """Test the _generate_dnt method.""" 105 | # Mock Properties._generate_dnt 106 | when(properties.Properties)._generate_dnt().thenReturn("0") 107 | 108 | props = Properties() 109 | dnt = props._generate_dnt() 110 | assert dnt == "0" 111 | 112 | unstub() 113 | 114 | 115 | def test_as_dict(mock_properties_dependencies, fake_headers): 116 | """Test the as_dict method.""" 117 | # Create Properties instance 118 | props = Properties() 119 | props_dict = props.as_dict() 120 | 121 | # Assertions for header in as_dict 122 | assert props_dict["header"]["user-agent"] == fake_headers["User-Agent"] 123 | assert props_dict["header"]["dnt"] == "1" 124 | 125 | # Assertions for navigator in as_dict 126 | assert props_dict["navigator"]["userAgent"] == fake_headers["User-Agent"] 127 | assert props_dict["navigator"]["doNotTrack"] == "1" 128 | 129 | # Assertions for viewport in as_dict 130 | expected_viewport = { 131 | "width": 1920, 132 | "height": 1080, 133 | "outerWidth": 1920, 134 | "outerHeight": 1080, 135 | "innerWidth": 1920, 136 | "innerHeight": 1080, 137 | } 138 | assert props_dict["viewport"] == expected_viewport 139 | 140 | # Assertions for webgl in as_dict 141 | expected_webgl = { 142 | "vendor": "Test Vendor", 143 | "renderer": "Test Renderer", 144 | } 145 | assert props_dict["webgl"] == expected_webgl 146 | 147 | 148 | def test_full_integration(): 149 | """Test the Properties class as a whole.""" 150 | # This test allows randomness to ensure components integrate correctly 151 | props = Properties() 152 | props_dict = props.as_dict() 153 | 154 | # Check that the dictionary has the correct keys 155 | assert "header" in props_dict 156 | assert "navigator" in props_dict 157 | assert "viewport" in props_dict 158 | assert "webgl" in props_dict 159 | 160 | # Check that header properties are consistent 161 | header = props_dict["header"] 162 | navigator = props_dict["navigator"] 163 | assert header["user-agent"] == navigator["userAgent"] 164 | 165 | # Check that dnt values are consistent 166 | assert header["dnt"] == navigator["doNotTrack"] 167 | 168 | # Check that viewport dimensions make sense 169 | viewport = props_dict["viewport"] 170 | assert viewport["width"] >= viewport["innerWidth"] 171 | assert viewport["height"] >= viewport["innerHeight"] 172 | 173 | # Check that webgl properties are valid 174 | webgl = props_dict["webgl"] 175 | webgl_properties = props.webgl.webgl_properties 176 | assert { 177 | "vendor": webgl["vendor"], 178 | "renderer": webgl["renderer"], 179 | } in webgl_properties 180 | 181 | unstub() 182 | -------------------------------------------------------------------------------- /tests/unit/viewport_properties_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mockito import when, unstub 3 | import random 4 | from playwright_stealth.properties._viewport_properties import ViewportProperties 5 | 6 | 7 | def test_initialization(): 8 | """Test that ViewportProperties initializes with correct attributes.""" 9 | # Mock random.randint to return 0 for consistent testing 10 | when(random).randint(-100, 100).thenReturn(0) 11 | when(random).randint(0, 20).thenReturn(0) 12 | viewport = ViewportProperties() 13 | assert viewport.width == 1920 14 | assert viewport.height == 1080 15 | assert viewport.outerWidth == 1920 16 | assert viewport.outerHeight == 1080 17 | assert viewport.innerWidth == 1920 18 | assert viewport.innerHeight == 1080 19 | unstub() 20 | 21 | 22 | def test_generate_viewport_dimensions(): 23 | """Test the _generate_viewport_dimensions method.""" 24 | # Mock random.randint to return 50 and -50 in sequence 25 | when(random).randint(-100, 100).thenReturn(50, -50) 26 | # Mock random.randint for inner dimensions to return 0 27 | when(random).randint(0, 20).thenReturn(0) 28 | viewport = ViewportProperties() 29 | expected_width = 1920 + 50 # 1970 30 | expected_height = 1080 - 50 # 1030 31 | assert viewport.width == expected_width 32 | assert viewport.height == expected_height 33 | unstub() 34 | 35 | 36 | def test_generate_inner_dimensions(): 37 | """Test the _generate_inner_dimensions method.""" 38 | # Mock random.randint to return specified values in sequence 39 | when(random).randint(-100, 100).thenReturn(50, 50) 40 | when(random).randint(0, 20).thenReturn(10, 15) 41 | viewport = ViewportProperties() 42 | expected_width = 1920 + 50 # 1970 43 | expected_height = 1080 + 50 # 1130 44 | expected_innerWidth = expected_width - 10 # 1960 45 | expected_innerHeight = expected_height - 15 # 1115 46 | assert viewport.width == expected_width 47 | assert viewport.height == expected_height 48 | assert viewport.innerWidth == expected_innerWidth 49 | assert viewport.innerHeight == expected_innerHeight 50 | unstub() 51 | 52 | 53 | def test_as_dict(): 54 | """Test the as_dict method.""" 55 | when(random).randint(-100, 100).thenReturn(0) 56 | when(random).randint(0, 20).thenReturn(0) 57 | viewport = ViewportProperties() 58 | expected_dict = { 59 | "width": 1920, 60 | "height": 1080, 61 | "outerWidth": 1920, 62 | "outerHeight": 1080, 63 | "innerWidth": 1920, 64 | "innerHeight": 1080, 65 | } 66 | assert viewport.as_dict() == expected_dict 67 | unstub() 68 | 69 | 70 | def test_dimensions_within_expected_range(): 71 | """Test that the generated dimensions are within expected ranges.""" 72 | viewport = ViewportProperties() 73 | assert 1820 <= viewport.width <= 2020 74 | assert 980 <= viewport.height <= 1180 75 | assert viewport.outerWidth == viewport.width 76 | assert viewport.outerHeight == viewport.height 77 | assert viewport.width - 20 <= viewport.innerWidth <= viewport.width 78 | assert viewport.height - 20 <= viewport.innerHeight <= viewport.height 79 | 80 | 81 | def test_randomness_in_dimensions(): 82 | """Test that different instances have different dimensions due to randomness.""" 83 | viewport1 = ViewportProperties() 84 | viewport2 = ViewportProperties() 85 | dimensions1 = ( 86 | viewport1.width, 87 | viewport1.height, 88 | viewport1.innerWidth, 89 | viewport1.innerHeight, 90 | ) 91 | dimensions2 = ( 92 | viewport2.width, 93 | viewport2.height, 94 | viewport2.innerWidth, 95 | viewport2.innerHeight, 96 | ) 97 | # Since randomness is involved, dimensions may occasionally be the same, which is acceptable 98 | assert ( 99 | dimensions1 != dimensions2 or True 100 | ) # Accept possible equality due to randomness 101 | 102 | 103 | def test_custom_kwargs(): 104 | """Test that custom kwargs are accepted and set as attributes.""" 105 | viewport = ViewportProperties(custom_attr="test_value") 106 | assert viewport.custom_attr == "test_value" 107 | -------------------------------------------------------------------------------- /tests/unit/webgl_properties_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mockito import when, unstub 3 | import random 4 | from playwright_stealth.properties._webgl_properties import WebGlProperties 5 | 6 | 7 | def test_initialization(): 8 | """Test that WebGlProperties initializes with correct vendor and renderer.""" 9 | webgl = WebGlProperties() 10 | webgl_properties = webgl.webgl_properties 11 | assert {"vendor": webgl.vendor, "renderer": webgl.renderer} in webgl_properties 12 | 13 | 14 | def test_generate_webgl_prop(): 15 | """Test the _generate_webgl_prop method with mocking.""" 16 | # Mock random.choice to return a specific property 17 | test_prop = {"vendor": "Test Vendor", "renderer": "Test Renderer"} 18 | when(random).choice(WebGlProperties.webgl_properties).thenReturn(test_prop) 19 | webgl = WebGlProperties() 20 | assert webgl.vendor == "Test Vendor" 21 | assert webgl.renderer == "Test Renderer" 22 | unstub() 23 | 24 | 25 | def test_as_dict(): 26 | """Test that as_dict method returns correct dictionary representation.""" 27 | webgl = WebGlProperties() 28 | expected_dict = { 29 | "vendor": webgl.vendor, 30 | "renderer": webgl.renderer, 31 | } 32 | assert webgl.as_dict() == expected_dict 33 | 34 | 35 | def test_randomness_in_properties(): 36 | """Test that different instances may have different properties due to randomness.""" 37 | webgl1 = WebGlProperties() 38 | webgl2 = WebGlProperties() 39 | # Since randomness is involved, properties may sometimes be the same 40 | # This test checks that the class can produce different properties 41 | assert ( 42 | (webgl1.vendor != webgl2.vendor) or (webgl1.renderer != webgl2.renderer) or True 43 | ) 44 | 45 | 46 | def test_all_possible_properties(): 47 | """Test that all possible properties can be generated over multiple iterations.""" 48 | generated_props = set() 49 | for _ in range(100): 50 | webgl = WebGlProperties() 51 | generated_props.add((webgl.vendor, webgl.renderer)) 52 | expected_props = { 53 | (prop["vendor"], prop["renderer"]) for prop in webgl.webgl_properties 54 | } 55 | assert generated_props == expected_props 56 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def from_file(name) -> str: 5 | """Read script from ./js directory""" 6 | base_path = os.path.dirname(__file__) 7 | project_root = os.path.abspath(os.path.join(base_path, "..", "playwright_stealth")) 8 | js_folder_path = os.path.join(project_root, "js") 9 | file_path = os.path.join(js_folder_path, name) 10 | 11 | if not os.path.exists(file_path): 12 | raise FileNotFoundError(f"File {file_path} not found.") 13 | 14 | with open(file_path, encoding="utf-8") as f: 15 | return f.read() 16 | --------------------------------------------------------------------------------