mithwire 0.50.3__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.
Files changed (76) hide show
  1. mithwire/__init__.py +32 -0
  2. mithwire/cdp/README.md +4 -0
  3. mithwire/cdp/__init__.py +6 -0
  4. mithwire/cdp/accessibility.py +668 -0
  5. mithwire/cdp/animation.py +494 -0
  6. mithwire/cdp/audits.py +1995 -0
  7. mithwire/cdp/autofill.py +292 -0
  8. mithwire/cdp/background_service.py +215 -0
  9. mithwire/cdp/bluetooth_emulation.py +626 -0
  10. mithwire/cdp/browser.py +821 -0
  11. mithwire/cdp/cache_storage.py +311 -0
  12. mithwire/cdp/cast.py +172 -0
  13. mithwire/cdp/console.py +107 -0
  14. mithwire/cdp/crash_report_context.py +55 -0
  15. mithwire/cdp/css.py +2750 -0
  16. mithwire/cdp/database.py +179 -0
  17. mithwire/cdp/debugger.py +1405 -0
  18. mithwire/cdp/device_access.py +141 -0
  19. mithwire/cdp/device_orientation.py +45 -0
  20. mithwire/cdp/dom.py +2257 -0
  21. mithwire/cdp/dom_debugger.py +321 -0
  22. mithwire/cdp/dom_snapshot.py +876 -0
  23. mithwire/cdp/dom_storage.py +222 -0
  24. mithwire/cdp/emulation.py +1779 -0
  25. mithwire/cdp/event_breakpoints.py +56 -0
  26. mithwire/cdp/extensions.py +238 -0
  27. mithwire/cdp/fed_cm.py +283 -0
  28. mithwire/cdp/fetch.py +507 -0
  29. mithwire/cdp/file_system.py +115 -0
  30. mithwire/cdp/headless_experimental.py +115 -0
  31. mithwire/cdp/heap_profiler.py +401 -0
  32. mithwire/cdp/indexed_db.py +528 -0
  33. mithwire/cdp/input_.py +701 -0
  34. mithwire/cdp/inspector.py +95 -0
  35. mithwire/cdp/io.py +101 -0
  36. mithwire/cdp/layer_tree.py +464 -0
  37. mithwire/cdp/log.py +190 -0
  38. mithwire/cdp/media.py +313 -0
  39. mithwire/cdp/memory.py +305 -0
  40. mithwire/cdp/network.py +5342 -0
  41. mithwire/cdp/overlay.py +1468 -0
  42. mithwire/cdp/page.py +3972 -0
  43. mithwire/cdp/performance.py +124 -0
  44. mithwire/cdp/performance_timeline.py +200 -0
  45. mithwire/cdp/preload.py +575 -0
  46. mithwire/cdp/profiler.py +420 -0
  47. mithwire/cdp/pwa.py +278 -0
  48. mithwire/cdp/py.typed +0 -0
  49. mithwire/cdp/runtime.py +1589 -0
  50. mithwire/cdp/schema.py +50 -0
  51. mithwire/cdp/security.py +518 -0
  52. mithwire/cdp/service_worker.py +401 -0
  53. mithwire/cdp/smart_card_emulation.py +891 -0
  54. mithwire/cdp/storage.py +1573 -0
  55. mithwire/cdp/system_info.py +327 -0
  56. mithwire/cdp/target.py +829 -0
  57. mithwire/cdp/tethering.py +65 -0
  58. mithwire/cdp/tracing.py +377 -0
  59. mithwire/cdp/util.py +18 -0
  60. mithwire/cdp/web_audio.py +606 -0
  61. mithwire/cdp/web_authn.py +598 -0
  62. mithwire/cdp/web_mcp.py +293 -0
  63. mithwire/core/_contradict.py +142 -0
  64. mithwire/core/browser.py +923 -0
  65. mithwire/core/cf_templates/cf_dark_checkbox.png +0 -0
  66. mithwire/core/cf_templates/cf_light_checkbox.png +0 -0
  67. mithwire/core/config.py +323 -0
  68. mithwire/core/connection.py +564 -0
  69. mithwire/core/element.py +1205 -0
  70. mithwire/core/tab.py +2202 -0
  71. mithwire/core/util.py +5063 -0
  72. mithwire-0.50.3.dist-info/METADATA +1049 -0
  73. mithwire-0.50.3.dist-info/RECORD +76 -0
  74. mithwire-0.50.3.dist-info/WHEEL +5 -0
  75. mithwire-0.50.3.dist-info/licenses/LICENSE.txt +619 -0
  76. mithwire-0.50.3.dist-info/top_level.txt +1 -0
mithwire/core/tab.py ADDED
@@ -0,0 +1,2202 @@
1
+ # Copyright 2024 by UltrafunkAmsterdam (https://github.com/UltrafunkAmsterdam)
2
+ # All rights reserved.
3
+ # This file is part of the mithwire package.
4
+ # and is released under the "GNU AFFERO GENERAL PUBLIC LICENSE".
5
+ # Please see the LICENSE.txt file that should have been included as part of this package.
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import functools
11
+ import logging
12
+ import os
13
+ import pathlib
14
+ import secrets
15
+ import typing
16
+ import warnings
17
+ from pathlib import Path
18
+ from typing import Any, Generator, List, Optional, Tuple, Union
19
+
20
+ import mithwire.core.browser
21
+
22
+ from .. import cdp
23
+ from . import element, util
24
+ from .config import PathLike
25
+ from .connection import Connection, ProtocolException
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class Tab(Connection):
31
+ """
32
+ :ref:`tab` is the controlling mechanism/connection to a 'target',
33
+ for most of us 'target' can be read as 'tab'. however it could also
34
+ be an iframe, serviceworker or background script for example,
35
+ although there isn't much to control for those.
36
+
37
+ if you open a new window by using :py:meth:`browser.get(..., new_window=True)`
38
+ your url will open a new window. this window is a 'tab'.
39
+ When you browse to another page, the tab will be the same (it is an browser view).
40
+
41
+ So it's important to keep some reference to tab objects, in case you're
42
+ done interacting with elements and want to operate on the page level again.
43
+
44
+ Custom CDP commands
45
+ ---------------------------
46
+ Tab object provide many useful and often-used methods. It is also
47
+ possible to utilize the included cdp classes to to something totally custom.
48
+
49
+ the cdp package is a set of so-called "domains" with each having methods, events and types.
50
+ to send a cdp method, for example :py:obj:`cdp.page.navigate`, you'll have to check
51
+ whether the method accepts any parameters and whether they are required or not.
52
+
53
+ you can use
54
+
55
+ ```python
56
+ await tab.send(cdp.page.navigate(url='https://yoururlhere'))
57
+ ```
58
+
59
+ so tab.send() accepts a generator object, which is created by calling a cdp method.
60
+ this way you can build very detailed and customized commands.
61
+ (note: finding correct command combo's can be a time consuming task, luckily i added a whole bunch
62
+ of useful methods, preferably having the same api's or lookalikes, as in selenium)
63
+
64
+
65
+ some useful, often needed and simply required methods
66
+ ===================================================================
67
+
68
+
69
+ :py:meth:`~find` | find(text)
70
+ ----------------------------------------
71
+ find and returns a single element by text match. by default returns the first element found.
72
+ much more powerful is the best_match flag, although also much more expensive.
73
+ when no match is found, it will retry for <timeout> seconds (default: 10), so
74
+ this is also suitable to use as wait condition.
75
+
76
+
77
+ :py:meth:`~find` | find(text, best_match=True) or find(text, True)
78
+ ---------------------------------------------------------------------------------
79
+ Much more powerful (and expensive!!) than the above, is the use of the `find(text, best_match=True)` flag.
80
+ It will still return 1 element, but when multiple matches are found, picks the one having the
81
+ most similar text length.
82
+ How would that help?
83
+ For example, you search for "login", you'd probably want the "login" button element,
84
+ and not thousands of scripts,meta,headings which happens to contain a string of "login".
85
+
86
+ when no match is found, it will retry for <timeout> seconds (default: 10), so
87
+ this is also suitable to use as wait condition.
88
+
89
+
90
+ :py:meth:`~select` | select(selector)
91
+ ----------------------------------------
92
+ find and returns a single element by css selector match.
93
+ when no match is found, it will retry for <timeout> seconds (default: 10), so
94
+ this is also suitable to use as wait condition.
95
+
96
+
97
+ :py:meth:`~select_all` | select_all(selector)
98
+ ------------------------------------------------
99
+ find and returns all elements by css selector match.
100
+ when no match is found, it will retry for <timeout> seconds (default: 10), so
101
+ this is also suitable to use as wait condition.
102
+
103
+
104
+ await :py:obj:`Tab`
105
+ ---------------------------
106
+ calling `await tab` will do a lot of stuff under the hood, and ensures all references
107
+ are up to date. also it allows for the script to "breathe", as it is oftentime faster than your browser or
108
+ webpage. So whenever you get stuck and things crashes or element could not be found, you should probably let
109
+ it "breathe" by calling `await page` and/or `await page.sleep()`
110
+
111
+ also, it's ensuring :py:obj:`~url` will be updated to the most recent one, which is quite important in some
112
+ other methods.
113
+
114
+ attempts to find the location of given template image in the current viewport
115
+ the only real use case for this is bot-detection systems.
116
+ you can find for example the location of a 'verify'-checkbox,
117
+ which are hidden from dom using shadow-root's or workers.
118
+
119
+
120
+
121
+ await :py:obj:`Tab.template_location` (and await :py:obj:`Tab.verify_cf`)
122
+ ------------------------------------------------------------------------------
123
+
124
+ attempts to find the location of given template image in the current viewport.
125
+ the only real use case for this is bot-detection systems.
126
+ you can find, for example the location of a ‘verify’-checkbox, which are hidden from dom
127
+ using shadow-root’s or/or workers and cannot be controlled by normal methods.
128
+
129
+ template_image can be custom (for example your language, included is english only),
130
+ but you need to create the template image yourself, which is just a cropped
131
+ image of the area, see example image, where the target is exactly in the center.
132
+ template_image can be custom (for example your language), but you need to
133
+ create the template image yourself, where the target is exactly in the center.
134
+
135
+
136
+ example (111x71)
137
+ ---------
138
+ this includes the white space on the left, to make the box center
139
+
140
+ .. image:: template_example.png
141
+ :width: 111
142
+ :alt: example template image
143
+
144
+
145
+ Using other and custom CDP commands
146
+ ======================================================
147
+ using the included cdp module, you can easily craft commands, which will always return an generator object.
148
+ this generator object can be easily sent to the :py:meth:`~send` method.
149
+
150
+ :py:meth:`~send`
151
+ ---------------------------
152
+ this is probably THE most important method, although you won't ever call it, unless you want to
153
+ go really custom. the send method accepts a :py:obj:`cdp` command. Each of which can be found in the
154
+ cdp section.
155
+
156
+ when you import * from this package, cdp will be in your namespace, and contains all domains/actions/events
157
+ you can act upon.
158
+ """
159
+
160
+ _download_behavior: List[str] = None
161
+
162
+ @property
163
+ def browser(self) -> "mithwire.Browser" | Connection | None:
164
+ if self.parent:
165
+ if self.parent.target:
166
+ if self.parent.target.type_ == "browser":
167
+ return self.parent
168
+
169
+ def __init__(
170
+ self,
171
+ target: cdp.target.TargetInfo | cdp.target.TargetID | str = None,
172
+ parent: Connection = None,
173
+ **kwargs,
174
+ ):
175
+ self._dom = None
176
+ self._window_id = None
177
+ self.target = target
178
+ super().__init__(target=target, parent=parent, **kwargs)
179
+
180
+ async def get_frames(self) -> List[IFrame]:
181
+ """find any 'connectable' frames.
182
+ mostly used for iframes. but this does NOT return :py:obj:`mithwire.Element` 's that are iframe.
183
+ it returns a list of `py:obj:`mithwire.core.tab.IFrame`if these are available.
184
+ this does not guarantee to return any iframe, as some are
185
+ isolated by security, origins, workers or other trickery.
186
+
187
+ """
188
+ targets = await self.send(cdp.target.get_targets())
189
+ frame_ids = [
190
+ x.id_
191
+ for x in util.flatten_frame_tree(await self.send(cdp.page.get_frame_tree()))
192
+ ]
193
+ frame_targets = [
194
+ x for x in targets if str(x.parent_frame_id) in map(str, frame_ids)
195
+ ]
196
+ return [IFrame(target=t, parent=self) for t in frame_targets]
197
+
198
+ @property
199
+ def inspector_url(self):
200
+ """
201
+ get the inspector url. this url can be used in another browser to show you the devtools interface for
202
+ current tab. useful for debugging (and headless)
203
+ :return:
204
+ :rtype:
205
+ """
206
+ return f"http://{self.browser.config.host}:{self.browser.config.port}/devtools/inspector.html?ws={self.websocket_url[5:]}"
207
+
208
+ def inspector_open(self):
209
+ import webbrowser
210
+
211
+ webbrowser.open(self.inspector_url, new=2)
212
+
213
+ async def open_external_inspector(self):
214
+ """
215
+ opens the system's browser containing the devtools inspector page
216
+ for this tab. could be handy, especially to debug in headless mode.
217
+ """
218
+ import webbrowser
219
+
220
+ webbrowser.open(self.inspector_url)
221
+
222
+ async def feed_cdp(
223
+ self, cmd: Generator[dict[str, Any], dict[str, Any], Any]
224
+ ) -> asyncio.Future:
225
+ return await super().send(cmd)
226
+
227
+ async def _prepare_expert(self):
228
+ if getattr(self, "_prep_expert_done", None):
229
+ return
230
+ if self.browser:
231
+ await self.send(cdp.page.enable())
232
+ await self.send(
233
+ cdp.page.add_script_to_evaluate_on_new_document(
234
+ """
235
+ console.log("hooking attachShadow");
236
+ Element.prototype._attachShadow = Element.prototype.attachShadow;
237
+ Element.prototype.attachShadow = function () {
238
+ console.log('calling hooked attachShadow')
239
+ return this._attachShadow( { mode: "open" } );
240
+ };"""
241
+ )
242
+ )
243
+
244
+ setattr(self, "_prep_expert_done", True)
245
+
246
+ async def find(
247
+ self,
248
+ text: str,
249
+ best_match: bool = True,
250
+ return_enclosing_element=True,
251
+ timeout: Union[int, float] = 10,
252
+ ):
253
+ """
254
+ find single element by text
255
+ can also be used to wait for such element to appear.
256
+
257
+ :param text: text to search for. note: script contents are also considered text
258
+ :type text: str
259
+ :param best_match: :param best_match: when True (default), it will return the element which has the most
260
+ comparable string length. this could help tremendously, when for example
261
+ you search for "login", you'd probably want the login button element,
262
+ and not thousands of scripts,meta,headings containing a string of "login".
263
+ When False, it will return naively just the first match (but is way faster).
264
+ :type best_match: bool
265
+ :param return_enclosing_element:
266
+ since we deal with nodes instead of elements, the find function most often returns
267
+ so called text nodes, which is actually a element of plain text, which is
268
+ the somehow imaginary "child" of a "span", "p", "script" or any other elements which have text between their opening
269
+ and closing tags.
270
+ most often when we search by text, we actually aim for the element containing the text instead of
271
+ a lousy plain text node, so by default the containing element is returned.
272
+
273
+ however, there are (why not) exceptions, for example elements that use the "placeholder=" property.
274
+ this text is rendered, but is not a pure text node. in that case you can set this flag to False.
275
+ since in this case we are probably interested in just that element, and not it's parent.
276
+
277
+
278
+ # todo, automatically determine node type
279
+ # ignore the return_enclosing_element flag if the found node is NOT a text node but a
280
+ # regular element (one having a tag) in which case that is exactly what we need.
281
+ :type return_enclosing_element: bool
282
+ :param timeout: raise timeout exception when after this many seconds nothing is found.
283
+ :type timeout: float,int
284
+ """
285
+ loop = asyncio.get_running_loop()
286
+ start_time = loop.time()
287
+
288
+ text = text.strip()
289
+
290
+ item = await self.find_element_by_text(
291
+ text, best_match, return_enclosing_element
292
+ )
293
+ while not item:
294
+
295
+ frame_conns = await self.get_frames()
296
+
297
+ futures = [
298
+ asyncio.ensure_future(
299
+ fc.find_element_by_text(text, best_match, return_enclosing_element)
300
+ )
301
+ for fc in frame_conns
302
+ ]
303
+ while futures:
304
+
305
+ futures_done, futures_pending = await asyncio.wait(
306
+ futures, return_when=asyncio.FIRST_COMPLETED
307
+ )
308
+
309
+ fd = futures_done.pop()
310
+ if not fd.exception():
311
+
312
+ res = fd.result()
313
+ if not res:
314
+
315
+ futures = futures_pending
316
+ continue
317
+ else:
318
+
319
+ item = res
320
+ futures = None
321
+ [f.cancel() for f in futures_pending]
322
+
323
+ # tasks = [asyncio.ensure_future(frame_con.find_element_by_text(text, best_match, return_enclosing_element)) for frame_con in frame_conns]
324
+ if item:
325
+ break
326
+
327
+ item = await self.find_element_by_text(
328
+ text, best_match, return_enclosing_element
329
+ )
330
+ if loop.time() - start_time > timeout:
331
+ return item
332
+ await self.sleep(0.5)
333
+ return item
334
+
335
+ async def select(
336
+ self,
337
+ selector: str,
338
+ timeout: Union[int, float] = 10,
339
+ ) -> mithwire.Element:
340
+ """
341
+ find single element by css selector.
342
+ can also be used to wait for such element to appear.
343
+
344
+ :param selector: css selector, eg a[href], button[class*=close], a > img[src]
345
+ :type selector: str
346
+
347
+ :param timeout: raise timeout exception when after this many seconds nothing is found.
348
+ :type timeout: float,int
349
+
350
+ """
351
+ loop = asyncio.get_running_loop()
352
+ start_time = loop.time()
353
+
354
+ selector = selector.strip()
355
+ item = await self.query_selector(selector)
356
+
357
+ while not item:
358
+ await self
359
+ item = await self.query_selector(selector)
360
+ if loop.time() - start_time > timeout:
361
+ return item
362
+ await self.sleep(0.5)
363
+ return item
364
+
365
+ async def find_all(
366
+ self,
367
+ text: str,
368
+ timeout: Union[int, float] = 10,
369
+ ) -> List[mithwire.Element]:
370
+ """
371
+ find multiple elements by text
372
+ can also be used to wait for such element to appear.
373
+
374
+ :param text: text to search for. note: script contents are also considered text
375
+ :type text: str
376
+
377
+ :param timeout: raise timeout exception when after this many seconds nothing is found.
378
+ :type timeout: float,int
379
+ """
380
+ loop = asyncio.get_running_loop()
381
+ now = loop.time()
382
+
383
+ text = text.strip()
384
+ items = await self.find_elements_by_text(text)
385
+
386
+ while not items:
387
+ await self
388
+ items = await self.find_elements_by_text(text)
389
+ if loop.time() - now > timeout:
390
+ return items
391
+ await self.sleep(0.5)
392
+ return items
393
+
394
+ async def select_all(
395
+ self, selector: str, timeout: Union[int, float] = 10, include_frames=False
396
+ ) -> List[mithwire.Element]:
397
+ """
398
+ find multiple elements by css selector.
399
+ can also be used to wait for such element to appear.
400
+
401
+
402
+ :param selector: css selector, eg a[href], button[class*=close], a > img[src]
403
+ :type selector: str
404
+ :param timeout: raise timeout exception when after this many seconds nothing is found.
405
+ :type timeout: float,int
406
+ :param include_frames: whether to include results in iframes.
407
+ :type include_frames: bool
408
+ """
409
+ loop = asyncio.get_running_loop()
410
+ now = loop.time()
411
+ selector = selector.strip()
412
+ items = []
413
+ if include_frames:
414
+ frames = await self.query_selector_all("iframe")
415
+ # unfortunately, asyncio.gather here is not an option
416
+ for fr in frames:
417
+ items.extend(await fr.query_selector_all(selector))
418
+
419
+ items.extend(await self.query_selector_all(selector))
420
+ while not items:
421
+ await self
422
+ items = await self.query_selector_all(selector)
423
+ if loop.time() - now > timeout:
424
+ return items
425
+ await self.sleep(0.5)
426
+ return items
427
+
428
+ async def sleep(self, t: float | int = 1):
429
+ if self.browser:
430
+ await asyncio.wait(
431
+ [
432
+ asyncio.create_task(self.browser.update_targets()),
433
+ asyncio.create_task(asyncio.sleep(t)),
434
+ ],
435
+ return_when=asyncio.ALL_COMPLETED,
436
+ )
437
+
438
+ async def xpath(
439
+ self, xpath: str, timeout: float = 2.5
440
+ ) -> List[Optional[mithwire.Element]]: # noqa
441
+ """
442
+ find elements by xpath string.
443
+ if not immediately found, retries are attempted until :ref:`timeout` is reached (default 2.5 seconds).
444
+ in case nothing is found, it returns an empty list. It will not raise.
445
+ this timeout mechanism helps when relying on some element to appear before continuing your script.
446
+
447
+
448
+ .. code-block:: python
449
+
450
+ # find all the inline scripts (script elements without src attribute )
451
+ await tab.xpath('//script[not(@src)]')
452
+
453
+ # or here, more complex, but my personal favorite to case-insensitive text search
454
+
455
+ await tab.xpath('//text()[ contains( translate(., "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"),"test")]')
456
+
457
+
458
+ :param xpath:
459
+ :type xpath: str
460
+ :param timeout: 2.5
461
+ :type timeout: float
462
+ :return:List[mithwire.Element] or []
463
+ :rtype:
464
+ """
465
+ items: List[Optional[mithwire.Element]] = []
466
+ try:
467
+ await self.send(cdp.dom.enable(), True)
468
+ items = await self.find_all(xpath, timeout=0)
469
+ if not items:
470
+ loop = asyncio.get_running_loop()
471
+ start_time = loop.time()
472
+ while not items:
473
+ items = await self.find_all(xpath, timeout=0)
474
+ await self.sleep(0.1)
475
+ if loop.time() - start_time > timeout:
476
+ break
477
+ finally:
478
+ try:
479
+ await self.send(cdp.dom.disable(), True)
480
+ except ProtocolException:
481
+ # for some strange reason, the call to dom.disable
482
+ # sometimes raises an exception that dom is not enabled.
483
+ pass
484
+ return items
485
+
486
+ async def get(
487
+ self, url="chrome://welcome", new_tab: bool = False, new_window: bool = False
488
+ ):
489
+ """top level get. utilizes the first tab to retrieve given url.
490
+
491
+ convenience function known from selenium.
492
+ this function handles waits/sleeps and detects when DOM events fired, so it's the safest
493
+ way of navigating.
494
+
495
+ :param url: the url to navigate to
496
+ :param new_tab: open new tab
497
+ :param new_window: open new window
498
+ :return: Page
499
+ """
500
+ if not self.browser:
501
+ raise AttributeError(
502
+ "this page/tab has no browser attribute, so you can't use get()"
503
+ )
504
+ # await self.browser.get(url, new_tab, new_window)
505
+ if new_window and not new_tab:
506
+ new_tab = True
507
+ if new_tab:
508
+ return await self.browser.get(url, new_tab, new_window)
509
+ else:
510
+ frame_id, loader_id, *_ = await self.send(cdp.page.navigate(url))
511
+ await self.attach()
512
+ return self
513
+
514
+ async def query_selector_all(
515
+ self,
516
+ selector: str,
517
+ _node: Optional[Union[cdp.dom.Node, "element.Element"]] = None,
518
+ ):
519
+ """
520
+ equivalent of javascripts document.querySelectorAll.
521
+ this is considered one of the main methods to use in this package.
522
+
523
+ it returns all matching :py:obj:`mithwire.Element` objects.
524
+
525
+ :param selector: css selector. (first time? => https://www.w3schools.com/cssref/css_selectors.php )
526
+ :type selector: str
527
+ :param _node: internal use
528
+ :type _node:
529
+ :return:
530
+ :rtype:
531
+ """
532
+
533
+ if not _node:
534
+ doc: cdp.dom.Node = await self.send(cdp.dom.get_document(-1, True))
535
+ else:
536
+ doc = _node
537
+ if _node.node_name == "IFRAME":
538
+ doc = _node.content_document
539
+
540
+ node_ids = []
541
+
542
+ try:
543
+ node_ids = await self.send(
544
+ cdp.dom.query_selector_all(doc.node_id, selector)
545
+ )
546
+ except AttributeError:
547
+ # has no content_document
548
+ return
549
+
550
+ except ProtocolException as e:
551
+ if _node is not None:
552
+ if "could not find node" in e.message.lower():
553
+ if getattr(_node, "__last", None):
554
+ del _node.__last
555
+ return []
556
+ # if supplied node is not found, the dom has changed since acquiring the element
557
+ # therefore we need to update our passed node and try again
558
+ await _node.update()
559
+ _node.__last = (
560
+ True # make sure this isn't turned into infinite loop
561
+ )
562
+ return await self.query_selector_all(selector, _node)
563
+ else:
564
+ await self.send(cdp.dom.disable())
565
+ raise
566
+ if not node_ids:
567
+ return []
568
+ items = []
569
+
570
+ for nid in node_ids:
571
+ node = util.filter_recurse(doc, lambda n: n.node_id == nid)
572
+ # we pass along the retrieved document tree,
573
+ # to improve performance
574
+ if not node:
575
+ continue
576
+ elem = element.create(node, self, doc)
577
+ items.append(elem)
578
+
579
+ return items
580
+
581
+ async def query_selector(
582
+ self,
583
+ selector: str,
584
+ _node: Optional[Union[cdp.dom.Node, element.Element]] = None,
585
+ ):
586
+ """
587
+ find single element based on css selector string
588
+
589
+ :param selector: css selector(s)
590
+ :type selector: str
591
+ :return:
592
+ :rtype:
593
+ """
594
+ selector = selector.strip()
595
+
596
+ if not _node:
597
+ doc: cdp.dom.Node = await self.send(cdp.dom.get_document(-1, True))
598
+ else:
599
+ doc = _node
600
+ if _node.node_name == "IFRAME":
601
+ doc = _node.content_document
602
+ node_id = None
603
+
604
+ try:
605
+ node_id = await self.send(cdp.dom.query_selector(doc.node_id, selector))
606
+
607
+ except ProtocolException as e:
608
+ if _node is not None:
609
+ if "could not find node" in e.message.lower():
610
+ if getattr(_node, "__last", None):
611
+ del _node.__last
612
+ return []
613
+ # if supplied node is not found, the dom has changed since acquiring the element
614
+ # therefore we need to update our passed node and try again
615
+ await _node.update()
616
+ _node.__last = (
617
+ True # make sure this isn't turned into infinite loop
618
+ )
619
+ return await self.query_selector(selector, _node)
620
+ else:
621
+ await self.send(cdp.dom.disable())
622
+ raise
623
+ if not node_id:
624
+ return
625
+ node = util.filter_recurse(doc, lambda n: n.node_id == node_id)
626
+ if not node:
627
+ return
628
+ return element.create(node, self, doc)
629
+
630
+ async def find_elements_by_text(
631
+ self,
632
+ text: str,
633
+ tag_hint: Optional[str] = None,
634
+ ) -> List[element.Element]:
635
+ """
636
+ returns element which match the given text.
637
+ returns element which match the given text.
638
+ please note: this may (or will) also return any other element (like inline scripts),
639
+ which happen to contain that text.
640
+
641
+ :param text:
642
+ :type text:
643
+ :param tag_hint: when provided, narrows down search to only elements which match given tag eg: a, div, script, span
644
+ :type tag_hint: str
645
+ :return:
646
+ :rtype:
647
+ """
648
+ text = text.strip()
649
+ doc = await self.send(cdp.dom.get_document(-1, True))
650
+ search_id, nresult = await self.send(cdp.dom.perform_search(text, True))
651
+ if nresult:
652
+ node_ids = await self.send(
653
+ cdp.dom.get_search_results(search_id, 0, nresult)
654
+ )
655
+ else:
656
+ node_ids = []
657
+
658
+ await self.send(cdp.dom.discard_search_results(search_id))
659
+
660
+ items = []
661
+ for nid in node_ids:
662
+ node = util.filter_recurse(doc, lambda n: n.node_id == nid)
663
+ if not node:
664
+ node = await self.send(cdp.dom.resolve_node(node_id=nid))
665
+ if not node:
666
+ continue
667
+ # remote_object = await self.send(cdp.dom.resolve_node(backend_node_id=node.backend_node_id))
668
+ # node_id = await self.send(cdp.dom.request_node(object_id=remote_object.object_id))
669
+ try:
670
+ elem = element.create(node, self, doc)
671
+ except: # noqa
672
+ continue
673
+ if elem.node_type == 3:
674
+ # if found element is a text node (which is plain text, and useless for our purpose),
675
+ # we return the parent element of the node (which is often a tag which can have text between their
676
+ # opening and closing tags (that is most tags, except for example "img" and "video", "br")
677
+
678
+ if not elem.parent:
679
+ # check if parent actually has a parent and update it to be absolutely sure
680
+ await elem.update()
681
+
682
+ items.append(
683
+ elem.parent or elem
684
+ ) # when it really has no parent, use the text node itself
685
+ continue
686
+ else:
687
+ # just add the element itself
688
+ items.append(elem)
689
+
690
+ # since we already fetched the entire doc, including shadow and frames
691
+ # let's also search through the iframes
692
+ iframes = util.filter_recurse_all(doc, lambda node: node.node_name == "IFRAME")
693
+ if iframes:
694
+ iframes_elems = [
695
+ element.create(iframe, self, iframe.content_document)
696
+ for iframe in iframes
697
+ ]
698
+ for iframe_elem in iframes_elems:
699
+ if iframe_elem.content_document:
700
+ iframe_text_nodes = util.filter_recurse_all(
701
+ iframe_elem,
702
+ lambda node: node.node_type == 3 # noqa
703
+ and text.lower() in node.node_value.lower(),
704
+ )
705
+ if iframe_text_nodes:
706
+ iframe_text_elems = [
707
+ element.create(text_node, self, iframe_elem.tree)
708
+ for text_node in iframe_text_nodes
709
+ ]
710
+ items.extend(
711
+ text_node.parent for text_node in iframe_text_elems
712
+ )
713
+ await self.send(cdp.dom.disable())
714
+ return items or []
715
+
716
+ async def find_element_by_text(
717
+ self,
718
+ text: str,
719
+ best_match: Optional[bool] = False,
720
+ return_enclosing_element: Optional[bool] = True,
721
+ ) -> Union[element.Element, None]:
722
+ """
723
+ finds and returns the first element containing <text>, or best match
724
+
725
+ :param text:
726
+ :type text:
727
+ :param best_match: when True, which is MUCH more expensive (thus much slower),
728
+ will find the closest match based on length.
729
+ this could help tremendously, when for example you search for "login", you'd probably want the login button element,
730
+ and not thousands of scripts,meta,headings containing a string of "login".
731
+
732
+ :type best_match: bool
733
+ :param return_enclosing_element:
734
+ :type return_enclosing_element:
735
+ :return:
736
+ :rtype:
737
+ """
738
+ doc = await self.send(cdp.dom.get_document(-1, True))
739
+ text = text.strip()
740
+ search_id, nresult = await self.send(cdp.dom.perform_search(text, True))
741
+ if nresult:
742
+ node_ids = await self.send(
743
+ cdp.dom.get_search_results(search_id, 0, nresult)
744
+ )
745
+ else:
746
+ node_ids = None
747
+ await self.send(cdp.dom.discard_search_results(search_id))
748
+ if not node_ids:
749
+ node_ids = []
750
+ items = []
751
+ for nid in node_ids:
752
+ node = util.filter_recurse(doc, lambda n: n.node_id == nid)
753
+ try:
754
+ elem = element.create(node, self, doc)
755
+ except: # noqa
756
+ continue
757
+ if elem.node_type == 3:
758
+ # if found element is a text node (which is plain text, and useless for our purpose),
759
+ # we return the parent element of the node (which is often a tag which can have text between their
760
+ # opening and closing tags (that is most tags, except for example "img" and "video", "br")
761
+
762
+ if not elem.parent:
763
+ # check if parent actually has a parent and update it to be absolutely sure
764
+ await elem.update()
765
+
766
+ items.append(
767
+ elem.parent or elem
768
+ ) # when it really has no parent, use the text node itself
769
+ continue
770
+ else:
771
+ # just add the element itself
772
+ items.append(elem)
773
+
774
+ # since we already fetched the entire doc, including shadow and frames
775
+ # let's also search through the iframes
776
+ iframes = util.filter_recurse_all(doc, lambda node: node.node_name == "IFRAME")
777
+ if iframes:
778
+ iframes_elems = [
779
+ element.create(iframe, self, iframe.content_document)
780
+ for iframe in iframes
781
+ ]
782
+ for iframe_elem in iframes_elems:
783
+ iframe_text_nodes = util.filter_recurse_all(
784
+ iframe_elem,
785
+ lambda node: node.node_type == 3 # noqa
786
+ and text.lower() in node.node_value.lower(),
787
+ )
788
+ if iframe_text_nodes:
789
+ iframe_text_elems = [
790
+ element.create(text_node, self, iframe_elem.tree)
791
+ for text_node in iframe_text_nodes
792
+ ]
793
+ items.extend(text_node.parent for text_node in iframe_text_elems)
794
+
795
+ try:
796
+ if not items:
797
+ return
798
+ if best_match:
799
+ closest_by_length = min(
800
+ items, key=lambda el: abs(len(text) - len(el.text_all))
801
+ )
802
+ elem = closest_by_length or items[0]
803
+
804
+ return elem
805
+ else:
806
+ # naively just return the first result
807
+ for elem in items:
808
+ if elem:
809
+ return elem
810
+ finally:
811
+ await self.send(cdp.dom.disable())
812
+
813
+ async def back(self):
814
+ """
815
+ history back
816
+ """
817
+ await self.send(cdp.runtime.evaluate("window.history.back()"))
818
+
819
+ async def forward(self):
820
+ """
821
+ history forward
822
+ """
823
+ await self.send(cdp.runtime.evaluate("window.history.forward()"))
824
+
825
+ async def reload(
826
+ self,
827
+ ignore_cache: Optional[bool] = True,
828
+ script_to_evaluate_on_load: Optional[str] = None,
829
+ ):
830
+ """
831
+ Reloads the page
832
+
833
+ :param ignore_cache: when set to True (default), it ignores cache, and re-downloads the items
834
+ :type ignore_cache:
835
+ :param script_to_evaluate_on_load: script to run on load. I actually haven't experimented with this one, so no guarantees.
836
+ :type script_to_evaluate_on_load:
837
+ :return:
838
+ :rtype:
839
+ """
840
+ await self.send(
841
+ cdp.page.reload(
842
+ ignore_cache=ignore_cache,
843
+ script_to_evaluate_on_load=script_to_evaluate_on_load,
844
+ ),
845
+ )
846
+
847
+ async def evaluate(
848
+ self, expression: str, await_promise=False, return_by_value=False
849
+ ) -> Union[
850
+ str,
851
+ Union[str, Any],
852
+ Tuple[cdp.runtime.RemoteObject, cdp.runtime.ExceptionDetails | None],
853
+ ]:
854
+
855
+ ser = cdp.runtime.SerializationOptions(
856
+ serialization="deep",
857
+ max_depth=10,
858
+ additional_parameters={"maxNodeDepth": 10, "includeShadowTree": "all"},
859
+ )
860
+ remote_object: cdp.runtime.RemoteObject = None
861
+ errors: cdp.runtime.ExceptionDetails = None
862
+
863
+ remote_object, errors = await self.send(
864
+ cdp.runtime.evaluate(
865
+ expression=expression,
866
+ user_gesture=True,
867
+ await_promise=await_promise,
868
+ return_by_value=return_by_value,
869
+ allow_unsafe_eval_blocked_by_csp=True,
870
+ serialization_options=ser,
871
+ )
872
+ )
873
+ if errors:
874
+ return errors
875
+ if remote_object:
876
+ if return_by_value:
877
+ if remote_object.value:
878
+ return remote_object.value
879
+ else:
880
+ if remote_object.deep_serialized_value:
881
+ return remote_object.deep_serialized_value.value
882
+
883
+ return remote_object
884
+
885
+ async def js_dumps(
886
+ self, obj_name: str, return_by_value: Optional[bool] = True
887
+ ) -> typing.Union[
888
+ typing.Dict,
889
+ typing.Tuple[cdp.runtime.RemoteObject, cdp.runtime.ExceptionDetails],
890
+ ]:
891
+ """
892
+ dump given js object with its properties and values as a dict
893
+
894
+ note: complex objects might not be serializable, therefore this method is not a "source of thruth"
895
+
896
+ :param obj_name: the js object to dump
897
+ :type obj_name: str
898
+
899
+ :param return_by_value: if you want an tuple of cdp objects (returnvalue, errors), set this to False
900
+ :type return_by_value: bool
901
+
902
+ example
903
+ ------
904
+
905
+ x = await self.js_dumps('window')
906
+ print(x)
907
+ '...{
908
+ 'pageYOffset': 0,
909
+ 'visualViewport': {},
910
+ 'screenX': 10,
911
+ 'screenY': 10,
912
+ 'outerWidth': 1050,
913
+ 'outerHeight': 832,
914
+ 'devicePixelRatio': 1,
915
+ 'screenLeft': 10,
916
+ 'screenTop': 10,
917
+ 'styleMedia': {},
918
+ 'onsearch': None,
919
+ 'isSecureContext': True,
920
+ 'trustedTypes': {},
921
+ 'performance': {'timeOrigin': 1707823094767.9,
922
+ 'timing': {'connectStart': 0,
923
+ 'navigationStart': 1707823094768,
924
+ ]...
925
+ '
926
+ """
927
+ js_code_a = (
928
+ """
929
+ function ___dump(obj, _d = 0) {
930
+ let _typesA = ['object', 'function'];
931
+ let _typesB = ['number', 'string', 'boolean'];
932
+ if (_d == 2) {
933
+ // console.log('maxdepth reached for ', obj);
934
+ return
935
+ }
936
+ let tmp = {}
937
+ for (let k in obj) {
938
+ if (obj[k] == window) continue;
939
+ let v;
940
+ try {
941
+ if (obj[k] === null || obj[k] === undefined || obj[k] === NaN) {
942
+ // console.log('obj[k] is null or undefined or Nan', k, '=>', obj[k])
943
+ tmp[k] = obj[k];
944
+ continue
945
+ }
946
+ } catch (e) {
947
+ tmp[k] = null;
948
+ continue
949
+ }
950
+
951
+ if (_typesB.includes(typeof obj[k])) {
952
+ tmp[k] = obj[k]
953
+ continue
954
+ }
955
+
956
+ try {
957
+ if (typeof obj[k] === 'function') {
958
+ tmp[k] = obj[k].toString()
959
+ continue
960
+ }
961
+
962
+
963
+ if (typeof obj[k] === 'object') {
964
+ tmp[k] = ___dump(obj[k], _d + 1);
965
+ continue
966
+ }
967
+
968
+
969
+ } catch (e) {}
970
+
971
+ try {
972
+ tmp[k] = JSON.stringify(obj[k])
973
+ continue
974
+ } catch (e) {
975
+
976
+ }
977
+ try {
978
+ tmp[k] = obj[k].toString();
979
+ continue
980
+ } catch (e) {}
981
+ }
982
+ return tmp
983
+ }
984
+
985
+ function ___dumpY(obj) {
986
+ var objKeys = (obj) => {
987
+ var [target, result] = [obj, []];
988
+ while (target !== null) {
989
+ result = result.concat(Object.getOwnPropertyNames(target));
990
+ target = Object.getPrototypeOf(target);
991
+ }
992
+ return result;
993
+ }
994
+ return Object.fromEntries(
995
+ objKeys(obj).map(_ => [_, ___dump(obj[_])]))
996
+
997
+ }
998
+ ___dumpY( %s )
999
+ """
1000
+ % obj_name
1001
+ )
1002
+ js_code_b = (
1003
+ """
1004
+ ((obj, visited = new WeakSet()) => {
1005
+ if (visited.has(obj)) {
1006
+ return {}
1007
+ }
1008
+ visited.add(obj)
1009
+ var result = {}, _tmp;
1010
+ for (var i in obj) {
1011
+ try {
1012
+ if (i === 'enabledPlugin' || typeof obj[i] === 'function') {
1013
+ continue;
1014
+ } else if (typeof obj[i] === 'object') {
1015
+ _tmp = recurse(obj[i], visited);
1016
+ if (Object.keys(_tmp).length) {
1017
+ result[i] = _tmp;
1018
+ }
1019
+ } else {
1020
+ result[i] = obj[i];
1021
+ }
1022
+ } catch (error) {
1023
+ // console.error('Error:', error);
1024
+ }
1025
+ }
1026
+ return result;
1027
+ })(%s)
1028
+ """
1029
+ % obj_name
1030
+ )
1031
+
1032
+ # we're purposely not calling self.evaluate here to prevent infinite loop on certain expressions
1033
+
1034
+ remote_object, exception_details = await self.send(
1035
+ cdp.runtime.evaluate(
1036
+ js_code_a,
1037
+ await_promise=True,
1038
+ return_by_value=return_by_value,
1039
+ allow_unsafe_eval_blocked_by_csp=True,
1040
+ )
1041
+ )
1042
+ if exception_details:
1043
+ # try second variant
1044
+
1045
+ remote_object, exception_details = await self.send(
1046
+ cdp.runtime.evaluate(
1047
+ js_code_b,
1048
+ await_promise=True,
1049
+ return_by_value=return_by_value,
1050
+ allow_unsafe_eval_blocked_by_csp=True,
1051
+ )
1052
+ )
1053
+
1054
+ if exception_details:
1055
+ raise ProtocolException(exception_details)
1056
+ if return_by_value:
1057
+ if remote_object.value:
1058
+ return remote_object.value
1059
+ else:
1060
+ return remote_object, exception_details
1061
+
1062
+ async def close(self):
1063
+ """
1064
+ close the current target (ie: tab,window,page)
1065
+ :return:
1066
+ :rtype:
1067
+ """
1068
+ if self.target and self.target.target_id:
1069
+ await self.send(cdp.target.close_target(target_id=self.target.target_id))
1070
+
1071
+ async def get_window(self) -> Tuple[cdp.browser.WindowID, cdp.browser.Bounds]:
1072
+ """
1073
+ get the window Bounds
1074
+ :return:
1075
+ :rtype:
1076
+ """
1077
+ window_id, bounds = await self.send(
1078
+ cdp.browser.get_window_for_target(self.target_id)
1079
+ )
1080
+ return window_id, bounds
1081
+
1082
+ async def get_content(self):
1083
+ """
1084
+ gets the current page source content (html)
1085
+ :return:
1086
+ :rtype:
1087
+ """
1088
+ doc: cdp.dom.Node = await self.send(cdp.dom.get_document(-1, True))
1089
+ return await self.send(
1090
+ cdp.dom.get_outer_html(backend_node_id=doc.backend_node_id)
1091
+ )
1092
+
1093
+ async def maximize(self):
1094
+ """
1095
+ maximize page/tab/window
1096
+ """
1097
+ return await self.set_window_state(state="maximize")
1098
+
1099
+ async def minimize(self):
1100
+ """
1101
+ minimize page/tab/window
1102
+ """
1103
+ return await self.set_window_state(state="minimize")
1104
+
1105
+ async def fullscreen(self):
1106
+ """
1107
+ minimize page/tab/window
1108
+ """
1109
+ return await self.set_window_state(state="fullscreen")
1110
+
1111
+ async def medimize(self):
1112
+ return await self.set_window_state(state="normal")
1113
+
1114
+ async def set_window_size(self, left=0, top=0, width=1280, height=1024):
1115
+ """
1116
+ set window size and position
1117
+
1118
+ :param left: pixels from the left of the screen to the window top-left corner
1119
+ :type left:
1120
+ :param top: pixels from the top of the screen to the window top-left corner
1121
+ :type top:
1122
+ :param width: width of the window in pixels
1123
+ :type width:
1124
+ :param height: height of the window in pixels
1125
+ :type height:
1126
+ :return:
1127
+ :rtype:
1128
+ """
1129
+ return await self.set_window_state(left, top, width, height)
1130
+
1131
+ async def activate(self):
1132
+ """
1133
+ active this target (ie: tab,window,page)
1134
+ """
1135
+ await self.send(cdp.target.activate_target(self.target.target_id))
1136
+
1137
+ async def bring_to_front(self):
1138
+ """
1139
+ alias to self.activate
1140
+ """
1141
+ await self.activate()
1142
+
1143
+ async def set_window_state(
1144
+ self, left=0, top=0, width=1280, height=720, state="normal"
1145
+ ):
1146
+ """
1147
+ sets the window size or state.
1148
+
1149
+ for state you can provide the full name like minimized, maximized, normal, fullscreen, or
1150
+ something which leads to either of those, like min, mini, mi, max, ma, maxi, full, fu, no, nor
1151
+ in case state is set other than "normal", the left, top, width, and height are ignored.
1152
+
1153
+ :param left:
1154
+ desired offset from left, in pixels
1155
+ :type left: int
1156
+
1157
+ :param top:
1158
+ desired offset from the top, in pixels
1159
+ :type top: int
1160
+
1161
+ :param width:
1162
+ desired width in pixels
1163
+ :type width: int
1164
+
1165
+ :param height:
1166
+ desired height in pixels
1167
+ :type height: int
1168
+
1169
+ :param state:
1170
+ can be one of the following strings:
1171
+ - normal
1172
+ - fullscreen
1173
+ - maximized
1174
+ - minimized
1175
+
1176
+ :type state: str
1177
+
1178
+ """
1179
+ available_states = ["minimized", "maximized", "fullscreen", "normal"]
1180
+ window_id: cdp.browser.WindowID
1181
+ bounds: cdp.browser.Bounds
1182
+ (window_id, bounds) = await self.get_window()
1183
+
1184
+ for state_name in available_states:
1185
+ if all(x in state_name for x in state.lower()):
1186
+ break
1187
+ else:
1188
+ raise NameError(
1189
+ "could not determine any of %s from input '%s'"
1190
+ % (",".join(available_states), state)
1191
+ )
1192
+ window_state = getattr(
1193
+ cdp.browser.WindowState, state_name.upper(), cdp.browser.WindowState.NORMAL
1194
+ )
1195
+ if window_state == cdp.browser.WindowState.NORMAL:
1196
+ bounds = cdp.browser.Bounds(left, top, width, height, window_state)
1197
+ else:
1198
+ # min, max, full can only be used when current state == NORMAL
1199
+ # therefore we first switch to NORMAL
1200
+ await self.set_window_state(state="normal")
1201
+ bounds = cdp.browser.Bounds(window_state=window_state)
1202
+
1203
+ await self.send(cdp.browser.set_window_bounds(window_id, bounds=bounds))
1204
+
1205
+ async def scroll_down(self, amount=25):
1206
+ """
1207
+ scrolls down maybe
1208
+
1209
+ :param amount: number in percentage. 25 is a quarter of page, 50 half, and 1000 is 10x the page
1210
+ :type amount: int
1211
+ :return:
1212
+ :rtype:
1213
+ """
1214
+ window_id: cdp.browser.WindowID
1215
+ bounds: cdp.browser.Bounds
1216
+ (window_id, bounds) = await self.get_window()
1217
+
1218
+ await self.send(
1219
+ cdp.input_.synthesize_scroll_gesture(
1220
+ x=0,
1221
+ y=0,
1222
+ y_distance=-(bounds.height * (amount / 100)),
1223
+ y_overscroll=0,
1224
+ x_overscroll=0,
1225
+ prevent_fling=True,
1226
+ repeat_delay_ms=0,
1227
+ speed=7777,
1228
+ )
1229
+ )
1230
+
1231
+ async def scroll_up(self, amount=25):
1232
+ """
1233
+ scrolls up maybe
1234
+
1235
+ :param amount: number in percentage. 25 is a quarter of page, 50 half, and 1000 is 10x the page
1236
+ :type amount: int
1237
+
1238
+ :return:
1239
+ :rtype:
1240
+ """
1241
+ window_id: cdp.browser.WindowID
1242
+ bounds: cdp.browser.Bounds
1243
+ (window_id, bounds) = await self.get_window()
1244
+
1245
+ await self.send(
1246
+ cdp.input_.synthesize_scroll_gesture(
1247
+ x=0,
1248
+ y=0,
1249
+ y_distance=(bounds.height * (amount / 100)),
1250
+ x_overscroll=0,
1251
+ prevent_fling=True,
1252
+ repeat_delay_ms=0,
1253
+ speed=7777,
1254
+ )
1255
+ )
1256
+
1257
+ async def wait(self, t: Union[int, float] = 0.5):
1258
+ return await self.sleep(t)
1259
+ # # await self.browser.wait()
1260
+ #
1261
+ # loop = asyncio.get_running_loop()
1262
+ # start = loop.time()
1263
+ # event = asyncio.Event()
1264
+ # wait_events = [
1265
+ # cdp.page.FrameStoppedLoading,
1266
+ # cdp.page.FrameDetached,
1267
+ # cdp.page.FrameNavigated,
1268
+ # cdp.page.LifecycleEvent,
1269
+ # cdp.page.LoadEventFired,
1270
+ # ]
1271
+ #
1272
+ # handler = lambda ev: event.set()
1273
+ #
1274
+ # self.add_handler(wait_events, handler=handler)
1275
+ # try:
1276
+ # if not t:
1277
+ # t = 0.5
1278
+ # done, pending = await asyncio.wait(
1279
+ # [
1280
+ # asyncio.ensure_future(event.wait()),
1281
+ # asyncio.ensure_future(asyncio.sleep(t)),
1282
+ # ],
1283
+ # return_when=asyncio.FIRST_COMPLETED,
1284
+ # )
1285
+ #
1286
+ # [p.cancel() for p in pending]
1287
+ #
1288
+ # finally:
1289
+ # self.remove_handler(wait_events, handler=handler)
1290
+ # await asyncio.wait_for()
1291
+ # except asyncio.TimeoutError:
1292
+ # if isinstance(t, (int, float)):
1293
+ # # explicit time is given, which is now passed
1294
+ # # so bail out early
1295
+ # return
1296
+
1297
+ def __await__(self):
1298
+ return self.wait().__await__()
1299
+
1300
+ async def wait_for(
1301
+ self,
1302
+ selector: Optional[str] = "",
1303
+ text: Optional[str] = "",
1304
+ timeout: Optional[Union[int, float]] = 10,
1305
+ ) -> element.Element:
1306
+ """
1307
+ variant on query_selector_all and find_elements_by_text
1308
+ this variant takes either selector or text, and will block until
1309
+ the requested element(s) are found.
1310
+
1311
+ it will block for a maximum of <timeout> seconds, after which
1312
+ an TimeoutError will be raised
1313
+
1314
+ :param selector: css selector
1315
+ :type selector:
1316
+ :param text: text
1317
+ :type text:
1318
+ :param timeout:
1319
+ :type timeout:
1320
+ :return:
1321
+ :rtype: Element
1322
+ :raises: asyncio.TimeoutError
1323
+ """
1324
+ loop = asyncio.get_running_loop()
1325
+ now = loop.time()
1326
+ if selector:
1327
+ item = await self.query_selector(selector)
1328
+ while not item:
1329
+ item = await self.query_selector(selector)
1330
+ if loop.time() - now > timeout:
1331
+ raise asyncio.TimeoutError(
1332
+ "time ran out while waiting for %s" % selector
1333
+ )
1334
+ await self.sleep(0.5)
1335
+ # await self.sleep(0.5)
1336
+ return item
1337
+ if text:
1338
+ item = await self.find_element_by_text(text)
1339
+ while not item:
1340
+ item = await self.find_element_by_text(text)
1341
+ if loop.time() - now > timeout:
1342
+ raise asyncio.TimeoutError(
1343
+ "time ran out while waiting for text: %s" % text
1344
+ )
1345
+ await self.sleep(0.5)
1346
+ return item
1347
+
1348
+ async def download_file(self, url: str, filename: Optional[PathLike] = None):
1349
+ """
1350
+ downloads file by given url.
1351
+
1352
+ :param url: url of the file
1353
+ :param filename: the name for the file. if not specified the name is composed from the url file name
1354
+ """
1355
+ if not self._download_behavior:
1356
+ directory_path = pathlib.Path.cwd() / "downloads"
1357
+ directory_path.mkdir(exist_ok=True)
1358
+ await self.set_download_path(directory_path)
1359
+
1360
+ warnings.warn(
1361
+ f"no download path set, so creating and using a default of"
1362
+ f"{directory_path}"
1363
+ )
1364
+ if not filename:
1365
+ filename = url.rsplit("/")[-1]
1366
+ filename = filename.split("?")[0]
1367
+
1368
+ code = """
1369
+ (elem) => {
1370
+ async function _downloadFile(
1371
+ imageSrc,
1372
+ nameOfDownload,
1373
+ ) {
1374
+ const response = await fetch(imageSrc);
1375
+ const blobImage = await response.blob();
1376
+ const href = URL.createObjectURL(blobImage);
1377
+
1378
+ const anchorElement = document.createElement('a');
1379
+ anchorElement.href = href;
1380
+ anchorElement.download = nameOfDownload;
1381
+
1382
+ document.body.appendChild(anchorElement);
1383
+ anchorElement.click();
1384
+
1385
+ setTimeout(() => {
1386
+ document.body.removeChild(anchorElement);
1387
+ window.URL.revokeObjectURL(href);
1388
+ }, 500);
1389
+ }
1390
+ _downloadFile('%s', '%s')
1391
+ }
1392
+ """ % (
1393
+ url,
1394
+ filename,
1395
+ )
1396
+
1397
+ body = (await self.query_selector_all("body"))[0]
1398
+ await body.update()
1399
+ await self.send(
1400
+ cdp.runtime.call_function_on(
1401
+ code,
1402
+ object_id=body.object_id,
1403
+ arguments=[cdp.runtime.CallArgument(object_id=body.object_id)],
1404
+ )
1405
+ )
1406
+ await self.wait(0.1)
1407
+
1408
+ async def save_screenshot(
1409
+ self,
1410
+ filename: Optional[PathLike] = "auto",
1411
+ format: Optional[str] = "jpeg",
1412
+ full_page: Optional[bool] = False,
1413
+ as_base64: Optional[bool] = False,
1414
+ ) -> str:
1415
+ """
1416
+ Saves a screenshot of the page.
1417
+ This is not the same as :py:obj:`Element.save_screenshot`, which saves a screenshot of a single element only
1418
+
1419
+ :param filename: uses this as the save path
1420
+ :type filename: PathLike
1421
+ :param format: jpeg or png (defaults to jpeg)
1422
+ :type format: str
1423
+ :param full_page: when False (default) it captures the current viewport. when True, it captures the entire page
1424
+ :type full_page: bool
1425
+ :param as_base64: whether to return a base64 string. this will ignore path, and will not write to a file
1426
+ :return: the path/filename of saved screenshot
1427
+ :rtype: str
1428
+ """
1429
+ # noqa
1430
+ import datetime
1431
+ import urllib.parse
1432
+
1433
+ await self.sleep() # update the target's url
1434
+ path = None
1435
+
1436
+ if format.lower() in ["jpg", "jpeg"]:
1437
+ ext = ".jpg"
1438
+ format = "jpeg"
1439
+
1440
+ elif format.lower() in ["png"]:
1441
+ ext = ".png"
1442
+ format = "png"
1443
+
1444
+ if not filename or filename == "auto" and not as_base64:
1445
+
1446
+ parsed = urllib.parse.urlparse(self.target.url)
1447
+ parts = parsed.path.split("/")
1448
+ last_part = parts[-1]
1449
+ last_part = last_part.rsplit("?", 1)[0]
1450
+ dt_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
1451
+ candidate = f"{parsed.hostname}__{last_part}_{dt_str}"
1452
+ path = pathlib.Path(candidate + ext) # noqa
1453
+ elif not as_base64:
1454
+ path = pathlib.Path(filename)
1455
+
1456
+ if not as_base64:
1457
+ path.parent.mkdir(parents=True, exist_ok=True)
1458
+ data = await self.send(
1459
+ cdp.page.capture_screenshot(
1460
+ format_=format, capture_beyond_viewport=full_page
1461
+ )
1462
+ )
1463
+ if as_base64:
1464
+ return data
1465
+ if not data:
1466
+ raise ProtocolException(
1467
+ "could not take screenshot. most possible cause is the page has not finished loading yet."
1468
+ )
1469
+ import base64
1470
+
1471
+ data_bytes = base64.b64decode(data)
1472
+ if not path:
1473
+ raise RuntimeError("invalid filename or path: '%s'" % filename)
1474
+ path.write_bytes(data_bytes)
1475
+ return str(path)
1476
+
1477
+ async def set_download_path(self, path: Union[str, PathLike]):
1478
+ """
1479
+ sets the download path and allows downloads
1480
+ this is required for any download function to work (well not entirely, since when unset we set a default folder)
1481
+
1482
+ :param path:
1483
+ :type path:
1484
+ :return:
1485
+ :rtype:
1486
+ """
1487
+ path = Path(path)
1488
+ path.mkdir(parents=True, exist_ok=True)
1489
+ await self.send(
1490
+ cdp.browser.set_download_behavior(
1491
+ behavior="allow", download_path=str(path.resolve())
1492
+ )
1493
+ )
1494
+ self._download_behavior = ["allow", str(path.resolve())]
1495
+
1496
+ async def get_all_linked_sources(self) -> List["mithwire.Element"]:
1497
+ """
1498
+ get all elements of tag: link, a, img, scripts meta, video, audio
1499
+
1500
+ :return:
1501
+ """
1502
+ all_assets = await self.query_selector_all(selector="a,link,img,script,meta")
1503
+ return [element.create(asset, self) for asset in all_assets]
1504
+
1505
+ async def get_all_urls(self, absolute=True) -> List[str]:
1506
+ """
1507
+ convenience function, which returns all links (a,link,img,script,meta)
1508
+
1509
+ :param absolute: try to build all the links in absolute form instead of "as is", often relative
1510
+ :return: list of urls
1511
+ """
1512
+
1513
+ import urllib.parse
1514
+
1515
+ res = []
1516
+ all_assets = await self.query_selector_all(selector="a,link,img,script,meta")
1517
+ for asset in all_assets:
1518
+ if not absolute:
1519
+ res.append(asset.src or asset.href)
1520
+ else:
1521
+ for k, v in asset.attrs.items():
1522
+ if k in ("src", "href"):
1523
+ if "#" in v:
1524
+ continue
1525
+ if not any([_ in v for _ in ("http", "//", "/")]):
1526
+ continue
1527
+ abs_url = urllib.parse.urljoin(
1528
+ "/".join(self.url.rsplit("/")[:3]), v
1529
+ )
1530
+ if not abs_url.startswith(("http", "//", "ws")):
1531
+ continue
1532
+ res.append(abs_url)
1533
+ return res
1534
+
1535
+ async def get_local_storage(self):
1536
+ """
1537
+ get local storage items as dict of strings (careful!, proper deserialization needs to be done if needed)
1538
+
1539
+ :return:
1540
+ :rtype:
1541
+ """
1542
+ if not self.target.url:
1543
+ await self
1544
+
1545
+ # there must be a better way...
1546
+ origin = "/".join(self.url.split("/", 3)[:-1])
1547
+
1548
+ items = await self.send(
1549
+ cdp.dom_storage.get_dom_storage_items(
1550
+ cdp.dom_storage.StorageId(is_local_storage=True, security_origin=origin)
1551
+ )
1552
+ )
1553
+ retval = {}
1554
+ for item in items:
1555
+ retval[item[0]] = item[1]
1556
+ return retval
1557
+
1558
+ async def set_local_storage(self, items: dict):
1559
+ """
1560
+ set local storage.
1561
+ dict items must be strings. simple types will be converted to strings automatically.
1562
+
1563
+ :param items: dict containing {key:str, value:str}
1564
+ :type items: dict[str,str]
1565
+ :return:
1566
+ :rtype:
1567
+ """
1568
+ if not self.target.url:
1569
+ await self
1570
+ # there must be a better way...
1571
+ origin = "/".join(self.url.split("/", 3)[:-1])
1572
+
1573
+ await asyncio.gather(
1574
+ *[
1575
+ self.send(
1576
+ cdp.dom_storage.set_dom_storage_item(
1577
+ storage_id=cdp.dom_storage.StorageId(
1578
+ is_local_storage=True, security_origin=origin
1579
+ ),
1580
+ key=str(key),
1581
+ value=str(val),
1582
+ )
1583
+ )
1584
+ for key, val in items.items()
1585
+ ]
1586
+ )
1587
+
1588
+ def __call__(
1589
+ self,
1590
+ text: Optional[str] = "",
1591
+ selector: Optional[str] = "",
1592
+ timeout: Optional[Union[int, float]] = 10,
1593
+ ):
1594
+ """
1595
+ alias to query_selector_all or find_elements_by_text, depending
1596
+ on whether text= is set or selector= is set
1597
+
1598
+ :param selector: css selector string
1599
+ :type selector: str
1600
+ :return:
1601
+ :rtype:
1602
+ """
1603
+ return self.wait_for(text, selector, timeout)
1604
+
1605
+ async def get_frame_tree(self) -> cdp.page.FrameTree:
1606
+ """
1607
+ retrieves the frame tree for current tab
1608
+ There seems no real difference between :ref:`Tab.get_frame_resource_tree()`
1609
+ :return:
1610
+ :rtype:
1611
+ """
1612
+ tree: cdp.page.FrameTree = await super().send(cdp.page.get_frame_tree())
1613
+ return tree
1614
+
1615
+ async def get_frame_resource_tree(self) -> cdp.page.FrameResourceTree:
1616
+ """
1617
+ retrieves the frame resource tree for current tab.
1618
+ There seems no real difference between :ref:`Tab.get_frame_tree()`
1619
+ but still it returns a different object
1620
+ :return:
1621
+ :rtype:
1622
+ """
1623
+ tree: cdp.page.FrameResourceTree = await super().send(
1624
+ cdp.page.get_resource_tree()
1625
+ )
1626
+ return tree
1627
+
1628
+ async def get_frame_resource_urls(self) -> List[str]:
1629
+ """
1630
+ gets the urls of resources
1631
+ :return:
1632
+ :rtype:
1633
+ """
1634
+ _tree = await self.get_frame_resource_tree()
1635
+ return [
1636
+ x
1637
+ for x in functools.reduce(
1638
+ lambda a, b: a + [b[1].url if isinstance(b, tuple) else ""],
1639
+ util.flatten_frame_tree_resources(_tree),
1640
+ [],
1641
+ )
1642
+ if x
1643
+ ]
1644
+
1645
+ async def search_frame_resources(
1646
+ self, query: str
1647
+ ) -> typing.Dict[str, List[cdp.debugger.SearchMatch]]:
1648
+ try:
1649
+ await self.send(cdp.page.enable())
1650
+ list_of_tuples = list(
1651
+ util.flatten_frame_tree_resources(await self.get_frame_resource_tree())
1652
+ )
1653
+ results = {}
1654
+ for item in list_of_tuples:
1655
+ if not isinstance(item, tuple):
1656
+ continue
1657
+ frame, resource = item
1658
+ res = await self.send(
1659
+ cdp.page.search_in_resource(
1660
+ frame_id=frame.id_, url=resource.url, query=query
1661
+ )
1662
+ )
1663
+ if not res:
1664
+ continue
1665
+ results[resource.url] = res
1666
+ finally:
1667
+ await self.send(cdp.page.disable())
1668
+
1669
+ return results
1670
+
1671
+ async def verify_cf(
1672
+ self,
1673
+ template_image: str = None,
1674
+ flash=False,
1675
+ *,
1676
+ max_retries: int = 5,
1677
+ timeout: float = 15.0,
1678
+ retry_interval: float = 2.0,
1679
+ ):
1680
+ """
1681
+ Solve a Cloudflare challenge (Turnstile widget or managed-challenge
1682
+ interstitial).
1683
+
1684
+ Primary strategy is DOM geometry: when an explicit Turnstile widget is
1685
+ present (``.cf-turnstile`` / ``[data-sitekey]``) its bounding box is read
1686
+ and the checkbox is clicked at the widget's left-center. This is robust
1687
+ across Cloudflare widget restyles, light/dark themes and HiDPI displays,
1688
+ because it never depends on pixel-matching a screenshot. The widget
1689
+ renders inside a closed shadow-root iframe, so the checkbox itself is not
1690
+ directly queryable -- only the host element's geometry is.
1691
+
1692
+ For full-page managed-challenge interstitials (no widget handle in the
1693
+ parent document) it falls back to OpenCV template matching against the
1694
+ bundled checkbox templates (or ``template_image``), with HiDPI scaling.
1695
+
1696
+ :param template_image: optional path to a custom template image, used
1697
+ only by the template-matching fallback.
1698
+ :param flash: show a visual indicator at the click location.
1699
+ :param max_retries: number of attempts before giving up.
1700
+ :param timeout: total time budget in seconds.
1701
+ :param retry_interval: seconds between retries.
1702
+ :return: True if solved (or no challenge present), False otherwise.
1703
+ """
1704
+ if self.browser and self.browser.config and self.browser.config.expert:
1705
+ raise Exception(
1706
+ "verify_cf is incompatible with expert mode, since it "
1707
+ "disables site-isolation-trials which gets detected."
1708
+ )
1709
+
1710
+ import random
1711
+
1712
+ loop = asyncio.get_running_loop()
1713
+ started = loop.time()
1714
+ attempt = 0
1715
+
1716
+ while attempt < max_retries:
1717
+ if loop.time() - started >= timeout:
1718
+ break
1719
+ attempt += 1
1720
+
1721
+ state = await self._cf_state()
1722
+ if state in ("solved", "none"):
1723
+ logger.info("verify_cf: no challenge pending (state=%s)", state)
1724
+ return True
1725
+
1726
+ clicked = False
1727
+ if state == "widget":
1728
+ rect = await self._cf_widget_rect()
1729
+ if rect is not None:
1730
+ cx = rect["x"] + random.uniform(26.0, 34.0)
1731
+ cy = rect["y"] + rect["h"] / 2 + random.uniform(-3.0, 3.0)
1732
+ await self.sleep(random.uniform(0.3, 0.8))
1733
+ await self.mouse_click(cx, cy)
1734
+ logger.info("verify_cf: clicked widget checkbox at (%.0f, %.0f) attempt %d", cx, cy, attempt)
1735
+ if flash:
1736
+ await self.flash_point(int(cx), int(cy))
1737
+ clicked = True
1738
+
1739
+ if not clicked:
1740
+ coords = await self.template_location(template_image=template_image)
1741
+ if coords is not None:
1742
+ cx, cy = coords
1743
+ cx += random.uniform(-2.0, 2.0)
1744
+ cy += random.uniform(-2.0, 2.0)
1745
+ await self.sleep(random.uniform(0.3, 0.8))
1746
+ await self.mouse_click(cx, cy)
1747
+ logger.info("verify_cf: clicked template match at (%.0f, %.0f) attempt %d", cx, cy, attempt)
1748
+ if flash:
1749
+ await self.flash_point(int(cx), int(cy))
1750
+ clicked = True
1751
+
1752
+ if not clicked:
1753
+ logger.debug("verify_cf: no checkbox located (state=%s, attempt %d)", state, attempt)
1754
+ await self.sleep(retry_interval)
1755
+ continue
1756
+
1757
+ await self.sleep(random.uniform(2.5, 4.0))
1758
+ if await self._cf_state() in ("solved", "none"):
1759
+ logger.info("verify_cf: challenge solved after %d attempt(s)", attempt)
1760
+ return True
1761
+
1762
+ logger.debug("verify_cf: challenge still present after attempt %d", attempt)
1763
+ await self.sleep(retry_interval)
1764
+
1765
+ logger.warning("verify_cf: failed to solve challenge after %d attempts", attempt)
1766
+ return False
1767
+
1768
+ async def _cf_state(self) -> str:
1769
+ """
1770
+ Classify the current Cloudflare challenge state from the parent document.
1771
+
1772
+ :return: ``"solved"`` (Turnstile token present), ``"widget"`` (an explicit
1773
+ Turnstile widget is rendered and unsolved), ``"challenge"`` (managed
1774
+ challenge interstitial, no widget handle) or ``"none"``.
1775
+ """
1776
+ return await self.evaluate(
1777
+ "(() => {"
1778
+ " const resp = document.querySelector('[name=\"cf-turnstile-response\"]');"
1779
+ " if (resp && resp.value) return 'solved';"
1780
+ " const widget = document.querySelector('.cf-turnstile, #cf-turnstile, [data-sitekey]');"
1781
+ " if (widget) {"
1782
+ " const r = widget.getBoundingClientRect();"
1783
+ " if (r.width >= 10 && r.height >= 10) return 'widget';"
1784
+ " }"
1785
+ " if (document.querySelector('#challenge-form, #challenge-running, #cf-challenge-running')) return 'challenge';"
1786
+ " const title = (document.title || '').toLowerCase();"
1787
+ " if (title.includes('just a moment') || title.includes('attention required')) return 'challenge';"
1788
+ " const txt = (document.body ? document.body.innerText : '').toLowerCase();"
1789
+ " const kws = ['verify you are human', 'performing security verification',"
1790
+ " 'checking your browser', 'needs to review the security'];"
1791
+ " for (const kw of kws) { if (txt.includes(kw)) return 'challenge'; }"
1792
+ " return 'none';"
1793
+ "})()"
1794
+ )
1795
+
1796
+ async def _cf_widget_rect(self) -> Union[dict, None]:
1797
+ """Bounding rect (CSS px) of an explicit Turnstile widget host, or None."""
1798
+ import json
1799
+
1800
+ raw = await self.evaluate(
1801
+ "(() => {"
1802
+ " const el = document.querySelector('.cf-turnstile, #cf-turnstile, [data-sitekey]');"
1803
+ " if (!el) return '';"
1804
+ " const r = el.getBoundingClientRect();"
1805
+ " if (r.width < 10 || r.height < 10) return '';"
1806
+ " return JSON.stringify({x: r.x, y: r.y, w: r.width, h: r.height});"
1807
+ "})()"
1808
+ )
1809
+ if not raw:
1810
+ return None
1811
+ try:
1812
+ return json.loads(raw)
1813
+ except (TypeError, ValueError):
1814
+ return None
1815
+
1816
+ async def template_location(
1817
+ self, template_image: PathLike = None
1818
+ ) -> Union[Tuple[int, int], None]:
1819
+ """
1820
+ Find the location of a template image in the current viewport using
1821
+ OpenCV template matching.
1822
+
1823
+ Handles HiDPI/Retina displays by detecting the device pixel ratio and
1824
+ scaling templates accordingly. When no custom template is provided,
1825
+ all bundled templates (light and dark mode) are tried and the best
1826
+ match is used. Returns CSS pixel coordinates suitable for mouse_click.
1827
+
1828
+ :param template_image: optional path to a custom template image.
1829
+ :return: (x, y) CSS pixel coordinates of the template center, or None.
1830
+ """
1831
+ try:
1832
+ import cv2
1833
+ except ImportError:
1834
+ logger.warning(
1835
+ "template_location requires opencv-python. "
1836
+ "Install it with: pip install opencv-python"
1837
+ )
1838
+ return None
1839
+
1840
+ import tempfile
1841
+
1842
+ dpr = 1.0
1843
+ try:
1844
+ dpr_val = await self.evaluate("window.devicePixelRatio")
1845
+ if isinstance(dpr_val, (int, float)) and dpr_val > 0:
1846
+ dpr = float(dpr_val)
1847
+ except Exception:
1848
+ pass
1849
+
1850
+ templates_gray = []
1851
+ if template_image:
1852
+ template_image = Path(template_image)
1853
+ if not template_image.exists():
1854
+ raise FileNotFoundError(
1855
+ "%s not found (cwd: %s)" % (template_image, os.getcwd())
1856
+ )
1857
+ img = cv2.imread(str(template_image))
1858
+ if img is not None:
1859
+ templates_gray.append(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY))
1860
+ else:
1861
+ templates_dir = Path(__file__).parent / "cf_templates"
1862
+ if templates_dir.is_dir():
1863
+ for tp in sorted(templates_dir.glob("*.png")):
1864
+ img = cv2.imread(str(tp))
1865
+ if img is not None:
1866
+ templates_gray.append(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY))
1867
+ if not templates_gray:
1868
+ data = util.get_cf_template()
1869
+ tmp = os.path.join(tempfile.gettempdir(), "_mithwire_cf_light.png")
1870
+ try:
1871
+ with open(tmp, "wb") as fh:
1872
+ fh.write(data)
1873
+ img = cv2.imread(tmp)
1874
+ if img is not None:
1875
+ templates_gray.append(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY))
1876
+ finally:
1877
+ try:
1878
+ os.unlink(tmp)
1879
+ except OSError:
1880
+ pass
1881
+
1882
+ if not templates_gray:
1883
+ logger.warning("template_location: no templates available")
1884
+ return None
1885
+
1886
+ if abs(dpr - 1.0) > 0.01:
1887
+ scaled = []
1888
+ for tmpl in templates_gray:
1889
+ new_w = int(tmpl.shape[1] * dpr)
1890
+ new_h = int(tmpl.shape[0] * dpr)
1891
+ scaled.append(cv2.resize(tmpl, (new_w, new_h), interpolation=cv2.INTER_LINEAR))
1892
+ templates_gray = scaled
1893
+
1894
+ tmp_screen = os.path.join(tempfile.gettempdir(), "_mithwire_screen_%d.png" % os.getpid())
1895
+ try:
1896
+ await self.save_screenshot(tmp_screen)
1897
+ await self.sleep(0.05)
1898
+ screen = cv2.imread(tmp_screen)
1899
+ if screen is None:
1900
+ logger.debug("template_location: failed to read screenshot")
1901
+ return None
1902
+ screen_gray = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY)
1903
+
1904
+ best_val = -1.0
1905
+ best_loc = None
1906
+ best_shape = None
1907
+
1908
+ for tmpl in templates_gray:
1909
+ th, tw = tmpl.shape[:2]
1910
+ if tw > screen_gray.shape[1] or th > screen_gray.shape[0]:
1911
+ continue
1912
+ result = cv2.matchTemplate(screen_gray, tmpl, cv2.TM_CCOEFF_NORMED)
1913
+ _, max_val, _, max_loc = cv2.minMaxLoc(result)
1914
+ if max_val > best_val:
1915
+ best_val = max_val
1916
+ best_loc = max_loc
1917
+ best_shape = (th, tw)
1918
+
1919
+ logger.debug(
1920
+ "template_location: best confidence=%.3f at %s (dpr=%.1f, screen=%dx%d)",
1921
+ best_val, best_loc, dpr,
1922
+ screen_gray.shape[1], screen_gray.shape[0],
1923
+ )
1924
+
1925
+ if best_val < 0.5 or best_loc is None or best_shape is None:
1926
+ return None
1927
+
1928
+ xs, ys = best_loc
1929
+ th, tw = best_shape
1930
+ pixel_cx = xs + tw // 2
1931
+ pixel_cy = ys + th // 2
1932
+
1933
+ css_cx = pixel_cx / dpr
1934
+ css_cy = pixel_cy / dpr
1935
+
1936
+ return int(css_cx), int(css_cy)
1937
+ except (TypeError, OSError, PermissionError):
1938
+ return None
1939
+ except Exception:
1940
+ raise
1941
+ finally:
1942
+ try:
1943
+ os.unlink(tmp_screen)
1944
+ except OSError:
1945
+ pass
1946
+
1947
+ async def bypass_insecure_connection_warning(self):
1948
+ """
1949
+ when you enter a site where the certificate is invalid
1950
+ you get a warning. call this function to "proceed"
1951
+ :return:
1952
+ :rtype:
1953
+ """
1954
+ body = await self.select("body")
1955
+ await body.send_keys("thisisunsafe")
1956
+
1957
+ async def mouse_move(self, x: float, y: float, steps=10, flash=False):
1958
+ steps = 1 if (not steps or steps < 1) else steps
1959
+ # probably the worst waay of calculating this. but couldn't think of a better solution today.
1960
+ if steps > 1:
1961
+ step_size_x = x // steps
1962
+ step_size_y = y // steps
1963
+ pathway = [(step_size_x * i, step_size_y * i) for i in range(steps + 1)]
1964
+ for point in pathway:
1965
+ if flash:
1966
+ await self.flash_point(point[0], point[1])
1967
+ await self.send(
1968
+ cdp.input_.dispatch_mouse_event(
1969
+ "mouseMoved", x=point[0], y=point[1]
1970
+ )
1971
+ )
1972
+ else:
1973
+ await self.send(cdp.input_.dispatch_mouse_event("mouseMoved", x=x, y=y))
1974
+ if flash:
1975
+ await self.flash_point(x, y)
1976
+ else:
1977
+ await self.sleep(0.05)
1978
+ await self.send(cdp.input_.dispatch_mouse_event("mouseReleased", x=x, y=y))
1979
+ if flash:
1980
+ await self.flash_point(x, y)
1981
+
1982
+ async def scroll_bottom_reached(self):
1983
+ """
1984
+ returns True if scroll is at the bottom of the page
1985
+ handy when you need to scroll over paginated pages of different lengths
1986
+ :return:
1987
+ :rtype:
1988
+ """
1989
+
1990
+ res = await self.evaluate(
1991
+ "document.body.offsetHeight - window.innerHeight == window.scrollY"
1992
+ )
1993
+ if res:
1994
+ return res[0].value
1995
+
1996
+ async def mouse_click(
1997
+ self,
1998
+ x: float,
1999
+ y: float,
2000
+ button: str = "left",
2001
+ buttons: typing.Optional[int] = 1,
2002
+ modifiers: typing.Optional[int] = 0,
2003
+ _until_event: typing.Optional[type] = None,
2004
+ ):
2005
+ """native click on position x,y
2006
+ :param y:
2007
+ :type y:
2008
+ :param x:
2009
+ :type x:
2010
+ :param button: str (default = "left")
2011
+ :param buttons: which button (default 1 = left)
2012
+ :param modifiers: *(Optional)* Bit field representing pressed modifier keys.
2013
+ Alt=1, Ctrl=2, Meta/Command=4, Shift=8 (default: 0).
2014
+ :param _until_event: internal. event to wait for before returning
2015
+ :return:
2016
+ """
2017
+
2018
+ await self.send(
2019
+ cdp.input_.dispatch_mouse_event(
2020
+ "mousePressed",
2021
+ x=x,
2022
+ y=y,
2023
+ modifiers=modifiers,
2024
+ button=cdp.input_.MouseButton(button),
2025
+ buttons=buttons,
2026
+ click_count=1,
2027
+ )
2028
+ )
2029
+
2030
+ await self.send(
2031
+ cdp.input_.dispatch_mouse_event(
2032
+ "mouseReleased",
2033
+ x=x,
2034
+ y=y,
2035
+ modifiers=modifiers,
2036
+ button=cdp.input_.MouseButton(button),
2037
+ buttons=buttons,
2038
+ click_count=1,
2039
+ )
2040
+ )
2041
+
2042
+ async def mouse_drag(
2043
+ self,
2044
+ source_point: tuple[float, float],
2045
+ dest_point: tuple[float, float],
2046
+ relative: bool = False,
2047
+ steps: int = 1,
2048
+ ):
2049
+ """
2050
+ drag mouse from one point to another. holding button pressed
2051
+ you are probably looking for :py:meth:`element.Element.mouse_drag` method. where you
2052
+ can drag on the element
2053
+
2054
+ :param dest_point:
2055
+ :type dest_point:
2056
+ :param source_point:
2057
+ :type source_point:
2058
+ :param relative: when True, treats point as relative. for example (-100, 200) will move left 100px and down 200px
2059
+ :type relative:
2060
+
2061
+ :param steps: move in <steps> points, this could make it look more "natural" (default 1),
2062
+ but also a lot slower.
2063
+ for very smooth action use 50-100
2064
+ :type steps: int
2065
+ :return:
2066
+ :rtype:
2067
+ """
2068
+ if relative:
2069
+ dest_point = (
2070
+ source_point[0] + dest_point[0],
2071
+ source_point[1] + dest_point[1],
2072
+ )
2073
+ await self.send(
2074
+ cdp.input_.dispatch_mouse_event(
2075
+ "mousePressed",
2076
+ x=source_point[0],
2077
+ y=source_point[1],
2078
+ button=cdp.input_.MouseButton("left"),
2079
+ )
2080
+ )
2081
+ steps = 1 if (not steps or steps < 1) else steps
2082
+
2083
+ if steps == 1:
2084
+ await self.send(
2085
+ cdp.input_.dispatch_mouse_event(
2086
+ "mouseMoved", x=dest_point[0], y=dest_point[1]
2087
+ )
2088
+ )
2089
+ elif steps > 1:
2090
+ # probably the worst waay of calculating this. but couldn't think of a better solution today.
2091
+ step_size_x = (dest_point[0] - source_point[0]) / steps
2092
+ step_size_y = (dest_point[1] - source_point[1]) / steps
2093
+ pathway = [
2094
+ (source_point[0] + step_size_x * i, source_point[1] + step_size_y * i)
2095
+ for i in range(steps + 1)
2096
+ ]
2097
+ for point in pathway:
2098
+ await self.send(
2099
+ cdp.input_.dispatch_mouse_event(
2100
+ "mouseMoved",
2101
+ x=point[0],
2102
+ y=point[1],
2103
+ )
2104
+ )
2105
+ await asyncio.sleep(0)
2106
+
2107
+ await self.send(
2108
+ cdp.input_.dispatch_mouse_event(
2109
+ type_="mouseReleased",
2110
+ x=dest_point[0],
2111
+ y=dest_point[1],
2112
+ button=cdp.input_.MouseButton("left"),
2113
+ )
2114
+ )
2115
+
2116
+ async def flash_point(self, x, y, duration=0.5, size=10):
2117
+ style = (
2118
+ "position:fixed;z-index:99999999;padding:0;margin:0;"
2119
+ "left:{:.1f}px; top: {:.1f}px;"
2120
+ "opacity:1;"
2121
+ "width:{:d}px;height:{:d}px;border-radius:50%;background:red;"
2122
+ "animation:show-pointer-ani {:.2f}s ease 1;"
2123
+ ).format(x - 8, y - 8, size, size, duration)
2124
+ script = (
2125
+ """
2126
+ var css = document.styleSheets[0];
2127
+ for( let css of [...document.styleSheets]) {{
2128
+ try {{
2129
+ css.insertRule(`
2130
+ @keyframes show-pointer-ani {{
2131
+ 0% {{ opacity: 1; transform: scale(1, 1);}}
2132
+ 50% {{ transform: scale(3, 3);}}
2133
+ 100% {{ transform: scale(1, 1); opacity: 0;}}
2134
+ }}`,css.cssRules.length);
2135
+ break;
2136
+ }} catch (e) {{
2137
+ console.log(e)
2138
+ }}
2139
+ }};
2140
+ var _d = document.createElement('div');
2141
+ _d.style = `{0:s}`;
2142
+ _d.id = `{1:s}`;
2143
+ document.body.insertAdjacentElement('afterBegin', _d);
2144
+
2145
+ setTimeout( () => document.getElementById('{1:s}').remove(), {2:d});
2146
+
2147
+ """.format(
2148
+ style, secrets.token_hex(8), int(duration * 1000)
2149
+ )
2150
+ .replace(" ", "")
2151
+ .replace("\n", "")
2152
+ )
2153
+ await self.send(
2154
+ cdp.runtime.evaluate(
2155
+ script,
2156
+ await_promise=True,
2157
+ user_gesture=True,
2158
+ )
2159
+ )
2160
+
2161
+ def __eq__(self, other: Tab):
2162
+ try:
2163
+ return other.target == self.target
2164
+ except (AttributeError, TypeError):
2165
+ return False
2166
+
2167
+ def __getattr__(self, item):
2168
+ try:
2169
+
2170
+ return getattr(self.target, item)
2171
+ except AttributeError:
2172
+ raise AttributeError(
2173
+ f'"{self.__class__.__name__}" has no attribute "%s"' % item
2174
+ )
2175
+
2176
+ # def __repr__(self):
2177
+ # extra = ""
2178
+ # if self.target:
2179
+ # url = getattr(self.target, 'url', None)
2180
+ # type_ = getattr(self.target, 'type_', None)
2181
+ # if url:
2182
+ # extra = f"[url: {url}]"
2183
+ # s = f"<{type(self).__name__} [{self.target}] [{type_}] {extra}>"
2184
+ # else:
2185
+ # return f"<{type(self).__name__} [unknown target]>"
2186
+ # return s
2187
+
2188
+
2189
+ class IFrame(Tab):
2190
+ """
2191
+ a iframe is like a `mithwire.Tab`, a connection to an iframe
2192
+ """
2193
+
2194
+ def __init__(
2195
+ self,
2196
+ target: cdp.target.TargetID | cdp.target.TargetInfo,
2197
+ parent: Connection,
2198
+ **kwargs,
2199
+ ):
2200
+ super().__init__(
2201
+ websocket_url=parent.websocket_url, target=target, parent=parent, **kwargs
2202
+ )