├── .dockerignore ├── .github ├── pull_request_template.md └── workflows │ ├── close-inactive-issues.yml │ ├── docs.yml │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── index.md ├── quickstart.md ├── stylesheets │ └── extra.css └── tutorials │ ├── account-creation.md │ ├── api-responses.md │ ├── great-power.jpg │ ├── index.md │ ├── infinite-scrolling.md │ └── tutorial-code │ ├── account-creation-1.py │ ├── account-creation-2.py │ ├── api-responses-1.py │ ├── api-responses-2.py │ ├── infinite-scrolling-1.py │ ├── infinite-scrolling-2.py │ └── infinite-scrolling-3.py ├── examples ├── browserscan.py ├── demo.py ├── expect_download.py ├── fetch_domain.py ├── imgur_upload_image.py ├── mouse_drag_boxes.py ├── network_monitor.py ├── set_user_agent.py └── wait_for_page.py ├── mkdocs.yml ├── pyproject.toml ├── scripts ├── format.sh ├── generate_cdp.py ├── lint.sh ├── mkdocs_generate_all.sh ├── mkdocs_generate_index.py ├── mkdocs_generate_reference.py ├── mkdocs_generate_release_notes.py ├── release.py └── test.sh ├── tests ├── Dockerfile ├── __init__.py ├── bot_detection │ ├── __init__.py │ └── test_browserscan.py ├── conftest.py ├── core │ ├── __init__.py │ ├── test_browser.py │ ├── test_multiple_browsers.py │ └── test_tab.py ├── docs │ ├── __init__.py │ ├── conftest.py │ └── tutorials │ │ ├── __init__.py │ │ ├── test_account_creation_tutorial.py │ │ ├── test_api_responses_tutorial.py │ │ └── test_infinite_scrolling_tutorial.py ├── next_test.sh └── sample_data │ ├── __init__.py │ └── groceries.html ├── uv.lock └── zendriver ├── __init__.py ├── _version.py ├── cdp ├── README.md ├── __init__.py ├── accessibility.py ├── animation.py ├── audits.py ├── autofill.py ├── background_service.py ├── bluetooth_emulation.py ├── browser.py ├── cache_storage.py ├── cast.py ├── console.py ├── css.py ├── database.py ├── debugger.py ├── device_access.py ├── device_orientation.py ├── dom.py ├── dom_debugger.py ├── dom_snapshot.py ├── dom_storage.py ├── emulation.py ├── event_breakpoints.py ├── extensions.py ├── fed_cm.py ├── fetch.py ├── file_system.py ├── headless_experimental.py ├── heap_profiler.py ├── indexed_db.py ├── input_.py ├── inspector.py ├── io.py ├── layer_tree.py ├── log.py ├── media.py ├── memory.py ├── network.py ├── overlay.py ├── page.py ├── performance.py ├── performance_timeline.py ├── preload.py ├── profiler.py ├── pwa.py ├── py.typed ├── runtime.py ├── schema.py ├── security.py ├── service_worker.py ├── storage.py ├── system_info.py ├── target.py ├── tethering.py ├── tracing.py ├── util.py ├── web_audio.py └── web_authn.py ├── core ├── _contradict.py ├── browser.py ├── config.py ├── connection.py ├── element.py ├── expect.py ├── tab.py └── util.py └── py.typed /.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | app.egg-info 4 | *.pyc 5 | .mypy_cache 6 | .ruff_cache 7 | .coverage 8 | htmlcov 9 | .venv 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Pre-merge Checklist 6 | 7 | 12 | 13 | - [ ] I have described my change in the section above. 14 | - [ ] I have ran the [`./scripts/format.sh`](https://github.com/stephanlensky/zendriver/blob/main/scripts/format.sh) and [`./scripts/lint.sh`](https://github.com/stephanlensky/zendriver/blob/main/scripts/lint.sh) scripts. My code is properly formatted and has no linting errors. 15 | - [ ] I have added my change to [CHANGELOG.md](https://github.com/stephanlensky/zendriver/blob/main/CHANGELOG.md) under the `[Unreleased]` section. 16 | -------------------------------------------------------------------------------- /.github/workflows/close-inactive-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | workflow_dispatch: 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v9 14 | with: 15 | days-before-issue-stale: 30 16 | days-before-issue-close: 7 17 | stale-issue-label: "stale" 18 | stale-issue-message: "This issue has been marked stale because it has been open for 30 days with no activity. If there is no activity within 7 days, it will be automatically closed." 19 | close-issue-message: "This issue was automatically closed because it has been inactive for 7 days since being marked as stale." 20 | only-issue-labels: "question" 21 | days-before-pr-stale: -1 22 | days-before-pr-close: -1 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "docs/**" 8 | # Files which are used during doc generation 9 | - "README.md" 10 | - "CHANGELOG.md" 11 | - "zendriver/cdp/**" 12 | workflow_dispatch: 13 | permissions: 14 | contents: write 15 | jobs: 16 | docs: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Configure Git Credentials 21 | run: | 22 | git config user.name github-actions[bot] 23 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v3 26 | - name: Set up Python 27 | run: uv python install 28 | - name: Install dependencies 29 | run: uv sync --all-extras --dev 30 | - name: Auto-generate docs 31 | run: | 32 | uv run python scripts/mkdocs_generate_reference.py 33 | uv run python scripts/mkdocs_generate_release_notes.py 34 | - name: Deploy to GitHub Pages 35 | run: uv run mkdocs gh-deploy --force 36 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "**.py" 8 | pull_request: 9 | branches: 10 | - main 11 | paths: 12 | - "**.py" 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | - name: Set up Python 21 | run: uv python install 22 | - name: Install dependencies 23 | run: uv sync --all-extras --dev 24 | - name: Lint with ruff/mypy 25 | run: /bin/bash ./scripts/lint.sh 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | workflow_dispatch: 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install uv 13 | uses: astral-sh/setup-uv@v3 14 | - name: Set up Python 15 | run: uv python install 16 | - name: Build wheel 17 | run: uv build 18 | - name: Publish to PyPI 19 | run: uv publish 20 | env: 21 | UV_PUBLISH_TOKEN: ${{ secrets.UV_PUBLISH_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "**.py" 8 | pull_request: 9 | branches: 10 | - main 11 | paths: 12 | - "**.py" 13 | workflow_dispatch: 14 | jobs: 15 | test: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ ubuntu-latest, windows-latest ] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v3 24 | - name: Set up Python 25 | run: uv python install 26 | - name: Install dependencies 27 | run: uv sync --all-extras --dev 28 | - name: Run tests 29 | env: 30 | ZENDRIVER_TEST_BROWSERS: "headless" 31 | ZENDRIVER_TEST_NO_SANDBOX: "true" 32 | shell: bash 33 | run: ./scripts/test.sh 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # mkdocs build 2 | site/ 3 | 4 | # auto-generated docs 5 | docs/reference 6 | docs/release-notes.md 7 | 8 | # uv/pyenv 9 | .python-version 10 | 11 | # scratch files 12 | scratch/ 13 | 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | eggs/ 28 | .eggs/ 29 | sdist/ 30 | wheels/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | 65 | # Sphinx documentation 66 | 67 | !docs/_build/html 68 | !docs/_build/markdown 69 | 70 | 71 | 72 | # IPython 73 | profile_default/ 74 | ipython_config.py 75 | 76 | # Environments 77 | .env 78 | .venv 79 | env/ 80 | venv/ 81 | ENV/ 82 | env.bak/ 83 | venv.bak/ 84 | /docs/_build/doctrees/ 85 | 86 | # VisualStudioCode 87 | .vscode 88 | .history 89 | 90 | # Pycharm 91 | /.idea 92 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Fixed 11 | 12 | ### Added 13 | 14 | ### Changed 15 | 16 | ### Removed 17 | 18 | ## [0.8.0] - 2025-06-01 19 | 20 | ### Fixed 21 | 22 | - Fixed tests so that they can run on Windows (and still run on Linux like before) @nathanfallet 23 | - Remove usage of asyncio subprocess for better compatibility on Windows @nathanfallet 24 | - Added a missing Chrome Canary path for Windows @nathanfallet 25 | - Added a flag to re-enable `--load-extension` (disabled by default in Chrome 136+) @nathanfallet 26 | 27 | ## [0.7.1] - 2025-05-08 28 | 29 | ### Changed 30 | 31 | - Updated CDP models @jsuarezl 32 | 33 | ## [0.7.0] - 2025-04-28 34 | 35 | ### Added 36 | 37 | - Added `Tab.screenshot_b64` and `Element.screenshot_b64` methods to return screenshot as base64 string @falmar 38 | - Added `Tab.print_to_pdf` to print the current page to a PDF file @stephanlensky 39 | 40 | ## [0.6.1] - 2025-04-25 41 | 42 | ### Fixed 43 | 44 | - Fix race condition in `Browser.get` and `Tab.close` which could cause exceptions, especially when running multiple browsers in parallel @stephanlensky 45 | 46 | ## [0.6.0] - 2025-04-20 47 | 48 | ### Fixed 49 | 50 | - `Browser.get` and `Tab.close` will now wait for their appropiate target events before returning @ccev 51 | 52 | ### Added 53 | 54 | - Added `Tab.save_snapshot` to export the current page to MHTML format. 55 | 56 | ## [0.5.2] - 2025-04-09 57 | 58 | ### Fixed 59 | 60 | - Fixed type annotation of `Element.children` @stephanlensky 61 | 62 | ## [0.5.1] - 2025-02-16 63 | 64 | ### Changed 65 | 66 | - Deprecated `zendriver.loop()` function. You should instead use `asyncio` functions directly, for example: 67 | 68 | ```python 69 | asyncio.run(your_main_method()) 70 | ``` 71 | 72 | ## [0.5.0] - 2025-02-16 73 | 74 | ### Added 75 | 76 | - Add `tab.expect_download` methods to wait for download file @3mora2 77 | 78 | ## [0.4.3] - 2025-02-11 79 | 80 | ### Added 81 | 82 | - Add logs for Chrome process output on connection failure @stephanlensky 83 | 84 | ### Changed 85 | 86 | - Default and launch changed to use `about:blank` (faster start and less bandwidth) @raycardillo 87 | 88 | ## [0.4.2] - 2025-02-11 89 | 90 | ### Fixed 91 | 92 | - Multiple Browsers can be created without one affecting the other @raycardillo 93 | 94 | ## [0.4.1] - 2025-02-09 95 | 96 | ### Fixed 97 | 98 | - Ignore irrelevant `DOM.disable` errors @raycardillo 99 | - Test scripts improved for running on macOS @raycardillo 100 | 101 | ## [0.4.0] - 2025-02-06 102 | 103 | ### Added 104 | 105 | - Add `tab.expect_request` and `tab.expect_response` methods to wait for a specific request or response @3mora2 106 | - Add `tab.wait_for_ready_state` method for to wait for page to load @3mora2 107 | - Add `tab.remove_handlers` method for removing handlers @khamaileon 108 | - Clean up temporary profiles when `Browser.stop()` is called @barrycarey 109 | 110 | ## [0.3.1] - 2025-01-28 111 | 112 | ### Fixed 113 | 114 | - Fixed bug in `find`/`find_element_by_text` which caused `ProtocolException` when no results were found @stephanlensky 115 | 116 | ## [0.3.0] - 2025-01-25 117 | 118 | ### Fixed 119 | 120 | - Added `Tab.set_user_agent()` function for programmatically configuring the user-agent, language, and platform @stephanlensky 121 | - Improved a few type annotations (`Connection.send()` function now returns correctly typed values based on the provided `cdp_obj`) @stephanlensky 122 | 123 | ## [0.2.3] - 2024-12-14 124 | 125 | ### Fixed 126 | 127 | - Fixed mypy linting errors (attempt 2) @stephanlensky 128 | 129 | ### Added 130 | 131 | - Handle browser process shutdown on 'Failed to connect to browser' @desoul99 132 | - Added configurable browser connection timeout and tries @desoul99 133 | 134 | ## [0.2.2] - 2024-11-23 135 | 136 | ### Fixed 137 | 138 | - Fix `AttributeError: 'tuple' object has no attribute 'value'` error in `connection.py` when using headless browser, @slimshreydy 139 | 140 | ## [0.2.1] - 2024-11-23 141 | 142 | ### Added 143 | 144 | - Add automated testing framework! @stephanlensky 145 | - For now, just a few tests are written, including one to test browserscan.com bot detection 146 | - In the future, we can expand this test suite further (see [Zendriver#18](https://github.com/stephanlensky/zendriver/issues/18)) 147 | - Add return type annotation to `Tab.get_content()` @stephanlensky 148 | 149 | ### Changed 150 | 151 | - Upgraded `websockets` to latest version (`>=14.0`) @yoori @stephanlensky 152 | 153 | ## [0.2.0] - 2024-11-17 154 | 155 | ### Changed 156 | 157 | - Updated CDP models @stephanlensky 158 | 159 | ## [0.1.5] - 2024-11-17 160 | 161 | ### Fixed 162 | 163 | - Reverted non-functional fixes for mypy linting errors (oops) @stephanlensky 164 | 165 | ## [0.1.4] - 2024-11-17 166 | 167 | ### Fixed 168 | 169 | - Fixed a large number of mypy linting errors (should not result in any functional change) @stephanlensky 170 | 171 | ### Added 172 | 173 | - Added `zendriver.__version__` attribute to get current package version at runtime @stephanlensky 174 | 175 | ## [0.1.3] - 2024-11-12 176 | 177 | ### Added 178 | 179 | - Added support for `DOM.scrollableFlagUpdated` experimental CDP event. @michaellee94 180 | 181 | ## [0.1.2] - 2024-11-11 182 | 183 | ### Fixed 184 | 185 | - Pinned requirement `websockets<14`, fixing the `AttributeError: 'NoneType' object has no attribute 'closed'` crash which occurs on the latest version of `websockets`. @stephanlensky 186 | - Fixed incorrect `browser.close()` method in examples and documentation -- the correct method is `browser.stop()`. @stephanlensky 187 | - Fixed `atexit` handler to correctly handle async `browser.stop()` method. @stephanlensky 188 | 189 | ## [0.1.1] - 2024-10-29 190 | 191 | ### Added 192 | 193 | - Support for Python 3.10 and Python 3.11. All versions >=3.10 are now supported. @stephanlensky 194 | 195 | ## [0.1.0] - 2024-10-20 196 | 197 | Initial version, forked from [ultrafunkamsterdam/nodriver@`1bb6003`](https://github.com/ultrafunkamsterdam/nodriver/commit/1bb6003c7f0db4d3ec05fdf3fc8c8e0804260103) with a variety of improvements. 198 | 199 | ### Fixed 200 | 201 | - `Browser.set_all` cookies function now correctly uses provided cookies @ilkecan 202 | - "successfully removed temp profile" message printed on exit is now only shown only when a profile was actually removed. Message is now logged at debug level instead of printed. @mreiden @stephanlensky 203 | - Fix crash on starting browser in headless mode @ilkecan 204 | - Fix `Browser.stop()` method to give the browser instance time to shut down before force killing @stephanlensky 205 | - Many `ruff` lint issues @stephanlensky 206 | 207 | ### Added 208 | 209 | - Support for linting with `ruff` and `mypy`. All `ruff` lints are fixed in the initial release, but many `mypy` issues remain to be fixed at a later date. @stephanlensky 210 | - `py.typed` marker so importing as a library in other packages no longer causes `mypy` errors. @stephanlensky 211 | 212 | ### Changed 213 | 214 | - Project is now built with [`uv`](https://github.com/astral-sh/uv). Automatically install dependencies to a venv with `uv sync`, run commands from the venv with `uv run`, and build the project with `uv build`. See the official [`uv` docs](https://docs.astral.sh/uv/) for more information. @stephanlensky 215 | - Docs migrated from sphinx to [mkdocs-material](https://squidfunk.github.io/mkdocs-material/). @stephanlensky 216 | - `Browser.stop()` is now async (so it must be `await`ed) @stephanlensky 217 | 218 | ### Removed 219 | 220 | - Twitter account creation example @stephanlensky 221 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zendriver ✌️ 2 | 3 | > This package is a fork of [`ultrafunkamsterdam/nodriver`](https://github.com/ultrafunkamsterdam/nodriver/), created to add new features, compile unmerged bugfixes, and increase community engagement. 4 | 5 | **Documentation:** [https://slensky.com/zendriver](https://slensky.com/zendriver) 6 | 7 | Zendriver is a blazing fast, async-first, undetectable webscraping/web automation framework implemented using the Chrome Devtools Protocol. Visit websites, scrape content, and run JavaScript using a real browser (no Selenium/Webdriver) all with just a few lines of Python. 8 | 9 | **Docker support is here!** Check out [`stephanlensky/zendriver-docker`](https://github.com/stephanlensky/zendriver-docker) for an example of how to run Zendriver with a real, GPU-accelerated browser (not headless) in a Docker container. (Linux-only) 10 | 11 | ## Features 12 | 13 | - **Undetectable** - Zendriver uses the Chrome Devtools Protocol instead of Selenium/WebDriver, making it (almost) impossible to detect 14 | - **Blazing fast** - Chrome Devtools Protocol is _fast_, much faster than previous Selenium/WebDriver solutions. CDP combined with an async Python API makes Zendriver highly performant. 15 | - **Feature complete and easy to use** - Packed with allowing you to get up and running in just a few lines of code. 16 | - **First-class Docker support** - Traditionally, browser automation has been incredibly difficult to package with Docker, especially if you want to run real, GPU-accelerated Chrome (not headless). Now, deploying with Docker is easier than ever using the officially supported [zendriver-docker project template](https://github.com/stephanlensky/zendriver-docker). 17 | - **Automatic cookie and profile management** - By default, uses fresh profile on each run, cleaning up on exit. Or, save and load cookies to a file to avoid repeating tedious login steps. 18 | - **Smart element lookup** - Find elements selector or text, including iframe content. This could also be used as wait condition for a element to appear, since it will retry for the duration of `timeout` until found. Single element lookup by text using `tab.find()` accepts a `best_match flag`, which will not naively return the first match, but will match candidates by closest matching text length. 19 | - **Easy debugging** - Descriptive `repr` for elements, which represents the element as HTML, makes debugging much easier. 20 | 21 | ## Installation 22 | 23 | To install, simply use `pip` (or your favorite package manager): 24 | 25 | ```sh 26 | pip install zendriver 27 | # or uv add zendriver, poetry add zendriver, etc. 28 | ``` 29 | 30 | ## Usage 31 | 32 | Example for visiting [https://www.browserscan.net/bot-detection](https://www.browserscan.net/bot-detection) and saving a screenshot of the results: 33 | 34 | ```python 35 | import asyncio 36 | 37 | import zendriver as zd 38 | 39 | 40 | async def main(): 41 | browser = await zd.start() 42 | page = await browser.get("https://www.browserscan.net/bot-detection") 43 | await page.save_screenshot("browserscan.png") 44 | await browser.stop() 45 | 46 | 47 | if __name__ == "__main__": 48 | asyncio.run(main()) 49 | ``` 50 | 51 | Check out the [Quickstart](https://slensky.com/zendriver/quickstart/) for more information and examples. 52 | 53 | ## Rationale for the fork 54 | 55 | Zendriver remains committed to `nodriver`'s goals of staying undetected for all modern anti-bot solutions and also keeps with the batteries-included approach of its predecessor. Unfortunately, contributions to the original [`nodriver` repo](https://github.com/ultrafunkamsterdam/nodriver/) are heavily restricted, making it difficult to submit issues or pull requests. At the time of writing, there are several pull requests open to fix critical bugs which have beeen left unaddressed for many months. 56 | 57 | Zendriver aims to change this by: 58 | 59 | 1. Including open pull requests in the original `nodriver` repo as part of the initial release 60 | 2. Modernizing the development process to include static analysis tools such as [`ruff`](https://docs.astral.sh/ruff/) and [`mypy`](https://mypy-lang.org/), reducing the number of easy-to-catch bugs which make it through in the future 61 | 3. Opening up the issue tracker and pull requests for community contributions, allowing the project to continue to grow along with its community. 62 | 63 | With these changes in place, we hope to further development of state-of-the-art open-source web automation tools even further, helping to once again make the web truly open for all. 64 | 65 | ## Contributing 66 | 67 | Contributions of all types are always welcome! Please see [CONTRIBUTING.md](https://github.com/stephanlensky/zendriver/blob/main/CONTRIBUTING.md) for details on how to contribute. 68 | 69 | ### Getting additional help 70 | 71 | If you have a question, bug report, or want to make a general inquiry about the project, please create a new GitHub issue. If you are having a problem with Zendriver, please make sure to include your operating system, Chrome version, code example demonstrating the issue, and any other information that may be relevant. 72 | 73 | Questions directed to any personal accounts outside of GitHub will be ignored. 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | test: 3 | build: 4 | context: . 5 | dockerfile: tests/Dockerfile 6 | ports: 7 | - 5911:5911 # VNC server port 8 | volumes: 9 | - swayvnc-wayvnc-certs:/certs 10 | - ./zendriver:/app/zendriver 11 | - ./tests:/app/tests 12 | - ./scripts:/app/scripts 13 | environment: 14 | - RENDER_GROUP_GID=107 # replace with GID of the group which owns the /dev/dri/renderD128 device 15 | - SWAY_RESOLUTION=1920x1080 16 | - WAYVNC_PORT=5911 17 | - WAYVNC_ENABLE_AUTH=true 18 | - WAYVNC_USERNAME=wayvnc 19 | - WAYVNC_PASSWORD=wayvnc 20 | # When set to true, the test runner will pause after each test is complete to allow debugging with VNC client 21 | # Proceed to the next test by sending Mod+Return over VNC 22 | - ZENDRIVER_PAUSE_AFTER_TEST=false 23 | privileged: true 24 | 25 | volumes: 26 | swayvnc-wayvnc-certs: 27 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" 2 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## Installation 4 | 5 | To install, simply use `pip` (or your favorite package manager): 6 | 7 | ```sh 8 | pip install zendriver 9 | # or uv add zendriver, poetry add zendriver, etc. 10 | ``` 11 | 12 | ## Basic usage 13 | 14 | Open a browser, navigate to a page, and scrape the content: 15 | 16 | ```python 17 | import asyncio 18 | import zendriver as zd 19 | 20 | async def main(): 21 | browser = await zd.start() 22 | page = await browser.get('https://example.com') 23 | 24 | # get HTML content of the page as a string 25 | content = await page.get_content() 26 | 27 | # save a screenshot 28 | await page.save_screenshot() 29 | 30 | # close the browser window 31 | await browser.stop() 32 | 33 | 34 | if __name__ == '__main__': 35 | asyncio.run(main()) 36 | ``` 37 | 38 | ## More complete example 39 | 40 | ```python 41 | import asyncio 42 | import zendriver as zd 43 | 44 | async def main(): 45 | browser = await zd.start() 46 | page = await browser.get('https://slensky.com/zendriver/') 47 | 48 | elems = await page.select_all('*[src]') 49 | 50 | for elem in elems: 51 | await elem.flash() 52 | 53 | page2 = await browser.get('https://twitter.com', new_tab=True) 54 | page3 = await browser.get('https://github.com/ultrafunkamsterdam/nodriver', new_window=True) 55 | 56 | for p in (page, page2, page3): 57 | await p.bring_to_front() 58 | await p.scroll_down(200) 59 | await p # wait for events to be processed 60 | await p.reload() 61 | if p != page3: 62 | await p.close() 63 | 64 | if __name__ == '__main__': 65 | asyncio.run(main()) 66 | ``` 67 | 68 | I'll leave out the async boilerplate here 69 | 70 | ```python 71 | import zendriver as zd 72 | 73 | browser = await zd.start( 74 | headless=False, 75 | user_data_dir="/path/to/existing/profile", # by specifying it, it won't be automatically cleaned up when finished 76 | browser_executable_path="/path/to/some/other/browser", 77 | browser_args=['--some-browser-arg=true', '--some-other-option'], 78 | lang="en-US" # this could set iso-language-code in navigator, not recommended to change 79 | ) 80 | tab = await browser.get('https://somewebsite.com') 81 | ``` 82 | 83 | ## Alternative custom options 84 | 85 | I'll leave out the async boilerplate here 86 | 87 | ```python 88 | import zendriver as zd 89 | 90 | config = zd.Config() 91 | config.headless = False 92 | config.user_data_dir="/path/to/existing/profile", # by specifying it, it won't be automatically cleaned up when finished 93 | config.browser_executable_path="/path/to/some/other/browser", 94 | config.browser_args=['--some-browser-arg=true', '--some-other-option'], 95 | config.lang="en-US" # this could set iso-language-code in navigator, not recommended to change 96 | ``` 97 | 98 | On Windows, we recommend using `WindowsSelectorEventLoopPolicy` for better compatibility with asyncio: 99 | 100 | ```python 101 | import asyncio 102 | import sys 103 | 104 | if sys.platform == "win32": 105 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) 106 | ``` 107 | 108 | A more concrete example, which can be found in the ./example/ folder, 109 | shows a script for uploading an image to imgur. 110 | 111 | ```python 112 | import asyncio 113 | from pathlib import Path 114 | import zendriver as zd 115 | 116 | # interesting, this is a typical site which runs completely on javascript, and that causes 117 | # this script to be faster than the js can present the elements. This may be one of the downsides 118 | # of this fast beast. You have to carefully consider timing. 119 | DELAY = 2 120 | 121 | async def main(): 122 | browser = await zd.start() 123 | tab = await browser.get("https://imgur.com") 124 | 125 | # now we first need an image to upload, lets make a screenshot of the project page 126 | save_path = Path("screenshot.jpg").resolve() 127 | # create new tab with the project page 128 | temp_tab = await browser.get( 129 | "https://github.com/ultrafunkamsterdam/undetected-chromedriver", new_tab=True 130 | ) 131 | 132 | # wait page to load 133 | await temp_tab 134 | # save the screenshot to the previously declared path of screenshot.jpg (which is just current directory) 135 | await temp_tab.save_screenshot(save_path) 136 | # done, discard the temp_tab 137 | await temp_tab.close() 138 | 139 | # accept goddamn cookies 140 | # the best_match flag will filter the best match from 141 | # matching elements containing "consent" and takes the 142 | # one having most similar text length 143 | consent = await tab.find("Consent", best_match=True) 144 | await consent.click() 145 | 146 | # shortcut 147 | await (await tab.find("new post", best_match=True)).click() 148 | 149 | file_input = await tab.select("input[type=file]") 150 | await file_input.send_file(save_path) 151 | # since file upload takes a while , the next buttons are not available yet 152 | 153 | await tab.wait(DELAY) 154 | 155 | # wait until the grab link becomes clickable, by waiting for the toast message 156 | await tab.select(".Toast-message--check") 157 | 158 | # this one is tricky. we are trying to find a element by text content 159 | # usually. the text node itself is not needed, but it's enclosing element. 160 | # in this case however, the text is NOT a text node, but an "placeholder" attribute of a span element. 161 | # so for this one, we use the flag return_enclosing_element and set it to False 162 | title_field = await tab.find("give your post a unique title", best_match=True) 163 | print(title_field) 164 | await title_field.send_keys("undetected zendriver") 165 | 166 | grab_link = await tab.find("grab link", best_match=True) 167 | await grab_link.click() 168 | 169 | # there is a delay for the link sharing popup. 170 | # let's pause for a sec 171 | await tab.wait(DELAY) 172 | 173 | # get inputs of which the value starts with http 174 | input_thing = await tab.select("input[value^=https]") 175 | 176 | my_link = input_thing.attrs.value 177 | 178 | print(my_link) 179 | await browser.stop() 180 | 181 | 182 | if __name__ == "__main__": 183 | asyncio.run(main()) 184 | ``` 185 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #2ea72d; 3 | /* --md-primary-fg-color--light: #ecb7b7; 4 | --md-primary-fg-color--dark: #90030c; */ 5 | } 6 | -------------------------------------------------------------------------------- /docs/tutorials/account-creation.md: -------------------------------------------------------------------------------- 1 | **Target page:** [https://slensky.com/zendriver-examples/login-page.html](https://slensky.com/zendriver-examples/login-page.html) 2 | 3 | In this tutorial, we will demonstrate how to fill out a new account sign-up form and then log in with the newly created account. The example page login/signup is implemented entirely with JavaScript, so created accounts do not persist once the tab has been closed. 4 | 5 | Feel free to open the page now in your current browser to get an idea of what we will be working with! 6 | 7 | ## Initial setup 8 | 9 | Begin by creating a new script for the tutorial: 10 | 11 | ```python 12 | --8<-- "docs/tutorials/tutorial-code/account-creation-1.py" 13 | ``` 14 | 15 | ## Creating a new account 16 | 17 | In this example page, you can create a new account by clicking on the "Sign up" link, which makes the sign-up form visible when clicked. 18 | 19 | We can create a new function to click this link, fill out the form, and submit it: 20 | 21 | ```python 22 | --8<-- "docs/tutorials/tutorial-code/account-creation-2.py:7:30" 23 | ``` 24 | 25 | ## Logging in 26 | 27 | Next, filling out the login form and logging in: 28 | 29 | ```python 30 | --8<-- "docs/tutorials/tutorial-code/account-creation-2.py:33:52" 31 | ``` 32 | 33 | ## Putting it all together 34 | 35 | ```python 36 | --8<-- "docs/tutorials/tutorial-code/account-creation-2.py" 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/tutorials/api-responses.md: -------------------------------------------------------------------------------- 1 | **Target page:** [https://slensky.com/zendriver-examples/api-request.html](https://slensky.com/zendriver-examples/api-request.html) 2 | 3 | In this tutorial, we will demonstrate how to read a dynamically loaded API response using response expectations. 4 | 5 | The example page simulates an API request by waiting for a few seconds and then fetching a static JSON file. While it would be far easier in this case to just fetch the JSON file directly, for demonstration purposes, let's instead pretend that the response comes from a more complex API that cannot easily be called directly. 6 | 7 | ## Initial setup 8 | 9 | Begin by creating a new script for the tutorial: 10 | 11 | ```python 12 | --8<-- "docs/tutorials/tutorial-code/api-responses-1.py" 13 | ``` 14 | 15 | ## Reading the API response 16 | 17 | ```python 18 | --8<-- "docs/tutorials/tutorial-code/api-responses-2.py" 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/tutorials/great-power.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/docs/tutorials/great-power.jpg -------------------------------------------------------------------------------- /docs/tutorials/index.md: -------------------------------------------------------------------------------- 1 | The following tutorials demonstrate how to use Zendriver for a variety of real-world web automation tasks, including 2 | 3 | - Navigating an infinitely scrolling feed 4 | - Creating user accounts 5 | - Retrieving the results of API requests 6 | - (Soon) Many more! 7 | 8 | The tutorials in this documentation use [example pages](https://github.com/stephanlensky/zendriver-examples) created expressly for demonstration purposes. None of the tutorials send real data or create real accounts on real websites. 9 | 10 | Always exercise caution when applying these techniques to automating live sites. Be a good web citizen! Ensure you do not make excessive numbers of requests or become a burden for the website owners, or you will quickly find your requests blocked and your IP banned. 11 | 12 |
13 | ![Movie screenshot: with great power comes great responsibility](./great-power.jpg) 14 |
15 | -------------------------------------------------------------------------------- /docs/tutorials/infinite-scrolling.md: -------------------------------------------------------------------------------- 1 | **Target page:** [https://slensky.com/zendriver-examples/scrollable-cards.html](https://slensky.com/zendriver-examples/scrollable-cards.html) 2 | 3 | In this tutorial, we will demonstrate how to scrape a page with an infinitely scrolling feed. Before we get started, check out the live website to get an idea of what we will be working with! 4 | 5 | ## Initial setup 6 | 7 | Begin by creating a new script for the tutorial: 8 | 9 | ```python 10 | --8<-- "docs/tutorials/tutorial-code/infinite-scrolling-1.py" 11 | ``` 12 | 13 | In this first version of the code, we do not wait for the cards to load before trying to print them out, so the printed list will always be empty. 14 | 15 | ## Waiting for cards to appear 16 | 17 | To solve this, we need to wait for the cards to load before printing them: 18 | 19 | ```python 20 | --8<-- "docs/tutorials/tutorial-code/infinite-scrolling-2.py" 21 | ``` 22 | 23 | The above change was a step in the right direction, but what if we want to keep scrolling down until we find the lucky card? 24 | 25 | ## Finding the lucky card 26 | 27 | In this final version of the script, we continuously scroll down to the bottom of the page, waiting for new sets of cards to appear until we find the lucky card. 28 | 29 | ```python 30 | --8<-- "docs/tutorials/tutorial-code/infinite-scrolling-3.py" 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/account-creation-1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | 5 | 6 | async def main() -> None: 7 | browser = await zd.start() 8 | page = await browser.get( 9 | "https://slensky.com/zendriver-examples/login-page.html", 10 | ) 11 | 12 | # TODO: Sign-up and login 13 | 14 | await browser.stop() 15 | 16 | 17 | if __name__ == "__main__": 18 | asyncio.run(main()) 19 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/account-creation-2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | from zendriver import Tab 5 | 6 | 7 | async def create_account(page: Tab, name: str, email: str, password: str) -> None: 8 | # Click on the "Sign up" link 9 | sign_up_link = next(a for a in await page.select_all("a") if "Sign up" in a.text) 10 | await sign_up_link.click() 11 | await asyncio.sleep(0.5) 12 | 13 | # Fill in the sign-up form 14 | name_input = await page.select("#signupName") 15 | await name_input.send_keys(name) 16 | email_input = await page.select("#signupEmail") 17 | await email_input.send_keys(email) 18 | password_input = await page.select("#signupPassword") 19 | await password_input.send_keys(password) 20 | 21 | # Click the "Sign Up" button 22 | sign_up_button = next( 23 | button for button in await page.select_all("button") if "Sign Up" in button.text 24 | ) 25 | await sign_up_button.click() 26 | await asyncio.sleep(0.5) 27 | 28 | # Click through confirmation dialog 29 | proceed_to_login = await page.find(text="Proceed to Login") 30 | await proceed_to_login.click() 31 | 32 | 33 | async def login(page: Tab, email: str, password: str) -> None: 34 | # Fill in the login form 35 | email_input = await page.select("#loginEmail") 36 | await email_input.send_keys(email) 37 | password_input = await page.select("#loginPassword") 38 | await password_input.send_keys(password) 39 | 40 | # Click the "Login" button 41 | login_button = next( 42 | button for button in await page.select_all("button") if "Login" in button.text 43 | ) 44 | await login_button.click() 45 | await asyncio.sleep(0.5) 46 | 47 | # Verify successful login 48 | message = await page.select("#message") 49 | if "Welcome back" in message.text_all: 50 | print("Login successful") 51 | else: 52 | print("Login failed") 53 | 54 | 55 | async def main() -> None: 56 | browser = await zd.start() 57 | page = await browser.get( 58 | "https://slensky.com/zendriver-examples/login-page.html", 59 | ) 60 | await asyncio.sleep(0.5) # Wait for the page to load 61 | 62 | name = "John Doe" 63 | email = "john.doe@example.com" 64 | password = "securepassword" 65 | 66 | await create_account(page, name, email, password) 67 | await login(page, email, password) 68 | 69 | await browser.stop() 70 | 71 | 72 | if __name__ == "__main__": 73 | asyncio.run(main()) 74 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/api-responses-1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | 5 | 6 | async def main() -> None: 7 | browser = await zd.start() 8 | 9 | # TODO: Read the API response 10 | page = await browser.get( 11 | "https://slensky.com/zendriver-examples/api-request.html", 12 | ) 13 | 14 | await browser.stop() 15 | 16 | 17 | if __name__ == "__main__": 18 | asyncio.run(main()) 19 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/api-responses-2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | import zendriver as zd 5 | from zendriver.cdp.network import get_response_body 6 | 7 | 8 | async def main() -> None: 9 | browser = await zd.start() 10 | 11 | page = browser.tabs[0] 12 | async with page.expect_response(".*/user-data.json") as response_expectation: 13 | await page.get( 14 | "https://slensky.com/zendriver-examples/api-request.html", 15 | ) 16 | response = await response_expectation.value 17 | 18 | request_id = response.request_id 19 | body, _ = await page.send(get_response_body(request_id=request_id)) 20 | user_data = json.loads(body) 21 | 22 | print("Successfully read user data response for user:", user_data["name"]) 23 | print(json.dumps(user_data, indent=2)) 24 | 25 | await browser.stop() 26 | 27 | 28 | if __name__ == "__main__": 29 | asyncio.run(main()) 30 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/infinite-scrolling-1.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | 5 | 6 | async def main() -> None: 7 | browser = await zd.start() 8 | page = await browser.get( 9 | "https://slensky.com/zendriver-examples/scrollable-cards.html", 10 | ) 11 | 12 | # Not yet loaded, so empty 13 | card_container = await page.select("#card-container") 14 | cards = card_container.children 15 | print(cards) # [] 16 | 17 | await browser.stop() 18 | 19 | 20 | if __name__ == "__main__": 21 | asyncio.run(main()) 22 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/infinite-scrolling-2.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | from zendriver import Element, Tab 5 | 6 | 7 | async def wait_for_cards(page: Tab, initial_card_count: int) -> list[Element]: 8 | while True: 9 | card_container = await page.select("#card-container") 10 | cards = card_container.children 11 | if len(cards) > initial_card_count: 12 | return cards 13 | await asyncio.sleep(0.5) 14 | 15 | 16 | async def main() -> None: 17 | browser = await zd.start() 18 | page = await browser.get( 19 | "https://slensky.com/zendriver-examples/scrollable-cards.html", 20 | ) 21 | 22 | # Wait for cards to load 23 | cards = await wait_for_cards(page, initial_card_count=0) 24 | 25 | # Now we can print the cards 26 | # (shows first 10 cards: Card 1, Card 2...Card 9, Card 10) 27 | for card in cards: 28 | print(card.text) 29 | 30 | await browser.stop() 31 | 32 | 33 | if __name__ == "__main__": 34 | asyncio.run(main()) 35 | -------------------------------------------------------------------------------- /docs/tutorials/tutorial-code/infinite-scrolling-3.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | from zendriver import Element, Tab 5 | 6 | 7 | async def wait_for_cards(page: Tab, initial_card_count: int) -> list[Element]: 8 | while True: 9 | card_container = await page.select("#card-container") 10 | cards = card_container.children 11 | if len(cards) > initial_card_count: 12 | print("Loaded new cards. Current count:", len(cards)) 13 | return cards 14 | await asyncio.sleep(0.5) 15 | 16 | 17 | def get_lucky_card(cards: list[Element]) -> Element | None: 18 | for card in cards: 19 | if "Congratulations, you found the lucky card!" in card.text_all: 20 | return card 21 | 22 | return None 23 | 24 | 25 | async def main() -> None: 26 | browser = await zd.start() 27 | page = await browser.get( 28 | "https://slensky.com/zendriver-examples/scrollable-cards.html", 29 | ) 30 | 31 | # Wait for the first batch of cards to load 32 | cards = await wait_for_cards(page, initial_card_count=0) 33 | 34 | # Loop until we find the lucky card 35 | while (lucky_card := get_lucky_card(cards)) is None: 36 | # Scroll to the bottom of the page 37 | await page.scroll_down(1000) # 10x page height, likely to be enough 38 | 39 | # Get the new cards 40 | cards = await wait_for_cards(page, initial_card_count=len(cards)) 41 | 42 | if lucky_card: 43 | print(f"Lucky card found: Card {cards.index(lucky_card) + 1}") 44 | 45 | await browser.stop() 46 | 47 | 48 | if __name__ == "__main__": 49 | asyncio.run(main()) 50 | -------------------------------------------------------------------------------- /examples/browserscan.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | 5 | 6 | async def main(): 7 | browser = await zd.start() 8 | page = await browser.get("https://www.browserscan.net/bot-detection") 9 | await page.save_screenshot("browserscan.png") 10 | await browser.stop() 11 | 12 | 13 | if __name__ == "__main__": 14 | asyncio.run(main()) 15 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | 4 | import asyncio 5 | import logging 6 | import logging.handlers 7 | import random 8 | import time 9 | 10 | import mss 11 | 12 | logger = logging.getLogger("demo") 13 | logging.basicConfig(level=10) 14 | 15 | 16 | try: 17 | import zendriver as uc 18 | except (ModuleNotFoundError, ImportError): 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 23 | import zendriver as uc 24 | 25 | _monitor = mss.mss().monitors[0] 26 | SCREEN_WIDTH = _monitor["width"] 27 | NUM_WINS = SCREEN_WIDTH // 325 # as to not fill up the screen to much 28 | 29 | 30 | class Timing: 31 | def __init__(self): 32 | self.start = None 33 | self.stop = None 34 | self.taken = None 35 | 36 | def __enter__(self): 37 | self.start = time.monotonic() 38 | return self 39 | 40 | def __exit__(self, *args, **kwargs): 41 | self.stop = time.monotonic() 42 | self.taken = self.stop - self.start 43 | print("taken:", self.taken, "seconds") 44 | 45 | 46 | async def main(): 47 | driver = await uc.start() 48 | 49 | URL1 = "https://www.bet365.com" 50 | URL2 = "https://www.nowsecure.nl" 51 | 52 | print( 53 | "the startup speed of the windows depend on the line speed and availability/load on the servers" 54 | ) 55 | await driver.get(URL2) 56 | 57 | for _ in range(NUM_WINS): 58 | if _ % 2 == 0: 59 | await driver.get(URL1, new_window=True) 60 | else: 61 | await driver.get(URL2, new_window=True) 62 | 63 | await driver 64 | 65 | grid = await driver.tile_windows(max_columns=NUM_WINS) 66 | await driver.sleep(5) 67 | for tab in driver.tabs: 68 | await tab.maximize() 69 | 70 | for _ in range(15): 71 | randbox = lambda: random.choice(grid) 72 | for i, tab in enumerate(driver.tabs): 73 | await tab.activate() 74 | await tab.set_window_size(*randbox()) 75 | await driver 76 | 77 | for i, tab in enumerate(driver): 78 | if i >= len(grid): 79 | i = len(grid) - i 80 | await tab.set_window_size(*grid[i]) 81 | await tab.sleep() 82 | 83 | await asyncio.gather( 84 | *[move_circle(tab, i % 2) for (i, tab) in enumerate(driver.tabs)] 85 | ) 86 | 87 | nowsecure_pages = [tab for tab in driver.tabs if "nowsecure" in tab.url] 88 | 89 | await asyncio.gather( 90 | *[tab.get("https://nowsecure.nl/mouse.html") for tab in nowsecure_pages] 91 | ) 92 | await driver.tile_windows(max_columns=NUM_WINS) 93 | await asyncio.gather(*[mouse_move(tab) for tab in nowsecure_pages]) 94 | 95 | b365pages = [tab for tab in driver.tabs if "bet365" in tab.url] 96 | # await driver.tile_windows() 97 | await driver.sleep(1) 98 | 99 | positions = await driver.tile_windows(b365pages) 100 | # await asyncio.gather(*[tab.set_window_size(*pos) for (tab, pos) in zip(b365pages, positions)]) 101 | await asyncio.gather(*[flash_spans(tab, i) for (i, tab) in enumerate(b365pages)]) 102 | await asyncio.gather(*[scroll_task(tab) for tab in b365pages]) 103 | 104 | for i, tab in enumerate(driver): 105 | try: 106 | await tab.get("https://www.google.com") 107 | await tab.activate() 108 | # skip first tab 109 | if tab == driver.main_tab: 110 | print("skipping main tab") 111 | continue 112 | except: 113 | pass 114 | await tab.close() 115 | 116 | for i, tab in enumerate(driver): 117 | try: 118 | if i == 0: 119 | continue 120 | # await driver.tile_windows(i) 121 | await tab.activate() 122 | # skip first tab 123 | await tab.close() 124 | 125 | except: 126 | pass 127 | print("TBCI=", driver.connection.listener.time_before_considered_idle) 128 | await driver.stop() 129 | 130 | 131 | async def scroll_task(tab): 132 | await tab.scroll_up(200) 133 | spans = await tab.select_all("span") 134 | [ 135 | await s.scroll_into_view() 136 | # since scroll_into_view does not return a value 137 | # we can abuse 'or' to run 2 functions in a list comprehension 138 | or await s.flash() 139 | for s in reversed(spans) 140 | ] 141 | [ 142 | (await tab.scroll_up(n // 2) or await tab.scroll_down(n)) 143 | # since the above does not return a value 144 | # we can abuse 'or' to run even more while doing a list comprehension 145 | or print("tab %s scrolling down : %d" % (tab, n)) 146 | for n in range(0, 75, 15) 147 | ] 148 | 149 | 150 | async def mouse_move(tab): 151 | await tab.activate() 152 | boxes = await tab.select_all(".box") 153 | for box in boxes: 154 | await box.mouse_move() 155 | 156 | 157 | async def move_circle(tab, x=0): 158 | window_id, bounds = await tab.get_window() 159 | 160 | old_left, old_top = bounds.left, bounds.top 161 | old_width, old_height = bounds.width, bounds.height 162 | 163 | center = int(old_left), int(old_top) 164 | 165 | for coord in uc.util.circle(*center, radius=100, num=1050, dir=x): 166 | new_left, new_top = int(coord[0]), int(coord[1]) 167 | await tab.set_window_size(new_left, new_top) 168 | 169 | for coord in uc.util.circle(*center, radius=250, num=500, dir=x): 170 | new_left, new_top = int(coord[0] / 2), int(coord[1] / 2) 171 | # await tab.set_window_size(old_left, old_top) 172 | await tab.set_window_size(new_left, new_top) 173 | 174 | await tab.set_window_size(old_left, old_top, old_width, old_height) 175 | 176 | 177 | async def flash_spans(tab, i): 178 | logger.info("flashing spans. i=%d , tab=%s, url=%s" % (i, tab, tab.url)) 179 | # await tab.fullscreen() 180 | elems = await tab.select_all("span") 181 | # await tab.medimize() 182 | await tab.activate() 183 | for elem in elems: 184 | await elem.flash(duration=0.25) 185 | await elem.scroll_into_view() 186 | 187 | 188 | if __name__ == "__main__": 189 | with Timing() as t: 190 | uc.loop().run_until_complete(main()) 191 | -------------------------------------------------------------------------------- /examples/expect_download.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import os 4 | 5 | import zendriver as zd 6 | 7 | 8 | async def main(): 9 | path_image = r"Your Image Path" 10 | out_dir = r"." 11 | async with await zd.start() as browser: 12 | page = browser.main_tab 13 | await page.get("https://translate.yandex.com/en/ocr") 14 | await (await page.select('input[type="file"]')).send_file(path_image) 15 | await asyncio.sleep(3) 16 | 17 | async with page.expect_download() as download_ex: 18 | await (await page.select("#downloadButton")).mouse_click() 19 | download = await download_ex.value 20 | print(download.suggested_filename) 21 | with open(os.path.join(out_dir, download.suggested_filename), "wb") as fw: 22 | bytes_file = base64.b64decode(download.url.split(",", 1)[-1]) 23 | fw.write(bytes_file) 24 | 25 | 26 | if __name__ == "__main__": 27 | asyncio.run(main()) 28 | -------------------------------------------------------------------------------- /examples/fetch_domain.py: -------------------------------------------------------------------------------- 1 | try: 2 | from zendriver import * 3 | except (ModuleNotFoundError, ImportError): 4 | import sys 5 | import os 6 | 7 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 8 | from zendriver import * 9 | 10 | import logging 11 | 12 | logging.basicConfig(level=logging.INFO) 13 | 14 | 15 | async def request_handler(ev: cdp.fetch.RequestPaused, tab: Tab): 16 | print("\nRequestPaused handler\n", ev, type(ev)) 17 | print("TAB = ", tab) 18 | tab.feed_cdp(cdp.fetch.continue_request(request_id=ev.request_id)) 19 | 20 | 21 | async def main(): 22 | browser = await start() 23 | 24 | [await browser.get("https://www.google.com", new_window=True) for _ in range(10)] 25 | 26 | for tab in browser: 27 | print(tab) 28 | tab.add_handler(cdp.fetch.RequestPaused, request_handler) 29 | await tab.send(cdp.fetch.enable()) 30 | 31 | for tab in browser: 32 | await tab 33 | 34 | for tab in browser: 35 | await tab.activate() 36 | 37 | for tab in reversed(browser): 38 | await tab.activate() 39 | await tab.close() 40 | 41 | await browser.stop() 42 | 43 | 44 | browser = loop().run_until_complete(main()) 45 | -------------------------------------------------------------------------------- /examples/imgur_upload_image.py: -------------------------------------------------------------------------------- 1 | try: 2 | from zendriver import * 3 | except (ModuleNotFoundError, ImportError): 4 | import os 5 | import sys 6 | 7 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 8 | from zendriver import * 9 | 10 | from pathlib import Path 11 | 12 | # interesting, this is a typical site which runs completely on javascript, and that causes 13 | # this script to be faster than the js can present the elements. This may be one of the downsides 14 | # of this fast beast. You have to carefully consider timing. 15 | 16 | DELAY = 2 17 | 18 | 19 | try: 20 | from zendriver import * 21 | except (ModuleNotFoundError, ImportError): 22 | import os 23 | import sys 24 | 25 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 26 | from zendriver import * 27 | 28 | 29 | async def main(): 30 | browser = await start() 31 | tab = await browser.get("https://imgur.com") 32 | 33 | # now we first need an image to upload, lets make a screenshot of the project page 34 | save_path = Path("screenshot.jpg").resolve() 35 | # create new tab with the project page 36 | temp_tab = await browser.get( 37 | "https://github.com/ultrafunkamsterdam/undetected-chromedriver", new_tab=True 38 | ) 39 | 40 | # wait page to load 41 | await temp_tab 42 | # save the screenshot to the previously declared path of screenshot.jpg (which is just current directory) 43 | await temp_tab.save_screenshot(save_path) 44 | # done, discard the temp_tab 45 | await temp_tab.close() 46 | 47 | # accept goddamn cookies 48 | # the best_match flag will filter the best match from 49 | # matching elements containing "consent" and takes the 50 | # one having most similar text length 51 | consent = await tab.find("Consent", best_match=True) 52 | await consent.click() 53 | 54 | # shortcut 55 | await (await tab.find("new post", best_match=True)).click() 56 | 57 | file_input = await tab.select("input[type=file]") 58 | await file_input.send_file(save_path) 59 | # since file upload takes a while , the next buttons are not available yet 60 | 61 | await tab.wait(DELAY) 62 | 63 | # wait until the grab link becomes clickable, by waiting for the toast message 64 | await tab.select(".Toast-message--check") 65 | 66 | # this one is tricky. we are trying to find a element by text content 67 | # usually. the text node itself is not needed, but it's enclosing element. 68 | # in this case however, the text is NOT a text node, but an "placeholder" attribute of a span element. 69 | # so for this one, we use the flag return_enclosing_element and set it to False 70 | title_field = await tab.find("give your post a unique title", best_match=True) 71 | print(title_field) 72 | await title_field.send_keys("undetected zendriver") 73 | 74 | grab_link = await tab.find("grab link", best_match=True) 75 | await grab_link.click() 76 | 77 | # there is a delay for the link sharing popup. 78 | # let's pause for a sec 79 | await tab.wait(DELAY) 80 | 81 | # get inputs of which the value starts with http 82 | input_thing = await tab.select("input[value^=https]") 83 | 84 | my_link = input_thing.attrs.value 85 | 86 | print(my_link) 87 | await tab 88 | 89 | 90 | if __name__ == "__main__": 91 | loop = loop() 92 | loop.run_until_complete(main()) 93 | -------------------------------------------------------------------------------- /examples/mouse_drag_boxes.py: -------------------------------------------------------------------------------- 1 | try: 2 | from zendriver import * 3 | except (ModuleNotFoundError, ImportError): 4 | import sys 5 | import os 6 | 7 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 8 | from zendriver import * 9 | 10 | 11 | async def main(): 12 | browser = await start() 13 | await demo_drag_to_target(browser) 14 | await demo_drag_to_target_in_steps(browser) 15 | await demo_drag_to_absolute_position(browser) 16 | await demo_drag_to_absolute_position_in_steps(browser) 17 | await demo_drag_to_relative_position(browser) 18 | await demo_drag_to_relative_position_in_steps(browser) 19 | 20 | 21 | async def demo_drag_to_target(browser): 22 | tab = await browser.get("https://nowsecure.nl/mouse.html?boxes=50") 23 | boxes = await tab.select_all(".box") 24 | area = await tab.select(".area-a") 25 | for box in boxes: 26 | await box.mouse_drag(area) 27 | 28 | 29 | async def demo_drag_to_target_in_steps(browser): 30 | tab = await browser.get("https://nowsecure.nl/mouse.html") 31 | boxes = await tab.select_all(".box") 32 | area = await tab.select(".area-a") 33 | 34 | for box in boxes: 35 | await box.mouse_drag(area, steps=100) 36 | 37 | 38 | async def demo_drag_to_absolute_position(browser): 39 | tab = await browser.get("https://nowsecure.nl/mouse.html?boxes=50") 40 | boxes = await tab.select_all(".box") 41 | area = await tab.select(".area-a") 42 | 43 | for box in boxes: 44 | await box.mouse_drag((500, 500)) 45 | 46 | 47 | async def demo_drag_to_absolute_position_in_steps(browser): 48 | tab = await browser.get("https://nowsecure.nl/mouse.html") 49 | boxes = await tab.select_all(".box") 50 | area = await tab.select(".area-a") 51 | 52 | for box in boxes: 53 | await box.mouse_drag((500, 500), steps=50) 54 | 55 | 56 | async def demo_drag_to_relative_position(browser): 57 | tab = await browser.get("https://nowsecure.nl/mouse.html?boxes=50") 58 | boxes = await tab.select_all(".box") 59 | 60 | for box in boxes: 61 | await box.mouse_drag((500, 500), relative=True) 62 | 63 | 64 | async def demo_drag_to_relative_position_in_steps(browser): 65 | tab = await browser.get("https://nowsecure.nl/mouse.html") 66 | boxes = await tab.select_all(".box") 67 | 68 | for box in boxes: 69 | await box.mouse_drag((500, 500), relative=True, steps=50) 70 | 71 | 72 | if __name__ == "__main__": 73 | loop().run_until_complete(main()) 74 | -------------------------------------------------------------------------------- /examples/network_monitor.py: -------------------------------------------------------------------------------- 1 | try: 2 | from zendriver import cdp, loop, start 3 | except (ModuleNotFoundError, ImportError): 4 | import os 5 | import sys 6 | 7 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 8 | from zendriver import cdp, loop, start 9 | 10 | 11 | async def main(): 12 | browser = await start() 13 | 14 | tab = browser.main_tab 15 | tab.add_handler(cdp.network.RequestWillBeSent, send_handler) 16 | tab.add_handler(cdp.network.ResponseReceived, receive_handler) 17 | 18 | tab = await browser.get("https://www.google.com/?hl=en") 19 | 20 | reject_btn = await tab.find("reject all", best_match=True) 21 | await reject_btn.click() 22 | 23 | search_inp = await tab.select("textarea") 24 | await search_inp.send_keys("undetected zendriver") 25 | 26 | search_btn = await tab.find("google search", True) 27 | await search_btn.click() 28 | 29 | for _ in range(10): 30 | await tab.scroll_down(50) 31 | 32 | await tab 33 | await tab.back() 34 | 35 | search_inp = await tab.select("textarea") 36 | 37 | for letter in "undetected zendriver": 38 | await search_inp.clear_input() 39 | await search_inp.send_keys( 40 | "undetected zendriver".replace(letter, letter.upper()) 41 | ) 42 | await tab.wait(0.1) 43 | 44 | all_urls = await tab.get_all_urls() 45 | for u in all_urls: 46 | print("downloading %s" % u) 47 | await tab.download_file(u) 48 | 49 | await tab.sleep(10) 50 | 51 | 52 | async def receive_handler(event: cdp.network.ResponseReceived): 53 | print(event.response) 54 | 55 | 56 | async def send_handler(event: cdp.network.RequestWillBeSent): 57 | r = event.request 58 | s = f"{r.method} {r.url}" 59 | for k, v in r.headers.items(): 60 | s += f"\n\t{k} : {v}" 61 | print(s) 62 | 63 | 64 | if __name__ == "__main__": 65 | loop().run_until_complete(main()) 66 | -------------------------------------------------------------------------------- /examples/set_user_agent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import zendriver as zd 4 | 5 | 6 | async def main(): 7 | browser = await zd.start() 8 | tab = browser.main_tab 9 | await tab.set_user_agent("My user agent", accept_language="de", platform="Win32") 10 | 11 | print(await tab.evaluate("navigator.userAgent")) # My user agent 12 | print(await tab.evaluate("navigator.language")) # de 13 | print(await tab.evaluate("navigator.platform")) # Win32 14 | 15 | await browser.stop() 16 | 17 | 18 | if __name__ == "__main__": 19 | asyncio.run(main()) 20 | -------------------------------------------------------------------------------- /examples/wait_for_page.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import zendriver as zd 3 | 4 | 5 | async def main(): 6 | async with await zd.start() as browser: 7 | tab = browser.main_tab 8 | async with tab.expect_request("https://github.com/") as request_info: 9 | async with tab.expect_response( 10 | "https://github.githubassets.com/assets/.*" 11 | ) as response_info: 12 | await tab.get("https://github.com/") 13 | await tab.wait_for_ready_state(until="complete") 14 | 15 | req = await request_info.value 16 | print(req.request_id) 17 | 18 | res = await response_info.value 19 | print(res.request_id) 20 | 21 | 22 | if __name__ == "__main__": 23 | asyncio.run(main()) 24 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | extra_css: 2 | - stylesheets/extra.css 3 | markdown_extensions: 4 | - pymdownx.highlight: 5 | anchor_linenums: true 6 | - pymdownx.inlinehilite 7 | - pymdownx.snippets 8 | - pymdownx.superfences 9 | - pymdownx.details 10 | - def_list 11 | - admonition 12 | - md_in_html 13 | nav: 14 | - Documentation: 15 | - Introduction: index.md 16 | - Quickstart: quickstart.md 17 | - Tutorials: 18 | - About these Tutorials: tutorials/index.md 19 | - Tutorials: 20 | - Infinitely scrolling feed: tutorials/infinite-scrolling.md 21 | - Creating accounts: tutorials/account-creation.md 22 | - Reading API responses: tutorials/api-responses.md 23 | - Reference: 24 | - cdp: 25 | - accessibility: reference/cdp/accessibility.md 26 | - animation: reference/cdp/animation.md 27 | - audits: reference/cdp/audits.md 28 | - autofill: reference/cdp/autofill.md 29 | - background_service: reference/cdp/background_service.md 30 | - bluetooth_emulation: reference/cdp/bluetooth_emulation.md 31 | - browser: reference/cdp/browser.md 32 | - cache_storage: reference/cdp/cache_storage.md 33 | - cast: reference/cdp/cast.md 34 | - console: reference/cdp/console.md 35 | - css: reference/cdp/css.md 36 | - database: reference/cdp/database.md 37 | - debugger: reference/cdp/debugger.md 38 | - device_access: reference/cdp/device_access.md 39 | - device_orientation: reference/cdp/device_orientation.md 40 | - dom: reference/cdp/dom.md 41 | - dom_debugger: reference/cdp/dom_debugger.md 42 | - dom_snapshot: reference/cdp/dom_snapshot.md 43 | - dom_storage: reference/cdp/dom_storage.md 44 | - emulation: reference/cdp/emulation.md 45 | - event_breakpoints: reference/cdp/event_breakpoints.md 46 | - extensions: reference/cdp/extensions.md 47 | - fed_cm: reference/cdp/fed_cm.md 48 | - fetch: reference/cdp/fetch.md 49 | - file_system: reference/cdp/file_system.md 50 | - headless_experimental: reference/cdp/headless_experimental.md 51 | - heap_profiler: reference/cdp/heap_profiler.md 52 | - indexed_db: reference/cdp/indexed_db.md 53 | - input_: reference/cdp/input_.md 54 | - inspector: reference/cdp/inspector.md 55 | - io: reference/cdp/io.md 56 | - layer_tree: reference/cdp/layer_tree.md 57 | - log: reference/cdp/log.md 58 | - media: reference/cdp/media.md 59 | - memory: reference/cdp/memory.md 60 | - network: reference/cdp/network.md 61 | - overlay: reference/cdp/overlay.md 62 | - page: reference/cdp/page.md 63 | - performance: reference/cdp/performance.md 64 | - performance_timeline: reference/cdp/performance_timeline.md 65 | - preload: reference/cdp/preload.md 66 | - profiler: reference/cdp/profiler.md 67 | - pwa: reference/cdp/pwa.md 68 | - runtime: reference/cdp/runtime.md 69 | - schema: reference/cdp/schema.md 70 | - security: reference/cdp/security.md 71 | - service_worker: reference/cdp/service_worker.md 72 | - storage: reference/cdp/storage.md 73 | - system_info: reference/cdp/system_info.md 74 | - target: reference/cdp/target.md 75 | - tethering: reference/cdp/tethering.md 76 | - tracing: reference/cdp/tracing.md 77 | - util: reference/cdp/util.md 78 | - web_audio: reference/cdp/web_audio.md 79 | - web_authn: reference/cdp/web_authn.md 80 | - Release Notes: 81 | - Release Notes: release-notes.md 82 | plugins: 83 | - search 84 | - mkdocstrings: 85 | handlers: 86 | python: 87 | options: 88 | docstring_style: sphinx 89 | show_if_no_docstring: true 90 | show_root_heading: false 91 | show_root_toc_entry: false 92 | site_name: Zendriver 93 | site_url: https://slensky.com/zendriver 94 | theme: 95 | features: 96 | - navigation.footer 97 | - navigation.tabs 98 | - navigation.expand 99 | name: material 100 | palette: 101 | - accent: green 102 | primary: custom 103 | scheme: slate 104 | toggle: 105 | icon: material/toggle-switch-off-outline 106 | name: Switch to light mode 107 | - accent: green 108 | primary: custom 109 | scheme: default 110 | toggle: 111 | icon: material/toggle-switch 112 | name: Switch to dark mode 113 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "zendriver" 3 | version = "0.8.0" 4 | description = "A blazing fast, async-first, undetectable webscraping/web automation framework" 5 | readme = "README.md" 6 | authors = [{ name = "Stephan Lensky", email = "oss@slensky.com" }] 7 | license = { file = "LICENSE" } 8 | classifiers = [ 9 | "Development Status :: 3 - Alpha", 10 | "Intended Audience :: Developers", 11 | "Topic :: Internet :: WWW/HTTP :: Browsers", 12 | "License :: OSI Approved :: GNU Affero General Public License v3", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.10", 15 | "Programming Language :: Python :: 3.11", 16 | "Programming Language :: Python :: 3.12", 17 | "Programming Language :: Python :: 3.13", 18 | ] 19 | requires-python = ">=3.10" 20 | dependencies = [ 21 | "asyncio-atexit>=1.0.1", 22 | "deprecated>=1.2.14", 23 | "mss>=9.0.2", 24 | "websockets>=14.0", 25 | ] 26 | 27 | [build-system] 28 | requires = ["hatchling"] 29 | build-backend = "hatchling.build" 30 | 31 | [tool.uv] 32 | dev-dependencies = [ 33 | "mkdocs-material>=9.5.42", 34 | "mkdocstrings[python]>=0.26.2", 35 | "mypy>=1.12.0", 36 | "pytest-asyncio>=0.24.0", 37 | "pytest>=8.3.3", 38 | "pyyaml>=6.0.2", 39 | "ruff>=0.7.4", 40 | "types-pyyaml>=6.0.12.20240917", 41 | "types-requests>=2.32.0.20241016", 42 | "pytest-mock>=3.14.0", 43 | "types-deprecated>=1.2.15.20241117", 44 | "inflection>=0.5.1", 45 | ] 46 | 47 | [tool.pytest.ini_options] 48 | asyncio_mode = "auto" 49 | asyncio_default_fixture_loop_scope = "function" 50 | log_level = "INFO" 51 | 52 | [tool.ruff] 53 | exclude = ["zendriver/cdp"] 54 | 55 | [tool.ruff.lint] 56 | exclude = [ 57 | "examples/demo.py", 58 | "examples/fetch_domain.py", 59 | "examples/imgur_upload_image.py", 60 | "examples/mouse_drag_boxes.py", 61 | ] 62 | 63 | [tool.mypy] 64 | exclude = [ 65 | "zendriver/cdp", 66 | "examples/demo.py", 67 | "examples/imgur_upload_image.py", 68 | ] 69 | check_untyped_defs = true 70 | 71 | [[tool.mypy.overrides]] 72 | module = [ 73 | "asyncio_atexit", 74 | ] 75 | ignore_missing_imports = true 76 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | python_files=(zendriver scripts examples tests) 7 | 8 | uv run ruff check "${python_files[@]}" --fix 9 | uv run ruff format "${python_files[@]}" 10 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | python_files=(zendriver scripts examples tests) 7 | 8 | echo "Running ruff check..." 9 | uv run ruff check "${python_files[@]}" 10 | echo "Running ruff format check..." 11 | uv run ruff format "${python_files[@]}" --check 12 | echo "Running mypy..." 13 | uv run mypy "${python_files[@]}" 14 | -------------------------------------------------------------------------------- /scripts/mkdocs_generate_all.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | uv run python scripts/mkdocs_generate_reference.py 7 | uv run python scripts/mkdocs_generate_release_notes.py 8 | -------------------------------------------------------------------------------- /scripts/mkdocs_generate_index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run 2 | """ 3 | Copy README.md to docs homepage at docs/index.md. 4 | """ 5 | 6 | from pathlib import Path 7 | 8 | REPO_ROOT = Path(__file__).parent.parent 9 | README_MD = REPO_ROOT / "README.md" 10 | INDEX_MD = REPO_ROOT / "docs" / "index.md" 11 | 12 | 13 | def main() -> None: 14 | readme_content = README_MD.read_text() 15 | INDEX_MD.write_text(readme_content) 16 | print("Successfully generated index.md!") 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /scripts/mkdocs_generate_reference.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run 2 | """ 3 | Automatically generate code reference pages under docs/reference. 4 | """ 5 | 6 | import shutil 7 | from pathlib import Path 8 | 9 | import yaml 10 | 11 | PACKAGE_NAME = "zendriver" 12 | REPO_ROOT = Path(__file__).parent.parent 13 | PACKAGE_ROOT = REPO_ROOT / PACKAGE_NAME 14 | REFERENCE_DOCS_ROOT = REPO_ROOT / "docs" / "reference" 15 | MKDOCS_YML = REPO_ROOT / "mkdocs.yml" 16 | 17 | 18 | def clean_reference_docs_dir() -> None: 19 | if not REFERENCE_DOCS_ROOT.exists(): 20 | return 21 | 22 | for path in REFERENCE_DOCS_ROOT.glob("*"): 23 | if path.is_dir(): 24 | shutil.rmtree(path) 25 | else: 26 | path.unlink() 27 | 28 | 29 | def get_documented_modules() -> list[Path]: 30 | return [ 31 | path.relative_to(PACKAGE_ROOT) 32 | for path in sorted(PACKAGE_ROOT.rglob("cdp/*.py")) 33 | if not path.stem.startswith("_") 34 | ] 35 | 36 | 37 | def load_mkdocs_yml() -> dict: 38 | mkdocs_yml_path = MKDOCS_YML 39 | with mkdocs_yml_path.open() as f: 40 | return yaml.safe_load(f) 41 | 42 | 43 | def write_mkdocs_yml(mkdocs_yml: dict) -> None: 44 | mkdocs_yml_path = MKDOCS_YML 45 | with mkdocs_yml_path.open("w") as f: 46 | yaml.safe_dump(mkdocs_yml, f) 47 | 48 | 49 | def get_nav_item_by_title(nav_section: list[dict], title: str) -> list[dict]: 50 | for item in nav_section: 51 | if title in item: 52 | return item[title] 53 | 54 | raise ValueError(f"Title '{title}' not found in nav section") 55 | 56 | 57 | def write_doc_md(doc_path: Path, module: str) -> None: 58 | doc_path.parent.mkdir(parents=True, exist_ok=True) 59 | 60 | with doc_path.open("w") as f: 61 | f.write(f"::: {PACKAGE_NAME}.{module}\n") 62 | 63 | 64 | def add_nav_items(reference_docs: list[Path]) -> None: 65 | mkdocs_yml = load_mkdocs_yml() 66 | current_section = mkdocs_yml["nav"] 67 | reference_section = get_nav_item_by_title(current_section, "Reference") 68 | reference_section.clear() 69 | 70 | for doc_path in reference_docs: 71 | path_parts = list(doc_path.relative_to(REFERENCE_DOCS_ROOT).parts) 72 | current_section = reference_section 73 | while len(path_parts) > 1: 74 | current_part = path_parts[0] 75 | try: 76 | current_section = get_nav_item_by_title(current_section, current_part) 77 | except ValueError: 78 | current_section.append({current_part: []}) 79 | current_section = current_section[-1][current_part] 80 | 81 | path_parts = path_parts[1:] 82 | 83 | current_section.append( 84 | { 85 | path_parts[0].removesuffix(".md"): doc_path.relative_to( 86 | REPO_ROOT / "docs" 87 | ).as_posix() 88 | } 89 | ) 90 | 91 | write_mkdocs_yml(mkdocs_yml) 92 | 93 | 94 | def main() -> None: 95 | clean_reference_docs_dir() 96 | 97 | reference_docs: list[Path] = [] 98 | for module_path in get_documented_modules(): 99 | doc_path = REFERENCE_DOCS_ROOT / module_path.with_suffix(".md") 100 | module = ".".join(module_path.with_suffix("").parts) 101 | 102 | print(f"Generating {doc_path}...") 103 | write_doc_md(doc_path, module) 104 | reference_docs.append(doc_path) 105 | 106 | add_nav_items(reference_docs) 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /scripts/mkdocs_generate_release_notes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run 2 | """ 3 | Generate release notes from CHANGELOG.md and write them to docs/release-notes.md. 4 | """ 5 | 6 | from pathlib import Path 7 | 8 | REPO_ROOT = Path(__file__).parent.parent 9 | CHANGELOG_MD = REPO_ROOT / "CHANGELOG.md" 10 | RELEASE_NOTES_MD = REPO_ROOT / "docs" / "release-notes.md" 11 | 12 | 13 | def get_releases() -> str: 14 | with CHANGELOG_MD.open() as f: 15 | changelog_md = f.read() 16 | 17 | sections = changelog_md.split("\n## ")[1:] 18 | unreleased = sections.pop(0) 19 | # small sanity check 20 | if not unreleased.startswith("[Unreleased]"): 21 | raise ValueError("Unexpected CHANGELOG.md format, aborting!") 22 | 23 | return "\n\n".join(f"## {section.strip()}" for section in sections) 24 | 25 | 26 | def main() -> None: 27 | releases = get_releases() 28 | 29 | release_notes_md = f"# Release Notes\n\n{releases}\n" 30 | 31 | with RELEASE_NOTES_MD.open("w") as f: 32 | f.write(release_notes_md) 33 | 34 | print("Successfully generated release notes!") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | command=( "$@" ) 6 | if [ "${#command[@]}" -eq 0 ]; then 7 | command=( "pytest" ) 8 | fi 9 | 10 | chrome_executable=$(uv run python -c "from zendriver.core.config import find_chrome_executable;print(find_chrome_executable())") 11 | echo "Chrome executable: $chrome_executable" 12 | 13 | chrome_version=$(uv run python -c "import os, subprocess, sys; path = r'$chrome_executable'; print(subprocess.run([path, '--version'], capture_output=True, text=True).stdout.strip()) if os.name != 'nt' else print('SKIP: Windows chrome.exe may not return version')") 14 | echo "Chrome version: $chrome_version" 15 | 16 | set -x 17 | uv run "${command[@]}" 18 | -------------------------------------------------------------------------------- /tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/stephanlensky/swayvnc-chrome:latest 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | 5 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 6 | 7 | ENV UV_COMPILE_BYTECODE=1 8 | ENV UV_LINK_MODE=copy 9 | 10 | # Make directory for the app 11 | RUN mkdir /app 12 | RUN chown $DOCKER_USER:$DOCKER_USER /app 13 | 14 | # Switch to the non-root user 15 | USER $DOCKER_USER 16 | 17 | # Add a hotkey to swaywm config for starting next test (for ZENDRIVER_PAUSE_AFTER_TEST=true) 18 | RUN echo 'bindsym Mod4+Return exec /app/tests/next_test.sh' >> ~/.config/sway/config 19 | 20 | # Set the working directory 21 | WORKDIR /app 22 | 23 | # Install python 24 | RUN uv python install 3.13 25 | 26 | # Install the Python project's dependencies using the lockfile and settings 27 | COPY --chown=$DOCKER_USER:$DOCKER_USER pyproject.toml uv.lock /app/ 28 | RUN --mount=type=cache,target=/home/$DOCKER_USER/.cache/uv,uid=$PUID,gid=$PGID \ 29 | uv sync --frozen --no-install-project 30 | 31 | # Then, add the rest of the project source code and install it 32 | # Installing separately from its dependencies allows optimal layer caching 33 | COPY --chown=$DOCKER_USER:$DOCKER_USER . /app 34 | 35 | # Add binaries from the project's virtual environment to the PATH 36 | ENV PATH="/app/.venv/bin:$PATH" 37 | 38 | # Sync the project's dependencies and install the project 39 | RUN --mount=type=cache,target=/home/$DOCKER_USER/.cache/uv,uid=$PUID,gid=$PGID \ 40 | uv sync --frozen 41 | 42 | USER root 43 | # Pass custom command to entrypoint script provided by the base image 44 | ENTRYPOINT ["/entrypoint.sh", "./scripts/test.sh"] 45 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/tests/__init__.py -------------------------------------------------------------------------------- /tests/bot_detection/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/tests/bot_detection/__init__.py -------------------------------------------------------------------------------- /tests/bot_detection/test_browserscan.py: -------------------------------------------------------------------------------- 1 | import zendriver as zd 2 | 3 | 4 | async def test_browserscan(browser: zd.Browser): 5 | page = await browser.get("https://www.browserscan.net/bot-detection") 6 | 7 | # wait for the page to fully load 8 | await page.wait_for_ready_state("complete") 9 | 10 | # give the javascript some time to finish executing 11 | await page.wait(2) 12 | 13 | element = await page.find_element_by_text("Test Results:") 14 | assert ( 15 | element is not None 16 | and element.parent is not None 17 | and isinstance(element.parent.children[-1], zd.Element) 18 | ) 19 | assert element.parent.children[-1].text == "Normal" 20 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import signal 5 | import sys 6 | from contextlib import AbstractAsyncContextManager 7 | from enum import Enum 8 | from threading import Event 9 | from typing import AsyncGenerator 10 | 11 | import pytest 12 | 13 | import zendriver as zd 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class BrowserMode(Enum): 19 | HEADLESS = "headless" 20 | HEADFUL = "headful" 21 | ALL = "all" 22 | 23 | @property 24 | def fixture_params(self): 25 | if self == BrowserMode.HEADLESS: 26 | return [{"headless": True}] 27 | elif self == BrowserMode.HEADFUL: 28 | return [{"headless": False}] 29 | elif self == BrowserMode.ALL: 30 | return [{"headless": True}, {"headless": False}] 31 | 32 | 33 | NEXT_TEST_EVENT = Event() 34 | 35 | 36 | class TestConfig: 37 | BROWSER_MODE = BrowserMode(os.getenv("ZENDRIVER_TEST_BROWSERS", "all")) 38 | PAUSE_AFTER_TEST = os.getenv("ZENDRIVER_PAUSE_AFTER_TEST", "false") == "true" 39 | SANDBOX = os.getenv("ZENDRIVER_TEST_SANDBOX", "false") == "true" 40 | USE_WAYLAND = os.getenv("WAYLAND_DISPLAY") is not None 41 | 42 | 43 | class CreateBrowser(AbstractAsyncContextManager): 44 | def __init__( 45 | self, 46 | *, 47 | headless: bool = True, 48 | sandbox: bool = TestConfig.SANDBOX, 49 | browser_args: list[str] | None = None, 50 | browser_connection_max_tries: int = 15, 51 | browser_connection_timeout: float = 3.0, 52 | ): 53 | args = [] 54 | if not headless and TestConfig.USE_WAYLAND: 55 | # use wayland backend instead of x11 56 | args.extend( 57 | ["--disable-features=UseOzonePlatform", "--ozone-platform=wayland"] 58 | ) 59 | if browser_args is not None: 60 | args.extend(browser_args) 61 | 62 | self.config = zd.Config( 63 | headless=headless, 64 | sandbox=sandbox, 65 | browser_args=args, 66 | browser_connection_max_tries=browser_connection_max_tries, 67 | browser_connection_timeout=browser_connection_timeout, 68 | ) 69 | 70 | self.browser: zd.Browser | None = None 71 | self.browser_pid: int | None = None 72 | 73 | async def __aenter__(self) -> zd.Browser: 74 | self.browser = await zd.start(self.config) 75 | browser_pid = self.browser._process_pid 76 | assert browser_pid is not None and browser_pid > 0 77 | await self.browser.wait(0) 78 | return self.browser 79 | 80 | async def __aexit__(self, exc_type, exc_val, exc_tb): 81 | if self.browser is not None: 82 | await self.browser.stop() 83 | assert self.browser_pid is None 84 | 85 | 86 | @pytest.fixture 87 | def create_browser() -> type[CreateBrowser]: 88 | if sys.platform == "win32": 89 | asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore 90 | 91 | return CreateBrowser 92 | 93 | 94 | @pytest.fixture(params=TestConfig.BROWSER_MODE.fixture_params) 95 | def headless(request: pytest.FixtureRequest) -> bool: 96 | return request.param["headless"] 97 | 98 | 99 | @pytest.fixture 100 | async def browser( 101 | headless: bool, create_browser: type[CreateBrowser] 102 | ) -> AsyncGenerator[zd.Browser, None]: 103 | NEXT_TEST_EVENT.clear() 104 | 105 | async with create_browser(headless=headless) as browser: 106 | yield browser 107 | 108 | if TestConfig.PAUSE_AFTER_TEST: 109 | logger.info( 110 | "Pausing after test. Send next test hotkey (default Mod+Return) to continue to next test" 111 | ) 112 | NEXT_TEST_EVENT.wait() 113 | await browser.stop() 114 | assert browser._process_pid is None 115 | 116 | 117 | # signal handler for starting next test 118 | def handle_next_test(signum, frame): 119 | if not TestConfig.PAUSE_AFTER_TEST: 120 | logger.warning( 121 | "Next test signal received, but ZENDRIVER_PAUSE_AFTER_TEST is not set." 122 | ) 123 | logger.warning( 124 | "To enable pausing after each test, set ZENDRIVER_PAUSE_AFTER_TEST=true" 125 | ) 126 | return 127 | 128 | NEXT_TEST_EVENT.set() 129 | 130 | 131 | if hasattr(signal, "SIGUSR1"): 132 | signal.signal(signal.SIGUSR1, handle_next_test) 133 | else: 134 | logger.warning( 135 | "SIGUSR1 not available on this platform, handle_next_test will not be called." 136 | ) 137 | -------------------------------------------------------------------------------- /tests/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/tests/core/__init__.py -------------------------------------------------------------------------------- /tests/core/test_browser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_mock import MockerFixture 3 | 4 | import zendriver as zd 5 | from tests.conftest import CreateBrowser 6 | 7 | 8 | async def test_connection_error_raises_exception_and_logs_stderr( 9 | create_browser: type[CreateBrowser], 10 | mocker: MockerFixture, 11 | caplog: pytest.LogCaptureFixture, 12 | ): 13 | mocker.patch( 14 | "zendriver.core.browser.Browser.test_connection", 15 | return_value=False, 16 | ) 17 | with caplog.at_level("INFO"): 18 | with pytest.raises(Exception): 19 | async with create_browser( 20 | browser_connection_max_tries=1, browser_connection_timeout=0.1 21 | ) as _: 22 | pass 23 | assert "Browser stderr" in caplog.text 24 | 25 | 26 | async def test_get_content_gets_html_content(browser: zd.Browser): 27 | page = await browser.get("https://example.com") 28 | content = await page.get_content() 29 | assert content.lower().startswith("") 30 | 31 | 32 | async def test_update_target_sets_target_title(browser: zd.Browser): 33 | page = await browser.get("https://example.com") 34 | await page.update_target() 35 | assert page.target 36 | assert page.target.title == "Example Domain" 37 | -------------------------------------------------------------------------------- /tests/core/test_multiple_browsers.py: -------------------------------------------------------------------------------- 1 | import zendriver as zd 2 | from tests.conftest import CreateBrowser 3 | 4 | 5 | async def test_multiple_browsers_diff_userdata(create_browser: type[CreateBrowser]): 6 | config = create_browser().config 7 | 8 | browser1 = await zd.start(config) 9 | browser2 = await zd.start(config) 10 | browser3 = await zd.start(config) 11 | 12 | assert not browser1.config.uses_custom_data_dir 13 | assert not browser2.config.uses_custom_data_dir 14 | assert not browser3.config.uses_custom_data_dir 15 | 16 | # make sure ports are unique 17 | ports = {browser1.config.port, browser2.config.port, browser3.config.port} 18 | assert len(ports) == 3 19 | 20 | # make sure user data dirs are unique 21 | udds = { 22 | browser1.config.user_data_dir, 23 | browser2.config.user_data_dir, 24 | browser3.config.user_data_dir, 25 | } 26 | assert len(udds) == 3 27 | 28 | page1 = await browser1.get("https://example.com/one") 29 | await page1 30 | assert page1.target 31 | assert page1.target.title == "Example Domain" 32 | 33 | page2 = await browser2.get("https://example.com/two") 34 | await page2 35 | assert page2.target 36 | assert page2.target.title == "Example Domain" 37 | 38 | page3 = await browser3.get("https://example.com/three") 39 | await page3 40 | assert page3.target 41 | assert page3.target.title == "Example Domain" 42 | 43 | await browser1.stop() 44 | await browser2.stop() 45 | await browser3.stop() 46 | -------------------------------------------------------------------------------- /tests/core/test_tab.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import pytest 4 | 5 | import zendriver as zd 6 | from tests.sample_data import sample_file 7 | 8 | 9 | async def test_set_user_agent_sets_navigator_values(browser: zd.Browser): 10 | tab = browser.main_tab 11 | 12 | await tab.set_user_agent( 13 | "Test user agent", accept_language="testLang", platform="TestPlatform" 14 | ) 15 | 16 | navigator_user_agent = await tab.evaluate("navigator.userAgent") 17 | navigator_language = await tab.evaluate("navigator.language") 18 | navigator_platform = await tab.evaluate("navigator.platform") 19 | assert navigator_user_agent == "Test user agent" 20 | assert navigator_language == "testLang" 21 | assert navigator_platform == "TestPlatform" 22 | 23 | 24 | async def test_set_user_agent_defaults_existing_user_agent(browser: zd.Browser): 25 | tab = browser.main_tab 26 | existing_user_agent = await tab.evaluate("navigator.userAgent") 27 | 28 | await tab.set_user_agent(accept_language="testLang") 29 | 30 | navigator_user_agent = await tab.evaluate("navigator.userAgent") 31 | navigator_language = await tab.evaluate("navigator.language") 32 | assert navigator_user_agent == existing_user_agent 33 | assert navigator_language == "testLang" 34 | 35 | 36 | async def test_find_finds_element_by_text(browser: zd.Browser): 37 | tab = await browser.get(sample_file("groceries.html")) 38 | 39 | result = await tab.find("Apples") 40 | 41 | assert result is not None 42 | assert result.tag == "li" 43 | assert result.text == "Apples" 44 | 45 | 46 | async def test_find_times_out_if_element_not_found(browser: zd.Browser): 47 | tab = await browser.get(sample_file("groceries.html")) 48 | 49 | with pytest.raises(asyncio.TimeoutError): 50 | await tab.find("Clothes", timeout=1) 51 | 52 | 53 | async def test_select(browser: zd.Browser): 54 | tab = await browser.get(sample_file("groceries.html")) 55 | 56 | result = await tab.select("li[aria-label^='Apples']") 57 | 58 | assert result is not None 59 | assert result.tag == "li" 60 | assert result.text == "Apples" 61 | 62 | 63 | async def test_add_handler_type_event(browser: zd.Browser): 64 | tab = await browser.get(sample_file("groceries.html")) 65 | 66 | async def request_handler_1(event): 67 | pass 68 | 69 | async def request_handler_2(event): 70 | pass 71 | 72 | assert len(tab.handlers) == 0 73 | 74 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler_1) 75 | 76 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler_2) 77 | 78 | assert len(tab.handlers) == 1 79 | assert len(tab.handlers[zd.cdp.network.RequestWillBeSent]) == 2 80 | assert tab.handlers[zd.cdp.network.RequestWillBeSent] == [ 81 | request_handler_1, 82 | request_handler_2, 83 | ] 84 | 85 | 86 | async def test_add_handler_module_event(browser: zd.Browser): 87 | tab = await browser.get(sample_file("groceries.html")) 88 | 89 | async def request_handler(event): 90 | pass 91 | 92 | assert len(tab.handlers) == 0 93 | 94 | tab.add_handler(zd.cdp.network, request_handler) 95 | 96 | assert len(tab.handlers) == 27 97 | 98 | 99 | async def test_remove_handlers(browser: zd.Browser): 100 | tab = await browser.get(sample_file("groceries.html")) 101 | 102 | async def request_handler(event): 103 | pass 104 | 105 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler) 106 | assert len(tab.handlers) == 1 107 | 108 | tab.remove_handlers() 109 | assert len(tab.handlers) == 0 110 | 111 | 112 | async def test_remove_handlers_specific_event(browser: zd.Browser): 113 | tab = await browser.get(sample_file("groceries.html")) 114 | 115 | async def request_handler(event): 116 | pass 117 | 118 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler) 119 | assert len(tab.handlers) == 1 120 | 121 | tab.remove_handlers( 122 | zd.cdp.network.RequestWillBeSent, 123 | ) 124 | assert len(tab.handlers) == 0 125 | 126 | 127 | async def test_remove_specific_handler(browser: zd.Browser): 128 | tab = await browser.get(sample_file("groceries.html")) 129 | 130 | async def request_handler_1(event): 131 | pass 132 | 133 | async def request_handler_2(event): 134 | pass 135 | 136 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler_1) 137 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler_2) 138 | assert len(tab.handlers) == 1 139 | assert len(tab.handlers[zd.cdp.network.RequestWillBeSent]) == 2 140 | 141 | tab.remove_handlers(zd.cdp.network.RequestWillBeSent, request_handler_1) 142 | assert len(tab.handlers) == 1 143 | assert len(tab.handlers[zd.cdp.network.RequestWillBeSent]) == 1 144 | 145 | 146 | async def test_remove_handlers_without_event(browser: zd.Browser): 147 | tab = await browser.get(sample_file("groceries.html")) 148 | 149 | async def request_handler(event): 150 | pass 151 | 152 | tab.add_handler(zd.cdp.network.RequestWillBeSent, request_handler) 153 | assert len(tab.handlers) == 1 154 | 155 | with pytest.raises(ValueError) as e: 156 | tab.remove_handlers(handler=request_handler) 157 | assert str(e) == "if handler is provided, event_type should be provided as well" 158 | 159 | 160 | async def test_wait_for_ready_state(browser: zd.Browser): 161 | tab = await browser.get(sample_file("groceries.html")) 162 | 163 | await tab.wait_for_ready_state("complete") 164 | 165 | ready_state = await tab.evaluate("document.readyState") 166 | assert ready_state == "complete" 167 | 168 | 169 | async def test_expect_request(browser: zd.Browser): 170 | tab = browser.main_tab 171 | 172 | async with tab.expect_request(sample_file("groceries.html")) as request_info: 173 | await tab.get(sample_file("groceries.html")) 174 | req = await asyncio.wait_for(request_info.value, timeout=3) 175 | assert type(req) is zd.cdp.network.RequestWillBeSent 176 | assert type(req.request) is zd.cdp.network.Request 177 | assert req.request.url == sample_file("groceries.html") 178 | assert req.request_id is not None 179 | 180 | response_body = await request_info.response_body 181 | assert response_body is not None 182 | assert type(response_body) is tuple 183 | 184 | 185 | async def test_expect_response(browser: zd.Browser): 186 | tab = browser.main_tab 187 | 188 | async with tab.expect_response(sample_file("groceries.html")) as response_info: 189 | await tab.get(sample_file("groceries.html")) 190 | resp = await asyncio.wait_for(response_info.value, timeout=3) 191 | assert type(resp) is zd.cdp.network.ResponseReceived 192 | assert type(resp.response) is zd.cdp.network.Response 193 | assert resp.request_id is not None 194 | 195 | response_body = await response_info.response_body 196 | assert response_body is not None 197 | assert type(response_body) is tuple 198 | 199 | 200 | async def test_expect_download(browser: zd.Browser): 201 | tab = browser.main_tab 202 | 203 | async with tab.expect_download() as download_ex: 204 | await tab.get(sample_file("groceries.html")) 205 | await (await tab.select("#download_file")).click() 206 | download = await asyncio.wait_for(download_ex.value, timeout=3) 207 | assert type(download) is zd.cdp.browser.DownloadWillBegin 208 | assert download.url is not None 209 | -------------------------------------------------------------------------------- /tests/docs/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.util 2 | import sys 3 | from pathlib import Path 4 | from types import ModuleType 5 | 6 | 7 | def import_from_path(file_path: Path | str) -> ModuleType: 8 | if isinstance(file_path, str): 9 | file_path = Path(file_path) 10 | 11 | module_name = file_path.stem 12 | spec = importlib.util.spec_from_file_location(module_name, file_path) 13 | if spec is None or spec.loader is None: 14 | raise ImportError(f"Cannot find module {module_name}") 15 | 16 | module = importlib.util.module_from_spec(spec) 17 | sys.modules[module_name] = module 18 | spec.loader.exec_module(module) 19 | return module 20 | -------------------------------------------------------------------------------- /tests/docs/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | from pytest_mock import MockerFixture 5 | 6 | from zendriver.core.browser import Browser 7 | 8 | 9 | @pytest.fixture 10 | def mock_print(mocker: MockerFixture) -> Mock: 11 | return mocker.patch("builtins.print") 12 | 13 | 14 | @pytest.fixture 15 | def mock_start(mocker: MockerFixture, browser: Browser) -> Mock: 16 | return mocker.patch("zendriver.start", return_value=browser) 17 | -------------------------------------------------------------------------------- /tests/docs/tutorials/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/tests/docs/tutorials/__init__.py -------------------------------------------------------------------------------- /tests/docs/tutorials/test_account_creation_tutorial.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.docs import import_from_path 6 | 7 | 8 | async def test_account_creation_tutorial_1( 9 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 10 | ) -> None: 11 | module = import_from_path("docs/tutorials/tutorial-code/account-creation-1.py") 12 | 13 | await module.main() # just loading the page 14 | 15 | 16 | async def test_account_creation_tutorial_2( 17 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 18 | ) -> None: 19 | module = import_from_path("docs/tutorials/tutorial-code/account-creation-2.py") 20 | 21 | await module.main() 22 | 23 | mock_print.assert_has_calls( 24 | [ 25 | mocker.call("Login successful"), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /tests/docs/tutorials/test_api_responses_tutorial.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.docs import import_from_path 6 | 7 | 8 | async def test_api_responses_tutorial_1( 9 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 10 | ) -> None: 11 | module = import_from_path("docs/tutorials/tutorial-code/api-responses-1.py") 12 | 13 | await module.main() 14 | 15 | 16 | async def test_api_responses_tutorial_2( 17 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 18 | ) -> None: 19 | module = import_from_path("docs/tutorials/tutorial-code/api-responses-2.py") 20 | 21 | await module.main() 22 | 23 | mock_print.assert_any_call( 24 | "Successfully read user data response for user:", "Zendriver" 25 | ) 26 | -------------------------------------------------------------------------------- /tests/docs/tutorials/test_infinite_scrolling_tutorial.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | from pytest_mock import MockerFixture 4 | 5 | from tests.docs import import_from_path 6 | 7 | 8 | async def test_infinite_scrolling_tutorial_1( 9 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 10 | ) -> None: 11 | module = import_from_path("docs/tutorials/tutorial-code/infinite-scrolling-1.py") 12 | 13 | await module.main() 14 | 15 | mock_print.assert_called_once_with([]) 16 | 17 | 18 | async def test_infinite_scrolling_tutorial_2( 19 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 20 | ) -> None: 21 | module = import_from_path("docs/tutorials/tutorial-code/infinite-scrolling-2.py") 22 | 23 | await module.main() 24 | 25 | mock_print.assert_has_calls( 26 | [mocker.call(f"Card {i}") for i in range(1, 11)], 27 | ) 28 | 29 | 30 | async def test_infinite_scrolling_tutorial_3( 31 | mocker: MockerFixture, mock_print: Mock, mock_start: Mock 32 | ) -> None: 33 | module = import_from_path("docs/tutorials/tutorial-code/infinite-scrolling-3.py") 34 | 35 | await module.main() 36 | 37 | mock_print.assert_has_calls( 38 | [ 39 | mocker.call("Loaded new cards. Current count:", 10), 40 | mocker.call("Loaded new cards. Current count:", 20), 41 | mocker.call("Loaded new cards. Current count:", 30), 42 | mocker.call("Lucky card found: Card 27"), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /tests/next_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to be run by "Next test" hotkey (default Mod+Return) configured in Dockerfile. 3 | # 4 | # Sends a SIGUSR1 signal to the pytest process to trigger the next test to run. 5 | # (Applies only when ZENDRIVER_PAUSE_AFTER_TEST env variable is set to true) 6 | 7 | set -e 8 | 9 | pkill -USR1 -f "/app/.venv/bin/python /app/.venv/bin/pytest tests" 10 | -------------------------------------------------------------------------------- /tests/sample_data/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def sample_file(name: str) -> str: 5 | path = (Path(__file__).parent / name).resolve() 6 | return path.as_uri() 7 | -------------------------------------------------------------------------------- /tests/sample_data/groceries.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Grocery List 5 | 26 | 27 | 28 | 29 |

Grocery List

30 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /zendriver/__init__.py: -------------------------------------------------------------------------------- 1 | from zendriver import cdp 2 | from zendriver._version import __version__ 3 | from zendriver.core import util 4 | from zendriver.core._contradict import ( 5 | ContraDict, # noqa 6 | cdict, 7 | ) 8 | from zendriver.core.browser import Browser 9 | from zendriver.core.config import Config 10 | from zendriver.core.connection import Connection 11 | from zendriver.core.element import Element 12 | from zendriver.core.tab import Tab 13 | from zendriver.core.util import loop, start 14 | 15 | __all__ = [ 16 | "__version__", 17 | "loop", 18 | "Browser", 19 | "Tab", 20 | "cdp", 21 | "Config", 22 | "start", 23 | "util", 24 | "Element", 25 | "ContraDict", 26 | "cdict", 27 | "Connection", 28 | ] 29 | -------------------------------------------------------------------------------- /zendriver/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.8.0" 2 | -------------------------------------------------------------------------------- /zendriver/cdp/README.md: -------------------------------------------------------------------------------- 1 | ## Generated by PyCDP 2 | 3 | The modules of this package were generated by [pycdp], do not modify their contents because the 4 | changes will be overwritten in next generations. 5 | -------------------------------------------------------------------------------- /zendriver/cdp/__init__.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | 6 | from . import ( 7 | accessibility, 8 | animation, 9 | audits, 10 | autofill, 11 | background_service, 12 | bluetooth_emulation, 13 | browser, 14 | css, 15 | cache_storage, 16 | cast, 17 | console, 18 | dom, 19 | dom_debugger, 20 | dom_snapshot, 21 | dom_storage, 22 | debugger, 23 | device_access, 24 | device_orientation, 25 | emulation, 26 | event_breakpoints, 27 | extensions, 28 | fed_cm, 29 | fetch, 30 | file_system, 31 | headless_experimental, 32 | heap_profiler, 33 | io, 34 | indexed_db, 35 | input_, 36 | inspector, 37 | layer_tree, 38 | log, 39 | media, 40 | memory, 41 | network, 42 | overlay, 43 | pwa, 44 | page, 45 | performance, 46 | performance_timeline, 47 | preload, 48 | profiler, 49 | runtime, 50 | schema, 51 | security, 52 | service_worker, 53 | storage, 54 | system_info, 55 | target, 56 | tethering, 57 | tracing, 58 | web_audio, 59 | web_authn, 60 | ) 61 | -------------------------------------------------------------------------------- /zendriver/cdp/autofill.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Autofill (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import dom 15 | from . import page 16 | 17 | 18 | @dataclass 19 | class CreditCard: 20 | #: 16-digit credit card number. 21 | number: str 22 | 23 | #: Name of the credit card owner. 24 | name: str 25 | 26 | #: 2-digit expiry month. 27 | expiry_month: str 28 | 29 | #: 4-digit expiry year. 30 | expiry_year: str 31 | 32 | #: 3-digit card verification code. 33 | cvc: str 34 | 35 | def to_json(self) -> T_JSON_DICT: 36 | json: T_JSON_DICT = dict() 37 | json["number"] = self.number 38 | json["name"] = self.name 39 | json["expiryMonth"] = self.expiry_month 40 | json["expiryYear"] = self.expiry_year 41 | json["cvc"] = self.cvc 42 | return json 43 | 44 | @classmethod 45 | def from_json(cls, json: T_JSON_DICT) -> CreditCard: 46 | return cls( 47 | number=str(json["number"]), 48 | name=str(json["name"]), 49 | expiry_month=str(json["expiryMonth"]), 50 | expiry_year=str(json["expiryYear"]), 51 | cvc=str(json["cvc"]), 52 | ) 53 | 54 | 55 | @dataclass 56 | class AddressField: 57 | #: address field name, for example GIVEN_NAME. 58 | name: str 59 | 60 | #: address field value, for example Jon Doe. 61 | value: str 62 | 63 | def to_json(self) -> T_JSON_DICT: 64 | json: T_JSON_DICT = dict() 65 | json["name"] = self.name 66 | json["value"] = self.value 67 | return json 68 | 69 | @classmethod 70 | def from_json(cls, json: T_JSON_DICT) -> AddressField: 71 | return cls( 72 | name=str(json["name"]), 73 | value=str(json["value"]), 74 | ) 75 | 76 | 77 | @dataclass 78 | class AddressFields: 79 | """ 80 | A list of address fields. 81 | """ 82 | 83 | fields: typing.List[AddressField] 84 | 85 | def to_json(self) -> T_JSON_DICT: 86 | json: T_JSON_DICT = dict() 87 | json["fields"] = [i.to_json() for i in self.fields] 88 | return json 89 | 90 | @classmethod 91 | def from_json(cls, json: T_JSON_DICT) -> AddressFields: 92 | return cls( 93 | fields=[AddressField.from_json(i) for i in json["fields"]], 94 | ) 95 | 96 | 97 | @dataclass 98 | class Address: 99 | #: fields and values defining an address. 100 | fields: typing.List[AddressField] 101 | 102 | def to_json(self) -> T_JSON_DICT: 103 | json: T_JSON_DICT = dict() 104 | json["fields"] = [i.to_json() for i in self.fields] 105 | return json 106 | 107 | @classmethod 108 | def from_json(cls, json: T_JSON_DICT) -> Address: 109 | return cls( 110 | fields=[AddressField.from_json(i) for i in json["fields"]], 111 | ) 112 | 113 | 114 | @dataclass 115 | class AddressUI: 116 | """ 117 | Defines how an address can be displayed like in chrome://settings/addresses. 118 | Address UI is a two dimensional array, each inner array is an "address information line", and when rendered in a UI surface should be displayed as such. 119 | The following address UI for instance: 120 | [[{name: "GIVE_NAME", value: "Jon"}, {name: "FAMILY_NAME", value: "Doe"}], [{name: "CITY", value: "Munich"}, {name: "ZIP", value: "81456"}]] 121 | should allow the receiver to render: 122 | Jon Doe 123 | Munich 81456 124 | """ 125 | 126 | #: A two dimension array containing the representation of values from an address profile. 127 | address_fields: typing.List[AddressFields] 128 | 129 | def to_json(self) -> T_JSON_DICT: 130 | json: T_JSON_DICT = dict() 131 | json["addressFields"] = [i.to_json() for i in self.address_fields] 132 | return json 133 | 134 | @classmethod 135 | def from_json(cls, json: T_JSON_DICT) -> AddressUI: 136 | return cls( 137 | address_fields=[AddressFields.from_json(i) for i in json["addressFields"]], 138 | ) 139 | 140 | 141 | class FillingStrategy(enum.Enum): 142 | """ 143 | Specified whether a filled field was done so by using the html autocomplete attribute or autofill heuristics. 144 | """ 145 | 146 | AUTOCOMPLETE_ATTRIBUTE = "autocompleteAttribute" 147 | AUTOFILL_INFERRED = "autofillInferred" 148 | 149 | def to_json(self) -> str: 150 | return self.value 151 | 152 | @classmethod 153 | def from_json(cls, json: str) -> FillingStrategy: 154 | return cls(json) 155 | 156 | 157 | @dataclass 158 | class FilledField: 159 | #: The type of the field, e.g text, password etc. 160 | html_type: str 161 | 162 | #: the html id 163 | id_: str 164 | 165 | #: the html name 166 | name: str 167 | 168 | #: the field value 169 | value: str 170 | 171 | #: The actual field type, e.g FAMILY_NAME 172 | autofill_type: str 173 | 174 | #: The filling strategy 175 | filling_strategy: FillingStrategy 176 | 177 | #: The frame the field belongs to 178 | frame_id: page.FrameId 179 | 180 | #: The form field's DOM node 181 | field_id: dom.BackendNodeId 182 | 183 | def to_json(self) -> T_JSON_DICT: 184 | json: T_JSON_DICT = dict() 185 | json["htmlType"] = self.html_type 186 | json["id"] = self.id_ 187 | json["name"] = self.name 188 | json["value"] = self.value 189 | json["autofillType"] = self.autofill_type 190 | json["fillingStrategy"] = self.filling_strategy.to_json() 191 | json["frameId"] = self.frame_id.to_json() 192 | json["fieldId"] = self.field_id.to_json() 193 | return json 194 | 195 | @classmethod 196 | def from_json(cls, json: T_JSON_DICT) -> FilledField: 197 | return cls( 198 | html_type=str(json["htmlType"]), 199 | id_=str(json["id"]), 200 | name=str(json["name"]), 201 | value=str(json["value"]), 202 | autofill_type=str(json["autofillType"]), 203 | filling_strategy=FillingStrategy.from_json(json["fillingStrategy"]), 204 | frame_id=page.FrameId.from_json(json["frameId"]), 205 | field_id=dom.BackendNodeId.from_json(json["fieldId"]), 206 | ) 207 | 208 | 209 | def trigger( 210 | field_id: dom.BackendNodeId, 211 | card: CreditCard, 212 | frame_id: typing.Optional[page.FrameId] = None, 213 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 214 | """ 215 | Trigger autofill on a form identified by the fieldId. 216 | If the field and related form cannot be autofilled, returns an error. 217 | 218 | :param field_id: Identifies a field that serves as an anchor for autofill. 219 | :param frame_id: *(Optional)* Identifies the frame that field belongs to. 220 | :param card: Credit card information to fill out the form. Credit card data is not saved. 221 | """ 222 | params: T_JSON_DICT = dict() 223 | params["fieldId"] = field_id.to_json() 224 | if frame_id is not None: 225 | params["frameId"] = frame_id.to_json() 226 | params["card"] = card.to_json() 227 | cmd_dict: T_JSON_DICT = { 228 | "method": "Autofill.trigger", 229 | "params": params, 230 | } 231 | json = yield cmd_dict 232 | 233 | 234 | def set_addresses( 235 | addresses: typing.List[Address], 236 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 237 | """ 238 | Set addresses so that developers can verify their forms implementation. 239 | 240 | :param addresses: 241 | """ 242 | params: T_JSON_DICT = dict() 243 | params["addresses"] = [i.to_json() for i in addresses] 244 | cmd_dict: T_JSON_DICT = { 245 | "method": "Autofill.setAddresses", 246 | "params": params, 247 | } 248 | json = yield cmd_dict 249 | 250 | 251 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 252 | """ 253 | Disables autofill domain notifications. 254 | """ 255 | cmd_dict: T_JSON_DICT = { 256 | "method": "Autofill.disable", 257 | } 258 | json = yield cmd_dict 259 | 260 | 261 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 262 | """ 263 | Enables autofill domain notifications. 264 | """ 265 | cmd_dict: T_JSON_DICT = { 266 | "method": "Autofill.enable", 267 | } 268 | json = yield cmd_dict 269 | 270 | 271 | @event_class("Autofill.addressFormFilled") 272 | @dataclass 273 | class AddressFormFilled: 274 | """ 275 | Emitted when an address form is filled. 276 | """ 277 | 278 | #: Information about the fields that were filled 279 | filled_fields: typing.List[FilledField] 280 | #: An UI representation of the address used to fill the form. 281 | #: Consists of a 2D array where each child represents an address/profile line. 282 | address_ui: AddressUI 283 | 284 | @classmethod 285 | def from_json(cls, json: T_JSON_DICT) -> AddressFormFilled: 286 | return cls( 287 | filled_fields=[FilledField.from_json(i) for i in json["filledFields"]], 288 | address_ui=AddressUI.from_json(json["addressUi"]), 289 | ) 290 | -------------------------------------------------------------------------------- /zendriver/cdp/background_service.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: BackgroundService (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import network 15 | from . import service_worker 16 | 17 | 18 | class ServiceName(enum.Enum): 19 | """ 20 | The Background Service that will be associated with the commands/events. 21 | Every Background Service operates independently, but they share the same 22 | API. 23 | """ 24 | 25 | BACKGROUND_FETCH = "backgroundFetch" 26 | BACKGROUND_SYNC = "backgroundSync" 27 | PUSH_MESSAGING = "pushMessaging" 28 | NOTIFICATIONS = "notifications" 29 | PAYMENT_HANDLER = "paymentHandler" 30 | PERIODIC_BACKGROUND_SYNC = "periodicBackgroundSync" 31 | 32 | def to_json(self) -> str: 33 | return self.value 34 | 35 | @classmethod 36 | def from_json(cls, json: str) -> ServiceName: 37 | return cls(json) 38 | 39 | 40 | @dataclass 41 | class EventMetadata: 42 | """ 43 | A key-value pair for additional event information to pass along. 44 | """ 45 | 46 | key: str 47 | 48 | value: str 49 | 50 | def to_json(self) -> T_JSON_DICT: 51 | json: T_JSON_DICT = dict() 52 | json["key"] = self.key 53 | json["value"] = self.value 54 | return json 55 | 56 | @classmethod 57 | def from_json(cls, json: T_JSON_DICT) -> EventMetadata: 58 | return cls( 59 | key=str(json["key"]), 60 | value=str(json["value"]), 61 | ) 62 | 63 | 64 | @dataclass 65 | class BackgroundServiceEvent: 66 | #: Timestamp of the event (in seconds). 67 | timestamp: network.TimeSinceEpoch 68 | 69 | #: The origin this event belongs to. 70 | origin: str 71 | 72 | #: The Service Worker ID that initiated the event. 73 | service_worker_registration_id: service_worker.RegistrationID 74 | 75 | #: The Background Service this event belongs to. 76 | service: ServiceName 77 | 78 | #: A description of the event. 79 | event_name: str 80 | 81 | #: An identifier that groups related events together. 82 | instance_id: str 83 | 84 | #: A list of event-specific information. 85 | event_metadata: typing.List[EventMetadata] 86 | 87 | #: Storage key this event belongs to. 88 | storage_key: str 89 | 90 | def to_json(self) -> T_JSON_DICT: 91 | json: T_JSON_DICT = dict() 92 | json["timestamp"] = self.timestamp.to_json() 93 | json["origin"] = self.origin 94 | json["serviceWorkerRegistrationId"] = ( 95 | self.service_worker_registration_id.to_json() 96 | ) 97 | json["service"] = self.service.to_json() 98 | json["eventName"] = self.event_name 99 | json["instanceId"] = self.instance_id 100 | json["eventMetadata"] = [i.to_json() for i in self.event_metadata] 101 | json["storageKey"] = self.storage_key 102 | return json 103 | 104 | @classmethod 105 | def from_json(cls, json: T_JSON_DICT) -> BackgroundServiceEvent: 106 | return cls( 107 | timestamp=network.TimeSinceEpoch.from_json(json["timestamp"]), 108 | origin=str(json["origin"]), 109 | service_worker_registration_id=service_worker.RegistrationID.from_json( 110 | json["serviceWorkerRegistrationId"] 111 | ), 112 | service=ServiceName.from_json(json["service"]), 113 | event_name=str(json["eventName"]), 114 | instance_id=str(json["instanceId"]), 115 | event_metadata=[EventMetadata.from_json(i) for i in json["eventMetadata"]], 116 | storage_key=str(json["storageKey"]), 117 | ) 118 | 119 | 120 | def start_observing( 121 | service: ServiceName, 122 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 123 | """ 124 | Enables event updates for the service. 125 | 126 | :param service: 127 | """ 128 | params: T_JSON_DICT = dict() 129 | params["service"] = service.to_json() 130 | cmd_dict: T_JSON_DICT = { 131 | "method": "BackgroundService.startObserving", 132 | "params": params, 133 | } 134 | json = yield cmd_dict 135 | 136 | 137 | def stop_observing( 138 | service: ServiceName, 139 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 140 | """ 141 | Disables event updates for the service. 142 | 143 | :param service: 144 | """ 145 | params: T_JSON_DICT = dict() 146 | params["service"] = service.to_json() 147 | cmd_dict: T_JSON_DICT = { 148 | "method": "BackgroundService.stopObserving", 149 | "params": params, 150 | } 151 | json = yield cmd_dict 152 | 153 | 154 | def set_recording( 155 | should_record: bool, service: ServiceName 156 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 157 | """ 158 | Set the recording state for the service. 159 | 160 | :param should_record: 161 | :param service: 162 | """ 163 | params: T_JSON_DICT = dict() 164 | params["shouldRecord"] = should_record 165 | params["service"] = service.to_json() 166 | cmd_dict: T_JSON_DICT = { 167 | "method": "BackgroundService.setRecording", 168 | "params": params, 169 | } 170 | json = yield cmd_dict 171 | 172 | 173 | def clear_events( 174 | service: ServiceName, 175 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 176 | """ 177 | Clears all stored data for the service. 178 | 179 | :param service: 180 | """ 181 | params: T_JSON_DICT = dict() 182 | params["service"] = service.to_json() 183 | cmd_dict: T_JSON_DICT = { 184 | "method": "BackgroundService.clearEvents", 185 | "params": params, 186 | } 187 | json = yield cmd_dict 188 | 189 | 190 | @event_class("BackgroundService.recordingStateChanged") 191 | @dataclass 192 | class RecordingStateChanged: 193 | """ 194 | Called when the recording state for the service has been updated. 195 | """ 196 | 197 | is_recording: bool 198 | service: ServiceName 199 | 200 | @classmethod 201 | def from_json(cls, json: T_JSON_DICT) -> RecordingStateChanged: 202 | return cls( 203 | is_recording=bool(json["isRecording"]), 204 | service=ServiceName.from_json(json["service"]), 205 | ) 206 | 207 | 208 | @event_class("BackgroundService.backgroundServiceEventReceived") 209 | @dataclass 210 | class BackgroundServiceEventReceived: 211 | """ 212 | Called with all existing backgroundServiceEvents when enabled, and all new 213 | events afterwards if enabled and recording. 214 | """ 215 | 216 | background_service_event: BackgroundServiceEvent 217 | 218 | @classmethod 219 | def from_json(cls, json: T_JSON_DICT) -> BackgroundServiceEventReceived: 220 | return cls( 221 | background_service_event=BackgroundServiceEvent.from_json( 222 | json["backgroundServiceEvent"] 223 | ) 224 | ) 225 | -------------------------------------------------------------------------------- /zendriver/cdp/cast.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Cast (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | @dataclass 16 | class Sink: 17 | name: str 18 | 19 | id_: str 20 | 21 | #: Text describing the current session. Present only if there is an active 22 | #: session on the sink. 23 | session: typing.Optional[str] = None 24 | 25 | def to_json(self) -> T_JSON_DICT: 26 | json: T_JSON_DICT = dict() 27 | json["name"] = self.name 28 | json["id"] = self.id_ 29 | if self.session is not None: 30 | json["session"] = self.session 31 | return json 32 | 33 | @classmethod 34 | def from_json(cls, json: T_JSON_DICT) -> Sink: 35 | return cls( 36 | name=str(json["name"]), 37 | id_=str(json["id"]), 38 | session=str(json["session"]) 39 | if json.get("session", None) is not None 40 | else None, 41 | ) 42 | 43 | 44 | def enable( 45 | presentation_url: typing.Optional[str] = None, 46 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 47 | """ 48 | Starts observing for sinks that can be used for tab mirroring, and if set, 49 | sinks compatible with ``presentationUrl`` as well. When sinks are found, a 50 | ``sinksUpdated`` event is fired. 51 | Also starts observing for issue messages. When an issue is added or removed, 52 | an ``issueUpdated`` event is fired. 53 | 54 | :param presentation_url: *(Optional)* 55 | """ 56 | params: T_JSON_DICT = dict() 57 | if presentation_url is not None: 58 | params["presentationUrl"] = presentation_url 59 | cmd_dict: T_JSON_DICT = { 60 | "method": "Cast.enable", 61 | "params": params, 62 | } 63 | json = yield cmd_dict 64 | 65 | 66 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 67 | """ 68 | Stops observing for sinks and issues. 69 | """ 70 | cmd_dict: T_JSON_DICT = { 71 | "method": "Cast.disable", 72 | } 73 | json = yield cmd_dict 74 | 75 | 76 | def set_sink_to_use(sink_name: str) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 77 | """ 78 | Sets a sink to be used when the web page requests the browser to choose a 79 | sink via Presentation API, Remote Playback API, or Cast SDK. 80 | 81 | :param sink_name: 82 | """ 83 | params: T_JSON_DICT = dict() 84 | params["sinkName"] = sink_name 85 | cmd_dict: T_JSON_DICT = { 86 | "method": "Cast.setSinkToUse", 87 | "params": params, 88 | } 89 | json = yield cmd_dict 90 | 91 | 92 | def start_desktop_mirroring( 93 | sink_name: str, 94 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 95 | """ 96 | Starts mirroring the desktop to the sink. 97 | 98 | :param sink_name: 99 | """ 100 | params: T_JSON_DICT = dict() 101 | params["sinkName"] = sink_name 102 | cmd_dict: T_JSON_DICT = { 103 | "method": "Cast.startDesktopMirroring", 104 | "params": params, 105 | } 106 | json = yield cmd_dict 107 | 108 | 109 | def start_tab_mirroring( 110 | sink_name: str, 111 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 112 | """ 113 | Starts mirroring the tab to the sink. 114 | 115 | :param sink_name: 116 | """ 117 | params: T_JSON_DICT = dict() 118 | params["sinkName"] = sink_name 119 | cmd_dict: T_JSON_DICT = { 120 | "method": "Cast.startTabMirroring", 121 | "params": params, 122 | } 123 | json = yield cmd_dict 124 | 125 | 126 | def stop_casting(sink_name: str) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 127 | """ 128 | Stops the active Cast session on the sink. 129 | 130 | :param sink_name: 131 | """ 132 | params: T_JSON_DICT = dict() 133 | params["sinkName"] = sink_name 134 | cmd_dict: T_JSON_DICT = { 135 | "method": "Cast.stopCasting", 136 | "params": params, 137 | } 138 | json = yield cmd_dict 139 | 140 | 141 | @event_class("Cast.sinksUpdated") 142 | @dataclass 143 | class SinksUpdated: 144 | """ 145 | This is fired whenever the list of available sinks changes. A sink is a 146 | device or a software surface that you can cast to. 147 | """ 148 | 149 | sinks: typing.List[Sink] 150 | 151 | @classmethod 152 | def from_json(cls, json: T_JSON_DICT) -> SinksUpdated: 153 | return cls(sinks=[Sink.from_json(i) for i in json["sinks"]]) 154 | 155 | 156 | @event_class("Cast.issueUpdated") 157 | @dataclass 158 | class IssueUpdated: 159 | """ 160 | This is fired whenever the outstanding issue/error message changes. 161 | ``issueMessage`` is empty if there is no issue. 162 | """ 163 | 164 | issue_message: str 165 | 166 | @classmethod 167 | def from_json(cls, json: T_JSON_DICT) -> IssueUpdated: 168 | return cls(issue_message=str(json["issueMessage"])) 169 | -------------------------------------------------------------------------------- /zendriver/cdp/console.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Console 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | @dataclass 16 | class ConsoleMessage: 17 | """ 18 | Console message. 19 | """ 20 | 21 | #: Message source. 22 | source: str 23 | 24 | #: Message severity. 25 | level: str 26 | 27 | #: Message text. 28 | text: str 29 | 30 | #: URL of the message origin. 31 | url: typing.Optional[str] = None 32 | 33 | #: Line number in the resource that generated this message (1-based). 34 | line: typing.Optional[int] = None 35 | 36 | #: Column number in the resource that generated this message (1-based). 37 | column: typing.Optional[int] = None 38 | 39 | def to_json(self) -> T_JSON_DICT: 40 | json: T_JSON_DICT = dict() 41 | json["source"] = self.source 42 | json["level"] = self.level 43 | json["text"] = self.text 44 | if self.url is not None: 45 | json["url"] = self.url 46 | if self.line is not None: 47 | json["line"] = self.line 48 | if self.column is not None: 49 | json["column"] = self.column 50 | return json 51 | 52 | @classmethod 53 | def from_json(cls, json: T_JSON_DICT) -> ConsoleMessage: 54 | return cls( 55 | source=str(json["source"]), 56 | level=str(json["level"]), 57 | text=str(json["text"]), 58 | url=str(json["url"]) if json.get("url", None) is not None else None, 59 | line=int(json["line"]) if json.get("line", None) is not None else None, 60 | column=int(json["column"]) 61 | if json.get("column", None) is not None 62 | else None, 63 | ) 64 | 65 | 66 | def clear_messages() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 67 | """ 68 | Does nothing. 69 | """ 70 | cmd_dict: T_JSON_DICT = { 71 | "method": "Console.clearMessages", 72 | } 73 | json = yield cmd_dict 74 | 75 | 76 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 77 | """ 78 | Disables console domain, prevents further console messages from being reported to the client. 79 | """ 80 | cmd_dict: T_JSON_DICT = { 81 | "method": "Console.disable", 82 | } 83 | json = yield cmd_dict 84 | 85 | 86 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 87 | """ 88 | Enables console domain, sends the messages collected so far to the client by means of the 89 | ``messageAdded`` notification. 90 | """ 91 | cmd_dict: T_JSON_DICT = { 92 | "method": "Console.enable", 93 | } 94 | json = yield cmd_dict 95 | 96 | 97 | @event_class("Console.messageAdded") 98 | @dataclass 99 | class MessageAdded: 100 | """ 101 | Issued when new console message is added. 102 | """ 103 | 104 | #: Console message that has been added. 105 | message: ConsoleMessage 106 | 107 | @classmethod 108 | def from_json(cls, json: T_JSON_DICT) -> MessageAdded: 109 | return cls(message=ConsoleMessage.from_json(json["message"])) 110 | -------------------------------------------------------------------------------- /zendriver/cdp/database.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Database (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class DatabaseId(str): 16 | """ 17 | Unique identifier of Database object. 18 | """ 19 | 20 | def to_json(self) -> str: 21 | return self 22 | 23 | @classmethod 24 | def from_json(cls, json: str) -> DatabaseId: 25 | return cls(json) 26 | 27 | def __repr__(self): 28 | return "DatabaseId({})".format(super().__repr__()) 29 | 30 | 31 | @dataclass 32 | class Database: 33 | """ 34 | Database object. 35 | """ 36 | 37 | #: Database ID. 38 | id_: DatabaseId 39 | 40 | #: Database domain. 41 | domain: str 42 | 43 | #: Database name. 44 | name: str 45 | 46 | #: Database version. 47 | version: str 48 | 49 | def to_json(self) -> T_JSON_DICT: 50 | json: T_JSON_DICT = dict() 51 | json["id"] = self.id_.to_json() 52 | json["domain"] = self.domain 53 | json["name"] = self.name 54 | json["version"] = self.version 55 | return json 56 | 57 | @classmethod 58 | def from_json(cls, json: T_JSON_DICT) -> Database: 59 | return cls( 60 | id_=DatabaseId.from_json(json["id"]), 61 | domain=str(json["domain"]), 62 | name=str(json["name"]), 63 | version=str(json["version"]), 64 | ) 65 | 66 | 67 | @dataclass 68 | class Error: 69 | """ 70 | Database error. 71 | """ 72 | 73 | #: Error message. 74 | message: str 75 | 76 | #: Error code. 77 | code: int 78 | 79 | def to_json(self) -> T_JSON_DICT: 80 | json: T_JSON_DICT = dict() 81 | json["message"] = self.message 82 | json["code"] = self.code 83 | return json 84 | 85 | @classmethod 86 | def from_json(cls, json: T_JSON_DICT) -> Error: 87 | return cls( 88 | message=str(json["message"]), 89 | code=int(json["code"]), 90 | ) 91 | 92 | 93 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 94 | """ 95 | Disables database tracking, prevents database events from being sent to the client. 96 | """ 97 | cmd_dict: T_JSON_DICT = { 98 | "method": "Database.disable", 99 | } 100 | json = yield cmd_dict 101 | 102 | 103 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 104 | """ 105 | Enables database tracking, database events will now be delivered to the client. 106 | """ 107 | cmd_dict: T_JSON_DICT = { 108 | "method": "Database.enable", 109 | } 110 | json = yield cmd_dict 111 | 112 | 113 | def execute_sql( 114 | database_id: DatabaseId, query: str 115 | ) -> typing.Generator[ 116 | T_JSON_DICT, 117 | T_JSON_DICT, 118 | typing.Tuple[ 119 | typing.Optional[typing.List[str]], 120 | typing.Optional[typing.List[typing.Any]], 121 | typing.Optional[Error], 122 | ], 123 | ]: 124 | """ 125 | :param database_id: 126 | :param query: 127 | :returns: A tuple with the following items: 128 | 129 | 0. **columnNames** - 130 | 1. **values** - 131 | 2. **sqlError** - 132 | """ 133 | params: T_JSON_DICT = dict() 134 | params["databaseId"] = database_id.to_json() 135 | params["query"] = query 136 | cmd_dict: T_JSON_DICT = { 137 | "method": "Database.executeSQL", 138 | "params": params, 139 | } 140 | json = yield cmd_dict 141 | return ( 142 | [str(i) for i in json["columnNames"]] 143 | if json.get("columnNames", None) is not None 144 | else None, 145 | [i for i in json["values"]] if json.get("values", None) is not None else None, 146 | Error.from_json(json["sqlError"]) 147 | if json.get("sqlError", None) is not None 148 | else None, 149 | ) 150 | 151 | 152 | def get_database_table_names( 153 | database_id: DatabaseId, 154 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, typing.List[str]]: 155 | """ 156 | :param database_id: 157 | :returns: 158 | """ 159 | params: T_JSON_DICT = dict() 160 | params["databaseId"] = database_id.to_json() 161 | cmd_dict: T_JSON_DICT = { 162 | "method": "Database.getDatabaseTableNames", 163 | "params": params, 164 | } 165 | json = yield cmd_dict 166 | return [str(i) for i in json["tableNames"]] 167 | 168 | 169 | @event_class("Database.addDatabase") 170 | @dataclass 171 | class AddDatabase: 172 | database: Database 173 | 174 | @classmethod 175 | def from_json(cls, json: T_JSON_DICT) -> AddDatabase: 176 | return cls(database=Database.from_json(json["database"])) 177 | -------------------------------------------------------------------------------- /zendriver/cdp/device_access.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: DeviceAccess (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class RequestId(str): 16 | """ 17 | Device request id. 18 | """ 19 | 20 | def to_json(self) -> str: 21 | return self 22 | 23 | @classmethod 24 | def from_json(cls, json: str) -> RequestId: 25 | return cls(json) 26 | 27 | def __repr__(self): 28 | return "RequestId({})".format(super().__repr__()) 29 | 30 | 31 | class DeviceId(str): 32 | """ 33 | A device id. 34 | """ 35 | 36 | def to_json(self) -> str: 37 | return self 38 | 39 | @classmethod 40 | def from_json(cls, json: str) -> DeviceId: 41 | return cls(json) 42 | 43 | def __repr__(self): 44 | return "DeviceId({})".format(super().__repr__()) 45 | 46 | 47 | @dataclass 48 | class PromptDevice: 49 | """ 50 | Device information displayed in a user prompt to select a device. 51 | """ 52 | 53 | id_: DeviceId 54 | 55 | #: Display name as it appears in a device request user prompt. 56 | name: str 57 | 58 | def to_json(self) -> T_JSON_DICT: 59 | json: T_JSON_DICT = dict() 60 | json["id"] = self.id_.to_json() 61 | json["name"] = self.name 62 | return json 63 | 64 | @classmethod 65 | def from_json(cls, json: T_JSON_DICT) -> PromptDevice: 66 | return cls( 67 | id_=DeviceId.from_json(json["id"]), 68 | name=str(json["name"]), 69 | ) 70 | 71 | 72 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 73 | """ 74 | Enable events in this domain. 75 | """ 76 | cmd_dict: T_JSON_DICT = { 77 | "method": "DeviceAccess.enable", 78 | } 79 | json = yield cmd_dict 80 | 81 | 82 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 83 | """ 84 | Disable events in this domain. 85 | """ 86 | cmd_dict: T_JSON_DICT = { 87 | "method": "DeviceAccess.disable", 88 | } 89 | json = yield cmd_dict 90 | 91 | 92 | def select_prompt( 93 | id_: RequestId, device_id: DeviceId 94 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 95 | """ 96 | Select a device in response to a DeviceAccess.deviceRequestPrompted event. 97 | 98 | :param id_: 99 | :param device_id: 100 | """ 101 | params: T_JSON_DICT = dict() 102 | params["id"] = id_.to_json() 103 | params["deviceId"] = device_id.to_json() 104 | cmd_dict: T_JSON_DICT = { 105 | "method": "DeviceAccess.selectPrompt", 106 | "params": params, 107 | } 108 | json = yield cmd_dict 109 | 110 | 111 | def cancel_prompt(id_: RequestId) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 112 | """ 113 | Cancel a prompt in response to a DeviceAccess.deviceRequestPrompted event. 114 | 115 | :param id_: 116 | """ 117 | params: T_JSON_DICT = dict() 118 | params["id"] = id_.to_json() 119 | cmd_dict: T_JSON_DICT = { 120 | "method": "DeviceAccess.cancelPrompt", 121 | "params": params, 122 | } 123 | json = yield cmd_dict 124 | 125 | 126 | @event_class("DeviceAccess.deviceRequestPrompted") 127 | @dataclass 128 | class DeviceRequestPrompted: 129 | """ 130 | A device request opened a user prompt to select a device. Respond with the 131 | selectPrompt or cancelPrompt command. 132 | """ 133 | 134 | id_: RequestId 135 | devices: typing.List[PromptDevice] 136 | 137 | @classmethod 138 | def from_json(cls, json: T_JSON_DICT) -> DeviceRequestPrompted: 139 | return cls( 140 | id_=RequestId.from_json(json["id"]), 141 | devices=[PromptDevice.from_json(i) for i in json["devices"]], 142 | ) 143 | -------------------------------------------------------------------------------- /zendriver/cdp/device_orientation.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: DeviceOrientation (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | def clear_device_orientation_override() -> typing.Generator[ 16 | T_JSON_DICT, T_JSON_DICT, None 17 | ]: 18 | """ 19 | Clears the overridden Device Orientation. 20 | """ 21 | cmd_dict: T_JSON_DICT = { 22 | "method": "DeviceOrientation.clearDeviceOrientationOverride", 23 | } 24 | json = yield cmd_dict 25 | 26 | 27 | def set_device_orientation_override( 28 | alpha: float, beta: float, gamma: float 29 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 30 | """ 31 | Overrides the Device Orientation. 32 | 33 | :param alpha: Mock alpha 34 | :param beta: Mock beta 35 | :param gamma: Mock gamma 36 | """ 37 | params: T_JSON_DICT = dict() 38 | params["alpha"] = alpha 39 | params["beta"] = beta 40 | params["gamma"] = gamma 41 | cmd_dict: T_JSON_DICT = { 42 | "method": "DeviceOrientation.setDeviceOrientationOverride", 43 | "params": params, 44 | } 45 | json = yield cmd_dict 46 | -------------------------------------------------------------------------------- /zendriver/cdp/dom_storage.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: DOMStorage (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class SerializedStorageKey(str): 16 | def to_json(self) -> str: 17 | return self 18 | 19 | @classmethod 20 | def from_json(cls, json: str) -> SerializedStorageKey: 21 | return cls(json) 22 | 23 | def __repr__(self): 24 | return "SerializedStorageKey({})".format(super().__repr__()) 25 | 26 | 27 | @dataclass 28 | class StorageId: 29 | """ 30 | DOM Storage identifier. 31 | """ 32 | 33 | #: Whether the storage is local storage (not session storage). 34 | is_local_storage: bool 35 | 36 | #: Security origin for the storage. 37 | security_origin: typing.Optional[str] = None 38 | 39 | #: Represents a key by which DOM Storage keys its CachedStorageAreas 40 | storage_key: typing.Optional[SerializedStorageKey] = None 41 | 42 | def to_json(self) -> T_JSON_DICT: 43 | json: T_JSON_DICT = dict() 44 | json["isLocalStorage"] = self.is_local_storage 45 | if self.security_origin is not None: 46 | json["securityOrigin"] = self.security_origin 47 | if self.storage_key is not None: 48 | json["storageKey"] = self.storage_key.to_json() 49 | return json 50 | 51 | @classmethod 52 | def from_json(cls, json: T_JSON_DICT) -> StorageId: 53 | return cls( 54 | is_local_storage=bool(json["isLocalStorage"]), 55 | security_origin=str(json["securityOrigin"]) 56 | if json.get("securityOrigin", None) is not None 57 | else None, 58 | storage_key=SerializedStorageKey.from_json(json["storageKey"]) 59 | if json.get("storageKey", None) is not None 60 | else None, 61 | ) 62 | 63 | 64 | class Item(list): 65 | """ 66 | DOM Storage item. 67 | """ 68 | 69 | def to_json(self) -> typing.List[str]: 70 | return self 71 | 72 | @classmethod 73 | def from_json(cls, json: typing.List[str]) -> Item: 74 | return cls(json) 75 | 76 | def __repr__(self): 77 | return "Item({})".format(super().__repr__()) 78 | 79 | 80 | def clear(storage_id: StorageId) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 81 | """ 82 | :param storage_id: 83 | """ 84 | params: T_JSON_DICT = dict() 85 | params["storageId"] = storage_id.to_json() 86 | cmd_dict: T_JSON_DICT = { 87 | "method": "DOMStorage.clear", 88 | "params": params, 89 | } 90 | json = yield cmd_dict 91 | 92 | 93 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 94 | """ 95 | Disables storage tracking, prevents storage events from being sent to the client. 96 | """ 97 | cmd_dict: T_JSON_DICT = { 98 | "method": "DOMStorage.disable", 99 | } 100 | json = yield cmd_dict 101 | 102 | 103 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 104 | """ 105 | Enables storage tracking, storage events will now be delivered to the client. 106 | """ 107 | cmd_dict: T_JSON_DICT = { 108 | "method": "DOMStorage.enable", 109 | } 110 | json = yield cmd_dict 111 | 112 | 113 | def get_dom_storage_items( 114 | storage_id: StorageId, 115 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, typing.List[Item]]: 116 | """ 117 | :param storage_id: 118 | :returns: 119 | """ 120 | params: T_JSON_DICT = dict() 121 | params["storageId"] = storage_id.to_json() 122 | cmd_dict: T_JSON_DICT = { 123 | "method": "DOMStorage.getDOMStorageItems", 124 | "params": params, 125 | } 126 | json = yield cmd_dict 127 | return [Item.from_json(i) for i in json["entries"]] 128 | 129 | 130 | def remove_dom_storage_item( 131 | storage_id: StorageId, key: str 132 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 133 | """ 134 | :param storage_id: 135 | :param key: 136 | """ 137 | params: T_JSON_DICT = dict() 138 | params["storageId"] = storage_id.to_json() 139 | params["key"] = key 140 | cmd_dict: T_JSON_DICT = { 141 | "method": "DOMStorage.removeDOMStorageItem", 142 | "params": params, 143 | } 144 | json = yield cmd_dict 145 | 146 | 147 | def set_dom_storage_item( 148 | storage_id: StorageId, key: str, value: str 149 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 150 | """ 151 | :param storage_id: 152 | :param key: 153 | :param value: 154 | """ 155 | params: T_JSON_DICT = dict() 156 | params["storageId"] = storage_id.to_json() 157 | params["key"] = key 158 | params["value"] = value 159 | cmd_dict: T_JSON_DICT = { 160 | "method": "DOMStorage.setDOMStorageItem", 161 | "params": params, 162 | } 163 | json = yield cmd_dict 164 | 165 | 166 | @event_class("DOMStorage.domStorageItemAdded") 167 | @dataclass 168 | class DomStorageItemAdded: 169 | storage_id: StorageId 170 | key: str 171 | new_value: str 172 | 173 | @classmethod 174 | def from_json(cls, json: T_JSON_DICT) -> DomStorageItemAdded: 175 | return cls( 176 | storage_id=StorageId.from_json(json["storageId"]), 177 | key=str(json["key"]), 178 | new_value=str(json["newValue"]), 179 | ) 180 | 181 | 182 | @event_class("DOMStorage.domStorageItemRemoved") 183 | @dataclass 184 | class DomStorageItemRemoved: 185 | storage_id: StorageId 186 | key: str 187 | 188 | @classmethod 189 | def from_json(cls, json: T_JSON_DICT) -> DomStorageItemRemoved: 190 | return cls( 191 | storage_id=StorageId.from_json(json["storageId"]), key=str(json["key"]) 192 | ) 193 | 194 | 195 | @event_class("DOMStorage.domStorageItemUpdated") 196 | @dataclass 197 | class DomStorageItemUpdated: 198 | storage_id: StorageId 199 | key: str 200 | old_value: str 201 | new_value: str 202 | 203 | @classmethod 204 | def from_json(cls, json: T_JSON_DICT) -> DomStorageItemUpdated: 205 | return cls( 206 | storage_id=StorageId.from_json(json["storageId"]), 207 | key=str(json["key"]), 208 | old_value=str(json["oldValue"]), 209 | new_value=str(json["newValue"]), 210 | ) 211 | 212 | 213 | @event_class("DOMStorage.domStorageItemsCleared") 214 | @dataclass 215 | class DomStorageItemsCleared: 216 | storage_id: StorageId 217 | 218 | @classmethod 219 | def from_json(cls, json: T_JSON_DICT) -> DomStorageItemsCleared: 220 | return cls(storage_id=StorageId.from_json(json["storageId"])) 221 | -------------------------------------------------------------------------------- /zendriver/cdp/event_breakpoints.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: EventBreakpoints (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | def set_instrumentation_breakpoint( 16 | event_name: str, 17 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 18 | """ 19 | Sets breakpoint on particular native event. 20 | 21 | :param event_name: Instrumentation name to stop on. 22 | """ 23 | params: T_JSON_DICT = dict() 24 | params["eventName"] = event_name 25 | cmd_dict: T_JSON_DICT = { 26 | "method": "EventBreakpoints.setInstrumentationBreakpoint", 27 | "params": params, 28 | } 29 | json = yield cmd_dict 30 | 31 | 32 | def remove_instrumentation_breakpoint( 33 | event_name: str, 34 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 35 | """ 36 | Removes breakpoint on particular native event. 37 | 38 | :param event_name: Instrumentation name to stop on. 39 | """ 40 | params: T_JSON_DICT = dict() 41 | params["eventName"] = event_name 42 | cmd_dict: T_JSON_DICT = { 43 | "method": "EventBreakpoints.removeInstrumentationBreakpoint", 44 | "params": params, 45 | } 46 | json = yield cmd_dict 47 | 48 | 49 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 50 | """ 51 | Removes all breakpoints 52 | """ 53 | cmd_dict: T_JSON_DICT = { 54 | "method": "EventBreakpoints.disable", 55 | } 56 | json = yield cmd_dict 57 | -------------------------------------------------------------------------------- /zendriver/cdp/extensions.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Extensions (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class StorageArea(enum.Enum): 16 | """ 17 | Storage areas. 18 | """ 19 | 20 | SESSION = "session" 21 | LOCAL = "local" 22 | SYNC = "sync" 23 | MANAGED = "managed" 24 | 25 | def to_json(self) -> str: 26 | return self.value 27 | 28 | @classmethod 29 | def from_json(cls, json: str) -> StorageArea: 30 | return cls(json) 31 | 32 | 33 | def load_unpacked(path: str) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, str]: 34 | """ 35 | Installs an unpacked extension from the filesystem similar to 36 | --load-extension CLI flags. Returns extension ID once the extension 37 | has been installed. Available if the client is connected using the 38 | --remote-debugging-pipe flag and the --enable-unsafe-extension-debugging 39 | flag is set. 40 | 41 | :param path: Absolute file path. 42 | :returns: Extension id. 43 | """ 44 | params: T_JSON_DICT = dict() 45 | params["path"] = path 46 | cmd_dict: T_JSON_DICT = { 47 | "method": "Extensions.loadUnpacked", 48 | "params": params, 49 | } 50 | json = yield cmd_dict 51 | return str(json["id"]) 52 | 53 | 54 | def uninstall(id_: str) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 55 | """ 56 | Uninstalls an unpacked extension (others not supported) from the profile. 57 | Available if the client is connected using the --remote-debugging-pipe flag 58 | and the --enable-unsafe-extension-debugging. 59 | 60 | :param id_: Extension id. 61 | """ 62 | params: T_JSON_DICT = dict() 63 | params["id"] = id_ 64 | cmd_dict: T_JSON_DICT = { 65 | "method": "Extensions.uninstall", 66 | "params": params, 67 | } 68 | json = yield cmd_dict 69 | 70 | 71 | def get_storage_items( 72 | id_: str, storage_area: StorageArea, keys: typing.Optional[typing.List[str]] = None 73 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, dict]: 74 | """ 75 | Gets data from extension storage in the given ``storageArea``. If ``keys`` is 76 | specified, these are used to filter the result. 77 | 78 | :param id_: ID of extension. 79 | :param storage_area: StorageArea to retrieve data from. 80 | :param keys: *(Optional)* Keys to retrieve. 81 | :returns: 82 | """ 83 | params: T_JSON_DICT = dict() 84 | params["id"] = id_ 85 | params["storageArea"] = storage_area.to_json() 86 | if keys is not None: 87 | params["keys"] = [i for i in keys] 88 | cmd_dict: T_JSON_DICT = { 89 | "method": "Extensions.getStorageItems", 90 | "params": params, 91 | } 92 | json = yield cmd_dict 93 | return dict(json["data"]) 94 | 95 | 96 | def remove_storage_items( 97 | id_: str, storage_area: StorageArea, keys: typing.List[str] 98 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 99 | """ 100 | Removes ``keys`` from extension storage in the given ``storageArea``. 101 | 102 | :param id_: ID of extension. 103 | :param storage_area: StorageArea to remove data from. 104 | :param keys: Keys to remove. 105 | """ 106 | params: T_JSON_DICT = dict() 107 | params["id"] = id_ 108 | params["storageArea"] = storage_area.to_json() 109 | params["keys"] = [i for i in keys] 110 | cmd_dict: T_JSON_DICT = { 111 | "method": "Extensions.removeStorageItems", 112 | "params": params, 113 | } 114 | json = yield cmd_dict 115 | 116 | 117 | def clear_storage_items( 118 | id_: str, storage_area: StorageArea 119 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 120 | """ 121 | Clears extension storage in the given ``storageArea``. 122 | 123 | :param id_: ID of extension. 124 | :param storage_area: StorageArea to remove data from. 125 | """ 126 | params: T_JSON_DICT = dict() 127 | params["id"] = id_ 128 | params["storageArea"] = storage_area.to_json() 129 | cmd_dict: T_JSON_DICT = { 130 | "method": "Extensions.clearStorageItems", 131 | "params": params, 132 | } 133 | json = yield cmd_dict 134 | 135 | 136 | def set_storage_items( 137 | id_: str, storage_area: StorageArea, values: dict 138 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 139 | """ 140 | Sets ``values`` in extension storage in the given ``storageArea``. The provided ``values`` 141 | will be merged with existing values in the storage area. 142 | 143 | :param id_: ID of extension. 144 | :param storage_area: StorageArea to set data in. 145 | :param values: Values to set. 146 | """ 147 | params: T_JSON_DICT = dict() 148 | params["id"] = id_ 149 | params["storageArea"] = storage_area.to_json() 150 | params["values"] = values 151 | cmd_dict: T_JSON_DICT = { 152 | "method": "Extensions.setStorageItems", 153 | "params": params, 154 | } 155 | json = yield cmd_dict 156 | -------------------------------------------------------------------------------- /zendriver/cdp/fed_cm.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: FedCm (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class LoginState(enum.Enum): 16 | """ 17 | Whether this is a sign-up or sign-in action for this account, i.e. 18 | whether this account has ever been used to sign in to this RP before. 19 | """ 20 | 21 | SIGN_IN = "SignIn" 22 | SIGN_UP = "SignUp" 23 | 24 | def to_json(self) -> str: 25 | return self.value 26 | 27 | @classmethod 28 | def from_json(cls, json: str) -> LoginState: 29 | return cls(json) 30 | 31 | 32 | class DialogType(enum.Enum): 33 | """ 34 | The types of FedCM dialogs. 35 | """ 36 | 37 | ACCOUNT_CHOOSER = "AccountChooser" 38 | AUTO_REAUTHN = "AutoReauthn" 39 | CONFIRM_IDP_LOGIN = "ConfirmIdpLogin" 40 | ERROR = "Error" 41 | 42 | def to_json(self) -> str: 43 | return self.value 44 | 45 | @classmethod 46 | def from_json(cls, json: str) -> DialogType: 47 | return cls(json) 48 | 49 | 50 | class DialogButton(enum.Enum): 51 | """ 52 | The buttons on the FedCM dialog. 53 | """ 54 | 55 | CONFIRM_IDP_LOGIN_CONTINUE = "ConfirmIdpLoginContinue" 56 | ERROR_GOT_IT = "ErrorGotIt" 57 | ERROR_MORE_DETAILS = "ErrorMoreDetails" 58 | 59 | def to_json(self) -> str: 60 | return self.value 61 | 62 | @classmethod 63 | def from_json(cls, json: str) -> DialogButton: 64 | return cls(json) 65 | 66 | 67 | class AccountUrlType(enum.Enum): 68 | """ 69 | The URLs that each account has 70 | """ 71 | 72 | TERMS_OF_SERVICE = "TermsOfService" 73 | PRIVACY_POLICY = "PrivacyPolicy" 74 | 75 | def to_json(self) -> str: 76 | return self.value 77 | 78 | @classmethod 79 | def from_json(cls, json: str) -> AccountUrlType: 80 | return cls(json) 81 | 82 | 83 | @dataclass 84 | class Account: 85 | """ 86 | Corresponds to IdentityRequestAccount 87 | """ 88 | 89 | account_id: str 90 | 91 | email: str 92 | 93 | name: str 94 | 95 | given_name: str 96 | 97 | picture_url: str 98 | 99 | idp_config_url: str 100 | 101 | idp_login_url: str 102 | 103 | login_state: LoginState 104 | 105 | #: These two are only set if the loginState is signUp 106 | terms_of_service_url: typing.Optional[str] = None 107 | 108 | privacy_policy_url: typing.Optional[str] = None 109 | 110 | def to_json(self) -> T_JSON_DICT: 111 | json: T_JSON_DICT = dict() 112 | json["accountId"] = self.account_id 113 | json["email"] = self.email 114 | json["name"] = self.name 115 | json["givenName"] = self.given_name 116 | json["pictureUrl"] = self.picture_url 117 | json["idpConfigUrl"] = self.idp_config_url 118 | json["idpLoginUrl"] = self.idp_login_url 119 | json["loginState"] = self.login_state.to_json() 120 | if self.terms_of_service_url is not None: 121 | json["termsOfServiceUrl"] = self.terms_of_service_url 122 | if self.privacy_policy_url is not None: 123 | json["privacyPolicyUrl"] = self.privacy_policy_url 124 | return json 125 | 126 | @classmethod 127 | def from_json(cls, json: T_JSON_DICT) -> Account: 128 | return cls( 129 | account_id=str(json["accountId"]), 130 | email=str(json["email"]), 131 | name=str(json["name"]), 132 | given_name=str(json["givenName"]), 133 | picture_url=str(json["pictureUrl"]), 134 | idp_config_url=str(json["idpConfigUrl"]), 135 | idp_login_url=str(json["idpLoginUrl"]), 136 | login_state=LoginState.from_json(json["loginState"]), 137 | terms_of_service_url=str(json["termsOfServiceUrl"]) 138 | if json.get("termsOfServiceUrl", None) is not None 139 | else None, 140 | privacy_policy_url=str(json["privacyPolicyUrl"]) 141 | if json.get("privacyPolicyUrl", None) is not None 142 | else None, 143 | ) 144 | 145 | 146 | def enable( 147 | disable_rejection_delay: typing.Optional[bool] = None, 148 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 149 | """ 150 | :param disable_rejection_delay: *(Optional)* Allows callers to disable the promise rejection delay that would normally happen, if this is unimportant to what's being tested. (step 4 of https://fedidcg.github.io/FedCM/#browser-api-rp-sign-in) 151 | """ 152 | params: T_JSON_DICT = dict() 153 | if disable_rejection_delay is not None: 154 | params["disableRejectionDelay"] = disable_rejection_delay 155 | cmd_dict: T_JSON_DICT = { 156 | "method": "FedCm.enable", 157 | "params": params, 158 | } 159 | json = yield cmd_dict 160 | 161 | 162 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 163 | cmd_dict: T_JSON_DICT = { 164 | "method": "FedCm.disable", 165 | } 166 | json = yield cmd_dict 167 | 168 | 169 | def select_account( 170 | dialog_id: str, account_index: int 171 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 172 | """ 173 | :param dialog_id: 174 | :param account_index: 175 | """ 176 | params: T_JSON_DICT = dict() 177 | params["dialogId"] = dialog_id 178 | params["accountIndex"] = account_index 179 | cmd_dict: T_JSON_DICT = { 180 | "method": "FedCm.selectAccount", 181 | "params": params, 182 | } 183 | json = yield cmd_dict 184 | 185 | 186 | def click_dialog_button( 187 | dialog_id: str, dialog_button: DialogButton 188 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 189 | """ 190 | :param dialog_id: 191 | :param dialog_button: 192 | """ 193 | params: T_JSON_DICT = dict() 194 | params["dialogId"] = dialog_id 195 | params["dialogButton"] = dialog_button.to_json() 196 | cmd_dict: T_JSON_DICT = { 197 | "method": "FedCm.clickDialogButton", 198 | "params": params, 199 | } 200 | json = yield cmd_dict 201 | 202 | 203 | def open_url( 204 | dialog_id: str, account_index: int, account_url_type: AccountUrlType 205 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 206 | """ 207 | :param dialog_id: 208 | :param account_index: 209 | :param account_url_type: 210 | """ 211 | params: T_JSON_DICT = dict() 212 | params["dialogId"] = dialog_id 213 | params["accountIndex"] = account_index 214 | params["accountUrlType"] = account_url_type.to_json() 215 | cmd_dict: T_JSON_DICT = { 216 | "method": "FedCm.openUrl", 217 | "params": params, 218 | } 219 | json = yield cmd_dict 220 | 221 | 222 | def dismiss_dialog( 223 | dialog_id: str, trigger_cooldown: typing.Optional[bool] = None 224 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 225 | """ 226 | :param dialog_id: 227 | :param trigger_cooldown: *(Optional)* 228 | """ 229 | params: T_JSON_DICT = dict() 230 | params["dialogId"] = dialog_id 231 | if trigger_cooldown is not None: 232 | params["triggerCooldown"] = trigger_cooldown 233 | cmd_dict: T_JSON_DICT = { 234 | "method": "FedCm.dismissDialog", 235 | "params": params, 236 | } 237 | json = yield cmd_dict 238 | 239 | 240 | def reset_cooldown() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 241 | """ 242 | Resets the cooldown time, if any, to allow the next FedCM call to show 243 | a dialog even if one was recently dismissed by the user. 244 | """ 245 | cmd_dict: T_JSON_DICT = { 246 | "method": "FedCm.resetCooldown", 247 | } 248 | json = yield cmd_dict 249 | 250 | 251 | @event_class("FedCm.dialogShown") 252 | @dataclass 253 | class DialogShown: 254 | dialog_id: str 255 | dialog_type: DialogType 256 | accounts: typing.List[Account] 257 | #: These exist primarily so that the caller can verify the 258 | #: RP context was used appropriately. 259 | title: str 260 | subtitle: typing.Optional[str] 261 | 262 | @classmethod 263 | def from_json(cls, json: T_JSON_DICT) -> DialogShown: 264 | return cls( 265 | dialog_id=str(json["dialogId"]), 266 | dialog_type=DialogType.from_json(json["dialogType"]), 267 | accounts=[Account.from_json(i) for i in json["accounts"]], 268 | title=str(json["title"]), 269 | subtitle=str(json["subtitle"]) 270 | if json.get("subtitle", None) is not None 271 | else None, 272 | ) 273 | 274 | 275 | @event_class("FedCm.dialogClosed") 276 | @dataclass 277 | class DialogClosed: 278 | """ 279 | Triggered when a dialog is closed, either by user action, JS abort, 280 | or a command below. 281 | """ 282 | 283 | dialog_id: str 284 | 285 | @classmethod 286 | def from_json(cls, json: T_JSON_DICT) -> DialogClosed: 287 | return cls(dialog_id=str(json["dialogId"])) 288 | -------------------------------------------------------------------------------- /zendriver/cdp/file_system.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: FileSystem (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import network 15 | from . import storage 16 | 17 | 18 | @dataclass 19 | class File: 20 | name: str 21 | 22 | #: Timestamp 23 | last_modified: network.TimeSinceEpoch 24 | 25 | #: Size in bytes 26 | size: float 27 | 28 | type_: str 29 | 30 | def to_json(self) -> T_JSON_DICT: 31 | json: T_JSON_DICT = dict() 32 | json["name"] = self.name 33 | json["lastModified"] = self.last_modified.to_json() 34 | json["size"] = self.size 35 | json["type"] = self.type_ 36 | return json 37 | 38 | @classmethod 39 | def from_json(cls, json: T_JSON_DICT) -> File: 40 | return cls( 41 | name=str(json["name"]), 42 | last_modified=network.TimeSinceEpoch.from_json(json["lastModified"]), 43 | size=float(json["size"]), 44 | type_=str(json["type"]), 45 | ) 46 | 47 | 48 | @dataclass 49 | class Directory: 50 | name: str 51 | 52 | nested_directories: typing.List[str] 53 | 54 | #: Files that are directly nested under this directory. 55 | nested_files: typing.List[File] 56 | 57 | def to_json(self) -> T_JSON_DICT: 58 | json: T_JSON_DICT = dict() 59 | json["name"] = self.name 60 | json["nestedDirectories"] = [i for i in self.nested_directories] 61 | json["nestedFiles"] = [i.to_json() for i in self.nested_files] 62 | return json 63 | 64 | @classmethod 65 | def from_json(cls, json: T_JSON_DICT) -> Directory: 66 | return cls( 67 | name=str(json["name"]), 68 | nested_directories=[str(i) for i in json["nestedDirectories"]], 69 | nested_files=[File.from_json(i) for i in json["nestedFiles"]], 70 | ) 71 | 72 | 73 | @dataclass 74 | class BucketFileSystemLocator: 75 | #: Storage key 76 | storage_key: storage.SerializedStorageKey 77 | 78 | #: Path to the directory using each path component as an array item. 79 | path_components: typing.List[str] 80 | 81 | #: Bucket name. Not passing a ``bucketName`` will retrieve the default Bucket. (https://developer.mozilla.org/en-US/docs/Web/API/Storage_API#storage_buckets) 82 | bucket_name: typing.Optional[str] = None 83 | 84 | def to_json(self) -> T_JSON_DICT: 85 | json: T_JSON_DICT = dict() 86 | json["storageKey"] = self.storage_key.to_json() 87 | json["pathComponents"] = [i for i in self.path_components] 88 | if self.bucket_name is not None: 89 | json["bucketName"] = self.bucket_name 90 | return json 91 | 92 | @classmethod 93 | def from_json(cls, json: T_JSON_DICT) -> BucketFileSystemLocator: 94 | return cls( 95 | storage_key=storage.SerializedStorageKey.from_json(json["storageKey"]), 96 | path_components=[str(i) for i in json["pathComponents"]], 97 | bucket_name=str(json["bucketName"]) 98 | if json.get("bucketName", None) is not None 99 | else None, 100 | ) 101 | 102 | 103 | def get_directory( 104 | bucket_file_system_locator: BucketFileSystemLocator, 105 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, Directory]: 106 | """ 107 | :param bucket_file_system_locator: 108 | :returns: Returns the directory object at the path. 109 | """ 110 | params: T_JSON_DICT = dict() 111 | params["bucketFileSystemLocator"] = bucket_file_system_locator.to_json() 112 | cmd_dict: T_JSON_DICT = { 113 | "method": "FileSystem.getDirectory", 114 | "params": params, 115 | } 116 | json = yield cmd_dict 117 | return Directory.from_json(json["directory"]) 118 | -------------------------------------------------------------------------------- /zendriver/cdp/headless_experimental.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: HeadlessExperimental (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | from deprecated.sphinx import deprecated # type: ignore 16 | 17 | 18 | @dataclass 19 | class ScreenshotParams: 20 | """ 21 | Encoding options for a screenshot. 22 | """ 23 | 24 | #: Image compression format (defaults to png). 25 | format_: typing.Optional[str] = None 26 | 27 | #: Compression quality from range [0..100] (jpeg and webp only). 28 | quality: typing.Optional[int] = None 29 | 30 | #: Optimize image encoding for speed, not for resulting size (defaults to false) 31 | optimize_for_speed: typing.Optional[bool] = None 32 | 33 | def to_json(self) -> T_JSON_DICT: 34 | json: T_JSON_DICT = dict() 35 | if self.format_ is not None: 36 | json["format"] = self.format_ 37 | if self.quality is not None: 38 | json["quality"] = self.quality 39 | if self.optimize_for_speed is not None: 40 | json["optimizeForSpeed"] = self.optimize_for_speed 41 | return json 42 | 43 | @classmethod 44 | def from_json(cls, json: T_JSON_DICT) -> ScreenshotParams: 45 | return cls( 46 | format_=str(json["format"]) 47 | if json.get("format", None) is not None 48 | else None, 49 | quality=int(json["quality"]) 50 | if json.get("quality", None) is not None 51 | else None, 52 | optimize_for_speed=bool(json["optimizeForSpeed"]) 53 | if json.get("optimizeForSpeed", None) is not None 54 | else None, 55 | ) 56 | 57 | 58 | def begin_frame( 59 | frame_time_ticks: typing.Optional[float] = None, 60 | interval: typing.Optional[float] = None, 61 | no_display_updates: typing.Optional[bool] = None, 62 | screenshot: typing.Optional[ScreenshotParams] = None, 63 | ) -> typing.Generator[ 64 | T_JSON_DICT, T_JSON_DICT, typing.Tuple[bool, typing.Optional[str]] 65 | ]: 66 | """ 67 | Sends a BeginFrame to the target and returns when the frame was completed. Optionally captures a 68 | screenshot from the resulting frame. Requires that the target was created with enabled 69 | BeginFrameControl. Designed for use with --run-all-compositor-stages-before-draw, see also 70 | https://goo.gle/chrome-headless-rendering for more background. 71 | 72 | :param frame_time_ticks: *(Optional)* Timestamp of this BeginFrame in Renderer TimeTicks (milliseconds of uptime). If not set, the current time will be used. 73 | :param interval: *(Optional)* The interval between BeginFrames that is reported to the compositor, in milliseconds. Defaults to a 60 frames/second interval, i.e. about 16.666 milliseconds. 74 | :param no_display_updates: *(Optional)* Whether updates should not be committed and drawn onto the display. False by default. If true, only side effects of the BeginFrame will be run, such as layout and animations, but any visual updates may not be visible on the display or in screenshots. 75 | :param screenshot: *(Optional)* If set, a screenshot of the frame will be captured and returned in the response. Otherwise, no screenshot will be captured. Note that capturing a screenshot can fail, for example, during renderer initialization. In such a case, no screenshot data will be returned. 76 | :returns: A tuple with the following items: 77 | 78 | 0. **hasDamage** - Whether the BeginFrame resulted in damage and, thus, a new frame was committed to the display. Reported for diagnostic uses, may be removed in the future. 79 | 1. **screenshotData** - *(Optional)* Base64-encoded image data of the screenshot, if one was requested and successfully taken. (Encoded as a base64 string when passed over JSON) 80 | """ 81 | params: T_JSON_DICT = dict() 82 | if frame_time_ticks is not None: 83 | params["frameTimeTicks"] = frame_time_ticks 84 | if interval is not None: 85 | params["interval"] = interval 86 | if no_display_updates is not None: 87 | params["noDisplayUpdates"] = no_display_updates 88 | if screenshot is not None: 89 | params["screenshot"] = screenshot.to_json() 90 | cmd_dict: T_JSON_DICT = { 91 | "method": "HeadlessExperimental.beginFrame", 92 | "params": params, 93 | } 94 | json = yield cmd_dict 95 | return ( 96 | bool(json["hasDamage"]), 97 | str(json["screenshotData"]) 98 | if json.get("screenshotData", None) is not None 99 | else None, 100 | ) 101 | 102 | 103 | @deprecated(version="1.3") 104 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 105 | """ 106 | Disables headless events for the target. 107 | 108 | .. deprecated:: 1.3 109 | """ 110 | cmd_dict: T_JSON_DICT = { 111 | "method": "HeadlessExperimental.disable", 112 | } 113 | json = yield cmd_dict 114 | 115 | 116 | @deprecated(version="1.3") 117 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 118 | """ 119 | Enables headless events for the target. 120 | 121 | .. deprecated:: 1.3 122 | """ 123 | cmd_dict: T_JSON_DICT = { 124 | "method": "HeadlessExperimental.enable", 125 | } 126 | json = yield cmd_dict 127 | -------------------------------------------------------------------------------- /zendriver/cdp/inspector.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Inspector (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 16 | """ 17 | Disables inspector domain notifications. 18 | """ 19 | cmd_dict: T_JSON_DICT = { 20 | "method": "Inspector.disable", 21 | } 22 | json = yield cmd_dict 23 | 24 | 25 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 26 | """ 27 | Enables inspector domain notifications. 28 | """ 29 | cmd_dict: T_JSON_DICT = { 30 | "method": "Inspector.enable", 31 | } 32 | json = yield cmd_dict 33 | 34 | 35 | @event_class("Inspector.detached") 36 | @dataclass 37 | class Detached: 38 | """ 39 | Fired when remote debugging connection is about to be terminated. Contains detach reason. 40 | """ 41 | 42 | #: The reason why connection has been terminated. 43 | reason: str 44 | 45 | @classmethod 46 | def from_json(cls, json: T_JSON_DICT) -> Detached: 47 | return cls(reason=str(json["reason"])) 48 | 49 | 50 | @event_class("Inspector.targetCrashed") 51 | @dataclass 52 | class TargetCrashed: 53 | """ 54 | Fired when debugging target has crashed 55 | """ 56 | 57 | @classmethod 58 | def from_json(cls, json: T_JSON_DICT) -> TargetCrashed: 59 | return cls() 60 | 61 | 62 | @event_class("Inspector.targetReloadedAfterCrash") 63 | @dataclass 64 | class TargetReloadedAfterCrash: 65 | """ 66 | Fired when debugging target has reloaded after crash 67 | """ 68 | 69 | @classmethod 70 | def from_json(cls, json: T_JSON_DICT) -> TargetReloadedAfterCrash: 71 | return cls() 72 | -------------------------------------------------------------------------------- /zendriver/cdp/io.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: IO 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import runtime 15 | 16 | 17 | class StreamHandle(str): 18 | """ 19 | This is either obtained from another method or specified as ``blob:`` where 20 | ```` is an UUID of a Blob. 21 | """ 22 | 23 | def to_json(self) -> str: 24 | return self 25 | 26 | @classmethod 27 | def from_json(cls, json: str) -> StreamHandle: 28 | return cls(json) 29 | 30 | def __repr__(self): 31 | return "StreamHandle({})".format(super().__repr__()) 32 | 33 | 34 | def close(handle: StreamHandle) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 35 | """ 36 | Close the stream, discard any temporary backing storage. 37 | 38 | :param handle: Handle of the stream to close. 39 | """ 40 | params: T_JSON_DICT = dict() 41 | params["handle"] = handle.to_json() 42 | cmd_dict: T_JSON_DICT = { 43 | "method": "IO.close", 44 | "params": params, 45 | } 46 | json = yield cmd_dict 47 | 48 | 49 | def read( 50 | handle: StreamHandle, 51 | offset: typing.Optional[int] = None, 52 | size: typing.Optional[int] = None, 53 | ) -> typing.Generator[ 54 | T_JSON_DICT, T_JSON_DICT, typing.Tuple[typing.Optional[bool], str, bool] 55 | ]: 56 | """ 57 | Read a chunk of the stream 58 | 59 | :param handle: Handle of the stream to read. 60 | :param offset: *(Optional)* Seek to the specified offset before reading (if not specified, proceed with offset following the last read). Some types of streams may only support sequential reads. 61 | :param size: *(Optional)* Maximum number of bytes to read (left upon the agent discretion if not specified). 62 | :returns: A tuple with the following items: 63 | 64 | 0. **base64Encoded** - *(Optional)* Set if the data is base64-encoded 65 | 1. **data** - Data that were read. 66 | 2. **eof** - Set if the end-of-file condition occurred while reading. 67 | """ 68 | params: T_JSON_DICT = dict() 69 | params["handle"] = handle.to_json() 70 | if offset is not None: 71 | params["offset"] = offset 72 | if size is not None: 73 | params["size"] = size 74 | cmd_dict: T_JSON_DICT = { 75 | "method": "IO.read", 76 | "params": params, 77 | } 78 | json = yield cmd_dict 79 | return ( 80 | bool(json["base64Encoded"]) 81 | if json.get("base64Encoded", None) is not None 82 | else None, 83 | str(json["data"]), 84 | bool(json["eof"]), 85 | ) 86 | 87 | 88 | def resolve_blob( 89 | object_id: runtime.RemoteObjectId, 90 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, str]: 91 | """ 92 | Return UUID of Blob object specified by a remote object id. 93 | 94 | :param object_id: Object id of a Blob object wrapper. 95 | :returns: UUID of the specified Blob. 96 | """ 97 | params: T_JSON_DICT = dict() 98 | params["objectId"] = object_id.to_json() 99 | cmd_dict: T_JSON_DICT = { 100 | "method": "IO.resolveBlob", 101 | "params": params, 102 | } 103 | json = yield cmd_dict 104 | return str(json["uuid"]) 105 | -------------------------------------------------------------------------------- /zendriver/cdp/log.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Log 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import network 15 | from . import runtime 16 | 17 | 18 | @dataclass 19 | class LogEntry: 20 | """ 21 | Log entry. 22 | """ 23 | 24 | #: Log entry source. 25 | source: str 26 | 27 | #: Log entry severity. 28 | level: str 29 | 30 | #: Logged text. 31 | text: str 32 | 33 | #: Timestamp when this entry was added. 34 | timestamp: runtime.Timestamp 35 | 36 | category: typing.Optional[str] = None 37 | 38 | #: URL of the resource if known. 39 | url: typing.Optional[str] = None 40 | 41 | #: Line number in the resource. 42 | line_number: typing.Optional[int] = None 43 | 44 | #: JavaScript stack trace. 45 | stack_trace: typing.Optional[runtime.StackTrace] = None 46 | 47 | #: Identifier of the network request associated with this entry. 48 | network_request_id: typing.Optional[network.RequestId] = None 49 | 50 | #: Identifier of the worker associated with this entry. 51 | worker_id: typing.Optional[str] = None 52 | 53 | #: Call arguments. 54 | args: typing.Optional[typing.List[runtime.RemoteObject]] = None 55 | 56 | def to_json(self) -> T_JSON_DICT: 57 | json: T_JSON_DICT = dict() 58 | json["source"] = self.source 59 | json["level"] = self.level 60 | json["text"] = self.text 61 | json["timestamp"] = self.timestamp.to_json() 62 | if self.category is not None: 63 | json["category"] = self.category 64 | if self.url is not None: 65 | json["url"] = self.url 66 | if self.line_number is not None: 67 | json["lineNumber"] = self.line_number 68 | if self.stack_trace is not None: 69 | json["stackTrace"] = self.stack_trace.to_json() 70 | if self.network_request_id is not None: 71 | json["networkRequestId"] = self.network_request_id.to_json() 72 | if self.worker_id is not None: 73 | json["workerId"] = self.worker_id 74 | if self.args is not None: 75 | json["args"] = [i.to_json() for i in self.args] 76 | return json 77 | 78 | @classmethod 79 | def from_json(cls, json: T_JSON_DICT) -> LogEntry: 80 | return cls( 81 | source=str(json["source"]), 82 | level=str(json["level"]), 83 | text=str(json["text"]), 84 | timestamp=runtime.Timestamp.from_json(json["timestamp"]), 85 | category=str(json["category"]) 86 | if json.get("category", None) is not None 87 | else None, 88 | url=str(json["url"]) if json.get("url", None) is not None else None, 89 | line_number=int(json["lineNumber"]) 90 | if json.get("lineNumber", None) is not None 91 | else None, 92 | stack_trace=runtime.StackTrace.from_json(json["stackTrace"]) 93 | if json.get("stackTrace", None) is not None 94 | else None, 95 | network_request_id=network.RequestId.from_json(json["networkRequestId"]) 96 | if json.get("networkRequestId", None) is not None 97 | else None, 98 | worker_id=str(json["workerId"]) 99 | if json.get("workerId", None) is not None 100 | else None, 101 | args=[runtime.RemoteObject.from_json(i) for i in json["args"]] 102 | if json.get("args", None) is not None 103 | else None, 104 | ) 105 | 106 | 107 | @dataclass 108 | class ViolationSetting: 109 | """ 110 | Violation configuration setting. 111 | """ 112 | 113 | #: Violation type. 114 | name: str 115 | 116 | #: Time threshold to trigger upon. 117 | threshold: float 118 | 119 | def to_json(self) -> T_JSON_DICT: 120 | json: T_JSON_DICT = dict() 121 | json["name"] = self.name 122 | json["threshold"] = self.threshold 123 | return json 124 | 125 | @classmethod 126 | def from_json(cls, json: T_JSON_DICT) -> ViolationSetting: 127 | return cls( 128 | name=str(json["name"]), 129 | threshold=float(json["threshold"]), 130 | ) 131 | 132 | 133 | def clear() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 134 | """ 135 | Clears the log. 136 | """ 137 | cmd_dict: T_JSON_DICT = { 138 | "method": "Log.clear", 139 | } 140 | json = yield cmd_dict 141 | 142 | 143 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 144 | """ 145 | Disables log domain, prevents further log entries from being reported to the client. 146 | """ 147 | cmd_dict: T_JSON_DICT = { 148 | "method": "Log.disable", 149 | } 150 | json = yield cmd_dict 151 | 152 | 153 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 154 | """ 155 | Enables log domain, sends the entries collected so far to the client by means of the 156 | ``entryAdded`` notification. 157 | """ 158 | cmd_dict: T_JSON_DICT = { 159 | "method": "Log.enable", 160 | } 161 | json = yield cmd_dict 162 | 163 | 164 | def start_violations_report( 165 | config: typing.List[ViolationSetting], 166 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 167 | """ 168 | start violation reporting. 169 | 170 | :param config: Configuration for violations. 171 | """ 172 | params: T_JSON_DICT = dict() 173 | params["config"] = [i.to_json() for i in config] 174 | cmd_dict: T_JSON_DICT = { 175 | "method": "Log.startViolationsReport", 176 | "params": params, 177 | } 178 | json = yield cmd_dict 179 | 180 | 181 | def stop_violations_report() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 182 | """ 183 | Stop violation reporting. 184 | """ 185 | cmd_dict: T_JSON_DICT = { 186 | "method": "Log.stopViolationsReport", 187 | } 188 | json = yield cmd_dict 189 | 190 | 191 | @event_class("Log.entryAdded") 192 | @dataclass 193 | class EntryAdded: 194 | """ 195 | Issued when new message was logged. 196 | """ 197 | 198 | #: The entry. 199 | entry: LogEntry 200 | 201 | @classmethod 202 | def from_json(cls, json: T_JSON_DICT) -> EntryAdded: 203 | return cls(entry=LogEntry.from_json(json["entry"])) 204 | -------------------------------------------------------------------------------- /zendriver/cdp/media.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Media (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class PlayerId(str): 16 | """ 17 | Players will get an ID that is unique within the agent context. 18 | """ 19 | 20 | def to_json(self) -> str: 21 | return self 22 | 23 | @classmethod 24 | def from_json(cls, json: str) -> PlayerId: 25 | return cls(json) 26 | 27 | def __repr__(self): 28 | return "PlayerId({})".format(super().__repr__()) 29 | 30 | 31 | class Timestamp(float): 32 | def to_json(self) -> float: 33 | return self 34 | 35 | @classmethod 36 | def from_json(cls, json: float) -> Timestamp: 37 | return cls(json) 38 | 39 | def __repr__(self): 40 | return "Timestamp({})".format(super().__repr__()) 41 | 42 | 43 | @dataclass 44 | class PlayerMessage: 45 | """ 46 | Have one type per entry in MediaLogRecord::Type 47 | Corresponds to kMessage 48 | """ 49 | 50 | #: Keep in sync with MediaLogMessageLevel 51 | #: We are currently keeping the message level 'error' separate from the 52 | #: PlayerError type because right now they represent different things, 53 | #: this one being a DVLOG(ERROR) style log message that gets printed 54 | #: based on what log level is selected in the UI, and the other is a 55 | #: representation of a media::PipelineStatus object. Soon however we're 56 | #: going to be moving away from using PipelineStatus for errors and 57 | #: introducing a new error type which should hopefully let us integrate 58 | #: the error log level into the PlayerError type. 59 | level: str 60 | 61 | message: str 62 | 63 | def to_json(self) -> T_JSON_DICT: 64 | json: T_JSON_DICT = dict() 65 | json["level"] = self.level 66 | json["message"] = self.message 67 | return json 68 | 69 | @classmethod 70 | def from_json(cls, json: T_JSON_DICT) -> PlayerMessage: 71 | return cls( 72 | level=str(json["level"]), 73 | message=str(json["message"]), 74 | ) 75 | 76 | 77 | @dataclass 78 | class PlayerProperty: 79 | """ 80 | Corresponds to kMediaPropertyChange 81 | """ 82 | 83 | name: str 84 | 85 | value: str 86 | 87 | def to_json(self) -> T_JSON_DICT: 88 | json: T_JSON_DICT = dict() 89 | json["name"] = self.name 90 | json["value"] = self.value 91 | return json 92 | 93 | @classmethod 94 | def from_json(cls, json: T_JSON_DICT) -> PlayerProperty: 95 | return cls( 96 | name=str(json["name"]), 97 | value=str(json["value"]), 98 | ) 99 | 100 | 101 | @dataclass 102 | class PlayerEvent: 103 | """ 104 | Corresponds to kMediaEventTriggered 105 | """ 106 | 107 | timestamp: Timestamp 108 | 109 | value: str 110 | 111 | def to_json(self) -> T_JSON_DICT: 112 | json: T_JSON_DICT = dict() 113 | json["timestamp"] = self.timestamp.to_json() 114 | json["value"] = self.value 115 | return json 116 | 117 | @classmethod 118 | def from_json(cls, json: T_JSON_DICT) -> PlayerEvent: 119 | return cls( 120 | timestamp=Timestamp.from_json(json["timestamp"]), 121 | value=str(json["value"]), 122 | ) 123 | 124 | 125 | @dataclass 126 | class PlayerErrorSourceLocation: 127 | """ 128 | Represents logged source line numbers reported in an error. 129 | NOTE: file and line are from chromium c++ implementation code, not js. 130 | """ 131 | 132 | file: str 133 | 134 | line: int 135 | 136 | def to_json(self) -> T_JSON_DICT: 137 | json: T_JSON_DICT = dict() 138 | json["file"] = self.file 139 | json["line"] = self.line 140 | return json 141 | 142 | @classmethod 143 | def from_json(cls, json: T_JSON_DICT) -> PlayerErrorSourceLocation: 144 | return cls( 145 | file=str(json["file"]), 146 | line=int(json["line"]), 147 | ) 148 | 149 | 150 | @dataclass 151 | class PlayerError: 152 | """ 153 | Corresponds to kMediaError 154 | """ 155 | 156 | error_type: str 157 | 158 | #: Code is the numeric enum entry for a specific set of error codes, such 159 | #: as PipelineStatusCodes in media/base/pipeline_status.h 160 | code: int 161 | 162 | #: A trace of where this error was caused / where it passed through. 163 | stack: typing.List[PlayerErrorSourceLocation] 164 | 165 | #: Errors potentially have a root cause error, ie, a DecoderError might be 166 | #: caused by an WindowsError 167 | cause: typing.List[PlayerError] 168 | 169 | #: Extra data attached to an error, such as an HRESULT, Video Codec, etc. 170 | data: dict 171 | 172 | def to_json(self) -> T_JSON_DICT: 173 | json: T_JSON_DICT = dict() 174 | json["errorType"] = self.error_type 175 | json["code"] = self.code 176 | json["stack"] = [i.to_json() for i in self.stack] 177 | json["cause"] = [i.to_json() for i in self.cause] 178 | json["data"] = self.data 179 | return json 180 | 181 | @classmethod 182 | def from_json(cls, json: T_JSON_DICT) -> PlayerError: 183 | return cls( 184 | error_type=str(json["errorType"]), 185 | code=int(json["code"]), 186 | stack=[PlayerErrorSourceLocation.from_json(i) for i in json["stack"]], 187 | cause=[PlayerError.from_json(i) for i in json["cause"]], 188 | data=dict(json["data"]), 189 | ) 190 | 191 | 192 | def enable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 193 | """ 194 | Enables the Media domain 195 | """ 196 | cmd_dict: T_JSON_DICT = { 197 | "method": "Media.enable", 198 | } 199 | json = yield cmd_dict 200 | 201 | 202 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 203 | """ 204 | Disables the Media domain. 205 | """ 206 | cmd_dict: T_JSON_DICT = { 207 | "method": "Media.disable", 208 | } 209 | json = yield cmd_dict 210 | 211 | 212 | @event_class("Media.playerPropertiesChanged") 213 | @dataclass 214 | class PlayerPropertiesChanged: 215 | """ 216 | This can be called multiple times, and can be used to set / override / 217 | remove player properties. A null propValue indicates removal. 218 | """ 219 | 220 | player_id: PlayerId 221 | properties: typing.List[PlayerProperty] 222 | 223 | @classmethod 224 | def from_json(cls, json: T_JSON_DICT) -> PlayerPropertiesChanged: 225 | return cls( 226 | player_id=PlayerId.from_json(json["playerId"]), 227 | properties=[PlayerProperty.from_json(i) for i in json["properties"]], 228 | ) 229 | 230 | 231 | @event_class("Media.playerEventsAdded") 232 | @dataclass 233 | class PlayerEventsAdded: 234 | """ 235 | Send events as a list, allowing them to be batched on the browser for less 236 | congestion. If batched, events must ALWAYS be in chronological order. 237 | """ 238 | 239 | player_id: PlayerId 240 | events: typing.List[PlayerEvent] 241 | 242 | @classmethod 243 | def from_json(cls, json: T_JSON_DICT) -> PlayerEventsAdded: 244 | return cls( 245 | player_id=PlayerId.from_json(json["playerId"]), 246 | events=[PlayerEvent.from_json(i) for i in json["events"]], 247 | ) 248 | 249 | 250 | @event_class("Media.playerMessagesLogged") 251 | @dataclass 252 | class PlayerMessagesLogged: 253 | """ 254 | Send a list of any messages that need to be delivered. 255 | """ 256 | 257 | player_id: PlayerId 258 | messages: typing.List[PlayerMessage] 259 | 260 | @classmethod 261 | def from_json(cls, json: T_JSON_DICT) -> PlayerMessagesLogged: 262 | return cls( 263 | player_id=PlayerId.from_json(json["playerId"]), 264 | messages=[PlayerMessage.from_json(i) for i in json["messages"]], 265 | ) 266 | 267 | 268 | @event_class("Media.playerErrorsRaised") 269 | @dataclass 270 | class PlayerErrorsRaised: 271 | """ 272 | Send a list of any errors that need to be delivered. 273 | """ 274 | 275 | player_id: PlayerId 276 | errors: typing.List[PlayerError] 277 | 278 | @classmethod 279 | def from_json(cls, json: T_JSON_DICT) -> PlayerErrorsRaised: 280 | return cls( 281 | player_id=PlayerId.from_json(json["playerId"]), 282 | errors=[PlayerError.from_json(i) for i in json["errors"]], 283 | ) 284 | 285 | 286 | @event_class("Media.playersCreated") 287 | @dataclass 288 | class PlayersCreated: 289 | """ 290 | Called whenever a player is created, or when a new agent joins and receives 291 | a list of active players. If an agent is restored, it will receive the full 292 | list of player ids and all events again. 293 | """ 294 | 295 | players: typing.List[PlayerId] 296 | 297 | @classmethod 298 | def from_json(cls, json: T_JSON_DICT) -> PlayersCreated: 299 | return cls(players=[PlayerId.from_json(i) for i in json["players"]]) 300 | -------------------------------------------------------------------------------- /zendriver/cdp/memory.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Memory (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | class PressureLevel(enum.Enum): 16 | """ 17 | Memory pressure level. 18 | """ 19 | 20 | MODERATE = "moderate" 21 | CRITICAL = "critical" 22 | 23 | def to_json(self) -> str: 24 | return self.value 25 | 26 | @classmethod 27 | def from_json(cls, json: str) -> PressureLevel: 28 | return cls(json) 29 | 30 | 31 | @dataclass 32 | class SamplingProfileNode: 33 | """ 34 | Heap profile sample. 35 | """ 36 | 37 | #: Size of the sampled allocation. 38 | size: float 39 | 40 | #: Total bytes attributed to this sample. 41 | total: float 42 | 43 | #: Execution stack at the point of allocation. 44 | stack: typing.List[str] 45 | 46 | def to_json(self) -> T_JSON_DICT: 47 | json: T_JSON_DICT = dict() 48 | json["size"] = self.size 49 | json["total"] = self.total 50 | json["stack"] = [i for i in self.stack] 51 | return json 52 | 53 | @classmethod 54 | def from_json(cls, json: T_JSON_DICT) -> SamplingProfileNode: 55 | return cls( 56 | size=float(json["size"]), 57 | total=float(json["total"]), 58 | stack=[str(i) for i in json["stack"]], 59 | ) 60 | 61 | 62 | @dataclass 63 | class SamplingProfile: 64 | """ 65 | Array of heap profile samples. 66 | """ 67 | 68 | samples: typing.List[SamplingProfileNode] 69 | 70 | modules: typing.List[Module] 71 | 72 | def to_json(self) -> T_JSON_DICT: 73 | json: T_JSON_DICT = dict() 74 | json["samples"] = [i.to_json() for i in self.samples] 75 | json["modules"] = [i.to_json() for i in self.modules] 76 | return json 77 | 78 | @classmethod 79 | def from_json(cls, json: T_JSON_DICT) -> SamplingProfile: 80 | return cls( 81 | samples=[SamplingProfileNode.from_json(i) for i in json["samples"]], 82 | modules=[Module.from_json(i) for i in json["modules"]], 83 | ) 84 | 85 | 86 | @dataclass 87 | class Module: 88 | """ 89 | Executable module information 90 | """ 91 | 92 | #: Name of the module. 93 | name: str 94 | 95 | #: UUID of the module. 96 | uuid: str 97 | 98 | #: Base address where the module is loaded into memory. Encoded as a decimal 99 | #: or hexadecimal (0x prefixed) string. 100 | base_address: str 101 | 102 | #: Size of the module in bytes. 103 | size: float 104 | 105 | def to_json(self) -> T_JSON_DICT: 106 | json: T_JSON_DICT = dict() 107 | json["name"] = self.name 108 | json["uuid"] = self.uuid 109 | json["baseAddress"] = self.base_address 110 | json["size"] = self.size 111 | return json 112 | 113 | @classmethod 114 | def from_json(cls, json: T_JSON_DICT) -> Module: 115 | return cls( 116 | name=str(json["name"]), 117 | uuid=str(json["uuid"]), 118 | base_address=str(json["baseAddress"]), 119 | size=float(json["size"]), 120 | ) 121 | 122 | 123 | @dataclass 124 | class DOMCounter: 125 | """ 126 | DOM object counter data. 127 | """ 128 | 129 | #: Object name. Note: object names should be presumed volatile and clients should not expect 130 | #: the returned names to be consistent across runs. 131 | name: str 132 | 133 | #: Object count. 134 | count: int 135 | 136 | def to_json(self) -> T_JSON_DICT: 137 | json: T_JSON_DICT = dict() 138 | json["name"] = self.name 139 | json["count"] = self.count 140 | return json 141 | 142 | @classmethod 143 | def from_json(cls, json: T_JSON_DICT) -> DOMCounter: 144 | return cls( 145 | name=str(json["name"]), 146 | count=int(json["count"]), 147 | ) 148 | 149 | 150 | def get_dom_counters() -> typing.Generator[ 151 | T_JSON_DICT, T_JSON_DICT, typing.Tuple[int, int, int] 152 | ]: 153 | """ 154 | Retruns current DOM object counters. 155 | 156 | :returns: A tuple with the following items: 157 | 158 | 0. **documents** - 159 | 1. **nodes** - 160 | 2. **jsEventListeners** - 161 | """ 162 | cmd_dict: T_JSON_DICT = { 163 | "method": "Memory.getDOMCounters", 164 | } 165 | json = yield cmd_dict 166 | return (int(json["documents"]), int(json["nodes"]), int(json["jsEventListeners"])) 167 | 168 | 169 | def get_dom_counters_for_leak_detection() -> typing.Generator[ 170 | T_JSON_DICT, T_JSON_DICT, typing.List[DOMCounter] 171 | ]: 172 | """ 173 | Retruns DOM object counters after preparing renderer for leak detection. 174 | 175 | :returns: DOM object counters. 176 | """ 177 | cmd_dict: T_JSON_DICT = { 178 | "method": "Memory.getDOMCountersForLeakDetection", 179 | } 180 | json = yield cmd_dict 181 | return [DOMCounter.from_json(i) for i in json["counters"]] 182 | 183 | 184 | def prepare_for_leak_detection() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 185 | """ 186 | Prepares for leak detection by terminating workers, stopping spellcheckers, 187 | dropping non-essential internal caches, running garbage collections, etc. 188 | """ 189 | cmd_dict: T_JSON_DICT = { 190 | "method": "Memory.prepareForLeakDetection", 191 | } 192 | json = yield cmd_dict 193 | 194 | 195 | def forcibly_purge_java_script_memory() -> typing.Generator[ 196 | T_JSON_DICT, T_JSON_DICT, None 197 | ]: 198 | """ 199 | Simulate OomIntervention by purging V8 memory. 200 | """ 201 | cmd_dict: T_JSON_DICT = { 202 | "method": "Memory.forciblyPurgeJavaScriptMemory", 203 | } 204 | json = yield cmd_dict 205 | 206 | 207 | def set_pressure_notifications_suppressed( 208 | suppressed: bool, 209 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 210 | """ 211 | Enable/disable suppressing memory pressure notifications in all processes. 212 | 213 | :param suppressed: If true, memory pressure notifications will be suppressed. 214 | """ 215 | params: T_JSON_DICT = dict() 216 | params["suppressed"] = suppressed 217 | cmd_dict: T_JSON_DICT = { 218 | "method": "Memory.setPressureNotificationsSuppressed", 219 | "params": params, 220 | } 221 | json = yield cmd_dict 222 | 223 | 224 | def simulate_pressure_notification( 225 | level: PressureLevel, 226 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 227 | """ 228 | Simulate a memory pressure notification in all processes. 229 | 230 | :param level: Memory pressure level of the notification. 231 | """ 232 | params: T_JSON_DICT = dict() 233 | params["level"] = level.to_json() 234 | cmd_dict: T_JSON_DICT = { 235 | "method": "Memory.simulatePressureNotification", 236 | "params": params, 237 | } 238 | json = yield cmd_dict 239 | 240 | 241 | def start_sampling( 242 | sampling_interval: typing.Optional[int] = None, 243 | suppress_randomness: typing.Optional[bool] = None, 244 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 245 | """ 246 | Start collecting native memory profile. 247 | 248 | :param sampling_interval: *(Optional)* Average number of bytes between samples. 249 | :param suppress_randomness: *(Optional)* Do not randomize intervals between samples. 250 | """ 251 | params: T_JSON_DICT = dict() 252 | if sampling_interval is not None: 253 | params["samplingInterval"] = sampling_interval 254 | if suppress_randomness is not None: 255 | params["suppressRandomness"] = suppress_randomness 256 | cmd_dict: T_JSON_DICT = { 257 | "method": "Memory.startSampling", 258 | "params": params, 259 | } 260 | json = yield cmd_dict 261 | 262 | 263 | def stop_sampling() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 264 | """ 265 | Stop collecting native memory profile. 266 | """ 267 | cmd_dict: T_JSON_DICT = { 268 | "method": "Memory.stopSampling", 269 | } 270 | json = yield cmd_dict 271 | 272 | 273 | def get_all_time_sampling_profile() -> typing.Generator[ 274 | T_JSON_DICT, T_JSON_DICT, SamplingProfile 275 | ]: 276 | """ 277 | Retrieve native memory allocations profile 278 | collected since renderer process startup. 279 | 280 | :returns: 281 | """ 282 | cmd_dict: T_JSON_DICT = { 283 | "method": "Memory.getAllTimeSamplingProfile", 284 | } 285 | json = yield cmd_dict 286 | return SamplingProfile.from_json(json["profile"]) 287 | 288 | 289 | def get_browser_sampling_profile() -> typing.Generator[ 290 | T_JSON_DICT, T_JSON_DICT, SamplingProfile 291 | ]: 292 | """ 293 | Retrieve native memory allocations profile 294 | collected since browser process startup. 295 | 296 | :returns: 297 | """ 298 | cmd_dict: T_JSON_DICT = { 299 | "method": "Memory.getBrowserSamplingProfile", 300 | } 301 | json = yield cmd_dict 302 | return SamplingProfile.from_json(json["profile"]) 303 | 304 | 305 | def get_sampling_profile() -> typing.Generator[ 306 | T_JSON_DICT, T_JSON_DICT, SamplingProfile 307 | ]: 308 | """ 309 | Retrieve native memory allocations profile collected since last 310 | ``startSampling`` call. 311 | 312 | :returns: 313 | """ 314 | cmd_dict: T_JSON_DICT = { 315 | "method": "Memory.getSamplingProfile", 316 | } 317 | json = yield cmd_dict 318 | return SamplingProfile.from_json(json["profile"]) 319 | -------------------------------------------------------------------------------- /zendriver/cdp/performance.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Performance 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | from deprecated.sphinx import deprecated # type: ignore 16 | 17 | 18 | @dataclass 19 | class Metric: 20 | """ 21 | Run-time execution metric. 22 | """ 23 | 24 | #: Metric name. 25 | name: str 26 | 27 | #: Metric value. 28 | value: float 29 | 30 | def to_json(self) -> T_JSON_DICT: 31 | json: T_JSON_DICT = dict() 32 | json["name"] = self.name 33 | json["value"] = self.value 34 | return json 35 | 36 | @classmethod 37 | def from_json(cls, json: T_JSON_DICT) -> Metric: 38 | return cls( 39 | name=str(json["name"]), 40 | value=float(json["value"]), 41 | ) 42 | 43 | 44 | def disable() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 45 | """ 46 | Disable collecting and reporting metrics. 47 | """ 48 | cmd_dict: T_JSON_DICT = { 49 | "method": "Performance.disable", 50 | } 51 | json = yield cmd_dict 52 | 53 | 54 | def enable( 55 | time_domain: typing.Optional[str] = None, 56 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 57 | """ 58 | Enable collecting and reporting metrics. 59 | 60 | :param time_domain: *(Optional)* Time domain to use for collecting and reporting duration metrics. 61 | """ 62 | params: T_JSON_DICT = dict() 63 | if time_domain is not None: 64 | params["timeDomain"] = time_domain 65 | cmd_dict: T_JSON_DICT = { 66 | "method": "Performance.enable", 67 | "params": params, 68 | } 69 | json = yield cmd_dict 70 | 71 | 72 | @deprecated(version="1.3") 73 | def set_time_domain( 74 | time_domain: str, 75 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 76 | """ 77 | Sets time domain to use for collecting and reporting duration metrics. 78 | Note that this must be called before enabling metrics collection. Calling 79 | this method while metrics collection is enabled returns an error. 80 | 81 | .. deprecated:: 1.3 82 | 83 | **EXPERIMENTAL** 84 | 85 | :param time_domain: Time domain 86 | """ 87 | params: T_JSON_DICT = dict() 88 | params["timeDomain"] = time_domain 89 | cmd_dict: T_JSON_DICT = { 90 | "method": "Performance.setTimeDomain", 91 | "params": params, 92 | } 93 | json = yield cmd_dict 94 | 95 | 96 | def get_metrics() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, typing.List[Metric]]: 97 | """ 98 | Retrieve current values of run-time metrics. 99 | 100 | :returns: Current values for run-time metrics. 101 | """ 102 | cmd_dict: T_JSON_DICT = { 103 | "method": "Performance.getMetrics", 104 | } 105 | json = yield cmd_dict 106 | return [Metric.from_json(i) for i in json["metrics"]] 107 | 108 | 109 | @event_class("Performance.metrics") 110 | @dataclass 111 | class Metrics: 112 | """ 113 | Current values of the metrics. 114 | """ 115 | 116 | #: Current values of the metrics. 117 | metrics: typing.List[Metric] 118 | #: Timestamp title. 119 | title: str 120 | 121 | @classmethod 122 | def from_json(cls, json: T_JSON_DICT) -> Metrics: 123 | return cls( 124 | metrics=[Metric.from_json(i) for i in json["metrics"]], 125 | title=str(json["title"]), 126 | ) 127 | -------------------------------------------------------------------------------- /zendriver/cdp/performance_timeline.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: PerformanceTimeline (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | from . import dom 15 | from . import network 16 | from . import page 17 | 18 | 19 | @dataclass 20 | class LargestContentfulPaint: 21 | """ 22 | See https://github.com/WICG/LargestContentfulPaint and largest_contentful_paint.idl 23 | """ 24 | 25 | render_time: network.TimeSinceEpoch 26 | 27 | load_time: network.TimeSinceEpoch 28 | 29 | #: The number of pixels being painted. 30 | size: float 31 | 32 | #: The id attribute of the element, if available. 33 | element_id: typing.Optional[str] = None 34 | 35 | #: The URL of the image (may be trimmed). 36 | url: typing.Optional[str] = None 37 | 38 | node_id: typing.Optional[dom.BackendNodeId] = None 39 | 40 | def to_json(self) -> T_JSON_DICT: 41 | json: T_JSON_DICT = dict() 42 | json["renderTime"] = self.render_time.to_json() 43 | json["loadTime"] = self.load_time.to_json() 44 | json["size"] = self.size 45 | if self.element_id is not None: 46 | json["elementId"] = self.element_id 47 | if self.url is not None: 48 | json["url"] = self.url 49 | if self.node_id is not None: 50 | json["nodeId"] = self.node_id.to_json() 51 | return json 52 | 53 | @classmethod 54 | def from_json(cls, json: T_JSON_DICT) -> LargestContentfulPaint: 55 | return cls( 56 | render_time=network.TimeSinceEpoch.from_json(json["renderTime"]), 57 | load_time=network.TimeSinceEpoch.from_json(json["loadTime"]), 58 | size=float(json["size"]), 59 | element_id=str(json["elementId"]) 60 | if json.get("elementId", None) is not None 61 | else None, 62 | url=str(json["url"]) if json.get("url", None) is not None else None, 63 | node_id=dom.BackendNodeId.from_json(json["nodeId"]) 64 | if json.get("nodeId", None) is not None 65 | else None, 66 | ) 67 | 68 | 69 | @dataclass 70 | class LayoutShiftAttribution: 71 | previous_rect: dom.Rect 72 | 73 | current_rect: dom.Rect 74 | 75 | node_id: typing.Optional[dom.BackendNodeId] = None 76 | 77 | def to_json(self) -> T_JSON_DICT: 78 | json: T_JSON_DICT = dict() 79 | json["previousRect"] = self.previous_rect.to_json() 80 | json["currentRect"] = self.current_rect.to_json() 81 | if self.node_id is not None: 82 | json["nodeId"] = self.node_id.to_json() 83 | return json 84 | 85 | @classmethod 86 | def from_json(cls, json: T_JSON_DICT) -> LayoutShiftAttribution: 87 | return cls( 88 | previous_rect=dom.Rect.from_json(json["previousRect"]), 89 | current_rect=dom.Rect.from_json(json["currentRect"]), 90 | node_id=dom.BackendNodeId.from_json(json["nodeId"]) 91 | if json.get("nodeId", None) is not None 92 | else None, 93 | ) 94 | 95 | 96 | @dataclass 97 | class LayoutShift: 98 | """ 99 | See https://wicg.github.io/layout-instability/#sec-layout-shift and layout_shift.idl 100 | """ 101 | 102 | #: Score increment produced by this event. 103 | value: float 104 | 105 | had_recent_input: bool 106 | 107 | last_input_time: network.TimeSinceEpoch 108 | 109 | sources: typing.List[LayoutShiftAttribution] 110 | 111 | def to_json(self) -> T_JSON_DICT: 112 | json: T_JSON_DICT = dict() 113 | json["value"] = self.value 114 | json["hadRecentInput"] = self.had_recent_input 115 | json["lastInputTime"] = self.last_input_time.to_json() 116 | json["sources"] = [i.to_json() for i in self.sources] 117 | return json 118 | 119 | @classmethod 120 | def from_json(cls, json: T_JSON_DICT) -> LayoutShift: 121 | return cls( 122 | value=float(json["value"]), 123 | had_recent_input=bool(json["hadRecentInput"]), 124 | last_input_time=network.TimeSinceEpoch.from_json(json["lastInputTime"]), 125 | sources=[LayoutShiftAttribution.from_json(i) for i in json["sources"]], 126 | ) 127 | 128 | 129 | @dataclass 130 | class TimelineEvent: 131 | #: Identifies the frame that this event is related to. Empty for non-frame targets. 132 | frame_id: page.FrameId 133 | 134 | #: The event type, as specified in https://w3c.github.io/performance-timeline/#dom-performanceentry-entrytype 135 | #: This determines which of the optional "details" fields is present. 136 | type_: str 137 | 138 | #: Name may be empty depending on the type. 139 | name: str 140 | 141 | #: Time in seconds since Epoch, monotonically increasing within document lifetime. 142 | time: network.TimeSinceEpoch 143 | 144 | #: Event duration, if applicable. 145 | duration: typing.Optional[float] = None 146 | 147 | lcp_details: typing.Optional[LargestContentfulPaint] = None 148 | 149 | layout_shift_details: typing.Optional[LayoutShift] = None 150 | 151 | def to_json(self) -> T_JSON_DICT: 152 | json: T_JSON_DICT = dict() 153 | json["frameId"] = self.frame_id.to_json() 154 | json["type"] = self.type_ 155 | json["name"] = self.name 156 | json["time"] = self.time.to_json() 157 | if self.duration is not None: 158 | json["duration"] = self.duration 159 | if self.lcp_details is not None: 160 | json["lcpDetails"] = self.lcp_details.to_json() 161 | if self.layout_shift_details is not None: 162 | json["layoutShiftDetails"] = self.layout_shift_details.to_json() 163 | return json 164 | 165 | @classmethod 166 | def from_json(cls, json: T_JSON_DICT) -> TimelineEvent: 167 | return cls( 168 | frame_id=page.FrameId.from_json(json["frameId"]), 169 | type_=str(json["type"]), 170 | name=str(json["name"]), 171 | time=network.TimeSinceEpoch.from_json(json["time"]), 172 | duration=float(json["duration"]) 173 | if json.get("duration", None) is not None 174 | else None, 175 | lcp_details=LargestContentfulPaint.from_json(json["lcpDetails"]) 176 | if json.get("lcpDetails", None) is not None 177 | else None, 178 | layout_shift_details=LayoutShift.from_json(json["layoutShiftDetails"]) 179 | if json.get("layoutShiftDetails", None) is not None 180 | else None, 181 | ) 182 | 183 | 184 | def enable( 185 | event_types: typing.List[str], 186 | ) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 187 | """ 188 | Previously buffered events would be reported before method returns. 189 | See also: timelineEventAdded 190 | 191 | :param event_types: The types of event to report, as specified in https://w3c.github.io/performance-timeline/#dom-performanceentry-entrytype The specified filter overrides any previous filters, passing empty filter disables recording. Note that not all types exposed to the web platform are currently supported. 192 | """ 193 | params: T_JSON_DICT = dict() 194 | params["eventTypes"] = [i for i in event_types] 195 | cmd_dict: T_JSON_DICT = { 196 | "method": "PerformanceTimeline.enable", 197 | "params": params, 198 | } 199 | json = yield cmd_dict 200 | 201 | 202 | @event_class("PerformanceTimeline.timelineEventAdded") 203 | @dataclass 204 | class TimelineEventAdded: 205 | """ 206 | Sent when a performance timeline event is added. See reportPerformanceTimeline method. 207 | """ 208 | 209 | event: TimelineEvent 210 | 211 | @classmethod 212 | def from_json(cls, json: T_JSON_DICT) -> TimelineEventAdded: 213 | return cls(event=TimelineEvent.from_json(json["event"])) 214 | -------------------------------------------------------------------------------- /zendriver/cdp/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/zendriver/cdp/py.typed -------------------------------------------------------------------------------- /zendriver/cdp/schema.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Schema 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | @dataclass 16 | class Domain: 17 | """ 18 | Description of the protocol domain. 19 | """ 20 | 21 | #: Domain name. 22 | name: str 23 | 24 | #: Domain version. 25 | version: str 26 | 27 | def to_json(self) -> T_JSON_DICT: 28 | json: T_JSON_DICT = dict() 29 | json["name"] = self.name 30 | json["version"] = self.version 31 | return json 32 | 33 | @classmethod 34 | def from_json(cls, json: T_JSON_DICT) -> Domain: 35 | return cls( 36 | name=str(json["name"]), 37 | version=str(json["version"]), 38 | ) 39 | 40 | 41 | def get_domains() -> typing.Generator[T_JSON_DICT, T_JSON_DICT, typing.List[Domain]]: 42 | """ 43 | Returns supported domains. 44 | 45 | :returns: List of supported domains. 46 | """ 47 | cmd_dict: T_JSON_DICT = { 48 | "method": "Schema.getDomains", 49 | } 50 | json = yield cmd_dict 51 | return [Domain.from_json(i) for i in json["domains"]] 52 | -------------------------------------------------------------------------------- /zendriver/cdp/tethering.py: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE! 2 | # 3 | # This file is generated from the CDP specification. If you need to make 4 | # changes, edit the generator and regenerate all of the modules. 5 | # 6 | # CDP domain: Tethering (experimental) 7 | 8 | from __future__ import annotations 9 | import enum 10 | import typing 11 | from dataclasses import dataclass 12 | from .util import event_class, T_JSON_DICT 13 | 14 | 15 | def bind(port: int) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 16 | """ 17 | Request browser port binding. 18 | 19 | :param port: Port number to bind. 20 | """ 21 | params: T_JSON_DICT = dict() 22 | params["port"] = port 23 | cmd_dict: T_JSON_DICT = { 24 | "method": "Tethering.bind", 25 | "params": params, 26 | } 27 | json = yield cmd_dict 28 | 29 | 30 | def unbind(port: int) -> typing.Generator[T_JSON_DICT, T_JSON_DICT, None]: 31 | """ 32 | Request browser port unbinding. 33 | 34 | :param port: Port number to unbind. 35 | """ 36 | params: T_JSON_DICT = dict() 37 | params["port"] = port 38 | cmd_dict: T_JSON_DICT = { 39 | "method": "Tethering.unbind", 40 | "params": params, 41 | } 42 | json = yield cmd_dict 43 | 44 | 45 | @event_class("Tethering.accepted") 46 | @dataclass 47 | class Accepted: 48 | """ 49 | Informs that port was successfully bound and got a specified connection id. 50 | """ 51 | 52 | #: Port number that was successfully bound. 53 | port: int 54 | #: Connection id to be used. 55 | connection_id: str 56 | 57 | @classmethod 58 | def from_json(cls, json: T_JSON_DICT) -> Accepted: 59 | return cls(port=int(json["port"]), connection_id=str(json["connectionId"])) 60 | -------------------------------------------------------------------------------- /zendriver/cdp/util.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | T_JSON_DICT = typing.Dict[str, typing.Any] 4 | _event_parsers = dict() 5 | 6 | 7 | def event_class(method): 8 | """A decorator that registers a class as an event class.""" 9 | 10 | def decorate(cls): 11 | _event_parsers[method] = cls 12 | return cls 13 | 14 | return decorate 15 | 16 | 17 | def parse_json_event(json: T_JSON_DICT) -> typing.Any: 18 | """Parse a JSON dictionary into a CDP event.""" 19 | return _event_parsers[json["method"]].from_json(json["params"]) 20 | -------------------------------------------------------------------------------- /zendriver/core/_contradict.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import warnings as _warnings 3 | from collections.abc import Mapping as _Mapping 4 | from collections.abc import Sequence as _Sequence 5 | 6 | __logger__ = logging.getLogger(__name__) 7 | 8 | 9 | __all__ = ["cdict", "ContraDict"] 10 | 11 | 12 | def cdict(*args, **kwargs): 13 | """ 14 | factory function 15 | """ 16 | return ContraDict(*args, **kwargs) 17 | 18 | 19 | class ContraDict(dict): 20 | """ 21 | directly inherited from dict 22 | 23 | accessible by attribute. o.x == o['x'] 24 | This works also for all corner cases. 25 | 26 | native json.dumps and json.loads work with it 27 | 28 | names like "keys", "update", "values" etc won't overwrite the methods, 29 | but will just be available using dict lookup notation obj['items'] instead of obj.items 30 | 31 | all key names are converted to snake_case 32 | hyphen's (-), dot's (.) or whitespaces are replaced by underscore (_) 33 | 34 | autocomplete works even if the objects comes from a list 35 | 36 | recursive action. dict assignments will be converted too. 37 | """ 38 | 39 | def __init__(self, *args, **kwargs): 40 | super().__init__() 41 | silent = kwargs.pop("silent", False) 42 | _ = dict(*args, **kwargs) 43 | 44 | # for key, val in dict(*args, **kwargs).items(): 45 | # _[key] = val 46 | super().__setattr__("__dict__", self) 47 | for k, v in _.items(): 48 | _check_key(k, self, False, silent) 49 | super().__setitem__(k, _wrap(self.__class__, v)) 50 | 51 | def __setitem__(self, key, value): 52 | super().__setitem__(key, _wrap(self.__class__, value)) 53 | 54 | def __setattr__(self, key, value): 55 | super().__setitem__(key, _wrap(self.__class__, value)) 56 | 57 | def __getattribute__(self, attribute): 58 | if attribute in self: 59 | return self[attribute] 60 | if not _check_key(attribute, self, True, silent=True): 61 | return getattr(super(), attribute) 62 | 63 | return object.__getattribute__(self, attribute) 64 | 65 | 66 | def _wrap(cls, v): 67 | if isinstance(v, _Mapping): 68 | v = cls(v) 69 | 70 | elif isinstance(v, _Sequence) and not isinstance( 71 | v, (str, bytes, bytearray, set, tuple) 72 | ): 73 | v = list([_wrap(cls, x) for x in v]) 74 | return v 75 | 76 | 77 | _warning_names = ( 78 | "items", 79 | "keys", 80 | "values", 81 | "update", 82 | "clear", 83 | "copy", 84 | "fromkeys", 85 | "get", 86 | "items", 87 | "keys", 88 | "pop", 89 | "popitem", 90 | "setdefault", 91 | "update", 92 | "values", 93 | "class", 94 | ) 95 | 96 | _warning_names_message = """\n\ 97 | While creating a ContraDict object, a key offending key name '{0}' has been found, which might behave unexpected. 98 | you will only be able to look it up using key, eg. myobject['{0}']. myobject.{0} will not work with that name. 99 | """ 100 | 101 | 102 | def _check_key(key: str, mapping: _Mapping, boolean: bool = False, silent=False): 103 | """checks `key` and warns if needed 104 | 105 | :param key: 106 | :param boolean: return True or False instead of passthrough 107 | :return: 108 | """ 109 | e = None 110 | if not isinstance(key, (str,)): 111 | if boolean: 112 | return True 113 | return key 114 | if key.lower() in _warning_names or any(_ in key for _ in ("-", ".")): 115 | if not silent: 116 | _warnings.warn(_warning_names_message.format(key)) 117 | e = True 118 | if not boolean: 119 | return key 120 | return not e 121 | -------------------------------------------------------------------------------- /zendriver/core/expect.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from typing import Union 4 | 5 | from .. import cdp 6 | from .connection import Connection 7 | 8 | 9 | class BaseRequestExpectation: 10 | """ 11 | Base class for handling request and response expectations. 12 | This class provides a context manager to wait for specific network requests and responses 13 | based on a URL pattern. It sets up handlers for request and response events and provides 14 | properties to access the request, response, and response body. 15 | :param tab: The Tab instance to monitor. 16 | :type tab: Tab 17 | :param url_pattern: The URL pattern to match requests and responses. 18 | :type url_pattern: Union[str, re.Pattern[str]] 19 | """ 20 | 21 | def __init__(self, tab: Connection, url_pattern: Union[str, re.Pattern[str]]): 22 | self.tab = tab 23 | self.url_pattern = url_pattern 24 | self.request_future: asyncio.Future[cdp.network.RequestWillBeSent] = ( 25 | asyncio.Future() 26 | ) 27 | self.response_future: asyncio.Future[cdp.network.ResponseReceived] = ( 28 | asyncio.Future() 29 | ) 30 | self.request_id: Union[cdp.network.RequestId, None] = None 31 | 32 | async def _request_handler(self, event: cdp.network.RequestWillBeSent): 33 | """ 34 | Internal handler for request events. 35 | :param event: The request event. 36 | :type event: cdp.network.RequestWillBeSent 37 | """ 38 | if re.fullmatch(self.url_pattern, event.request.url): 39 | self._remove_request_handler() 40 | self.request_id = event.request_id 41 | self.request_future.set_result(event) 42 | 43 | async def _response_handler(self, event: cdp.network.ResponseReceived): 44 | """ 45 | Internal handler for response events. 46 | :param event: The response event. 47 | :type event: cdp.network.ResponseReceived 48 | """ 49 | if event.request_id == self.request_id: 50 | self._remove_response_handler() 51 | self.response_future.set_result(event) 52 | 53 | def _remove_request_handler(self): 54 | """ 55 | Remove the request event handler. 56 | """ 57 | self.tab.remove_handlers(cdp.network.RequestWillBeSent, self._request_handler) 58 | 59 | def _remove_response_handler(self): 60 | """ 61 | Remove the response event handler. 62 | """ 63 | self.tab.remove_handlers(cdp.network.ResponseReceived, self._response_handler) 64 | 65 | async def __aenter__(self): 66 | """ 67 | Enter the context manager, adding request and response handlers. 68 | """ 69 | self.tab.add_handler(cdp.network.RequestWillBeSent, self._request_handler) 70 | self.tab.add_handler(cdp.network.ResponseReceived, self._response_handler) 71 | return self 72 | 73 | async def __aexit__(self, *args): 74 | """ 75 | Exit the context manager, removing request and response handlers. 76 | """ 77 | self._remove_request_handler() 78 | self._remove_response_handler() 79 | 80 | @property 81 | async def request(self): 82 | """ 83 | Get the matched request. 84 | :return: The matched request. 85 | :rtype: cdp.network.Request 86 | """ 87 | return (await self.request_future).request 88 | 89 | @property 90 | async def response(self): 91 | """ 92 | Get the matched response. 93 | :return: The matched response. 94 | :rtype: cdp.network.Response 95 | """ 96 | return (await self.response_future).response 97 | 98 | @property 99 | async def response_body(self): 100 | """ 101 | Get the body of the matched response. 102 | :return: The response body. 103 | :rtype: str 104 | """ 105 | request_id = (await self.request_future).request_id 106 | body = await self.tab.send(cdp.network.get_response_body(request_id=request_id)) 107 | return body 108 | 109 | 110 | class RequestExpectation(BaseRequestExpectation): 111 | """ 112 | Class for handling request expectations. 113 | This class extends `BaseRequestExpectation` and provides a property to access the matched request. 114 | :param tab: The Tab instance to monitor. 115 | :type tab: Tab 116 | :param url_pattern: The URL pattern to match requests. 117 | :type url_pattern: Union[str, re.Pattern[str]] 118 | """ 119 | 120 | @property 121 | async def value(self) -> cdp.network.RequestWillBeSent: 122 | """ 123 | Get the matched request event. 124 | :return: The matched request event. 125 | :rtype: cdp.network.RequestWillBeSent 126 | """ 127 | return await self.request_future 128 | 129 | 130 | class ResponseExpectation(BaseRequestExpectation): 131 | """ 132 | Class for handling response expectations. 133 | This class extends `BaseRequestExpectation` and provides a property to access the matched response. 134 | :param tab: The Tab instance to monitor. 135 | :type tab: Tab 136 | :param url_pattern: The URL pattern to match responses. 137 | :type url_pattern: Union[str, re.Pattern[str]] 138 | """ 139 | 140 | @property 141 | async def value(self) -> cdp.network.ResponseReceived: 142 | """ 143 | Get the matched response event. 144 | :return: The matched response event. 145 | :rtype: cdp.network.ResponseReceived 146 | """ 147 | return await self.response_future 148 | 149 | 150 | class DownloadExpectation: 151 | def __init__(self, tab: Connection): 152 | self.tab = tab 153 | self.future: asyncio.Future[cdp.browser.DownloadWillBegin] = asyncio.Future() 154 | # TODO: Improve 155 | self.default_behavior = ( 156 | self.tab._download_behavior[0] if self.tab._download_behavior else "default" 157 | ) 158 | 159 | async def _handler(self, event: cdp.browser.DownloadWillBegin): 160 | self._remove_handler() 161 | self.future.set_result(event) 162 | 163 | def _remove_handler(self): 164 | self.tab.remove_handlers(cdp.browser.DownloadWillBegin, self._handler) 165 | 166 | async def __aenter__(self): 167 | """ 168 | Enter the context manager, adding download handler, set download behavior to deny. 169 | """ 170 | await self.tab.send( 171 | cdp.browser.set_download_behavior(behavior="deny", events_enabled=True) 172 | ) 173 | self.tab.add_handler(cdp.browser.DownloadWillBegin, self._handler) 174 | return self 175 | 176 | async def __aexit__(self, *args): 177 | """ 178 | Exit the context manager, removing handler, set download behavior to default. 179 | """ 180 | await self.tab.send( 181 | cdp.browser.set_download_behavior(behavior=self.default_behavior) 182 | ) 183 | self._remove_handler() 184 | 185 | @property 186 | async def value(self) -> cdp.browser.DownloadWillBegin: 187 | return await self.future 188 | -------------------------------------------------------------------------------- /zendriver/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stephanlensky/zendriver/fad53c215c1cacc394a5406b2f4dbe369f52ad03/zendriver/py.typed --------------------------------------------------------------------------------