parsek-cdp 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.
- parsek_cdp/__init__.py +55 -0
- parsek_cdp/_logging.py +49 -0
- parsek_cdp/cdp/__init__.py +247 -0
- parsek_cdp/cdp/accessibility/__init__.py +15 -0
- parsek_cdp/cdp/accessibility/events.py +35 -0
- parsek_cdp/cdp/accessibility/functions.py +202 -0
- parsek_cdp/cdp/accessibility/types.py +235 -0
- parsek_cdp/cdp/animation/__init__.py +15 -0
- parsek_cdp/cdp/animation/events.py +52 -0
- parsek_cdp/cdp/animation/functions.py +140 -0
- parsek_cdp/cdp/animation/types.py +113 -0
- parsek_cdp/cdp/audits/__init__.py +15 -0
- parsek_cdp/cdp/audits/events.py +21 -0
- parsek_cdp/cdp/audits/functions.py +95 -0
- parsek_cdp/cdp/audits/types.py +1051 -0
- parsek_cdp/cdp/autofill/__init__.py +15 -0
- parsek_cdp/cdp/autofill/events.py +27 -0
- parsek_cdp/cdp/autofill/functions.py +64 -0
- parsek_cdp/cdp/autofill/types.py +108 -0
- parsek_cdp/cdp/backgroundservice/__init__.py +15 -0
- parsek_cdp/cdp/backgroundservice/events.py +40 -0
- parsek_cdp/cdp/backgroundservice/functions.py +59 -0
- parsek_cdp/cdp/backgroundservice/types.py +63 -0
- parsek_cdp/cdp/bluetoothemulation/__init__.py +15 -0
- parsek_cdp/cdp/bluetoothemulation/events.py +70 -0
- parsek_cdp/cdp/bluetoothemulation/functions.py +247 -0
- parsek_cdp/cdp/bluetoothemulation/types.py +120 -0
- parsek_cdp/cdp/browser/__init__.py +15 -0
- parsek_cdp/cdp/browser/events.py +46 -0
- parsek_cdp/cdp/browser/functions.py +350 -0
- parsek_cdp/cdp/browser/types.py +158 -0
- parsek_cdp/cdp/cachestorage/__init__.py +15 -0
- parsek_cdp/cdp/cachestorage/events.py +13 -0
- parsek_cdp/cdp/cachestorage/functions.py +126 -0
- parsek_cdp/cdp/cachestorage/types.py +89 -0
- parsek_cdp/cdp/cast/__init__.py +15 -0
- parsek_cdp/cdp/cast/events.py +38 -0
- parsek_cdp/cdp/cast/functions.py +77 -0
- parsek_cdp/cdp/cast/types.py +23 -0
- parsek_cdp/cdp/console/__init__.py +15 -0
- parsek_cdp/cdp/console/events.py +22 -0
- parsek_cdp/cdp/console/functions.py +36 -0
- parsek_cdp/cdp/console/types.py +30 -0
- parsek_cdp/cdp/css/__init__.py +15 -0
- parsek_cdp/cdp/css/events.py +79 -0
- parsek_cdp/cdp/css/functions.py +788 -0
- parsek_cdp/cdp/css/types.py +742 -0
- parsek_cdp/cdp/debugger/__init__.py +15 -0
- parsek_cdp/cdp/debugger/events.py +164 -0
- parsek_cdp/cdp/debugger/functions.py +663 -0
- parsek_cdp/cdp/debugger/types.py +171 -0
- parsek_cdp/cdp/deviceaccess/__init__.py +15 -0
- parsek_cdp/cdp/deviceaccess/events.py +30 -0
- parsek_cdp/cdp/deviceaccess/functions.py +54 -0
- parsek_cdp/cdp/deviceaccess/types.py +28 -0
- parsek_cdp/cdp/deviceorientation/__init__.py +15 -0
- parsek_cdp/cdp/deviceorientation/events.py +13 -0
- parsek_cdp/cdp/deviceorientation/functions.py +35 -0
- parsek_cdp/cdp/deviceorientation/types.py +10 -0
- parsek_cdp/cdp/dom/__init__.py +15 -0
- parsek_cdp/cdp/dom/events.py +228 -0
- parsek_cdp/cdp/dom/functions.py +1033 -0
- parsek_cdp/cdp/dom/types.py +293 -0
- parsek_cdp/cdp/domdebugger/__init__.py +15 -0
- parsek_cdp/cdp/domdebugger/events.py +13 -0
- parsek_cdp/cdp/domdebugger/functions.py +155 -0
- parsek_cdp/cdp/domdebugger/types.py +57 -0
- parsek_cdp/cdp/domsnapshot/__init__.py +15 -0
- parsek_cdp/cdp/domsnapshot/events.py +13 -0
- parsek_cdp/cdp/domsnapshot/functions.py +111 -0
- parsek_cdp/cdp/domsnapshot/types.py +312 -0
- parsek_cdp/cdp/domstorage/__init__.py +15 -0
- parsek_cdp/cdp/domstorage/events.py +60 -0
- parsek_cdp/cdp/domstorage/functions.py +79 -0
- parsek_cdp/cdp/domstorage/types.py +30 -0
- parsek_cdp/cdp/emulation/__init__.py +15 -0
- parsek_cdp/cdp/emulation/events.py +17 -0
- parsek_cdp/cdp/emulation/functions.py +688 -0
- parsek_cdp/cdp/emulation/types.py +305 -0
- parsek_cdp/cdp/eventbreakpoints/__init__.py +15 -0
- parsek_cdp/cdp/eventbreakpoints/events.py +13 -0
- parsek_cdp/cdp/eventbreakpoints/functions.py +41 -0
- parsek_cdp/cdp/eventbreakpoints/types.py +10 -0
- parsek_cdp/cdp/extensions/__init__.py +15 -0
- parsek_cdp/cdp/extensions/events.py +13 -0
- parsek_cdp/cdp/extensions/functions.py +118 -0
- parsek_cdp/cdp/extensions/types.py +19 -0
- parsek_cdp/cdp/fedcm/__init__.py +15 -0
- parsek_cdp/cdp/fedcm/events.py +45 -0
- parsek_cdp/cdp/fedcm/functions.py +93 -0
- parsek_cdp/cdp/fedcm/types.py +72 -0
- parsek_cdp/cdp/fetch/__init__.py +15 -0
- parsek_cdp/cdp/fetch/events.py +82 -0
- parsek_cdp/cdp/fetch/functions.py +215 -0
- parsek_cdp/cdp/fetch/types.py +81 -0
- parsek_cdp/cdp/filesystem/__init__.py +15 -0
- parsek_cdp/cdp/filesystem/events.py +13 -0
- parsek_cdp/cdp/filesystem/functions.py +36 -0
- parsek_cdp/cdp/filesystem/types.py +54 -0
- parsek_cdp/cdp/headlessexperimental/__init__.py +15 -0
- parsek_cdp/cdp/headlessexperimental/events.py +13 -0
- parsek_cdp/cdp/headlessexperimental/functions.py +77 -0
- parsek_cdp/cdp/headlessexperimental/types.py +24 -0
- parsek_cdp/cdp/heapprofiler/__init__.py +15 -0
- parsek_cdp/cdp/heapprofiler/events.py +65 -0
- parsek_cdp/cdp/heapprofiler/functions.py +193 -0
- parsek_cdp/cdp/heapprofiler/types.py +57 -0
- parsek_cdp/cdp/indexeddb/__init__.py +15 -0
- parsek_cdp/cdp/indexeddb/events.py +13 -0
- parsek_cdp/cdp/indexeddb/functions.py +232 -0
- parsek_cdp/cdp/indexeddb/types.py +120 -0
- parsek_cdp/cdp/input/__init__.py +15 -0
- parsek_cdp/cdp/input/events.py +25 -0
- parsek_cdp/cdp/input/functions.py +361 -0
- parsek_cdp/cdp/input/types.py +87 -0
- parsek_cdp/cdp/inspector/__init__.py +15 -0
- parsek_cdp/cdp/inspector/events.py +41 -0
- parsek_cdp/cdp/inspector/functions.py +27 -0
- parsek_cdp/cdp/inspector/types.py +10 -0
- parsek_cdp/cdp/io/__init__.py +15 -0
- parsek_cdp/cdp/io/events.py +13 -0
- parsek_cdp/cdp/io/functions.py +77 -0
- parsek_cdp/cdp/io/types.py +13 -0
- parsek_cdp/cdp/layertree/__init__.py +15 -0
- parsek_cdp/cdp/layertree/events.py +36 -0
- parsek_cdp/cdp/layertree/functions.py +178 -0
- parsek_cdp/cdp/layertree/types.py +104 -0
- parsek_cdp/cdp/log/__init__.py +15 -0
- parsek_cdp/cdp/log/events.py +22 -0
- parsek_cdp/cdp/log/functions.py +54 -0
- parsek_cdp/cdp/log/types.py +57 -0
- parsek_cdp/cdp/media/__init__.py +15 -0
- parsek_cdp/cdp/media/events.py +87 -0
- parsek_cdp/cdp/media/functions.py +27 -0
- parsek_cdp/cdp/media/types.py +101 -0
- parsek_cdp/cdp/memory/__init__.py +15 -0
- parsek_cdp/cdp/memory/events.py +13 -0
- parsek_cdp/cdp/memory/functions.py +164 -0
- parsek_cdp/cdp/memory/types.py +71 -0
- parsek_cdp/cdp/mixins/__init__.py +21 -0
- parsek_cdp/cdp/mixins/datatype.py +123 -0
- parsek_cdp/cdp/mixins/event.py +78 -0
- parsek_cdp/cdp/mixins/namespace.py +31 -0
- parsek_cdp/cdp/network/__init__.py +15 -0
- parsek_cdp/cdp/network/events.py +726 -0
- parsek_cdp/cdp/network/functions.py +734 -0
- parsek_cdp/cdp/network/types.py +1247 -0
- parsek_cdp/cdp/overlay/__init__.py +15 -0
- parsek_cdp/cdp/overlay/events.py +54 -0
- parsek_cdp/cdp/overlay/functions.py +407 -0
- parsek_cdp/cdp/overlay/types.py +328 -0
- parsek_cdp/cdp/page/__init__.py +15 -0
- parsek_cdp/cdp/page/events.py +416 -0
- parsek_cdp/cdp/page/functions.py +1081 -0
- parsek_cdp/cdp/page/types.py +1088 -0
- parsek_cdp/cdp/performance/__init__.py +15 -0
- parsek_cdp/cdp/performance/events.py +24 -0
- parsek_cdp/cdp/performance/functions.py +63 -0
- parsek_cdp/cdp/performance/types.py +22 -0
- parsek_cdp/cdp/performancetimeline/__init__.py +15 -0
- parsek_cdp/cdp/performancetimeline/events.py +22 -0
- parsek_cdp/cdp/performancetimeline/functions.py +30 -0
- parsek_cdp/cdp/performancetimeline/types.py +85 -0
- parsek_cdp/cdp/preload/__init__.py +15 -0
- parsek_cdp/cdp/preload/events.py +116 -0
- parsek_cdp/cdp/preload/functions.py +25 -0
- parsek_cdp/cdp/preload/types.py +267 -0
- parsek_cdp/cdp/profiler/__init__.py +15 -0
- parsek_cdp/cdp/profiler/events.py +64 -0
- parsek_cdp/cdp/profiler/functions.py +134 -0
- parsek_cdp/cdp/profiler/types.py +105 -0
- parsek_cdp/cdp/pwa/__init__.py +15 -0
- parsek_cdp/cdp/pwa/events.py +13 -0
- parsek_cdp/cdp/pwa/functions.py +196 -0
- parsek_cdp/cdp/pwa/types.py +45 -0
- parsek_cdp/cdp/runtime/__init__.py +15 -0
- parsek_cdp/cdp/runtime/events.py +123 -0
- parsek_cdp/cdp/runtime/functions.py +552 -0
- parsek_cdp/cdp/runtime/types.py +319 -0
- parsek_cdp/cdp/schema/__init__.py +15 -0
- parsek_cdp/cdp/schema/events.py +13 -0
- parsek_cdp/cdp/schema/functions.py +32 -0
- parsek_cdp/cdp/schema/types.py +22 -0
- parsek_cdp/cdp/security/__init__.py +15 -0
- parsek_cdp/cdp/security/events.py +64 -0
- parsek_cdp/cdp/security/functions.py +66 -0
- parsek_cdp/cdp/security/types.py +168 -0
- parsek_cdp/cdp/serviceworker/__init__.py +15 -0
- parsek_cdp/cdp/serviceworker/events.py +43 -0
- parsek_cdp/cdp/serviceworker/functions.py +115 -0
- parsek_cdp/cdp/serviceworker/types.py +95 -0
- parsek_cdp/cdp/storage/__init__.py +15 -0
- parsek_cdp/cdp/storage/events.py +288 -0
- parsek_cdp/cdp/storage/functions.py +571 -0
- parsek_cdp/cdp/storage/types.py +656 -0
- parsek_cdp/cdp/systeminfo/__init__.py +15 -0
- parsek_cdp/cdp/systeminfo/events.py +13 -0
- parsek_cdp/cdp/systeminfo/functions.py +75 -0
- parsek_cdp/cdp/systeminfo/types.py +132 -0
- parsek_cdp/cdp/target/__init__.py +15 -0
- parsek_cdp/cdp/target/events.py +109 -0
- parsek_cdp/cdp/target/functions.py +424 -0
- parsek_cdp/cdp/target/types.py +83 -0
- parsek_cdp/cdp/tethering/__init__.py +15 -0
- parsek_cdp/cdp/tethering/events.py +22 -0
- parsek_cdp/cdp/tethering/functions.py +35 -0
- parsek_cdp/cdp/tethering/types.py +10 -0
- parsek_cdp/cdp/tracing/__init__.py +15 -0
- parsek_cdp/cdp/tracing/events.py +61 -0
- parsek_cdp/cdp/tracing/functions.py +119 -0
- parsek_cdp/cdp/tracing/types.py +81 -0
- parsek_cdp/cdp/webaudio/__init__.py +15 -0
- parsek_cdp/cdp/webaudio/events.py +184 -0
- parsek_cdp/cdp/webaudio/functions.py +51 -0
- parsek_cdp/cdp/webaudio/types.py +155 -0
- parsek_cdp/cdp/webauthn/__init__.py +15 -0
- parsek_cdp/cdp/webauthn/events.py +69 -0
- parsek_cdp/cdp/webauthn/functions.py +214 -0
- parsek_cdp/cdp/webauthn/types.py +96 -0
- parsek_cdp/core/__init__.py +18 -0
- parsek_cdp/core/browser.py +240 -0
- parsek_cdp/core/browser_context.py +58 -0
- parsek_cdp/core/element.py +405 -0
- parsek_cdp/core/feature.py +376 -0
- parsek_cdp/core/frame.py +351 -0
- parsek_cdp/core/pagable.py +175 -0
- parsek_cdp/core/page.py +163 -0
- parsek_cdp/core/target.py +490 -0
- parsek_cdp/features/__init__.py +14 -0
- parsek_cdp/features/request_listener/__init__.py +256 -0
- parsek_cdp/features/request_listener/events.py +48 -0
- parsek_cdp/features/request_listener/types.py +235 -0
- parsek_cdp/generate.py +642 -0
- parsek_cdp/parsek/__init__.py +18 -0
- parsek_cdp/parsek/events.py +38 -0
- parsek_cdp/parsek/types.py +36 -0
- parsek_cdp/py.typed +0 -0
- parsek_cdp-0.1.0.dist-info/METADATA +277 -0
- parsek_cdp-0.1.0.dist-info/RECORD +242 -0
- parsek_cdp-0.1.0.dist-info/WHEEL +5 -0
- parsek_cdp-0.1.0.dist-info/licenses/LICENSE +201 -0
- parsek_cdp-0.1.0.dist-info/top_level.txt +1 -0
parsek_cdp/core/frame.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""A single frame -- the mandatory browser abstraction.
|
|
2
|
+
|
|
3
|
+
A :class:`Frame` is self-contained: it binds to the CDP session that owns it
|
|
4
|
+
(:attr:`Frame.target` -- a dedicated session of its own for a cross-origin
|
|
5
|
+
OOPIF, the page's session otherwise) and exposes :meth:`Frame.select` /
|
|
6
|
+
:meth:`Frame.evaluate` and the ``wait_for_*`` helpers, all scoped to that frame.
|
|
7
|
+
Everything routes through :attr:`Frame.cdp` so same- and cross-origin frames
|
|
8
|
+
behave identically.
|
|
9
|
+
|
|
10
|
+
Tracking the *tree* of frames (parent/child links, attach/detach, OOPIF
|
|
11
|
+
discovery) is a separate, optional concern handled by a tree-manager feature --
|
|
12
|
+
a :class:`Frame` never depends on it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from ..cdp import DOM, Page
|
|
22
|
+
from .target import ProtocolError, Target
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from .element import Element
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ElementState(Enum):
|
|
29
|
+
"""Condition awaited by :meth:`Frame.wait_for_selector` / :meth:`Frame.wait_for_xpath`."""
|
|
30
|
+
|
|
31
|
+
ATTACHED = "attached"
|
|
32
|
+
VISIBLE = "visible"
|
|
33
|
+
HIDDEN = "hidden"
|
|
34
|
+
DETACHED = "detached"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LoadState(Enum):
|
|
38
|
+
"""A page lifecycle milestone; values are the CDP ``Page.lifecycleEvent`` names."""
|
|
39
|
+
|
|
40
|
+
LOAD = "load"
|
|
41
|
+
DOMCONTENTLOADED = "DOMContentLoaded"
|
|
42
|
+
NETWORKIDLE = "networkIdle"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Frame:
|
|
46
|
+
"""A single frame, bound to the CDP session that owns it.
|
|
47
|
+
|
|
48
|
+
Holds the protocol :class:`~parsek_cdp.cdp.page.types.Frame` fields that
|
|
49
|
+
matter day to day (``url``, ``name``, ``loader_id``) and offers
|
|
50
|
+
:meth:`evaluate` / :meth:`select` scoped to it. A cross-origin frame is
|
|
51
|
+
bound to its own dedicated CDP session (:attr:`target`); everything routes
|
|
52
|
+
through :attr:`cdp` so same- and cross-origin frames behave identically.
|
|
53
|
+
|
|
54
|
+
``target`` is the session this frame runs on (the page for same-origin
|
|
55
|
+
frames; for the main frame the page *is* the frame). ``parent_id`` is kept
|
|
56
|
+
as raw protocol data, but resolving it to a parent frame -- and listing
|
|
57
|
+
children -- is the job of the optional tree-manager feature.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
_head_elem: Element | None
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
target: "Target",
|
|
65
|
+
frame_info: Page.Frame,
|
|
66
|
+
parent: Frame | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.target = target
|
|
69
|
+
self._frame = frame_info
|
|
70
|
+
self._parent = parent
|
|
71
|
+
self._frames: dict[Page.FrameId, Frame] = {}
|
|
72
|
+
|
|
73
|
+
self._dom_generation = 0
|
|
74
|
+
self._head_elem = None
|
|
75
|
+
|
|
76
|
+
self._bind_dom_tracking()
|
|
77
|
+
|
|
78
|
+
def _bind_dom_tracking(self) -> None:
|
|
79
|
+
"""Track every event that invalidates this frame's ``nodeId`` map.
|
|
80
|
+
|
|
81
|
+
``DOM.enable``/``DOM.disable`` rebuild (or drop) the whole node-id map and
|
|
82
|
+
``DOM.documentUpdated`` fires when the document is replaced -- each leaves
|
|
83
|
+
previously minted ``nodeId``s stale. Which one happened doesn't matter,
|
|
84
|
+
only that the map changed, so all three bump a single monotonic counter;
|
|
85
|
+
an :class:`Element` whose ``_generation`` lags re-mints its id from the
|
|
86
|
+
stable ``backendNodeId`` (see :meth:`Element.refresh`).
|
|
87
|
+
"""
|
|
88
|
+
self.target.on_function(self.target.cdp.DOM.disable, self._bump_dom_generation)
|
|
89
|
+
self.target.on_function(self.target.cdp.DOM.enable, self._bump_dom_generation)
|
|
90
|
+
self.target.on(DOM.DocumentUpdated, self._bump_dom_generation)
|
|
91
|
+
|
|
92
|
+
def _unbind_dom_tracking(self) -> None:
|
|
93
|
+
self.target.off_function(self.target.cdp.DOM.disable, self._bump_dom_generation)
|
|
94
|
+
self.target.off_function(self.target.cdp.DOM.enable, self._bump_dom_generation)
|
|
95
|
+
self.target.off(DOM.DocumentUpdated, self._bump_dom_generation)
|
|
96
|
+
|
|
97
|
+
def _bump_dom_generation(self, _) -> None:
|
|
98
|
+
self._dom_generation += 1
|
|
99
|
+
|
|
100
|
+
def dispose(self) -> None:
|
|
101
|
+
"""Unregister this frame's (and its subtree's) DOM-tracking handlers.
|
|
102
|
+
|
|
103
|
+
Same-origin frames share one target, so the handlers bound in
|
|
104
|
+
:meth:`_bind_dom_tracking` outlive the frame unless removed explicitly.
|
|
105
|
+
Call this whenever a frame leaves the tree (see :meth:`detach_frame` /
|
|
106
|
+
:meth:`refresh_frame`) so recreated frames don't pile up dead handlers on
|
|
107
|
+
the target.
|
|
108
|
+
"""
|
|
109
|
+
self._unbind_dom_tracking()
|
|
110
|
+
for child in list(self._frames.values()):
|
|
111
|
+
child.dispose()
|
|
112
|
+
self._frames.clear()
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def id(self) -> Page.FrameId:
|
|
116
|
+
return self._frame.id
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def url(self) -> str:
|
|
120
|
+
return self._frame.url
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def name(self) -> Optional[str]:
|
|
124
|
+
return self._frame.name
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def loader_id(self) -> str:
|
|
128
|
+
return self._frame.loader_id
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_main(self) -> bool:
|
|
132
|
+
return self._frame.parent_id is None
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def is_oopif(self) -> bool:
|
|
136
|
+
"""``True`` when this frame is a cross-origin frame with its own session.
|
|
137
|
+
|
|
138
|
+
A frame keeps its own identity (:attr:`id`); this only asks whether the
|
|
139
|
+
session it is *bound* to is dedicated to it (an out-of-process iframe).
|
|
140
|
+
"""
|
|
141
|
+
return self.target.id == self._frame.id
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def raw_frame(self) -> Page.Frame:
|
|
145
|
+
return self._frame
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def frames(self):
|
|
149
|
+
return list(self._frames.values())
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def cdp(self):
|
|
153
|
+
"""The CDP entry point of the session this frame is bound to."""
|
|
154
|
+
return self.target.cdp
|
|
155
|
+
|
|
156
|
+
async def _get_head_elem(self):
|
|
157
|
+
if (
|
|
158
|
+
self._head_elem is None
|
|
159
|
+
or self._dom_generation != self._head_elem._generation
|
|
160
|
+
):
|
|
161
|
+
from .element import Element
|
|
162
|
+
|
|
163
|
+
document = await self.cdp.DOM.get_document(depth=0)
|
|
164
|
+
self._head_elem = Element._from_node(document.root, frame=self)
|
|
165
|
+
self._head_elem._generation = self._dom_generation
|
|
166
|
+
return self._head_elem
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_frame_tree(cls, origin_frame: Frame, frame_tree: Page.FrameTree):
|
|
170
|
+
frame_id = frame_tree.frame.id
|
|
171
|
+
target = origin_frame.target._targets.get(frame_id, origin_frame.target)
|
|
172
|
+
instance = cls(target, frame_tree.frame, origin_frame)
|
|
173
|
+
for frame_tree in frame_tree.child_frames or []:
|
|
174
|
+
child = Frame.from_frame_tree(instance, frame_tree)
|
|
175
|
+
instance._frames[child.id] = child
|
|
176
|
+
return instance
|
|
177
|
+
|
|
178
|
+
async def refresh_frame(self):
|
|
179
|
+
tree = await self.cdp.Page.get_frame_tree()
|
|
180
|
+
self._merge_frame_tree(tree.frame_tree)
|
|
181
|
+
|
|
182
|
+
def _merge_frame_tree(self, frame_tree: Page.FrameTree) -> None:
|
|
183
|
+
"""Reconcile this frame's children against a fresh ``Page.getFrameTree``.
|
|
184
|
+
|
|
185
|
+
Only frames absent from ``frame_tree`` are disposed; survivors keep their
|
|
186
|
+
identity (and their bound DOM-tracking handlers), so we neither leak
|
|
187
|
+
handlers by recreating them nor throw away their per-frame state (DOM
|
|
188
|
+
generation, cached head element). New frames are built fresh.
|
|
189
|
+
"""
|
|
190
|
+
self._frame = frame_tree.frame
|
|
191
|
+
children = frame_tree.child_frames or []
|
|
192
|
+
live_ids = {child.frame.id for child in children}
|
|
193
|
+
for frame_id in list(self._frames):
|
|
194
|
+
if frame_id not in live_ids:
|
|
195
|
+
self._frames.pop(frame_id).dispose()
|
|
196
|
+
for child_tree in children:
|
|
197
|
+
existing = self._frames.get(child_tree.frame.id)
|
|
198
|
+
if existing is not None:
|
|
199
|
+
existing._merge_frame_tree(child_tree)
|
|
200
|
+
else:
|
|
201
|
+
child = Frame.from_frame_tree(self, child_tree)
|
|
202
|
+
self._frames[child.id] = child
|
|
203
|
+
|
|
204
|
+
async def select(
|
|
205
|
+
self, *, selector: Optional[str] = None, xpath: Optional[str] = None
|
|
206
|
+
) -> Optional[Element]:
|
|
207
|
+
"""Find the first node matching a CSS ``selector`` or an ``xpath``.
|
|
208
|
+
|
|
209
|
+
Returns its :class:`Element`, or ``None`` if nothing matches. The query
|
|
210
|
+
runs in this frame's document, so subframes are resolved correctly.
|
|
211
|
+
"""
|
|
212
|
+
from .element import Element
|
|
213
|
+
|
|
214
|
+
if xpath is not None:
|
|
215
|
+
node_ids = await self._perform_search(xpath)
|
|
216
|
+
if not node_ids:
|
|
217
|
+
return None
|
|
218
|
+
return await Element.describe_node(node_id=node_ids[0], frame=self)
|
|
219
|
+
if selector is None:
|
|
220
|
+
raise ValueError("select() needs either selector= or xpath=")
|
|
221
|
+
head_node = await self._get_head_elem()
|
|
222
|
+
return await head_node.query_selector(selector=selector)
|
|
223
|
+
|
|
224
|
+
async def select_all(self, selector: str) -> List[Element]:
|
|
225
|
+
"""Return an :class:`Element` for every node matching ``selector``."""
|
|
226
|
+
|
|
227
|
+
head_node = await self._get_head_elem()
|
|
228
|
+
res = []
|
|
229
|
+
async for elem in head_node.query_selector_all(selector=selector):
|
|
230
|
+
res.append(elem)
|
|
231
|
+
return res
|
|
232
|
+
|
|
233
|
+
async def _perform_search(self, query: str) -> List[int]:
|
|
234
|
+
"""Run a DevTools DOM search (text / attributes / markup / XPath)."""
|
|
235
|
+
dom = self.cdp.DOM
|
|
236
|
+
search = await dom.perform_search(
|
|
237
|
+
query=query, include_user_agent_shadow_dom=True
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
if not search.result_count:
|
|
241
|
+
return []
|
|
242
|
+
results = await dom.get_search_results(
|
|
243
|
+
search_id=search.search_id,
|
|
244
|
+
from_index=0,
|
|
245
|
+
to_index=min(search.result_count, 50),
|
|
246
|
+
)
|
|
247
|
+
return [n for n in results.node_ids if n]
|
|
248
|
+
finally:
|
|
249
|
+
try:
|
|
250
|
+
await dom.discard_search_results(search_id=search.search_id)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
async def evaluate(self, expression: str, *, await_promise: bool = False):
|
|
255
|
+
"""Evaluate JavaScript in this frame and return the value.
|
|
256
|
+
|
|
257
|
+
Runs in the frame's own execution context, so subframes (including
|
|
258
|
+
cross-origin ones) are evaluated correctly rather than against the main
|
|
259
|
+
page. Raises :class:`RuntimeError` if a non-main frame has no context
|
|
260
|
+
yet (e.g. before it has loaded).
|
|
261
|
+
"""
|
|
262
|
+
result = await self.cdp.Runtime.evaluate(
|
|
263
|
+
expression=expression,
|
|
264
|
+
await_promise=await_promise,
|
|
265
|
+
return_by_value=True,
|
|
266
|
+
) # TODO: Сделать по контексту
|
|
267
|
+
return result.result.value if result.result else None
|
|
268
|
+
|
|
269
|
+
async def wait_for_selector(
|
|
270
|
+
self,
|
|
271
|
+
selector: str,
|
|
272
|
+
*,
|
|
273
|
+
state: ElementState = ElementState.VISIBLE,
|
|
274
|
+
timeout: float = 30,
|
|
275
|
+
poll_interval: float = 0.1,
|
|
276
|
+
) -> Optional[Element]:
|
|
277
|
+
"""Poll until a node matching the CSS ``selector`` reaches ``state``.
|
|
278
|
+
|
|
279
|
+
Returns the :class:`Element` for ``ATTACHED`` / ``VISIBLE`` (or ``None``
|
|
280
|
+
for ``HIDDEN`` / ``DETACHED``). Raises :class:`asyncio.TimeoutError` if
|
|
281
|
+
the state is not reached within ``timeout`` seconds. Each poll
|
|
282
|
+
re-queries, so a re-mounted node (fresh ``nodeId``) is handled correctly.
|
|
283
|
+
"""
|
|
284
|
+
return await self._wait_for_query(
|
|
285
|
+
{"selector": selector}, state, timeout, poll_interval
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
async def wait_for_xpath(
|
|
289
|
+
self,
|
|
290
|
+
xpath: str,
|
|
291
|
+
*,
|
|
292
|
+
state: ElementState = ElementState.VISIBLE,
|
|
293
|
+
timeout: float = 30,
|
|
294
|
+
poll_interval: float = 0.1,
|
|
295
|
+
) -> Optional[Element]:
|
|
296
|
+
"""Like :meth:`wait_for_selector` but matching an XPath expression."""
|
|
297
|
+
return await self._wait_for_query(
|
|
298
|
+
{"xpath": xpath}, state, timeout, poll_interval
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def _wait_for_query(
|
|
302
|
+
self,
|
|
303
|
+
query: dict,
|
|
304
|
+
state: ElementState,
|
|
305
|
+
timeout: float,
|
|
306
|
+
poll_interval: float,
|
|
307
|
+
) -> Optional[Element]:
|
|
308
|
+
loop = asyncio.get_running_loop()
|
|
309
|
+
deadline = loop.time() + timeout
|
|
310
|
+
while True:
|
|
311
|
+
try:
|
|
312
|
+
element = await self.select(**query)
|
|
313
|
+
except ProtocolError:
|
|
314
|
+
element = None
|
|
315
|
+
done, result = await self._state_satisfied(element, state)
|
|
316
|
+
if done:
|
|
317
|
+
return result
|
|
318
|
+
if loop.time() >= deadline:
|
|
319
|
+
target = query.get("selector") or query.get("xpath")
|
|
320
|
+
raise asyncio.TimeoutError(
|
|
321
|
+
f"waiting for {target!r} to be {state.value} timed out after {timeout}s"
|
|
322
|
+
)
|
|
323
|
+
await asyncio.sleep(poll_interval)
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
async def _state_satisfied(
|
|
327
|
+
element: Optional[Element], state: ElementState
|
|
328
|
+
) -> Tuple[bool, Optional[Element]]:
|
|
329
|
+
"""Whether ``element`` meets ``state`` (and what to return on success)."""
|
|
330
|
+
if state is ElementState.ATTACHED:
|
|
331
|
+
return element is not None, element
|
|
332
|
+
if state is ElementState.DETACHED:
|
|
333
|
+
return element is None, None
|
|
334
|
+
visible = element is not None and await element.is_visible()
|
|
335
|
+
if state is ElementState.VISIBLE:
|
|
336
|
+
return visible, element
|
|
337
|
+
return not visible, element
|
|
338
|
+
|
|
339
|
+
def attach_frame(self, frame: Frame):
|
|
340
|
+
self._frames[frame.id] = frame
|
|
341
|
+
|
|
342
|
+
def detach_frame(self, frame: Frame):
|
|
343
|
+
detached = self._frames.pop(frame.id)
|
|
344
|
+
detached.dispose()
|
|
345
|
+
return detached
|
|
346
|
+
|
|
347
|
+
def __str__(self) -> str:
|
|
348
|
+
kind = "oopif" if self.is_oopif else ("main" if self.is_main else "child")
|
|
349
|
+
return f"<Frame {kind} {self.target.url!r}>"
|
|
350
|
+
|
|
351
|
+
__repr__ = __str__
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""``PagableTarget`` -- shared management of pages (the page/tab handling).
|
|
2
|
+
|
|
3
|
+
Both :class:`Browser` (all pages) and :class:`BrowserContext` (pages of one
|
|
4
|
+
context) manage a collection of pages with identical bookkeeping, so the whole
|
|
5
|
+
page/tab lifecycle -- *including reacting to the ``Target.*`` discovery events*
|
|
6
|
+
-- lives here:
|
|
7
|
+
|
|
8
|
+
* :meth:`watch` registers the discovery handlers on the connection that delivers
|
|
9
|
+
them (the browser's own websocket);
|
|
10
|
+
* :meth:`_on_target_created` / :meth:`_on_target_destroyed` /
|
|
11
|
+
:meth:`_on_target_info_changed` react to the protocol events;
|
|
12
|
+
* :meth:`new_page` issues ``Target.createTarget`` and waits for the matching
|
|
13
|
+
target to be discovered;
|
|
14
|
+
* :meth:`_ensure_page` builds a :class:`Page` (via ``self.page_class``), connects
|
|
15
|
+
its own websocket, binds it and registers it.
|
|
16
|
+
|
|
17
|
+
Pages are keyed by their **target id** (each target has its own websocket, so
|
|
18
|
+
there is no multiplexing ``sessionId``). A page registered in a scope *bubbles*
|
|
19
|
+
to its :attr:`_parent` (a context's parent is its browser), so a page created in
|
|
20
|
+
a context also lands in the browser's global registry -- one :class:`Page`
|
|
21
|
+
instance shared by both. The only browser-specific bit is resolving which
|
|
22
|
+
context owns a target, exposed through :meth:`_context_for` (defaults to
|
|
23
|
+
``self``; the browser overrides it).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import functools
|
|
30
|
+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, cast
|
|
31
|
+
|
|
32
|
+
from ..cdp import Target as TargetDomain
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..cdp import CDP
|
|
36
|
+
from .browser import Browser
|
|
37
|
+
from .page import Page
|
|
38
|
+
from .target import Target
|
|
39
|
+
|
|
40
|
+
PageCallback = Callable[["Page"], None]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PagableTarget[T: Page]:
|
|
44
|
+
"""Mixin managing a collection of pages within a scope."""
|
|
45
|
+
|
|
46
|
+
_cdp: CDP
|
|
47
|
+
page_class: type[T]
|
|
48
|
+
_parent: PagableTarget[T] | None = None
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self._pages: Dict[TargetDomain.TargetID, T] = {}
|
|
52
|
+
self._page_callbacks: List[PageCallback] = []
|
|
53
|
+
self._page_waiters: List[list] = []
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def pages(self):
|
|
57
|
+
return list(self._pages.values())
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def _scope_context_id(self) -> Optional[str]:
|
|
61
|
+
"""Browser-context id new pages are created in (``None`` = default)."""
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
def _bind_page(self, page: T) -> None:
|
|
65
|
+
"""Set ``page.browser`` / ``page.browser_context``. Overridden per scope."""
|
|
66
|
+
|
|
67
|
+
def _context_for(self, context_id: Optional[str]) -> "PagableTarget[T]":
|
|
68
|
+
"""Resolve the scope that owns a target. Browser overrides; else self."""
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def watch(self, connection: "Target") -> None:
|
|
72
|
+
"""Register the page-discovery handlers on ``connection``'s websocket.
|
|
73
|
+
|
|
74
|
+
Only the browser calls this (its websocket is where ``Target.*`` events
|
|
75
|
+
arrive); contexts are driven by the browser routing through
|
|
76
|
+
:meth:`_context_for`.
|
|
77
|
+
"""
|
|
78
|
+
connection.on(TargetDomain.TargetCreated, self.__on_target_created)
|
|
79
|
+
connection.on(TargetDomain.TargetDestroyed, self.__on_target_destroyed)
|
|
80
|
+
connection.on(TargetDomain.TargetInfoChanged, self.__on_target_info_changed)
|
|
81
|
+
|
|
82
|
+
def __on_target_created(self, event: TargetDomain.TargetCreated) -> None:
|
|
83
|
+
info = event.target_info
|
|
84
|
+
if info.type_ in ("page", "tab"):
|
|
85
|
+
scope = self._context_for(info.browser_context_id)
|
|
86
|
+
asyncio.ensure_future(scope._ensure_page(info))
|
|
87
|
+
|
|
88
|
+
def __on_target_destroyed(self, event: TargetDomain.TargetDestroyed) -> None:
|
|
89
|
+
self._drop_page(event.target_id)
|
|
90
|
+
|
|
91
|
+
def __on_target_info_changed(self, event: TargetDomain.TargetInfoChanged) -> None:
|
|
92
|
+
page = self.get_page(target_id=event.target_info.target_id)
|
|
93
|
+
if page is not None:
|
|
94
|
+
page._target = event.target_info
|
|
95
|
+
|
|
96
|
+
def on_page(self, callback: PageCallback) -> PageCallback:
|
|
97
|
+
"""Register a callback invoked for every page added to this scope."""
|
|
98
|
+
self._page_callbacks.append(callback)
|
|
99
|
+
|
|
100
|
+
@functools.wraps(callback)
|
|
101
|
+
def wrapper(page: Page):
|
|
102
|
+
callback(page)
|
|
103
|
+
|
|
104
|
+
return wrapper
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def page_list(self) -> List[T]:
|
|
108
|
+
return list(self._pages.values())
|
|
109
|
+
|
|
110
|
+
def get_page(self, *, target_id: Optional[str] = None) -> Optional[T]:
|
|
111
|
+
"""Look a page up by target id."""
|
|
112
|
+
if target_id is not None:
|
|
113
|
+
return self._pages.get(target_id)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
async def new_page(self, url: str = "about:blank", *, timeout: float = 30) -> T:
|
|
117
|
+
"""Create a new page/tab in this scope and return it once connected.
|
|
118
|
+
|
|
119
|
+
Relies on target discovery to deliver the new target, build the page and
|
|
120
|
+
open its own websocket.
|
|
121
|
+
"""
|
|
122
|
+
loop = asyncio.get_running_loop()
|
|
123
|
+
future: asyncio.Future = loop.create_future()
|
|
124
|
+
waiter: list = [None, future]
|
|
125
|
+
self._page_waiters.append(waiter)
|
|
126
|
+
try:
|
|
127
|
+
created = await self._cdp.Target.create_target(
|
|
128
|
+
url=url,
|
|
129
|
+
browser_context_id=self._scope_context_id,
|
|
130
|
+
)
|
|
131
|
+
waiter[0] = created.target_id
|
|
132
|
+
existing = self.get_page(target_id=created.target_id)
|
|
133
|
+
if existing is not None and not future.done():
|
|
134
|
+
future.set_result(existing)
|
|
135
|
+
page: T = await asyncio.wait_for(future, timeout)
|
|
136
|
+
await page.wait_features_ready()
|
|
137
|
+
return page
|
|
138
|
+
finally:
|
|
139
|
+
if waiter in self._page_waiters:
|
|
140
|
+
self._page_waiters.remove(waiter)
|
|
141
|
+
|
|
142
|
+
async def _ensure_page(self, target_info: TargetDomain.TargetInfo) -> T:
|
|
143
|
+
"""Build, connect + register a page in this scope (once per target)."""
|
|
144
|
+
page = self._pages.get(target_info.target_id)
|
|
145
|
+
if page is None:
|
|
146
|
+
owner = cast("Browser[T]", self._parent or self)
|
|
147
|
+
page = self.page_class(owner, target_info)
|
|
148
|
+
self._bind_page(page)
|
|
149
|
+
await page.connect()
|
|
150
|
+
await page.wait_features_ready()
|
|
151
|
+
self._add_page(page)
|
|
152
|
+
return page
|
|
153
|
+
|
|
154
|
+
def _add_page(self, page: T) -> None:
|
|
155
|
+
if page.id is not None:
|
|
156
|
+
self._pages[page.id] = page
|
|
157
|
+
for callback in list(self._page_callbacks):
|
|
158
|
+
callback(page)
|
|
159
|
+
for waiter in list(self._page_waiters):
|
|
160
|
+
target_id, future = waiter
|
|
161
|
+
if target_id == page.id and not future.done():
|
|
162
|
+
future.set_result(page)
|
|
163
|
+
if self._parent is not None:
|
|
164
|
+
self._parent._add_page(page) # bubble to the global registry
|
|
165
|
+
|
|
166
|
+
def _drop_page(self, target_id: Optional[str]) -> None:
|
|
167
|
+
page = self._pages.pop(target_id or "", None)
|
|
168
|
+
if page is None:
|
|
169
|
+
return
|
|
170
|
+
context = getattr(page, "browser_context", None)
|
|
171
|
+
if context is not None and context is not self:
|
|
172
|
+
context._remove_page(page.id)
|
|
173
|
+
|
|
174
|
+
def _remove_page(self, target_id: Optional[str]) -> None:
|
|
175
|
+
self._pages.pop(target_id or "", None)
|
parsek_cdp/core/page.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""A page target: a CDP session attached to a page/tab.
|
|
2
|
+
|
|
3
|
+
``Page`` is a bare host -- it attaches **no** features by default; every feature
|
|
4
|
+
(frame-tree tracking, request listening, ...) is optional and opted into:
|
|
5
|
+
|
|
6
|
+
* declaratively -- ``Page[Frames]`` / ``Page[Frames, RequestListener]`` (or
|
|
7
|
+
chained ``Page[A][B]``); each is attached on construction;
|
|
8
|
+
* imperatively + type-safely -- ``page.get_feature(Frames)`` returns the feature
|
|
9
|
+
typed as ``Frames`` (attaching it on first use).
|
|
10
|
+
|
|
11
|
+
A page *is its own main frame*: it subclasses :class:`Frame` (the main frame's id
|
|
12
|
+
equals the page's target id), so ``page.select(...)`` / ``page.evaluate(...)``
|
|
13
|
+
work on the top frame without any feature. The frame *tree* (subframes, OOPIFs)
|
|
14
|
+
lives in the optional :class:`Frames` feature.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from typing import TYPE_CHECKING, AsyncIterator
|
|
22
|
+
|
|
23
|
+
from ..cdp import Target as TargetDomain
|
|
24
|
+
from .feature import Feature, FeatureHost
|
|
25
|
+
from .frame import Frame
|
|
26
|
+
from .target import Target
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .browser import Browser
|
|
30
|
+
from .browser_context import BrowserContext
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NavigationError(Exception):
|
|
34
|
+
"""Raised by :meth:`Page.no_navigation` when the guarded block was cut short
|
|
35
|
+
by a main-frame navigation (only when ``raise_=True``)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _NavGuard:
|
|
39
|
+
"""Handle yielded by :meth:`Page.no_navigation`; ``navigated`` flips on nav."""
|
|
40
|
+
|
|
41
|
+
navigated: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Page[T: Feature](Target, FeatureHost[T], Frame):
|
|
45
|
+
"""A page (tab) target exposing every CDP domain through :attr:`cdp`.
|
|
46
|
+
|
|
47
|
+
A page *is its own main frame*: it subclasses :class:`Frame` (in CDP the
|
|
48
|
+
main frame's id equals the page's target id), so ``page.url`` and
|
|
49
|
+
``page.evaluate(...)`` operate on the top frame, while the full frame tree
|
|
50
|
+
(and navigation like ``page.frames.children_of(page)``) lives in
|
|
51
|
+
:attr:`frames`.
|
|
52
|
+
|
|
53
|
+
Generic over the feature type so ``Page[RequestListener]`` is accepted by
|
|
54
|
+
type checkers (the runtime composition is handled by ``FeatureHost``).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
browser: "Browser"
|
|
58
|
+
browser_context: "BrowserContext"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
browser: Browser,
|
|
63
|
+
target_info: TargetDomain.TargetInfo,
|
|
64
|
+
):
|
|
65
|
+
Target.__init__(self, target_info, browser)
|
|
66
|
+
self._init_features()
|
|
67
|
+
self.browser = browser
|
|
68
|
+
self.browser._targets[self.id] = self
|
|
69
|
+
|
|
70
|
+
async def connect(self) -> "Page[T]":
|
|
71
|
+
"""Open the page's own websocket, learn its main frame, start features."""
|
|
72
|
+
await super().connect()
|
|
73
|
+
tree = await self.cdp.Page.get_frame_tree()
|
|
74
|
+
Frame.__init__(self, self, tree.frame_tree.frame, None)
|
|
75
|
+
await self.wait_features_ready()
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
async def navigate(self, url: str, *, wait_load: bool = False, timeout: float = 30):
|
|
79
|
+
"""Navigate to ``url``; optionally wait for the load event.
|
|
80
|
+
|
|
81
|
+
``Page.loadEventFired`` only arrives while the ``Page`` domain is enabled,
|
|
82
|
+
so ``wait_load`` enables it for the duration here. The waiter is armed
|
|
83
|
+
*before* navigating, so a fast load that fires before the command returns
|
|
84
|
+
is not missed.
|
|
85
|
+
"""
|
|
86
|
+
from ..cdp.page.events import LoadEventFired
|
|
87
|
+
|
|
88
|
+
if not wait_load:
|
|
89
|
+
return await self.cdp.Page.navigate(url=url)
|
|
90
|
+
|
|
91
|
+
async with self.domain_enabled(self.cdp.Page):
|
|
92
|
+
loop = asyncio.get_running_loop()
|
|
93
|
+
loaded: asyncio.Future = loop.create_future()
|
|
94
|
+
|
|
95
|
+
def _on_load(_event: object) -> None:
|
|
96
|
+
if not loaded.done():
|
|
97
|
+
loaded.set_result(None)
|
|
98
|
+
|
|
99
|
+
self.on(LoadEventFired, _on_load)
|
|
100
|
+
try:
|
|
101
|
+
data = await self.cdp.Page.navigate(url=url)
|
|
102
|
+
await asyncio.wait_for(loaded, timeout)
|
|
103
|
+
finally:
|
|
104
|
+
self.off(LoadEventFired, _on_load)
|
|
105
|
+
return data
|
|
106
|
+
|
|
107
|
+
@asynccontextmanager
|
|
108
|
+
async def with_same_navigation(
|
|
109
|
+
self, *, raise_: bool = True
|
|
110
|
+
) -> AsyncIterator[_NavGuard]:
|
|
111
|
+
"""Run the block only while the document stays the same.
|
|
112
|
+
|
|
113
|
+
Guards a block of work against a main-frame navigation: if the page
|
|
114
|
+
navigates (the main frame commits a new ``loaderId``) while the body
|
|
115
|
+
runs, the body is cancelled at its next ``await`` -- so logic never keeps
|
|
116
|
+
operating on a document that has been replaced underneath it::
|
|
117
|
+
|
|
118
|
+
async with page.with_same_navigation():
|
|
119
|
+
el = await page.select(selector="#row")
|
|
120
|
+
await el.click() # cut short if navigation happens here
|
|
121
|
+
|
|
122
|
+
With ``raise_=True`` (default) a :class:`NavigationError` is raised when
|
|
123
|
+
the block is cut short; with ``raise_=False`` it is swallowed and the
|
|
124
|
+
yielded guard's ``navigated`` flag lets the caller decide (e.g. retry)::
|
|
125
|
+
|
|
126
|
+
async with page.with_same_navigation(raise_=False) as guard:
|
|
127
|
+
...
|
|
128
|
+
if guard.navigated:
|
|
129
|
+
... # the block was interrupted by a navigation
|
|
130
|
+
"""
|
|
131
|
+
from ..cdp.page.events import FrameNavigated
|
|
132
|
+
|
|
133
|
+
guard = _NavGuard()
|
|
134
|
+
baseline = self.loader_id
|
|
135
|
+
task = asyncio.current_task()
|
|
136
|
+
|
|
137
|
+
def _on(e: "FrameNavigated") -> None:
|
|
138
|
+
if (
|
|
139
|
+
e.frame.parent_id is None
|
|
140
|
+
and e.frame.loader_id != baseline
|
|
141
|
+
and not guard.navigated
|
|
142
|
+
):
|
|
143
|
+
guard.navigated = True
|
|
144
|
+
if task is not None:
|
|
145
|
+
task.cancel()
|
|
146
|
+
|
|
147
|
+
self.on(FrameNavigated, _on)
|
|
148
|
+
try:
|
|
149
|
+
async with self.domain_enabled(self.cdp.Page):
|
|
150
|
+
yield guard
|
|
151
|
+
if guard.navigated and raise_:
|
|
152
|
+
raise NavigationError(
|
|
153
|
+
f"navigation away from {self.url!r} cut the block short"
|
|
154
|
+
)
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
if not guard.navigated:
|
|
157
|
+
raise # a genuine external cancellation -- propagate untouched
|
|
158
|
+
if raise_:
|
|
159
|
+
raise NavigationError(
|
|
160
|
+
f"navigation away from {self.url!r} cut the block short"
|
|
161
|
+
) from None
|
|
162
|
+
finally:
|
|
163
|
+
self.off(FrameNavigated, _on)
|