py4quality 0.1.0__py3-none-any.whl
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.
- py4quality-0.1.0.dist-info/METADATA +129 -0
- py4quality-0.1.0.dist-info/RECORD +59 -0
- py4quality-0.1.0.dist-info/WHEEL +5 -0
- py4quality-0.1.0.dist-info/top_level.txt +1 -0
- pyquality/__init__.py +15 -0
- pyquality/api.py +59 -0
- pyquality/browser.py +179 -0
- pyquality/cdp_driver.py +126 -0
- pyquality/domains/__init__.py +50 -0
- pyquality/domains/accessibility.py +114 -0
- pyquality/domains/animation.py +96 -0
- pyquality/domains/audits.py +42 -0
- pyquality/domains/autofill.py +45 -0
- pyquality/domains/backgroundservice.py +42 -0
- pyquality/domains/bluetoothemulation.py +171 -0
- pyquality/domains/browser.py +242 -0
- pyquality/domains/cachestorage.py +72 -0
- pyquality/domains/cast.py +57 -0
- pyquality/domains/css.py +409 -0
- pyquality/domains/deviceaccess.py +38 -0
- pyquality/domains/deviceorientation.py +24 -0
- pyquality/domains/dom.py +641 -0
- pyquality/domains/domdebugger.py +113 -0
- pyquality/domains/domsnapshot.py +61 -0
- pyquality/domains/domstorage.py +60 -0
- pyquality/domains/emulation.py +610 -0
- pyquality/domains/eventbreakpoints.py +29 -0
- pyquality/domains/extensions.py +94 -0
- pyquality/domains/fedcm.py +77 -0
- pyquality/domains/fetch.py +147 -0
- pyquality/domains/filesystem.py +13 -0
- pyquality/domains/headlessexperimental.py +43 -0
- pyquality/domains/indexeddb.py +169 -0
- pyquality/domains/input.py +327 -0
- pyquality/domains/inspector.py +18 -0
- pyquality/domains/io.py +38 -0
- pyquality/domains/layertree.py +99 -0
- pyquality/domains/log.py +41 -0
- pyquality/domains/media.py +18 -0
- pyquality/domains/memory.py +91 -0
- pyquality/domains/network.py +518 -0
- pyquality/domains/overlay.py +332 -0
- pyquality/domains/page.py +738 -0
- pyquality/domains/performance.py +37 -0
- pyquality/domains/performancetimeline.py +17 -0
- pyquality/domains/preload.py +18 -0
- pyquality/domains/pwa.py +94 -0
- pyquality/domains/security.py +47 -0
- pyquality/domains/serviceworker.py +120 -0
- pyquality/domains/smartcardemulation.py +134 -0
- pyquality/domains/storage.py +377 -0
- pyquality/domains/systeminfo.py +27 -0
- pyquality/domains/target.py +280 -0
- pyquality/domains/tethering.py +22 -0
- pyquality/domains/tracing.py +86 -0
- pyquality/domains/webaudio.py +27 -0
- pyquality/domains/webauthn.py +154 -0
- pyquality/domains/webmcp.py +11 -0
- pyquality/generate_cdp_wrappers.py +83 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py4quality
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A highly user-friendly, kid-friendly automation framework using Chrome DevTools Protocol (CDP).
|
|
5
|
+
Author: Venkata Santosh Tharigoppala
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/santoshtvk-new/Pyquality
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/santoshtvk-new/Pyquality/issues
|
|
9
|
+
Requires-Python: >=3.8
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: websocket-client>=1.6.1
|
|
12
|
+
Requires-Dist: requests>=2.31.0
|
|
13
|
+
|
|
14
|
+
# pyquality 🎈
|
|
15
|
+
|
|
16
|
+
A kid-friendly, yet exceptionally powerful automation framework for Python driven directly by the Chrome DevTools Protocol (CDP).
|
|
17
|
+
|
|
18
|
+
`pyquality` is designed to be as simple as possible. Want to close the browser? Just call `b.bye()`. Want to navigate? `b.go_to("https://...")`
|
|
19
|
+
|
|
20
|
+
Under the hood, it retains uncompromising power. Advanced users have **unfiltered access** to all 40+ raw Chromium DevTools domains (DOM, Network, Page, etc.) auto-generated from the official Google spec!
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 🚀 Installation (Source)
|
|
25
|
+
If you are developing locally:
|
|
26
|
+
```bash
|
|
27
|
+
git clone https://github.com/santoshtvk-new/Pyquality.git
|
|
28
|
+
cd pyquality
|
|
29
|
+
pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 🛠️ Installation (PIP)
|
|
33
|
+
*(When published to PyPi)*
|
|
34
|
+
```bash
|
|
35
|
+
pip install pyquality
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 📖 Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pyquality.browser import Browser
|
|
44
|
+
|
|
45
|
+
# 1. Start it up
|
|
46
|
+
b = Browser(headless=False)
|
|
47
|
+
|
|
48
|
+
# 2. Go somewhere
|
|
49
|
+
b.go_to("https://example.com")
|
|
50
|
+
|
|
51
|
+
# 3. Validation is easy
|
|
52
|
+
b.should_be_seen("h1")
|
|
53
|
+
|
|
54
|
+
# 4. Say goodbye
|
|
55
|
+
b.bye()
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## ✨ The Kid-Friendly Aliases
|
|
59
|
+
|
|
60
|
+
`pyquality` dynamically binds methods defined in `METHOD_ALIASES.yml` so you can use vocabulary that makes sense to you.
|
|
61
|
+
|
|
62
|
+
### Navigation
|
|
63
|
+
- `navigate(url)` -> `go_to`, `open_website`
|
|
64
|
+
- `reload()` -> `refresh`, `reload_page`
|
|
65
|
+
- `close()` -> `close_browser`, `bye`
|
|
66
|
+
- `go_back()` -> `step_back`, `previous_page`
|
|
67
|
+
- `go_forward()` -> `step_forward`, `next_page`
|
|
68
|
+
|
|
69
|
+
### Interaction
|
|
70
|
+
- `click_element(selector)` -> `click_this`, `tap`
|
|
71
|
+
- `type_text(selector, text)` -> `type_in`, `write_this`
|
|
72
|
+
- `hover_element(selector)` -> `point_at`, `hover_over`
|
|
73
|
+
|
|
74
|
+
### APIs & Data
|
|
75
|
+
- `API.get(endpoint)` -> `fetch_data`, `grab`
|
|
76
|
+
- `API.post(endpoint, json)` -> `create_data`, `send_new`
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## ⚡ Raw CDP Power (Advanced)
|
|
81
|
+
|
|
82
|
+
If you need capabilities beyond the simple wrappers, `pyquality` has raw auto-generated bindings for **EVERY** CDP Domain. They are attached natively to the browser instance:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from pyquality.browser import Browser
|
|
86
|
+
b = Browser()
|
|
87
|
+
|
|
88
|
+
# Use raw 'Page' domain to capture a PDF
|
|
89
|
+
b.page.printToPDF()
|
|
90
|
+
|
|
91
|
+
# Use raw 'Network' domain to throttle connection
|
|
92
|
+
b.network.emulateNetworkConditions(
|
|
93
|
+
offline=False,
|
|
94
|
+
latency=200,
|
|
95
|
+
downloadThroughput=1000,
|
|
96
|
+
uploadThroughput=1000
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Use raw 'DOM' domain to get node information
|
|
100
|
+
b.dom.getDocument(depth=-1)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 📦 Deploying Initial PIP Package
|
|
104
|
+
|
|
105
|
+
To package this framework and upload it to PyPi:
|
|
106
|
+
|
|
107
|
+
1. **Install build tools:**
|
|
108
|
+
```bash
|
|
109
|
+
pip install build twine
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
2. **Generate Native Wrappers:**
|
|
113
|
+
Ensure your CDP protocol bindings are up-to-date:
|
|
114
|
+
```bash
|
|
115
|
+
python src/pyquality/generate_cdp_wrappers.py
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
3. **Build wheels:**
|
|
119
|
+
```bash
|
|
120
|
+
python -m build
|
|
121
|
+
```
|
|
122
|
+
*This generates `.tar.gz` and `.whl` files in the `/dist` folder.*
|
|
123
|
+
|
|
124
|
+
4. **Upload to PyPi (Requires Account):**
|
|
125
|
+
```bash
|
|
126
|
+
python -m twine upload dist/*
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
5. **Done!** Users can now run `pip install pyquality`.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
pyquality/__init__.py,sha256=HHsujZB71SvZ6QlCT31aQqoCsPFVPmst04O2k7s1Wi4,416
|
|
2
|
+
pyquality/api.py,sha256=Nvn9anJpojGKgY-K9nxDAE1_PhRFtl9gYgY0Tm3rCuU,2272
|
|
3
|
+
pyquality/browser.py,sha256=IcPp2bI0df7QvRct67lZfucGHsX5XIZ8gqKcwSBEPOs,7144
|
|
4
|
+
pyquality/cdp_driver.py,sha256=akVj_sjzqKsmFaV7xBB8641Z3LoE3PrfrpLB7RNetJg,4097
|
|
5
|
+
pyquality/generate_cdp_wrappers.py,sha256=evf7QgYjZpPhMaegZ1h2mq_dujSGvBgoXhwSTD8-DSA,3087
|
|
6
|
+
pyquality/domains/__init__.py,sha256=0gsq3rF99-4uvgjRwdXxiTsaawEVMuBaAI1v4se-yEg,1992
|
|
7
|
+
pyquality/domains/accessibility.py,sha256=WgpLadf9olN5W0nwsGb3xxk2RcilkJstq1CJnwAL5Nw,5674
|
|
8
|
+
pyquality/domains/animation.py,sha256=QgY4iAVmvZe-FeK_s6SfT5jVnqWTIH5-I2CWP2gu7BQ,3428
|
|
9
|
+
pyquality/domains/audits.py,sha256=cEq8azHFw0FQE-5atj-zjXSWmNE8oIWjYEbhVvqKiV8,1731
|
|
10
|
+
pyquality/domains/autofill.py,sha256=PEHOpaEDsQk9XbomFlcecD1AtRB1yeAlyhv_bMvkDwo,1777
|
|
11
|
+
pyquality/domains/backgroundservice.py,sha256=CPfqpUnU6weiDeSIuSkgfhuAfFKRjmIt1DM3pKD0LMo,1346
|
|
12
|
+
pyquality/domains/bluetoothemulation.py,sha256=x39bOCFAYX2OGw44MvOt0ELjlJ1y8anTl90KcbItCNU,7369
|
|
13
|
+
pyquality/domains/browser.py,sha256=UiYFvDyU7q128bE0mPHei6sybexXBxWOeOHlCMhRMec,10732
|
|
14
|
+
pyquality/domains/cachestorage.py,sha256=fh-cYMbAvJgg9EATy48995m_sXBiVzic_7Pk9flJdz4,2963
|
|
15
|
+
pyquality/domains/cast.py,sha256=uET2TgiHgib-Axs4cNv_SDyZV2sm3UQmHso-BvZybhg,2118
|
|
16
|
+
pyquality/domains/css.py,sha256=nyUOq6WXhjyY41w2IMbJHAaKGjnZ0UBxOpTmPC00ZWg,18042
|
|
17
|
+
pyquality/domains/deviceaccess.py,sha256=Re_OieoKFAfbYGk1C-M-amaCkrfUuKi0AakQ-mbUc8Q,1144
|
|
18
|
+
pyquality/domains/deviceorientation.py,sha256=4Med9HC--h4BVIsCCK7jhSioB5i8djliXGxYkj5iAIo,818
|
|
19
|
+
pyquality/domains/dom.py,sha256=1gXgLS1tdeGW1q88dFD6rIHSGVzq8kc_CIqR2KTkffU,28498
|
|
20
|
+
pyquality/domains/domdebugger.py,sha256=IkvlRlll_Y-IBDDPoyEIR-tzceb4IMqewy0-IfA4Wmc,4733
|
|
21
|
+
pyquality/domains/domsnapshot.py,sha256=4Fr5QrxjfwldqoSBMlAhLqfE2p4zV6HgbdOre7hRcVk,3907
|
|
22
|
+
pyquality/domains/domstorage.py,sha256=oky4QBdftbV6f9mBWwAVev7_-_83lObruyXDAQUTQaw,1894
|
|
23
|
+
pyquality/domains/emulation.py,sha256=0ks1WIVNQKW00Az9wm8s-QEoOFtjABahjseyegj2aTs,29839
|
|
24
|
+
pyquality/domains/eventbreakpoints.py,sha256=KrP66M00M5rw0bX0F9prbNyVAZnQKM_PlwP6UJxNnJk,1022
|
|
25
|
+
pyquality/domains/extensions.py,sha256=xReADdABrXOpaemW3CHeLZtuX23AyNMDsCWDgoKxdTE,4285
|
|
26
|
+
pyquality/domains/fedcm.py,sha256=Q6wPtAgARce8V1u50Xx2Xv7rjRh770mrPCYOtvdPGzY,2720
|
|
27
|
+
pyquality/domains/fetch.py,sha256=jq1W9AZQyTZINzzd25j5PB7aMJQruI1yb-CHY56BUWU,8677
|
|
28
|
+
pyquality/domains/filesystem.py,sha256=DkKcf7blP5HsqRX2cB3CNAQuQh1aDMSWfzi8gkK3Tnw,423
|
|
29
|
+
pyquality/domains/headlessexperimental.py,sha256=L5Nq0FNIvGYkDZA5DO4knrg5VjHNwaGvZzzYhQ34NVQ,2391
|
|
30
|
+
pyquality/domains/indexeddb.py,sha256=6BBCAlJZlh5i2tni96ybmQGcLnshaFlKTakwPdrmI6U,8036
|
|
31
|
+
pyquality/domains/input.py,sha256=wAJrOZNs2dpoPDxi9sYYffKo_op8VTKzPRefvMJ-f4Q,17963
|
|
32
|
+
pyquality/domains/inspector.py,sha256=XV0paJ_oowB8r3NZmGndoOr7TUwCVDSD1l0JsFPbrzM,480
|
|
33
|
+
pyquality/domains/io.py,sha256=CrDgCBmfskWESloabCLSylX99T7EHqo4kUQsw_hapY4,1446
|
|
34
|
+
pyquality/domains/layertree.py,sha256=bp6oqTz0_cpRrlN0xQ6wPy842ufJ76kqsWlN6IDIh1Q,3940
|
|
35
|
+
pyquality/domains/log.py,sha256=PkexU_T_FUHw887KxAoaiR8cZ1TeazFiESU4jwHLMmg,1247
|
|
36
|
+
pyquality/domains/media.py,sha256=w8G38lEZh1qhjGzzAf4e2Eskvm0n8U8wstxfalvrrKk,439
|
|
37
|
+
pyquality/domains/memory.py,sha256=BPeksrnBFBcSp2z5h6XLB6-hpi0BNZn5eajcVvp3Iew,3566
|
|
38
|
+
pyquality/domains/network.py,sha256=-dQs_YDwpd_a11HY742VJvPLuC_rFF5yaYr1tXkl5Lk,26817
|
|
39
|
+
pyquality/domains/overlay.py,sha256=sZvHRb1cXbbZW26BEUouv6xNIdwVzQhcU52ambKCOVY,14918
|
|
40
|
+
pyquality/domains/page.py,sha256=kPiVD9L3rObkWcA4S1RCuZWUl0s7oQOseiHgLcI9Gv0,33744
|
|
41
|
+
pyquality/domains/performance.py,sha256=y6A1HgEEZRJBXUQXW0oPzi4Zq0pW2FPc0uhBVZlp4lc,1375
|
|
42
|
+
pyquality/domains/performancetimeline.py,sha256=GPtXkjdgVnsRIHPLBoOI1dw3Md_nDODDxNufdsbQNtk,742
|
|
43
|
+
pyquality/domains/preload.py,sha256=XaZeI9JAvojjnYFElBcSrKdQWswRH-6gbT3Pxx-ZbPM,434
|
|
44
|
+
pyquality/domains/pwa.py,sha256=Redp4yZSbFrM4dz4uJ2bjSTSSwSLrjuUSz9i73MC5Zw,5971
|
|
45
|
+
pyquality/domains/security.py,sha256=a9i2beL_Q-d-dQdYol99eUCPDDLAIyunoT46aooVMh4,1835
|
|
46
|
+
pyquality/domains/serviceworker.py,sha256=0s0PQrGY1ddHqDebHdXcsl4yKOIFxVKsPtLoKoK2ebE,3927
|
|
47
|
+
pyquality/domains/smartcardemulation.py,sha256=ktRgNAMeq2ISxsqV2bHaT4PPyo9Meq4_Zf1wVGMCeaM,8136
|
|
48
|
+
pyquality/domains/storage.py,sha256=B1QzqZPAUt7BP5PSM2XtiKDCzleImz7OGROcndC5Mc8,15233
|
|
49
|
+
pyquality/domains/systeminfo.py,sha256=Lh93Ndor1h6NygtFGdSkoqc6tNlIuxGebZ6IlD4uzEk,819
|
|
50
|
+
pyquality/domains/target.py,sha256=QcBHgJJdm4oXlYq71zh8TDeg_oIa57gn-uXqOFmDDMM,14564
|
|
51
|
+
pyquality/domains/tethering.py,sha256=Lw9zOYAHC3dna0yBDpgv2iAU6nc2wrvYA69fCDEWmW0,627
|
|
52
|
+
pyquality/domains/tracing.py,sha256=VXo1Qp_861RkFyc25wMH2aihHCDWhaO5Wypf3UjSCjM,3908
|
|
53
|
+
pyquality/domains/webaudio.py,sha256=wJGBgK3mPsRdJbI_kWly03odOQC4P34cEVXdFbLSWIk,804
|
|
54
|
+
pyquality/domains/webauthn.py,sha256=euR3K3EDQRyhS4_khiOJYosoiWhE0x8qb3yrEkt8AHE,6487
|
|
55
|
+
pyquality/domains/webmcp.py,sha256=LwopI6ctyDGwjfigaHH-vq6URQQiaR5PwNHPbAJ2L_c,380
|
|
56
|
+
py4quality-0.1.0.dist-info/METADATA,sha256=odRepNz5yaegFHhPoJx2Seqc4Tga4HdUevIgOYVBwgo,3514
|
|
57
|
+
py4quality-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
58
|
+
py4quality-0.1.0.dist-info/top_level.txt,sha256=Ee7HP6bjyNHs6rDI1Pp21yQ8sBDrr47snG9LhORbtuk,10
|
|
59
|
+
py4quality-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyquality
|
pyquality/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
def load_aliases():
|
|
5
|
+
alias_path = os.path.join(os.path.dirname(__file__), "..", "..", "METHOD_ALIASES.yml")
|
|
6
|
+
if not os.path.exists(alias_path):
|
|
7
|
+
return {}
|
|
8
|
+
with open(alias_path, 'r') as f:
|
|
9
|
+
return yaml.safe_load(f)
|
|
10
|
+
|
|
11
|
+
# The loaded aliases can be used by the browser instance to dynamically map methods.
|
|
12
|
+
ALIASES = load_aliases()
|
|
13
|
+
|
|
14
|
+
from .api import API
|
|
15
|
+
__version__ = "0.1.0"
|
pyquality/api.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
class API:
|
|
4
|
+
"""
|
|
5
|
+
Kid-friendly wrapper for REST API calls.
|
|
6
|
+
"""
|
|
7
|
+
def __init__(self, base_url=""):
|
|
8
|
+
self.base_url = base_url
|
|
9
|
+
self.session = requests.Session()
|
|
10
|
+
self._apply_aliases()
|
|
11
|
+
|
|
12
|
+
def _apply_aliases(self):
|
|
13
|
+
"""Bind kid-friendly aliases to instance methods dynamically."""
|
|
14
|
+
from .__init__ import ALIASES
|
|
15
|
+
if "api" in ALIASES:
|
|
16
|
+
for method_name, aliases in ALIASES["api"].items():
|
|
17
|
+
if hasattr(self, method_name):
|
|
18
|
+
func = getattr(self, method_name)
|
|
19
|
+
for alias in aliases:
|
|
20
|
+
setattr(self, alias, func)
|
|
21
|
+
|
|
22
|
+
def _build_url(self, endpoint):
|
|
23
|
+
if endpoint.startswith("http"):
|
|
24
|
+
return endpoint
|
|
25
|
+
return f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
|
26
|
+
|
|
27
|
+
def set_header(self, key, value):
|
|
28
|
+
"""Set a header for subsequent requests."""
|
|
29
|
+
self.session.headers.update({key: value})
|
|
30
|
+
|
|
31
|
+
def use_token(self, token):
|
|
32
|
+
"""Authenticate with a Bearer token."""
|
|
33
|
+
self.set_header("Authorization", f"Bearer {token}")
|
|
34
|
+
|
|
35
|
+
def get(self, endpoint, **kwargs):
|
|
36
|
+
"""Send a GET request."""
|
|
37
|
+
return self.session.get(self._build_url(endpoint), **kwargs)
|
|
38
|
+
|
|
39
|
+
def post(self, endpoint, json=None, data=None, **kwargs):
|
|
40
|
+
"""Send a POST request."""
|
|
41
|
+
return self.session.post(self._build_url(endpoint), json=json, data=data, **kwargs)
|
|
42
|
+
|
|
43
|
+
def put(self, endpoint, json=None, data=None, **kwargs):
|
|
44
|
+
"""Send a PUT request."""
|
|
45
|
+
return self.session.put(self._build_url(endpoint), json=json, data=data, **kwargs)
|
|
46
|
+
|
|
47
|
+
def delete(self, endpoint, **kwargs):
|
|
48
|
+
"""Send a DELETE request."""
|
|
49
|
+
return self.session.delete(self._build_url(endpoint), **kwargs)
|
|
50
|
+
|
|
51
|
+
def patch(self, endpoint, json=None, data=None, **kwargs):
|
|
52
|
+
"""Send a PATCH request."""
|
|
53
|
+
return self.session.patch(self._build_url(endpoint), json=json, data=data, **kwargs)
|
|
54
|
+
|
|
55
|
+
def should_be_status(self, response, expected_status):
|
|
56
|
+
"""Kid-friendly assertion for status codes."""
|
|
57
|
+
if response.status_code != expected_status:
|
|
58
|
+
raise AssertionError(f"Expected status {expected_status}, but got {response.status_code}.")
|
|
59
|
+
return True
|
pyquality/browser.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from .cdp_driver import CDPDriver
|
|
2
|
+
from .__init__ import ALIASES
|
|
3
|
+
from . import domains
|
|
4
|
+
|
|
5
|
+
class Browser:
|
|
6
|
+
"""
|
|
7
|
+
Kid-friendly wrapper around CDPDriver.
|
|
8
|
+
This class can dynamically expose methods mapped by aliases.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, headless=False):
|
|
11
|
+
self.driver = CDPDriver()
|
|
12
|
+
self.driver.start_chrome(headless=headless)
|
|
13
|
+
self.driver.connect()
|
|
14
|
+
# Enable network tracking
|
|
15
|
+
self.driver.execute_and_wait("Network.enable")
|
|
16
|
+
|
|
17
|
+
# Initialize native CDP domains dynamically
|
|
18
|
+
for attr_name in dir(domains):
|
|
19
|
+
if attr_name.endswith("Domain"):
|
|
20
|
+
domain_class = getattr(domains, attr_name)
|
|
21
|
+
# Ensure the property name doesn't collide with existing class/instance vars like `browser` -> `cdp_browser`
|
|
22
|
+
prop_name = attr_name[:-6].lower()
|
|
23
|
+
if prop_name == "browser":
|
|
24
|
+
prop_name = "cdp_browser"
|
|
25
|
+
|
|
26
|
+
setattr(self, prop_name, domain_class(self.driver))
|
|
27
|
+
|
|
28
|
+
self._apply_aliases()
|
|
29
|
+
|
|
30
|
+
def _apply_aliases(self):
|
|
31
|
+
"""Bind kid-friendly aliases to instance methods dynamically."""
|
|
32
|
+
for category, methods in ALIASES.items():
|
|
33
|
+
for method_name, aliases in methods.items():
|
|
34
|
+
if hasattr(self, method_name):
|
|
35
|
+
func = getattr(self, method_name)
|
|
36
|
+
for alias in aliases:
|
|
37
|
+
setattr(self, alias, func)
|
|
38
|
+
|
|
39
|
+
# ===============
|
|
40
|
+
# CORE METHODS
|
|
41
|
+
# ===============
|
|
42
|
+
def navigate(self, url):
|
|
43
|
+
"""Navigate to a URL."""
|
|
44
|
+
response = self.driver.execute_and_wait("Page.navigate", {"url": url})
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
def reload(self):
|
|
48
|
+
"""Reload the current page using raw CDP."""
|
|
49
|
+
return self.page.reload()
|
|
50
|
+
|
|
51
|
+
def go_back(self):
|
|
52
|
+
"""Navigate backward in history."""
|
|
53
|
+
return self.evaluate_js("window.history.back()")
|
|
54
|
+
|
|
55
|
+
def go_forward(self):
|
|
56
|
+
"""Navigate forward in history."""
|
|
57
|
+
return self.evaluate_js("window.history.forward()")
|
|
58
|
+
|
|
59
|
+
def get_title(self):
|
|
60
|
+
"""Get page title."""
|
|
61
|
+
res = self.evaluate_js("document.title")
|
|
62
|
+
return res.get('result', {}).get('result', {}).get('value', '')
|
|
63
|
+
|
|
64
|
+
def get_url(self):
|
|
65
|
+
"""Get current URL."""
|
|
66
|
+
res = self.evaluate_js("window.location.href")
|
|
67
|
+
return res.get('result', {}).get('result', {}).get('value', '')
|
|
68
|
+
|
|
69
|
+
def evaluate_js(self, expression):
|
|
70
|
+
"""Evaluate JS in the browser."""
|
|
71
|
+
response = self.driver.execute_and_wait("Runtime.evaluate", {
|
|
72
|
+
"expression": expression,
|
|
73
|
+
"returnByValue": True
|
|
74
|
+
})
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
def find_element(self, selector):
|
|
78
|
+
"""Check if element exists."""
|
|
79
|
+
res = self.evaluate_js(f"document.querySelector('{selector}') !== null")
|
|
80
|
+
return res.get('result', {}).get('result', {}).get('value', False)
|
|
81
|
+
|
|
82
|
+
def click_element(self, selector):
|
|
83
|
+
"""Click an element using JS."""
|
|
84
|
+
return self.evaluate_js(f"document.querySelector('{selector}').click()")
|
|
85
|
+
|
|
86
|
+
def type_text(self, selector, text):
|
|
87
|
+
"""Type text into an input."""
|
|
88
|
+
# Escape quotes in text
|
|
89
|
+
safe_text = text.replace("'", "\\'").replace('"', '\\"')
|
|
90
|
+
return self.evaluate_js(
|
|
91
|
+
f"document.querySelector('{selector}').value = '{safe_text}';"
|
|
92
|
+
f"document.querySelector('{selector}').dispatchEvent(new Event('input'));"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def hover_element(self, selector):
|
|
96
|
+
"""Hover over an element (dispatches mouseover/mouseenter events)."""
|
|
97
|
+
return self.evaluate_js(
|
|
98
|
+
f"var el = document.querySelector('{selector}');"
|
|
99
|
+
f"if (el) {{ el.dispatchEvent(new MouseEvent('mouseenter', {{bubbles: true}}));"
|
|
100
|
+
f"el.dispatchEvent(new MouseEvent('mouseover', {{bubbles: true}})); }}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# ===============
|
|
104
|
+
# DISCOVERY & VALIDATION
|
|
105
|
+
# ===============
|
|
106
|
+
def wait_for_selector(self, selector, timeout=10):
|
|
107
|
+
"""Wait until an element appears in the DOM."""
|
|
108
|
+
import time
|
|
109
|
+
start = time.time()
|
|
110
|
+
while time.time() - start < timeout:
|
|
111
|
+
if self.find_element(selector):
|
|
112
|
+
return True
|
|
113
|
+
time.sleep(0.5)
|
|
114
|
+
raise Exception(f"Timeout waiting for selector: {selector}")
|
|
115
|
+
|
|
116
|
+
def assert_visible(self, selector):
|
|
117
|
+
"""Check if an element is visible (naive check for presence and display structure)."""
|
|
118
|
+
res = self.evaluate_js(
|
|
119
|
+
f"var el = document.querySelector('{selector}');"
|
|
120
|
+
f"el ? (el.offsetWidth > 0 && el.offsetHeight > 0 && getComputedStyle(el).display !== 'none') : false"
|
|
121
|
+
)
|
|
122
|
+
is_vis = res.get('result', {}).get('result', {}).get('value', False)
|
|
123
|
+
if not is_vis:
|
|
124
|
+
raise AssertionError(f"Element {selector} is not visible.")
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def assert_text(self, selector, expected_text):
|
|
128
|
+
"""Check if an element contains expected text."""
|
|
129
|
+
res = self.evaluate_js(f"var el = document.querySelector('{selector}'); el ? el.innerText : ''")
|
|
130
|
+
actual_text = res.get('result', {}).get('result', {}).get('value', '')
|
|
131
|
+
if expected_text not in actual_text:
|
|
132
|
+
raise AssertionError(f"Expected text '{expected_text}' not found in '{actual_text}'.")
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
# ===============
|
|
136
|
+
# ADVANCED
|
|
137
|
+
# ===============
|
|
138
|
+
def take_screenshot(self, filename="screenshot.png"):
|
|
139
|
+
"""Take a full page screenshot."""
|
|
140
|
+
import base64
|
|
141
|
+
response = self.driver.execute_and_wait("Page.captureScreenshot")
|
|
142
|
+
data = response.get('result', {}).get('data', '')
|
|
143
|
+
if data:
|
|
144
|
+
with open(filename, "wb") as f:
|
|
145
|
+
f.write(base64.b64decode(data))
|
|
146
|
+
return filename
|
|
147
|
+
|
|
148
|
+
def get_network_logs(self):
|
|
149
|
+
"""Return all network events captured."""
|
|
150
|
+
return self.driver.events
|
|
151
|
+
|
|
152
|
+
def get_accessibility_tree(self):
|
|
153
|
+
"""Fetch the accessibility tree for the page."""
|
|
154
|
+
response = self.driver.execute_and_wait("Accessibility.getFullAXTree")
|
|
155
|
+
return response.get('result', {}).get('nodes', [])
|
|
156
|
+
|
|
157
|
+
def get_cookies(self):
|
|
158
|
+
"""Get all browser cookies via Network domain."""
|
|
159
|
+
return self.network.getCookies().get('result', {}).get('cookies', [])
|
|
160
|
+
|
|
161
|
+
def clear_cookies(self):
|
|
162
|
+
"""Clear all browser cookies via Network domain."""
|
|
163
|
+
return self.network.clearBrowserCookies()
|
|
164
|
+
|
|
165
|
+
def set_viewport(self, width, height, is_mobile=False):
|
|
166
|
+
"""Emulate device metrics / viewport."""
|
|
167
|
+
return self.emulation.setDeviceMetricsOverride(width=width, height=height, deviceScaleFactor=3 if is_mobile else 1, mobile=is_mobile)
|
|
168
|
+
|
|
169
|
+
def act_like_phone(self):
|
|
170
|
+
"""Emulate a generic phone viewport."""
|
|
171
|
+
return self.set_viewport(375, 812, is_mobile=True)
|
|
172
|
+
|
|
173
|
+
def set_location(self, latitude, longitude, accuracy=100):
|
|
174
|
+
"""Emulate geographical location."""
|
|
175
|
+
return self.emulation.setGeolocationOverride(latitude=latitude, longitude=longitude, accuracy=accuracy)
|
|
176
|
+
|
|
177
|
+
def close(self):
|
|
178
|
+
"""Close the browser."""
|
|
179
|
+
self.driver.stop_chrome()
|
pyquality/cdp_driver.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import websocket
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import requests
|
|
6
|
+
import subprocess
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
|
|
10
|
+
class CDPDriver:
|
|
11
|
+
"""
|
|
12
|
+
Direct websocket communicator for Chrome DevTools Protocol.
|
|
13
|
+
This avoids relying on heavy CDP wrapper dependencies.
|
|
14
|
+
"""
|
|
15
|
+
def __init__(self, port=9222):
|
|
16
|
+
self.port = port
|
|
17
|
+
self.ws = None
|
|
18
|
+
self.msg_id = 1
|
|
19
|
+
self.responses = {}
|
|
20
|
+
self.events = []
|
|
21
|
+
self._browser_process = None
|
|
22
|
+
self.connected_event = threading.Event()
|
|
23
|
+
|
|
24
|
+
def start_chrome(self, headless=False):
|
|
25
|
+
# We need a robust way to find/start Chrome across platforms.
|
|
26
|
+
# For Windows, typically it's in Program Files.
|
|
27
|
+
chrome_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe"
|
|
28
|
+
if not os.path.exists(chrome_path):
|
|
29
|
+
chrome_path = r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
|
|
30
|
+
|
|
31
|
+
args = [
|
|
32
|
+
chrome_path,
|
|
33
|
+
f"--remote-debugging-port={self.port}",
|
|
34
|
+
"--remote-allow-origins=*",
|
|
35
|
+
"--user-data-dir=C:\\Temp\\pyquality_profile" # Temporary profile
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
if headless:
|
|
39
|
+
args.append("--headless")
|
|
40
|
+
args.append("--disable-gpu")
|
|
41
|
+
args.append("--no-sandbox")
|
|
42
|
+
|
|
43
|
+
self._browser_process = subprocess.Popen(args)
|
|
44
|
+
time.sleep(2) # Give it time to start
|
|
45
|
+
|
|
46
|
+
def stop_chrome(self):
|
|
47
|
+
if self._browser_process:
|
|
48
|
+
self._browser_process.send_signal(signal.SIGTERM)
|
|
49
|
+
|
|
50
|
+
def _on_open(self, ws):
|
|
51
|
+
self.connected_event.set()
|
|
52
|
+
|
|
53
|
+
def _on_error(self, ws, error):
|
|
54
|
+
print(f"WebSocket Error: {error}")
|
|
55
|
+
|
|
56
|
+
def _on_close(self, ws, close_status_code, close_msg):
|
|
57
|
+
print("WebSocket Closed")
|
|
58
|
+
|
|
59
|
+
def connect(self):
|
|
60
|
+
# Fetch the websocket debugger URL
|
|
61
|
+
url = f"http://127.0.0.1:{self.port}/json"
|
|
62
|
+
|
|
63
|
+
# Add a retry logic in case Chrome takes time to open the port
|
|
64
|
+
response = None
|
|
65
|
+
for _ in range(5):
|
|
66
|
+
try:
|
|
67
|
+
response = requests.get(url).json()
|
|
68
|
+
break
|
|
69
|
+
except Exception:
|
|
70
|
+
time.sleep(1)
|
|
71
|
+
|
|
72
|
+
# We just grab the first page
|
|
73
|
+
if not response:
|
|
74
|
+
raise Exception("No active pages found or CDP port not responding!")
|
|
75
|
+
|
|
76
|
+
page_ws_url = response[0]['webSocketDebuggerUrl']
|
|
77
|
+
self.ws = websocket.WebSocketApp(
|
|
78
|
+
page_ws_url,
|
|
79
|
+
on_message=self._on_message,
|
|
80
|
+
on_open=self._on_open,
|
|
81
|
+
on_error=self._on_error,
|
|
82
|
+
on_close=self._on_close
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Start receiver thread
|
|
86
|
+
self.connected_event.clear()
|
|
87
|
+
self._thread = threading.Thread(target=self.ws.run_forever, kwargs={"suppress_origin": True})
|
|
88
|
+
self._thread.daemon = True
|
|
89
|
+
self._thread.start()
|
|
90
|
+
|
|
91
|
+
# Wait up to 5 seconds for the connection to establish
|
|
92
|
+
if not self.connected_event.wait(5):
|
|
93
|
+
raise Exception("Failed to connect to browser websocket within 5 seconds")
|
|
94
|
+
|
|
95
|
+
def _on_message(self, ws, message):
|
|
96
|
+
data = json.loads(message)
|
|
97
|
+
if 'id' in data:
|
|
98
|
+
self.responses[data['id']] = data
|
|
99
|
+
else:
|
|
100
|
+
self.events.append(data)
|
|
101
|
+
|
|
102
|
+
def send_command(self, method, params=None):
|
|
103
|
+
if params is None:
|
|
104
|
+
params = {}
|
|
105
|
+
|
|
106
|
+
cmd = {
|
|
107
|
+
"id": self.msg_id,
|
|
108
|
+
"method": method,
|
|
109
|
+
"params": params
|
|
110
|
+
}
|
|
111
|
+
self.ws.send(json.dumps(cmd))
|
|
112
|
+
current_id = self.msg_id
|
|
113
|
+
self.msg_id += 1
|
|
114
|
+
return current_id
|
|
115
|
+
|
|
116
|
+
def wait_for_response(self, msg_id, timeout=5):
|
|
117
|
+
start_time = time.time()
|
|
118
|
+
while time.time() - start_time < timeout:
|
|
119
|
+
if msg_id in self.responses:
|
|
120
|
+
return self.responses.pop(msg_id)
|
|
121
|
+
time.sleep(0.01)
|
|
122
|
+
raise TimeoutError(f"No response for message {msg_id}")
|
|
123
|
+
|
|
124
|
+
def execute_and_wait(self, method, params=None):
|
|
125
|
+
msg_id = self.send_command(method, params)
|
|
126
|
+
return self.wait_for_response(msg_id)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Auto-generated CDP Domains
|
|
2
|
+
from .accessibility import AccessibilityDomain
|
|
3
|
+
from .animation import AnimationDomain
|
|
4
|
+
from .audits import AuditsDomain
|
|
5
|
+
from .autofill import AutofillDomain
|
|
6
|
+
from .backgroundservice import BackgroundServiceDomain
|
|
7
|
+
from .bluetoothemulation import BluetoothEmulationDomain
|
|
8
|
+
from .browser import BrowserDomain
|
|
9
|
+
from .css import CSSDomain
|
|
10
|
+
from .cachestorage import CacheStorageDomain
|
|
11
|
+
from .cast import CastDomain
|
|
12
|
+
from .dom import DOMDomain
|
|
13
|
+
from .domdebugger import DOMDebuggerDomain
|
|
14
|
+
from .domsnapshot import DOMSnapshotDomain
|
|
15
|
+
from .domstorage import DOMStorageDomain
|
|
16
|
+
from .deviceaccess import DeviceAccessDomain
|
|
17
|
+
from .deviceorientation import DeviceOrientationDomain
|
|
18
|
+
from .emulation import EmulationDomain
|
|
19
|
+
from .eventbreakpoints import EventBreakpointsDomain
|
|
20
|
+
from .extensions import ExtensionsDomain
|
|
21
|
+
from .fedcm import FedCmDomain
|
|
22
|
+
from .fetch import FetchDomain
|
|
23
|
+
from .filesystem import FileSystemDomain
|
|
24
|
+
from .headlessexperimental import HeadlessExperimentalDomain
|
|
25
|
+
from .io import IODomain
|
|
26
|
+
from .indexeddb import IndexedDBDomain
|
|
27
|
+
from .input import InputDomain
|
|
28
|
+
from .inspector import InspectorDomain
|
|
29
|
+
from .layertree import LayerTreeDomain
|
|
30
|
+
from .log import LogDomain
|
|
31
|
+
from .media import MediaDomain
|
|
32
|
+
from .memory import MemoryDomain
|
|
33
|
+
from .network import NetworkDomain
|
|
34
|
+
from .overlay import OverlayDomain
|
|
35
|
+
from .pwa import PWADomain
|
|
36
|
+
from .page import PageDomain
|
|
37
|
+
from .performance import PerformanceDomain
|
|
38
|
+
from .performancetimeline import PerformanceTimelineDomain
|
|
39
|
+
from .preload import PreloadDomain
|
|
40
|
+
from .security import SecurityDomain
|
|
41
|
+
from .serviceworker import ServiceWorkerDomain
|
|
42
|
+
from .smartcardemulation import SmartCardEmulationDomain
|
|
43
|
+
from .storage import StorageDomain
|
|
44
|
+
from .systeminfo import SystemInfoDomain
|
|
45
|
+
from .target import TargetDomain
|
|
46
|
+
from .tethering import TetheringDomain
|
|
47
|
+
from .tracing import TracingDomain
|
|
48
|
+
from .webaudio import WebAudioDomain
|
|
49
|
+
from .webauthn import WebAuthnDomain
|
|
50
|
+
from .webmcp import WebMCPDomain
|