purecdp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- purecdp-0.1.0/PKG-INFO +193 -0
- purecdp-0.1.0/Readme.md +174 -0
- purecdp-0.1.0/pyproject.toml +46 -0
- purecdp-0.1.0/setup.cfg +4 -0
- purecdp-0.1.0/src/purecdp/__init__.py +64 -0
- purecdp-0.1.0/src/purecdp/_b64.py +24 -0
- purecdp-0.1.0/src/purecdp/_generator/__init__.py +10 -0
- purecdp-0.1.0/src/purecdp/_generator/__main__.py +53 -0
- purecdp-0.1.0/src/purecdp/_generator/emit.py +532 -0
- purecdp-0.1.0/src/purecdp/_generator/model.py +175 -0
- purecdp-0.1.0/src/purecdp/_json.py +45 -0
- purecdp-0.1.0/src/purecdp/_loop.py +53 -0
- purecdp-0.1.0/src/purecdp/_shared.py +51 -0
- purecdp-0.1.0/src/purecdp/browser.py +363 -0
- purecdp-0.1.0/src/purecdp/connection.py +513 -0
- purecdp-0.1.0/src/purecdp/discovery.py +50 -0
- purecdp-0.1.0/src/purecdp/engine.py +192 -0
- purecdp-0.1.0/src/purecdp/errors.py +48 -0
- purecdp-0.1.0/src/purecdp/mcp.py +233 -0
- purecdp-0.1.0/src/purecdp/protocol/__init__.py +156 -0
- purecdp-0.1.0/src/purecdp/protocol/accessibility.py +585 -0
- purecdp-0.1.0/src/purecdp/protocol/ads.py +103 -0
- purecdp-0.1.0/src/purecdp/protocol/animation.py +385 -0
- purecdp-0.1.0/src/purecdp/protocol/audits.py +1925 -0
- purecdp-0.1.0/src/purecdp/protocol/autofill.py +250 -0
- purecdp-0.1.0/src/purecdp/protocol/background_service.py +177 -0
- purecdp-0.1.0/src/purecdp/protocol/bluetooth_emulation.py +520 -0
- purecdp-0.1.0/src/purecdp/protocol/browser.py +670 -0
- purecdp-0.1.0/src/purecdp/protocol/cache_storage.py +262 -0
- purecdp-0.1.0/src/purecdp/protocol/cast.py +131 -0
- purecdp-0.1.0/src/purecdp/protocol/console.py +87 -0
- purecdp-0.1.0/src/purecdp/protocol/crash_report_context.py +50 -0
- purecdp-0.1.0/src/purecdp/protocol/css.py +2336 -0
- purecdp-0.1.0/src/purecdp/protocol/debugger.py +1164 -0
- purecdp-0.1.0/src/purecdp/protocol/device_access.py +112 -0
- purecdp-0.1.0/src/purecdp/protocol/device_orientation.py +36 -0
- purecdp-0.1.0/src/purecdp/protocol/digital_credentials.py +57 -0
- purecdp-0.1.0/src/purecdp/protocol/dom.py +1869 -0
- purecdp-0.1.0/src/purecdp/protocol/dom_debugger.py +249 -0
- purecdp-0.1.0/src/purecdp/protocol/dom_snapshot.py +734 -0
- purecdp-0.1.0/src/purecdp/protocol/dom_storage.py +186 -0
- purecdp-0.1.0/src/purecdp/protocol/emulation.py +1443 -0
- purecdp-0.1.0/src/purecdp/protocol/event_breakpoints.py +46 -0
- purecdp-0.1.0/src/purecdp/protocol/extensions.py +189 -0
- purecdp-0.1.0/src/purecdp/protocol/fed_cm.py +234 -0
- purecdp-0.1.0/src/purecdp/protocol/fetch.py +433 -0
- purecdp-0.1.0/src/purecdp/protocol/file_system.py +103 -0
- purecdp-0.1.0/src/purecdp/protocol/headless_experimental.py +95 -0
- purecdp-0.1.0/src/purecdp/protocol/heap_profiler.py +317 -0
- purecdp-0.1.0/src/purecdp/protocol/indexed_db.py +447 -0
- purecdp-0.1.0/src/purecdp/protocol/input.py +610 -0
- purecdp-0.1.0/src/purecdp/protocol/inspector.py +70 -0
- purecdp-0.1.0/src/purecdp/protocol/io.py +83 -0
- purecdp-0.1.0/src/purecdp/protocol/layer_tree.py +379 -0
- purecdp-0.1.0/src/purecdp/protocol/log.py +150 -0
- purecdp-0.1.0/src/purecdp/protocol/media.py +271 -0
- purecdp-0.1.0/src/purecdp/protocol/memory.py +237 -0
- purecdp-0.1.0/src/purecdp/protocol/network.py +4730 -0
- purecdp-0.1.0/src/purecdp/protocol/overlay.py +1282 -0
- purecdp-0.1.0/src/purecdp/protocol/page.py +3477 -0
- purecdp-0.1.0/src/purecdp/protocol/performance.py +88 -0
- purecdp-0.1.0/src/purecdp/protocol/performance_timeline.py +176 -0
- purecdp-0.1.0/src/purecdp/protocol/preload.py +518 -0
- purecdp-0.1.0/src/purecdp/protocol/profiler.py +340 -0
- purecdp-0.1.0/src/purecdp/protocol/pwa.py +234 -0
- purecdp-0.1.0/src/purecdp/protocol/runtime.py +1344 -0
- purecdp-0.1.0/src/purecdp/protocol/schema.py +46 -0
- purecdp-0.1.0/src/purecdp/protocol/security.py +448 -0
- purecdp-0.1.0/src/purecdp/protocol/service_worker.py +309 -0
- purecdp-0.1.0/src/purecdp/protocol/smart_card_emulation.py +801 -0
- purecdp-0.1.0/src/purecdp/protocol/storage.py +1074 -0
- purecdp-0.1.0/src/purecdp/protocol/system_info.py +277 -0
- purecdp-0.1.0/src/purecdp/protocol/target.py +712 -0
- purecdp-0.1.0/src/purecdp/protocol/tethering.py +52 -0
- purecdp-0.1.0/src/purecdp/protocol/tracing.py +339 -0
- purecdp-0.1.0/src/purecdp/protocol/web_audio.py +524 -0
- purecdp-0.1.0/src/purecdp/protocol/web_authn.py +475 -0
- purecdp-0.1.0/src/purecdp/protocol/web_mcp.py +245 -0
- purecdp-0.1.0/src/purecdp/py.typed +0 -0
- purecdp-0.1.0/src/purecdp/testing/__init__.py +62 -0
- purecdp-0.1.0/src/purecdp/testing/_agent.py +241 -0
- purecdp-0.1.0/src/purecdp/testing/element.py +399 -0
- purecdp-0.1.0/src/purecdp/testing/frame.py +167 -0
- purecdp-0.1.0/src/purecdp/testing/human.py +189 -0
- purecdp-0.1.0/src/purecdp/testing/intercept.py +162 -0
- purecdp-0.1.0/src/purecdp/testing/page.py +1092 -0
- purecdp-0.1.0/src/purecdp/testing/pytest_plugin.py +79 -0
- purecdp-0.1.0/src/purecdp/testing/recorder.py +155 -0
- purecdp-0.1.0/src/purecdp/testing/snapshot.py +199 -0
- purecdp-0.1.0/src/purecdp/testing/sse.py +85 -0
- purecdp-0.1.0/src/purecdp/testing/stealth.py +338 -0
- purecdp-0.1.0/src/purecdp/testing/testcase.py +55 -0
- purecdp-0.1.0/src/purecdp/transport/__init__.py +10 -0
- purecdp-0.1.0/src/purecdp/transport/pipe.py +142 -0
- purecdp-0.1.0/src/purecdp/transport/websocket.py +214 -0
- purecdp-0.1.0/src/purecdp.egg-info/PKG-INFO +193 -0
- purecdp-0.1.0/src/purecdp.egg-info/SOURCES.txt +112 -0
- purecdp-0.1.0/src/purecdp.egg-info/dependency_links.txt +1 -0
- purecdp-0.1.0/src/purecdp.egg-info/entry_points.txt +2 -0
- purecdp-0.1.0/src/purecdp.egg-info/requires.txt +10 -0
- purecdp-0.1.0/src/purecdp.egg-info/top_level.txt +1 -0
- purecdp-0.1.0/tests/test_backends.py +68 -0
- purecdp-0.1.0/tests/test_connection.py +257 -0
- purecdp-0.1.0/tests/test_e2e.py +161 -0
- purecdp-0.1.0/tests/test_engine.py +158 -0
- purecdp-0.1.0/tests/test_generator.py +115 -0
- purecdp-0.1.0/tests/test_json.py +53 -0
- purecdp-0.1.0/tests/test_lifecycle.py +198 -0
- purecdp-0.1.0/tests/test_mcp.py +148 -0
- purecdp-0.1.0/tests/test_protocol.py +218 -0
- purecdp-0.1.0/tests/test_sse.py +80 -0
- purecdp-0.1.0/tests/test_testing.py +809 -0
- purecdp-0.1.0/tests/test_testing_e2e.py +1028 -0
- purecdp-0.1.0/tests/test_transports.py +161 -0
purecdp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: purecdp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Vanilla Python Chrome DevTools Protocol. Optional dependencies for accelerated performance
|
|
5
|
+
License-Expression: 0BSD
|
|
6
|
+
Project-URL: Homepage, https://github.com/h5rdly/purecdp
|
|
7
|
+
Keywords: cdp,chrome-devtools-protocol,chromium,browser,devtools,browser-automation
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Classifier: Topic :: Software Development :: Testing
|
|
11
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
12
|
+
Requires-Python: >=3.14
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Provides-Extra: faster
|
|
15
|
+
Requires-Dist: orjson>=3.11; extra == "faster"
|
|
16
|
+
Requires-Dist: pybase64>=1.4.3; extra == "faster"
|
|
17
|
+
Requires-Dist: uvloop>=0.22; platform_system != "Windows" and extra == "faster"
|
|
18
|
+
Requires-Dist: winloop>=0.6; platform_system == "Windows" and extra == "faster"
|
|
19
|
+
|
|
20
|
+
# purecdp
|
|
21
|
+
|
|
22
|
+
Pure-Python (stdlib-only) library for the Chrome DevTools Protocol, targeting Python 3.14+.
|
|
23
|
+
|
|
24
|
+
Three things, layered:
|
|
25
|
+
|
|
26
|
+
1. **`purecdp.protocol`** — low-level typed bindings, generated from the official
|
|
27
|
+
[devtools-protocol](https://github.com/ChromeDevTools/devtools-protocol) JSON specs.
|
|
28
|
+
2. **`purecdp` core** — sans-I/O protocol engine + asyncio transports (websocket / pipe) + browser launcher.
|
|
29
|
+
3. **`purecdp.testing`** — opinionated helpers for driving a browser in tests, incl. a pytest plugin.
|
|
30
|
+
|
|
31
|
+
See [Examples.md](Examples.md) for task-grouped recipes (basics, testing, stealth, LLM agents, frames),
|
|
32
|
+
[DESIGN.md](DESIGN.md) for architecture and rationale, and [ROADMAP.md](ROADMAP.md) for work order and status.
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import asyncio
|
|
38
|
+
import purecdp
|
|
39
|
+
from purecdp.protocol import page, runtime
|
|
40
|
+
|
|
41
|
+
async def main():
|
|
42
|
+
async with await purecdp.launch() as browser: # finds Chromium/Chrome/Brave
|
|
43
|
+
session = await browser.new_page() # create target + attach
|
|
44
|
+
|
|
45
|
+
await session.execute(page.enable())
|
|
46
|
+
waiter = asyncio.create_task(session.wait_for(page.LoadEventFired))
|
|
47
|
+
await asyncio.sleep(0) # subscribe before triggering
|
|
48
|
+
await session.execute(page.navigate(url="https://example.com"))
|
|
49
|
+
await waiter
|
|
50
|
+
|
|
51
|
+
result, _ = await session.execute(
|
|
52
|
+
runtime.evaluate(expression="document.title", return_by_value=True))
|
|
53
|
+
print(result.value)
|
|
54
|
+
|
|
55
|
+
asyncio.run(main())
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`purecdp.launch(pipe=True)` uses `--remote-debugging-pipe` instead of a websocket;
|
|
59
|
+
`purecdp.discovery.get_version(port=9222)` reaches a browser someone else started.
|
|
60
|
+
`browser.new_context()` gives an isolated cookies/storage context (cheap per-test
|
|
61
|
+
isolation); `connection.set_auto_attach()` auto-attaches popups/workers/OOPIFs,
|
|
62
|
+
resuming paused targets automatically.
|
|
63
|
+
|
|
64
|
+
## Writing tests with purecdp
|
|
65
|
+
|
|
66
|
+
The stdlib way (`purecdp.testing.CDPTestCase` — fresh browser, isolated
|
|
67
|
+
context, and a `Page` per test):
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from purecdp.testing import CDPTestCase, JSError
|
|
71
|
+
|
|
72
|
+
class MyAppTests(CDPTestCase):
|
|
73
|
+
async def test_stubbed_api(self):
|
|
74
|
+
async def api(request):
|
|
75
|
+
await request.fulfill(body='{"items": []}',
|
|
76
|
+
content_type="application/json")
|
|
77
|
+
await self.page.route("*/api/*", api)
|
|
78
|
+
await self.page.goto("https://myapp.example/", wait="idle")
|
|
79
|
+
await self.page.wait_for_selector("#empty-state")
|
|
80
|
+
assert not self.page.js_errors
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`Page` gives you `goto` (load/idle waits), `evaluate` (promises awaited,
|
|
84
|
+
`JSError` raised), `wait_for_selector`/`wait_for_function`, `click`,
|
|
85
|
+
`query`/`query_all` (held elements, text-filtered, stale-copy-proof),
|
|
86
|
+
`screenshot`, always-on `page.console` / `page.js_errors` capture, and
|
|
87
|
+
`page.route()` request stubbing/rewriting/aborting. For traffic-level
|
|
88
|
+
assertions there's `page.record()` (full request/response exchanges, with
|
|
89
|
+
`parse_sse()` for streamed bodies) and `page.expect_download()` (capture a
|
|
90
|
+
download's bytes without touching disk).
|
|
91
|
+
|
|
92
|
+
Prefer pytest? The optional plugin (auto-registered when installed, or
|
|
93
|
+
`-p purecdp.testing.pytest_plugin`) provides a session-scoped `cdp_browser`
|
|
94
|
+
and per-test `cdp_page`, and runs `async def` tests itself — no
|
|
95
|
+
pytest-asyncio required:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
async def test_title(cdp_page):
|
|
99
|
+
await cdp_page.goto("data:text/html,<title>hi</title>")
|
|
100
|
+
assert await cdp_page.title() == "hi"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Low-observability / stealth
|
|
104
|
+
|
|
105
|
+
For automation that shouldn't advertise itself, purecdp layers three opt-in
|
|
106
|
+
pieces (reduces common detection signals — not a promise of undetectability;
|
|
107
|
+
CreepJS-class and TLS/HTTP2 fingerprints still identify automation):
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
import purecdp
|
|
111
|
+
from purecdp.testing import Page, apply_stealth
|
|
112
|
+
|
|
113
|
+
async with await purecdp.launch(stealth=True) as browser: # AutomationControlled off, headless=new
|
|
114
|
+
session = await browser.new_page()
|
|
115
|
+
page = await Page.create(session, capture=False, track_network=False) # no Runtime.enable leak
|
|
116
|
+
await apply_stealth(page) # fingerprint init-scripts, before goto
|
|
117
|
+
await page.goto("https://example.com")
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`apply_stealth(page, evasions=[...])` selects a subset; keyword overrides
|
|
121
|
+
(`webgl_vendor`, `webgl_renderer`, `languages`, `vendor`,
|
|
122
|
+
`hardware_concurrency`) tune the spoofed values.
|
|
123
|
+
|
|
124
|
+
For behavioral realism (the more durable signal — how input *moves*, all over
|
|
125
|
+
trusted Input events):
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
el = await page.query("#submit")
|
|
129
|
+
await el.human_click() # curved, eased path to a near-center point
|
|
130
|
+
await page.human_type("hello@example.com") # per-key events, human cadence
|
|
131
|
+
await page.human_scroll(1200) # eased wheel steps, not one jump
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Since purecdp drives a *real* browser, its TLS/HTTP2 (JA3/JA4) fingerprint is
|
|
135
|
+
already authentically Chrome's — no spoofing needed, unlike browserless HTTP
|
|
136
|
+
scrapers.
|
|
137
|
+
|
|
138
|
+
## Driving with an LLM (agent surface)
|
|
139
|
+
|
|
140
|
+
`page.snapshot()` returns a compact, ref-tagged view of the page from the
|
|
141
|
+
accessibility tree — small and stable enough to hand an LLM, instead of raw
|
|
142
|
+
HTML or screenshots. Each control gets a session ref (`e1`, `e2`, …); password
|
|
143
|
+
fields are masked.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
snap = await page.snapshot()
|
|
147
|
+
print(snap)
|
|
148
|
+
# - RootWebArea "Login"
|
|
149
|
+
# - textbox "Email" [e1]
|
|
150
|
+
# - textbox "Password" = "••••••" [e2]
|
|
151
|
+
# - button "Sign in" [e3]
|
|
152
|
+
|
|
153
|
+
await page.act("e1", "fill", text="me@example.com") # fill/type/select/click/…
|
|
154
|
+
await page.act("e3", "click")
|
|
155
|
+
el = await page.element_for_ref("e1") # escape hatch: full Element API
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
An optional MCP server exposes this to any Model Context Protocol host — no
|
|
159
|
+
extra dependency, stdlib JSON-RPC over stdio:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
python -m purecdp.mcp # tools: navigate, snapshot, act, evaluate
|
|
163
|
+
python -m purecdp.mcp --stealth # low-observability launch
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Layout
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
spec/ vendored protocol JSON + PIN (source commit)
|
|
170
|
+
src/purecdp/ the library (src layout)
|
|
171
|
+
src/purecdp/_generator/ code generator (dev tool, reads spec/, writes src/purecdp/protocol/)
|
|
172
|
+
src/purecdp/protocol/ GENERATED bindings — do not edit by hand
|
|
173
|
+
tests/ tests and test utilities
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Regenerating the bindings
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
python -m purecdp._generator # uses spec/ and src/purecdp/protocol/ defaults
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Update `spec/*.json` + `spec/PIN` from a devtools-protocol checkout first when moving the pin.
|
|
183
|
+
|
|
184
|
+
## Running tests
|
|
185
|
+
|
|
186
|
+
Stdlib only — no test dependencies:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
python -m unittest # discovery from repo root
|
|
190
|
+
python tests/test_protocol.py # single file works too
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`python -m pytest` also works (nicer output, subtest counts) but is never required.
|
purecdp-0.1.0/Readme.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# purecdp
|
|
2
|
+
|
|
3
|
+
Pure-Python (stdlib-only) library for the Chrome DevTools Protocol, targeting Python 3.14+.
|
|
4
|
+
|
|
5
|
+
Three things, layered:
|
|
6
|
+
|
|
7
|
+
1. **`purecdp.protocol`** — low-level typed bindings, generated from the official
|
|
8
|
+
[devtools-protocol](https://github.com/ChromeDevTools/devtools-protocol) JSON specs.
|
|
9
|
+
2. **`purecdp` core** — sans-I/O protocol engine + asyncio transports (websocket / pipe) + browser launcher.
|
|
10
|
+
3. **`purecdp.testing`** — opinionated helpers for driving a browser in tests, incl. a pytest plugin.
|
|
11
|
+
|
|
12
|
+
See [Examples.md](Examples.md) for task-grouped recipes (basics, testing, stealth, LLM agents, frames),
|
|
13
|
+
[DESIGN.md](DESIGN.md) for architecture and rationale, and [ROADMAP.md](ROADMAP.md) for work order and status.
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
import purecdp
|
|
20
|
+
from purecdp.protocol import page, runtime
|
|
21
|
+
|
|
22
|
+
async def main():
|
|
23
|
+
async with await purecdp.launch() as browser: # finds Chromium/Chrome/Brave
|
|
24
|
+
session = await browser.new_page() # create target + attach
|
|
25
|
+
|
|
26
|
+
await session.execute(page.enable())
|
|
27
|
+
waiter = asyncio.create_task(session.wait_for(page.LoadEventFired))
|
|
28
|
+
await asyncio.sleep(0) # subscribe before triggering
|
|
29
|
+
await session.execute(page.navigate(url="https://example.com"))
|
|
30
|
+
await waiter
|
|
31
|
+
|
|
32
|
+
result, _ = await session.execute(
|
|
33
|
+
runtime.evaluate(expression="document.title", return_by_value=True))
|
|
34
|
+
print(result.value)
|
|
35
|
+
|
|
36
|
+
asyncio.run(main())
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`purecdp.launch(pipe=True)` uses `--remote-debugging-pipe` instead of a websocket;
|
|
40
|
+
`purecdp.discovery.get_version(port=9222)` reaches a browser someone else started.
|
|
41
|
+
`browser.new_context()` gives an isolated cookies/storage context (cheap per-test
|
|
42
|
+
isolation); `connection.set_auto_attach()` auto-attaches popups/workers/OOPIFs,
|
|
43
|
+
resuming paused targets automatically.
|
|
44
|
+
|
|
45
|
+
## Writing tests with purecdp
|
|
46
|
+
|
|
47
|
+
The stdlib way (`purecdp.testing.CDPTestCase` — fresh browser, isolated
|
|
48
|
+
context, and a `Page` per test):
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from purecdp.testing import CDPTestCase, JSError
|
|
52
|
+
|
|
53
|
+
class MyAppTests(CDPTestCase):
|
|
54
|
+
async def test_stubbed_api(self):
|
|
55
|
+
async def api(request):
|
|
56
|
+
await request.fulfill(body='{"items": []}',
|
|
57
|
+
content_type="application/json")
|
|
58
|
+
await self.page.route("*/api/*", api)
|
|
59
|
+
await self.page.goto("https://myapp.example/", wait="idle")
|
|
60
|
+
await self.page.wait_for_selector("#empty-state")
|
|
61
|
+
assert not self.page.js_errors
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`Page` gives you `goto` (load/idle waits), `evaluate` (promises awaited,
|
|
65
|
+
`JSError` raised), `wait_for_selector`/`wait_for_function`, `click`,
|
|
66
|
+
`query`/`query_all` (held elements, text-filtered, stale-copy-proof),
|
|
67
|
+
`screenshot`, always-on `page.console` / `page.js_errors` capture, and
|
|
68
|
+
`page.route()` request stubbing/rewriting/aborting. For traffic-level
|
|
69
|
+
assertions there's `page.record()` (full request/response exchanges, with
|
|
70
|
+
`parse_sse()` for streamed bodies) and `page.expect_download()` (capture a
|
|
71
|
+
download's bytes without touching disk).
|
|
72
|
+
|
|
73
|
+
Prefer pytest? The optional plugin (auto-registered when installed, or
|
|
74
|
+
`-p purecdp.testing.pytest_plugin`) provides a session-scoped `cdp_browser`
|
|
75
|
+
and per-test `cdp_page`, and runs `async def` tests itself — no
|
|
76
|
+
pytest-asyncio required:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
async def test_title(cdp_page):
|
|
80
|
+
await cdp_page.goto("data:text/html,<title>hi</title>")
|
|
81
|
+
assert await cdp_page.title() == "hi"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Low-observability / stealth
|
|
85
|
+
|
|
86
|
+
For automation that shouldn't advertise itself, purecdp layers three opt-in
|
|
87
|
+
pieces (reduces common detection signals — not a promise of undetectability;
|
|
88
|
+
CreepJS-class and TLS/HTTP2 fingerprints still identify automation):
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
import purecdp
|
|
92
|
+
from purecdp.testing import Page, apply_stealth
|
|
93
|
+
|
|
94
|
+
async with await purecdp.launch(stealth=True) as browser: # AutomationControlled off, headless=new
|
|
95
|
+
session = await browser.new_page()
|
|
96
|
+
page = await Page.create(session, capture=False, track_network=False) # no Runtime.enable leak
|
|
97
|
+
await apply_stealth(page) # fingerprint init-scripts, before goto
|
|
98
|
+
await page.goto("https://example.com")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`apply_stealth(page, evasions=[...])` selects a subset; keyword overrides
|
|
102
|
+
(`webgl_vendor`, `webgl_renderer`, `languages`, `vendor`,
|
|
103
|
+
`hardware_concurrency`) tune the spoofed values.
|
|
104
|
+
|
|
105
|
+
For behavioral realism (the more durable signal — how input *moves*, all over
|
|
106
|
+
trusted Input events):
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
el = await page.query("#submit")
|
|
110
|
+
await el.human_click() # curved, eased path to a near-center point
|
|
111
|
+
await page.human_type("hello@example.com") # per-key events, human cadence
|
|
112
|
+
await page.human_scroll(1200) # eased wheel steps, not one jump
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Since purecdp drives a *real* browser, its TLS/HTTP2 (JA3/JA4) fingerprint is
|
|
116
|
+
already authentically Chrome's — no spoofing needed, unlike browserless HTTP
|
|
117
|
+
scrapers.
|
|
118
|
+
|
|
119
|
+
## Driving with an LLM (agent surface)
|
|
120
|
+
|
|
121
|
+
`page.snapshot()` returns a compact, ref-tagged view of the page from the
|
|
122
|
+
accessibility tree — small and stable enough to hand an LLM, instead of raw
|
|
123
|
+
HTML or screenshots. Each control gets a session ref (`e1`, `e2`, …); password
|
|
124
|
+
fields are masked.
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
snap = await page.snapshot()
|
|
128
|
+
print(snap)
|
|
129
|
+
# - RootWebArea "Login"
|
|
130
|
+
# - textbox "Email" [e1]
|
|
131
|
+
# - textbox "Password" = "••••••" [e2]
|
|
132
|
+
# - button "Sign in" [e3]
|
|
133
|
+
|
|
134
|
+
await page.act("e1", "fill", text="me@example.com") # fill/type/select/click/…
|
|
135
|
+
await page.act("e3", "click")
|
|
136
|
+
el = await page.element_for_ref("e1") # escape hatch: full Element API
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
An optional MCP server exposes this to any Model Context Protocol host — no
|
|
140
|
+
extra dependency, stdlib JSON-RPC over stdio:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
python -m purecdp.mcp # tools: navigate, snapshot, act, evaluate
|
|
144
|
+
python -m purecdp.mcp --stealth # low-observability launch
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Layout
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
spec/ vendored protocol JSON + PIN (source commit)
|
|
151
|
+
src/purecdp/ the library (src layout)
|
|
152
|
+
src/purecdp/_generator/ code generator (dev tool, reads spec/, writes src/purecdp/protocol/)
|
|
153
|
+
src/purecdp/protocol/ GENERATED bindings — do not edit by hand
|
|
154
|
+
tests/ tests and test utilities
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Regenerating the bindings
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
python -m purecdp._generator # uses spec/ and src/purecdp/protocol/ defaults
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Update `spec/*.json` + `spec/PIN` from a devtools-protocol checkout first when moving the pin.
|
|
164
|
+
|
|
165
|
+
## Running tests
|
|
166
|
+
|
|
167
|
+
Stdlib only — no test dependencies:
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
python -m unittest # discovery from repo root
|
|
171
|
+
python tests/test_protocol.py # single file works too
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`python -m pytest` also works (nicer output, subtest counts) but is never required.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=83"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "purecdp"
|
|
8
|
+
dynamic = ["version"] # single source: purecdp.__version__
|
|
9
|
+
description = "Vanilla Python Chrome DevTools Protocol. Optional dependencies for accelerated performance"
|
|
10
|
+
readme = "Readme.md" # exact on-disk case — GitHub's Linux runner is case-sensitive
|
|
11
|
+
license = "0BSD"
|
|
12
|
+
requires-python = ">=3.14"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
keywords = [
|
|
16
|
+
"cdp", "chrome-devtools-protocol", "chromium", "browser", "devtools", "browser-automation",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
faster = [
|
|
27
|
+
"orjson>=3.11", # wire JSON dumps/loads
|
|
28
|
+
"pybase64>=1.4.3", # screenshot/PDF/body base64
|
|
29
|
+
"uvloop>=0.22; platform_system != 'Windows'", # faster event loop (POSIX)
|
|
30
|
+
"winloop>=0.6; platform_system == 'Windows'", # faster event loop (Windows)
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/h5rdly/purecdp"
|
|
35
|
+
|
|
36
|
+
[project.entry-points.pytest11]
|
|
37
|
+
purecdp = "purecdp.testing.pytest_plugin"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.dynamic]
|
|
40
|
+
version = { attr = "purecdp.__version__" }
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
where = ["src"]
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.package-data]
|
|
46
|
+
purecdp = ["py.typed"]
|
purecdp-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'''Pure-Python Chrome DevTools Protocol library.
|
|
2
|
+
|
|
3
|
+
Layers (see DESIGN.md):
|
|
4
|
+
- purecdp.protocol — generated typed bindings, one module per CDP domain
|
|
5
|
+
- purecdp.engine — sans-I/O protocol engine (strings in, typed happenings out)
|
|
6
|
+
- purecdp.connection — asyncio Connection/Session over an abstract Transport
|
|
7
|
+
- purecdp.transport — stdlib websocket + pipe transports
|
|
8
|
+
- purecdp.browser — find/launch/clean-up a debuggable browser
|
|
9
|
+
- purecdp.discovery — /json/version, /json/list HTTP endpoints
|
|
10
|
+
- purecdp.errors — exception types
|
|
11
|
+
- purecdp._shared — support types shared by generated code and the engine
|
|
12
|
+
'''
|
|
13
|
+
|
|
14
|
+
from ._loop import run
|
|
15
|
+
from .browser import (
|
|
16
|
+
STEALTH_ARGS,
|
|
17
|
+
Browser,
|
|
18
|
+
BrowserContext,
|
|
19
|
+
close_page,
|
|
20
|
+
connect,
|
|
21
|
+
find_browser,
|
|
22
|
+
launch,
|
|
23
|
+
new_context,
|
|
24
|
+
new_page,
|
|
25
|
+
)
|
|
26
|
+
from .connection import Connection, EventStream, Session, Transport
|
|
27
|
+
from .errors import (
|
|
28
|
+
BrowserLaunchError,
|
|
29
|
+
CDPConnectionClosed,
|
|
30
|
+
CDPError,
|
|
31
|
+
CDPProtocolError,
|
|
32
|
+
CDPSessionClosed,
|
|
33
|
+
CDPTransportError,
|
|
34
|
+
ProtocolDriftWarning,
|
|
35
|
+
)
|
|
36
|
+
from .transport import PipeTransport, WebSocketTransport
|
|
37
|
+
|
|
38
|
+
__version__ = '0.1.0'
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
'Connection',
|
|
42
|
+
'Session',
|
|
43
|
+
'EventStream',
|
|
44
|
+
'Transport',
|
|
45
|
+
'WebSocketTransport',
|
|
46
|
+
'PipeTransport',
|
|
47
|
+
'Browser',
|
|
48
|
+
'BrowserContext',
|
|
49
|
+
'launch',
|
|
50
|
+
'connect',
|
|
51
|
+
'find_browser',
|
|
52
|
+
'new_context',
|
|
53
|
+
'new_page',
|
|
54
|
+
'close_page',
|
|
55
|
+
'STEALTH_ARGS',
|
|
56
|
+
'run',
|
|
57
|
+
'CDPError',
|
|
58
|
+
'CDPProtocolError',
|
|
59
|
+
'CDPConnectionClosed',
|
|
60
|
+
'CDPSessionClosed',
|
|
61
|
+
'CDPTransportError',
|
|
62
|
+
'BrowserLaunchError',
|
|
63
|
+
'ProtocolDriftWarning',
|
|
64
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'''base64 backend: pybase64 (SIMD) when installed, stdlib base64 otherwise.
|
|
2
|
+
|
|
3
|
+
CDP moves base64 in bulk — screenshots, PDFs, and recorded response bodies —
|
|
4
|
+
and pybase64 decodes them ~3-4x faster than the stdlib. Optional: purecdp
|
|
5
|
+
stays zero-dependency; these are drop-in replacements (identical signatures)
|
|
6
|
+
used only where large blobs are decoded/encoded.
|
|
7
|
+
'''
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import pybase64 as _backend
|
|
13
|
+
|
|
14
|
+
HAVE_PYBASE64 = True
|
|
15
|
+
except ImportError: # pragma: no cover - stdlib path exercised in CI
|
|
16
|
+
import base64 as _backend
|
|
17
|
+
|
|
18
|
+
HAVE_PYBASE64 = False
|
|
19
|
+
|
|
20
|
+
b64decode = _backend.b64decode
|
|
21
|
+
b64encode = _backend.b64encode
|
|
22
|
+
|
|
23
|
+
#: Name of the active backend, for diagnostics.
|
|
24
|
+
BACKEND = 'pybase64' if HAVE_PYBASE64 else 'base64'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'''Code generator: devtools-protocol JSON specs -> purecdp.protocol modules.
|
|
2
|
+
|
|
3
|
+
Dev-time tool only - not imported by the runtime library. Run with:
|
|
4
|
+
|
|
5
|
+
python -m purecdp._generator [--spec DIR] [--out DIR]
|
|
6
|
+
|
|
7
|
+
CDP bindings are generated from the Chrome DevTools Protocol JSON (BSD-3-Clause,
|
|
8
|
+
© Google), via the hyperiongray python-chrome-devtools-protocol (MIT) convention"
|
|
9
|
+
|
|
10
|
+
'''
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'''CLI: python -m purecdp._generator [--spec DIR] [--out DIR]'''
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import pathlib
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import emit, model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def repo_root() -> pathlib.Path:
|
|
13
|
+
# src/purecdp/_generator/__main__.py -> repo root is three levels above src/
|
|
14
|
+
return pathlib.Path(__file__).resolve().parents[3]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
root = repo_root()
|
|
19
|
+
ap = argparse.ArgumentParser(prog='purecdp._generator')
|
|
20
|
+
ap.add_argument('--spec', type=pathlib.Path, default=root / 'spec',
|
|
21
|
+
help='directory containing browser_protocol.json, js_protocol.json, PIN')
|
|
22
|
+
ap.add_argument('--out', type=pathlib.Path,
|
|
23
|
+
default=root / 'src' / 'purecdp' / 'protocol',
|
|
24
|
+
help='output package directory (existing *.py files are removed)')
|
|
25
|
+
args = ap.parse_args(argv)
|
|
26
|
+
|
|
27
|
+
spec_files = [args.spec / 'browser_protocol.json', args.spec / 'js_protocol.json']
|
|
28
|
+
for f in spec_files:
|
|
29
|
+
if not f.is_file():
|
|
30
|
+
ap.error(f'spec file not found: {f}')
|
|
31
|
+
pin_file = args.spec / 'PIN'
|
|
32
|
+
pin = pin_file.read_text().split()[0][:12] if pin_file.is_file() else 'unpinned'
|
|
33
|
+
|
|
34
|
+
spec = model.load(spec_files)
|
|
35
|
+
files = emit.emit_all(spec, pin)
|
|
36
|
+
|
|
37
|
+
args.out.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
for stale in args.out.glob('*.py'):
|
|
39
|
+
if stale.name not in files:
|
|
40
|
+
stale.unlink()
|
|
41
|
+
changed = 0
|
|
42
|
+
for name, source in sorted(files.items()):
|
|
43
|
+
path = args.out / name
|
|
44
|
+
if not path.is_file() or path.read_text(encoding='utf-8') != source:
|
|
45
|
+
path.write_text(source, encoding='utf-8')
|
|
46
|
+
changed += 1
|
|
47
|
+
print(f'{len(spec.domains)} domains -> {len(files)} files '
|
|
48
|
+
f'({changed} written) in {args.out}')
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == '__main__':
|
|
53
|
+
sys.exit(main())
|