solvecdp 0.0.4__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.
- solvecdp/__init__.py +2 -0
- solvecdp/_modidx.py +62 -0
- solvecdp/browser_protocol.json +31501 -0
- solvecdp/core.py +302 -0
- solvecdp/js_protocol.json +3810 -0
- solvecdp-0.0.4.dist-info/METADATA +174 -0
- solvecdp-0.0.4.dist-info/RECORD +11 -0
- solvecdp-0.0.4.dist-info/WHEEL +5 -0
- solvecdp-0.0.4.dist-info/entry_points.txt +2 -0
- solvecdp-0.0.4.dist-info/licenses/LICENSE +201 -0
- solvecdp-0.0.4.dist-info/top_level.txt +1 -0
solvecdp/core.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Source and API details"""
|
|
2
|
+
|
|
3
|
+
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
|
|
4
|
+
|
|
5
|
+
# %% auto #0
|
|
6
|
+
__all__ = ['cdp_search', 'js_cdp', 'JsCDP', 'JsSub', 'JsCDPDomain', 'JsCDPMethod', 'AXNode', 'AXTree', 'build_ax_tree',
|
|
7
|
+
'cdp_yolo']
|
|
8
|
+
|
|
9
|
+
# %% ../nbs/00_core.ipynb #34dc06ce
|
|
10
|
+
from fastcore.utils import *
|
|
11
|
+
import json, asyncio, inspect, base64
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from dialoghelper import *
|
|
14
|
+
|
|
15
|
+
# %% ../nbs/00_core.ipynb #930c2391
|
|
16
|
+
if '__file__' not in globals():
|
|
17
|
+
_root = Path.cwd()
|
|
18
|
+
if not (Path.cwd()/'pyproject.toml').exists(): _root = _root.parent
|
|
19
|
+
__file__ = _root/'solvecdp'/'core.py'
|
|
20
|
+
|
|
21
|
+
_path = Path(__file__).parent
|
|
22
|
+
_bp = (_path/'browser_protocol.json').read_json()
|
|
23
|
+
_jp = (_path/'js_protocol.json').read_json()
|
|
24
|
+
_cdp_domains = _bp['domains'] + _jp['domains']
|
|
25
|
+
|
|
26
|
+
# %% ../nbs/00_core.ipynb #12182cb2
|
|
27
|
+
def cdp_search(q:str):
|
|
28
|
+
"Search CDP domains and commands by name or description"
|
|
29
|
+
q = q.lower()
|
|
30
|
+
res = []
|
|
31
|
+
for d in _cdp_domains:
|
|
32
|
+
for cmd in d.get('commands', []):
|
|
33
|
+
desc = cmd.get('description', '')
|
|
34
|
+
if q in cmd['name'].lower() or q in desc.lower():
|
|
35
|
+
res.append(f"{d['domain']}.{cmd['name']}: {desc[:120]}")
|
|
36
|
+
for evt in d.get('events', []):
|
|
37
|
+
desc = evt.get('description', '')
|
|
38
|
+
if q in evt['name'].lower() or q in desc.lower():
|
|
39
|
+
res.append(f" evt {d['domain']}.{evt['name']}: {desc[:120]}")
|
|
40
|
+
return '\n'.join(res)
|
|
41
|
+
|
|
42
|
+
# %% ../nbs/00_core.ipynb #504551f1
|
|
43
|
+
async def js_cdp(method, tabId=None, **params):
|
|
44
|
+
res = (await event_get_a('cdp-send', tabId=tabId, method=method, params=params)).result
|
|
45
|
+
return first(res.values()) if res and len(res)==1 else res
|
|
46
|
+
|
|
47
|
+
# %% ../nbs/00_core.ipynb #2bc182b9
|
|
48
|
+
def _lower1(s): return s[0].lower() + s[1:]
|
|
49
|
+
def _upper1(s): return s[0].upper() + s[1:]
|
|
50
|
+
|
|
51
|
+
# %% ../nbs/00_core.ipynb #0b40661b
|
|
52
|
+
class JsCDP:
|
|
53
|
+
"Chrome DevTools Protocol via JS bridge with event support"
|
|
54
|
+
def __init__(self, tid): self.tid,self._sub_id = tid,0
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
async def new(cls, tid=None, url='about:blank', active=False):
|
|
58
|
+
if tid: await event_get_a('cdp-attach', tabId=tid)
|
|
59
|
+
else:
|
|
60
|
+
r = await event_get_a('cdp-new-tab', url=url, active=active)
|
|
61
|
+
tid = r.result['tabId']
|
|
62
|
+
self = cls(tid)
|
|
63
|
+
await self('Page.enable')
|
|
64
|
+
await self('Network.enable')
|
|
65
|
+
await self('Page.setLifecycleEventsEnabled', enabled=True)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __call__(self, method, **params): return await js_cdp(method, tabId=self.tid, **params)
|
|
69
|
+
|
|
70
|
+
def __getattr__(self, domain):
|
|
71
|
+
if domain.startswith('_'): raise AttributeError()
|
|
72
|
+
return JsCDPDomain(self, _upper1(domain))
|
|
73
|
+
|
|
74
|
+
async def close(self): await event_get_a('cdp-close-tab', tabId=self.tid)
|
|
75
|
+
async def detach(self): await event_get_a('cdp-detach', tabId=self.tid)
|
|
76
|
+
async def __aenter__(self): return self
|
|
77
|
+
async def __aexit__(self, *exc): await self.detach()
|
|
78
|
+
|
|
79
|
+
def __del__(self):
|
|
80
|
+
try: asyncio.get_event_loop().create_task(self.detach())
|
|
81
|
+
except Exception: pass
|
|
82
|
+
|
|
83
|
+
# %% ../nbs/00_core.ipynb #1b014de9
|
|
84
|
+
class JsSub:
|
|
85
|
+
def __init__(self, cdp, events): self.cdp,self.events,self.seq = cdp,events,0
|
|
86
|
+
async def __call__(self, timeout=10):
|
|
87
|
+
self.seq += 1
|
|
88
|
+
return await pop_data_a(f'{self.sub_id}:{self.seq}', timeout)
|
|
89
|
+
async def __aenter__(self):
|
|
90
|
+
self.cdp._sub_id += 1
|
|
91
|
+
self.sub_id = f'jsc_{self.cdp.tid}_{self.cdp._sub_id}'
|
|
92
|
+
await event_get_a('cdp-subscribe', tabId=self.cdp.tid, subId=self.sub_id, events=self.events)
|
|
93
|
+
return self
|
|
94
|
+
async def __aexit__(self, *exc): await self.cdp.unsubscribe(self.sub_id)
|
|
95
|
+
|
|
96
|
+
@patch
|
|
97
|
+
async def unsubscribe(self:JsCDP, sub_id): await event_get_a('cdp-unsubscribe', tabId=self.tid, subId=sub_id)
|
|
98
|
+
|
|
99
|
+
@patch
|
|
100
|
+
def on(self:JsCDP, *events): return JsSub(self, events)
|
|
101
|
+
|
|
102
|
+
# %% ../nbs/00_core.ipynb #0af68b93
|
|
103
|
+
_domains = {d['domain']: d for d in _cdp_domains}
|
|
104
|
+
|
|
105
|
+
def _find_cmd(domain, method):
|
|
106
|
+
d = _domains.get(domain, {})
|
|
107
|
+
return first(c for c in d.get('commands', []) if c['name'] == method)
|
|
108
|
+
|
|
109
|
+
def _cmd_sig(cmd):
|
|
110
|
+
_P = inspect.Parameter
|
|
111
|
+
ps = [_P(p['name'], _P.KEYWORD_ONLY, default=None if p.get('optional') else _P.empty) for p in cmd.get('parameters', [])]
|
|
112
|
+
return inspect.Signature(ps)
|
|
113
|
+
|
|
114
|
+
def _cmd_doc(domain, method, cmd):
|
|
115
|
+
doc = f"{domain}.{method}"
|
|
116
|
+
if cmd.get('description'): doc += f" - {cmd['description']}"
|
|
117
|
+
for p in cmd.get('parameters', []):
|
|
118
|
+
opt = ' (optional)' if p.get('optional') else ''
|
|
119
|
+
doc += f"\n {p['name']}{opt}: {p.get('description', '')}"
|
|
120
|
+
return doc
|
|
121
|
+
|
|
122
|
+
# %% ../nbs/00_core.ipynb #ac370497
|
|
123
|
+
class JsCDPDomain:
|
|
124
|
+
def __init__(self, cdp, domain): store_attr()
|
|
125
|
+
def __getattr__(self, method):
|
|
126
|
+
if method.startswith('_'): raise AttributeError()
|
|
127
|
+
return JsCDPMethod(self.cdp, self.domain, method)
|
|
128
|
+
def __dir__(self):
|
|
129
|
+
d = _domains.get(self.domain, {})
|
|
130
|
+
return [c['name'] for c in d.get('commands', [])] + [e['name'] for e in d.get('events', [])]
|
|
131
|
+
|
|
132
|
+
class JsCDPMethod:
|
|
133
|
+
def __init__(self, cdp, domain, method):
|
|
134
|
+
store_attr()
|
|
135
|
+
if (cmd := _find_cmd(domain, method)):
|
|
136
|
+
self.__doc__ = _cmd_doc(domain, method, cmd)
|
|
137
|
+
self.__signature__ = _cmd_sig(cmd)
|
|
138
|
+
async def __call__(self, **kw): return await self.cdp(f'{self.domain}.{self.method}', **kw)
|
|
139
|
+
|
|
140
|
+
# %% ../nbs/00_core.ipynb #39b7647d
|
|
141
|
+
@patch
|
|
142
|
+
async def wait_for(self:JsCDP, expr, timeout=10):
|
|
143
|
+
"Wait for JS expression to be truthy, return its value"
|
|
144
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
145
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
146
|
+
r = await self.eval(expr)
|
|
147
|
+
if r: return r
|
|
148
|
+
await asyncio.sleep(0.05)
|
|
149
|
+
raise TimeoutError(f'Timed out waiting for: {expr}')
|
|
150
|
+
|
|
151
|
+
@patch
|
|
152
|
+
async def wait_for_selector(self:JsCDP, sel, timeout=10):
|
|
153
|
+
"Wait for CSS selector to match an element"
|
|
154
|
+
return await self.wait_for(f'!!document.querySelector("{sel}")', timeout)
|
|
155
|
+
|
|
156
|
+
@patch
|
|
157
|
+
async def eval(self:JsCDP, expr):
|
|
158
|
+
res = await self.runtime.evaluate(expression=expr)
|
|
159
|
+
return res['value'] if res else res
|
|
160
|
+
|
|
161
|
+
# %% ../nbs/00_core.ipynb #9814014d
|
|
162
|
+
@patch
|
|
163
|
+
async def wait_for_ready(self:JsCDP, timeout=10, idle_ms=500):
|
|
164
|
+
"Wait until network is idle for idle_ms"
|
|
165
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
166
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
167
|
+
async with self.on('Network.requestWillBeSent') as q:
|
|
168
|
+
if not await q(idle_ms/1000): return
|
|
169
|
+
raise TimeoutError('Timed out waiting for network idle')
|
|
170
|
+
|
|
171
|
+
@patch
|
|
172
|
+
async def goto(self:JsCDP, url, **kwargs):
|
|
173
|
+
"Navigate to url and wait for load+idle"
|
|
174
|
+
await self.page.navigate(url=url)
|
|
175
|
+
await self.wait_for_ready(**kwargs)
|
|
176
|
+
|
|
177
|
+
# %% ../nbs/00_core.ipynb #11ceac02
|
|
178
|
+
@patch(as_prop=True)
|
|
179
|
+
async def pages(self:JsCDP):
|
|
180
|
+
targets = (await event_get_a('cdp-get-targets')).result
|
|
181
|
+
return [t for t in targets if t.get('type') == 'page']
|
|
182
|
+
|
|
183
|
+
# %% ../nbs/00_core.ipynb #b71ec061
|
|
184
|
+
@patch
|
|
185
|
+
async def screenshot(self:JsCDP):
|
|
186
|
+
from IPython.display import Image
|
|
187
|
+
b64 = await self.page.captureScreenshot(format='png')
|
|
188
|
+
return Image(base64.b64decode(b64))
|
|
189
|
+
|
|
190
|
+
# %% ../nbs/00_core.ipynb #258e3058
|
|
191
|
+
class AXNode:
|
|
192
|
+
"Chrome accessibility tree node with compact repr"
|
|
193
|
+
def __init__(self, raw):
|
|
194
|
+
self.role = nested_idx(raw, 'role', 'value') or ''
|
|
195
|
+
self.name = nested_idx(raw, 'name', 'value') or ''
|
|
196
|
+
self.props = {p['name']: nested_idx(p, 'value', 'value') for p in raw.get('properties', [])}
|
|
197
|
+
self.children = []
|
|
198
|
+
self.node_id = raw.get('nodeId')
|
|
199
|
+
self.backend_id = raw.get('backendDOMNodeId')
|
|
200
|
+
self.ignored = raw.get('ignored', False)
|
|
201
|
+
self._raw = raw
|
|
202
|
+
|
|
203
|
+
def _repr_markdown_(self, depth=0):
|
|
204
|
+
pre = ' ' * depth
|
|
205
|
+
ps = ''.join(f' `{k}={v}`' for k,v in self.props.items() if v not in (False, None, '', 'false'))
|
|
206
|
+
bid = f' [#{self.backend_id}]' if self.backend_id else ''
|
|
207
|
+
line = f'{pre}- **{self.role}** "{self.name}"{ps}{bid}'
|
|
208
|
+
return '\n'.join([line] + [c._repr_markdown_(depth+1) for c in self.children])
|
|
209
|
+
|
|
210
|
+
__str__ = __repr__ = _repr_markdown_
|
|
211
|
+
|
|
212
|
+
class AXTree(AXNode):
|
|
213
|
+
def _repr_markdown_(self): return f'<div class="prose">\n\n{super()._repr_markdown_(0)}\n\n</div>'
|
|
214
|
+
|
|
215
|
+
# %% ../nbs/00_core.ipynb #492259b0
|
|
216
|
+
def _simplify(node):
|
|
217
|
+
node.children = [n for c in node.children for n in _simplify(c)]
|
|
218
|
+
if node.role in ('none', 'generic', 'paragraph') and not node.name:
|
|
219
|
+
if not node.children: return []
|
|
220
|
+
return node.children # splice children up
|
|
221
|
+
return [node]
|
|
222
|
+
|
|
223
|
+
def build_ax_tree(nodes):
|
|
224
|
+
"Build AXNode tree from flat CDP accessibility node list"
|
|
225
|
+
by_id = {}
|
|
226
|
+
for raw in nodes:
|
|
227
|
+
n = AXNode(raw)
|
|
228
|
+
by_id[n.node_id] = n
|
|
229
|
+
for raw in nodes:
|
|
230
|
+
parent = by_id.get(raw.get('nodeId'))
|
|
231
|
+
for cid in raw.get('childIds', []):
|
|
232
|
+
if (child := by_id.get(cid)): parent.children.append(child)
|
|
233
|
+
if not nodes: return
|
|
234
|
+
root = by_id.get(nodes[0]['nodeId'])
|
|
235
|
+
if not (result := _simplify(root)): return None
|
|
236
|
+
result = result[0]
|
|
237
|
+
result.__class__ = AXTree
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
# %% ../nbs/00_core.ipynb #d3e7a635
|
|
241
|
+
@patch
|
|
242
|
+
async def ax_tree(self:JsCDP, sid=None):
|
|
243
|
+
"Get accessibility tree for session"
|
|
244
|
+
await self.accessibility.enable(sid=sid)
|
|
245
|
+
return build_ax_tree(await self.accessibility.getFullAXTree(sid=sid))
|
|
246
|
+
|
|
247
|
+
# %% ../nbs/00_core.ipynb #d317c80b
|
|
248
|
+
@patch
|
|
249
|
+
def find(self:AXNode, role=None, name=None):
|
|
250
|
+
"Find first descendant matching role and/or name substring"
|
|
251
|
+
if (not role or role == self.role) and (not name or name in self.name): return self
|
|
252
|
+
for c in self.children:
|
|
253
|
+
if (r := c.find(role, name)): return r
|
|
254
|
+
|
|
255
|
+
@patch
|
|
256
|
+
def find_id(self:AXNode, role=None, name=None):
|
|
257
|
+
"Find first descendant matching role and/or name substring"
|
|
258
|
+
res = self.find(role, name)
|
|
259
|
+
return res.backend_id if res else None
|
|
260
|
+
|
|
261
|
+
@patch
|
|
262
|
+
def find_all(self:AXNode, role=None, name=None):
|
|
263
|
+
"Find all descendants matching role and/or name substring"
|
|
264
|
+
res = []
|
|
265
|
+
if (not role or role == self.role) and (not name or name in self.name): res.append(self)
|
|
266
|
+
for c in self.children: res += c.find_all(role, name)
|
|
267
|
+
return res
|
|
268
|
+
|
|
269
|
+
# %% ../nbs/00_core.ipynb #28b872d9
|
|
270
|
+
@patch
|
|
271
|
+
async def js_node(self:JsCDP, fn, backendNodeId, sid=None):
|
|
272
|
+
obj = await self.DOM.resolveNode(sid=sid, backendNodeId=backendNodeId)
|
|
273
|
+
return await self.runtime.callFunctionOn(sid=sid, functionDeclaration=fn,
|
|
274
|
+
objectId=obj['objectId'])
|
|
275
|
+
|
|
276
|
+
@patch
|
|
277
|
+
async def js_node_run(self:JsCDP, code, backendNodeId, sid=None):
|
|
278
|
+
return await self.js_node(f'function() {{ {code} }}', backendNodeId, sid=sid)
|
|
279
|
+
|
|
280
|
+
@patch
|
|
281
|
+
async def click(self:JsCDP, backendNodeId, sid=None):
|
|
282
|
+
await self.js_node_run('this.click()', backendNodeId, sid=sid)
|
|
283
|
+
|
|
284
|
+
# %% ../nbs/00_core.ipynb #3e826905
|
|
285
|
+
@patch
|
|
286
|
+
async def fill_text(self:JsCDP, backendNodeId, text, sid=None):
|
|
287
|
+
await self.DOM.focus(sid=sid, backendNodeId=backendNodeId)
|
|
288
|
+
await self.input.insertText(sid=sid, text=text)
|
|
289
|
+
|
|
290
|
+
# %% ../nbs/00_core.ipynb #3eec012b
|
|
291
|
+
@patch
|
|
292
|
+
async def click_and_wait(self:JsCDP, backendNodeId, **kwargs):
|
|
293
|
+
"Click element and wait for load+idle"
|
|
294
|
+
await self.js_node_run('this.click()', backendNodeId)
|
|
295
|
+
await self.wait_for_ready(**kwargs)
|
|
296
|
+
|
|
297
|
+
# %% ../nbs/00_core.ipynb #605c1669
|
|
298
|
+
def cdp_yolo():
|
|
299
|
+
"Allow all CDP classes in safepyrun"
|
|
300
|
+
from safepyrun import allow
|
|
301
|
+
allow({JsCDP:..., JsCDPDomain:..., JsCDPMethod:...})
|
|
302
|
+
allow({AXNode:..., AXTree:...})
|