mooncat-browser-client 0.4.7__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.
- behavior.py +138 -0
- browser.py +469 -0
- captcha.py +115 -0
- crawler.py +125 -0
- find.py +150 -0
- honeypot.py +73 -0
- humanize.py +392 -0
- image.py +186 -0
- interact.py +193 -0
- markdown.py +38 -0
- mooncat_browser_client-0.4.7.dist-info/METADATA +70 -0
- mooncat_browser_client-0.4.7.dist-info/RECORD +19 -0
- mooncat_browser_client-0.4.7.dist-info/WHEEL +5 -0
- mooncat_browser_client-0.4.7.dist-info/top_level.txt +15 -0
- ready.py +208 -0
- scroll.py +180 -0
- upload.py +103 -0
- visual_target.py +47 -0
- visualize.py +91 -0
behavior.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""web/behavior — 人类行为模拟 (路由无关, 全走 operate).
|
|
2
|
+
|
|
3
|
+
忠实复刻 lib/web/behavior.js。
|
|
4
|
+
贝塞尔鼠标轨迹 / 随机点击偏移 / 打字节奏 / 平滑滚动。
|
|
5
|
+
所有页面动作统一走 operate(pageHandle, {action}),本模块只编排动作序列。
|
|
6
|
+
|
|
7
|
+
依赖: browser (operate)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import math
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else __file__.rsplit("/", 1)[0])
|
|
17
|
+
from browser import operate # noqa: E402
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _bezier(p0: float, p1: float, p2: float, p3: float, t: float) -> float:
|
|
21
|
+
u = 1 - t
|
|
22
|
+
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _trajectory(from_pt: dict, to_pt: dict, steps: int) -> list[dict]:
|
|
26
|
+
"""生成贝塞尔轨迹点 (含随机抖动)."""
|
|
27
|
+
pts = []
|
|
28
|
+
mx = (from_pt["x"] + to_pt["x"]) / 2 + (random.random() - 0.5) * abs(to_pt["x"] - from_pt["x"]) * 0.6
|
|
29
|
+
my = (from_pt["y"] + to_pt["y"]) / 2 + (random.random() - 0.5) * abs(to_pt["y"] - from_pt["y"]) * 0.6
|
|
30
|
+
c1 = {"x": from_pt["x"] + (mx - from_pt["x"]) * 0.3, "y": from_pt["y"] + (my - from_pt["y"]) * 0.3}
|
|
31
|
+
c2 = {"x": to_pt["x"] + (mx - to_pt["x"]) * 0.3, "y": to_pt["y"] + (my - to_pt["y"]) * 0.3}
|
|
32
|
+
for i in range(steps + 1):
|
|
33
|
+
t = i / steps
|
|
34
|
+
pts.append({
|
|
35
|
+
"x": _bezier(from_pt["x"], c1["x"], c2["x"], to_pt["x"], t) + (random.random() - 0.5) * 2,
|
|
36
|
+
"y": _bezier(from_pt["y"], c1["y"], c2["y"], to_pt["y"], t) + (random.random() - 0.5) * 2,
|
|
37
|
+
})
|
|
38
|
+
return pts
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _random_point_in_rect(rect: dict) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"x": rect["x"] + rect["width"] * (0.3 + random.random() * 0.4),
|
|
44
|
+
"y": rect["y"] + rect["height"] * (0.3 + random.random() * 0.4),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _move_trajectory(page_handle: dict, from_pt: dict, to_pt: dict) -> None:
|
|
49
|
+
"""沿贝塞尔轨迹多点 mouseMove 注入."""
|
|
50
|
+
pts = _trajectory(from_pt, to_pt, 10 + random.randint(0, 5))
|
|
51
|
+
for pt in pts:
|
|
52
|
+
operate(page_handle, {"action": "mouseMove", "x": pt["x"], "y": pt["y"]})
|
|
53
|
+
time.sleep(0.008 + random.random() * 0.016)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_behavior(page_handle: dict) -> dict:
|
|
57
|
+
"""创建绑定到 pageHandle 的行为模拟器.
|
|
58
|
+
|
|
59
|
+
返回: move_to, click, click_element, type_text, type_in_element, scroll_down, scroll_to_bottom, scroll_to_element
|
|
60
|
+
"""
|
|
61
|
+
if not page_handle:
|
|
62
|
+
raise ValueError("create_behavior: page_handle required")
|
|
63
|
+
last_pos = {"x": 100, "y": 100}
|
|
64
|
+
|
|
65
|
+
def move_to(x: float, y: float) -> None:
|
|
66
|
+
nonlocal last_pos
|
|
67
|
+
_move_trajectory(page_handle, last_pos, {"x": x, "y": y})
|
|
68
|
+
last_pos = {"x": x, "y": y}
|
|
69
|
+
|
|
70
|
+
def click(x: float, y: float, options: dict | None = None) -> None:
|
|
71
|
+
nonlocal last_pos
|
|
72
|
+
_move_trajectory(page_handle, last_pos, {"x": x, "y": y})
|
|
73
|
+
last_pos = {"x": x, "y": y}
|
|
74
|
+
time.sleep(0.05 + random.random() * 0.1)
|
|
75
|
+
operate(page_handle, {"action": "click", "x": x, "y": y})
|
|
76
|
+
delay = (options or {}).get("delay", 0.05 + random.random() * 0.1)
|
|
77
|
+
time.sleep(delay)
|
|
78
|
+
|
|
79
|
+
def click_element(selector: str, timeout: int = 10000) -> None:
|
|
80
|
+
nonlocal last_pos
|
|
81
|
+
operate(page_handle, {"action": "waitForSelector", "selector": selector, "timeout": timeout})
|
|
82
|
+
box_r = operate(page_handle, {"action": "boundingBox", "selector": selector})
|
|
83
|
+
box = None
|
|
84
|
+
if isinstance(box_r, dict) and box_r.get("width") is not None:
|
|
85
|
+
box = box_r
|
|
86
|
+
if not box:
|
|
87
|
+
operate(page_handle, {"action": "click", "selector": selector})
|
|
88
|
+
time.sleep(0.05 + random.random() * 0.1)
|
|
89
|
+
return
|
|
90
|
+
pt = _random_point_in_rect(box)
|
|
91
|
+
move_to(pt["x"], pt["y"])
|
|
92
|
+
time.sleep(0.05 + random.random() * 0.1)
|
|
93
|
+
operate(page_handle, {"action": "click", "x": pt["x"], "y": pt["y"]})
|
|
94
|
+
time.sleep(0.05 + random.random() * 0.1)
|
|
95
|
+
|
|
96
|
+
def type_text(text: str, delay: float = 0.1) -> None:
|
|
97
|
+
for ch in text:
|
|
98
|
+
operate(page_handle, {"action": "press", "selector": "body", "key": ch})
|
|
99
|
+
d = delay * (0.5 + random.random())
|
|
100
|
+
if random.random() < 0.1:
|
|
101
|
+
d += 0.2 + random.random() * 0.4
|
|
102
|
+
time.sleep(d)
|
|
103
|
+
|
|
104
|
+
def type_in_element(selector: str, text: str, options: dict | None = None) -> None:
|
|
105
|
+
opts = options or {}
|
|
106
|
+
delay = opts.get("delay", 0.1)
|
|
107
|
+
timeout = opts.get("timeout", 10000)
|
|
108
|
+
operate(page_handle, {"action": "waitForSelector", "selector": selector, "timeout": timeout})
|
|
109
|
+
operate(page_handle, {"action": "click", "selector": selector})
|
|
110
|
+
operate(page_handle, {"action": "type", "selector": selector, "value": text, "delay": delay})
|
|
111
|
+
|
|
112
|
+
def scroll_down(pixels: int = 400) -> None:
|
|
113
|
+
step = 30 + random.randint(0, 29)
|
|
114
|
+
operate(page_handle, {
|
|
115
|
+
"action": "evaluate",
|
|
116
|
+
"source": f"(p) => new Promise((r) => {{ let done = 0; const t = setInterval(() => {{ window.scrollBy(0, {step}); done += {step}; if (done >= p) {{ clearInterval(t); r() }} }}, 30 + Math.random() * 30) }})",
|
|
117
|
+
"args": pixels
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
def scroll_to_bottom() -> None:
|
|
121
|
+
operate(page_handle, {
|
|
122
|
+
"action": "evaluate",
|
|
123
|
+
"source": "() => new Promise((resolve) => { let last=-1; const t=setInterval(()=>{ window.scrollBy(0,100+Math.random()*100); const h=document.body.scrollHeight; if(h===last){clearInterval(t);resolve()} last=h }, 150) })"
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
def scroll_to_element(selector: str, timeout: int = 10000) -> None:
|
|
127
|
+
operate(page_handle, {
|
|
128
|
+
"action": "evaluate",
|
|
129
|
+
"source": f'(s) => {{ const el = document.querySelector(s); if (el) el.scrollIntoView({{ behavior: "smooth", block: "center" }}) }}',
|
|
130
|
+
"args": selector
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"move_to": move_to, "click": click, "click_element": click_element,
|
|
135
|
+
"type_text": type_text, "type_in_element": type_in_element,
|
|
136
|
+
"scroll_down": scroll_down, "scroll_to_bottom": scroll_to_bottom,
|
|
137
|
+
"scroll_to_element": scroll_to_element,
|
|
138
|
+
}
|
browser.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Python client for the local MoonCat browserd HTTP service.
|
|
2
|
+
|
|
3
|
+
The client contains no browser automation implementation. It only forwards
|
|
4
|
+
requests to browserd, so Python programs and the JavaScript client use the same
|
|
5
|
+
backend behavior.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any, Pattern
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
19
|
+
DEFAULT_PORT = 17322
|
|
20
|
+
DEFAULT_TIMEOUT_MS = 60_000
|
|
21
|
+
|
|
22
|
+
_rpc_seq = 0
|
|
23
|
+
_rpc_seq_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BrowserdError(RuntimeError):
|
|
27
|
+
"""Raised when browserd cannot be reached or rejects an RPC request."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _next_id() -> int:
|
|
31
|
+
global _rpc_seq
|
|
32
|
+
with _rpc_seq_lock:
|
|
33
|
+
_rpc_seq += 1
|
|
34
|
+
return _rpc_seq
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _compact(params: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
"""Drop None values while preserving false, zero, and empty strings."""
|
|
39
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Browser:
|
|
43
|
+
"""Thin synchronous client for browserd.
|
|
44
|
+
|
|
45
|
+
Connection precedence is ``base_url`` > explicit ``host``/``port`` >
|
|
46
|
+
``MOONCAT_BROWSERD_BASE_URL``/``MOONCAT_BROWSERD_HOST``/
|
|
47
|
+
``MOONCAT_BROWSERD_PORT`` > local defaults.
|
|
48
|
+
|
|
49
|
+
No local "browser open" flag is kept. browserd is the source of truth,
|
|
50
|
+
which means a process can connect to and operate a browser opened by
|
|
51
|
+
another process.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
host: str | None = None,
|
|
57
|
+
port: int | None = None,
|
|
58
|
+
base_url: str | None = None,
|
|
59
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS,
|
|
60
|
+
) -> None:
|
|
61
|
+
configured_base = base_url or os.getenv("MOONCAT_BROWSERD_BASE_URL")
|
|
62
|
+
if configured_base:
|
|
63
|
+
parsed = urlparse(configured_base if "://" in configured_base else f"http://{configured_base}")
|
|
64
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
65
|
+
raise ValueError(f"invalid browserd base_url: {configured_base!r}")
|
|
66
|
+
self.base_url = f"{parsed.scheme}://{parsed.hostname}"
|
|
67
|
+
if parsed.port:
|
|
68
|
+
self.base_url += f":{parsed.port}"
|
|
69
|
+
if parsed.path and parsed.path != "/":
|
|
70
|
+
self.base_url += parsed.path.rstrip("/")
|
|
71
|
+
else:
|
|
72
|
+
resolved_host = host or os.getenv("MOONCAT_BROWSERD_HOST") or DEFAULT_HOST
|
|
73
|
+
resolved_port = port
|
|
74
|
+
if resolved_port is None:
|
|
75
|
+
raw_port = os.getenv("MOONCAT_BROWSERD_PORT")
|
|
76
|
+
resolved_port = int(raw_port) if raw_port else DEFAULT_PORT
|
|
77
|
+
self.base_url = f"http://{resolved_host}:{resolved_port}"
|
|
78
|
+
|
|
79
|
+
if timeout_ms <= 0:
|
|
80
|
+
raise ValueError("timeout_ms must be greater than zero")
|
|
81
|
+
self.default_timeout_ms = timeout_ms
|
|
82
|
+
self.rpc_url = f"{self.base_url}/rpc"
|
|
83
|
+
self.health_url = f"{self.base_url}/health"
|
|
84
|
+
|
|
85
|
+
def health(self, timeout_ms: int = 2_000) -> dict[str, Any] | None:
|
|
86
|
+
"""Return browserd health data, or ``None`` when it is unavailable."""
|
|
87
|
+
try:
|
|
88
|
+
response = httpx.get(self.health_url, timeout=timeout_ms / 1_000)
|
|
89
|
+
if response.status_code == 200:
|
|
90
|
+
data = response.json()
|
|
91
|
+
return data if isinstance(data, dict) else None
|
|
92
|
+
except (httpx.HTTPError, OSError, json.JSONDecodeError):
|
|
93
|
+
pass
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _ensure_browserd(self) -> None:
|
|
97
|
+
if self.health():
|
|
98
|
+
return
|
|
99
|
+
raise BrowserdError(
|
|
100
|
+
f"browserd is not running at {self.base_url}. "
|
|
101
|
+
"Start the local MoonCat browser service first."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _rpc(
|
|
105
|
+
self,
|
|
106
|
+
method: str,
|
|
107
|
+
params: dict[str, Any] | None = None,
|
|
108
|
+
timeout_ms: int | None = None,
|
|
109
|
+
) -> Any:
|
|
110
|
+
timeout = timeout_ms if timeout_ms is not None else self.default_timeout_ms
|
|
111
|
+
payload = {"id": _next_id(), "method": method, "params": params or {}}
|
|
112
|
+
try:
|
|
113
|
+
response = httpx.post(self.rpc_url, json=payload, timeout=timeout / 1_000)
|
|
114
|
+
data = response.json()
|
|
115
|
+
except (httpx.HTTPError, OSError, json.JSONDecodeError) as exc:
|
|
116
|
+
raise BrowserdError(f"browserd RPC failed ({method}): {exc}") from exc
|
|
117
|
+
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise BrowserdError(f"{method}: invalid RPC response")
|
|
120
|
+
if not data.get("ok"):
|
|
121
|
+
raise BrowserdError(f"{method}: {data.get('error', 'rpc failed')}")
|
|
122
|
+
return data.get("result")
|
|
123
|
+
|
|
124
|
+
def open(
|
|
125
|
+
self,
|
|
126
|
+
route_mode: str = "auto",
|
|
127
|
+
headless: bool = False,
|
|
128
|
+
browser_owner: str | None = None,
|
|
129
|
+
extension_connect_timeout_ms: int | None = None,
|
|
130
|
+
**legacy_options: Any,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Open or attach to the browser and return its browser handle."""
|
|
133
|
+
self._ensure_browserd()
|
|
134
|
+
params = _compact(
|
|
135
|
+
{
|
|
136
|
+
"routeMode": route_mode,
|
|
137
|
+
"headless": headless,
|
|
138
|
+
"browserOwner": browser_owner,
|
|
139
|
+
"extensionConnectTimeoutMs": extension_connect_timeout_ms,
|
|
140
|
+
**legacy_options,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
return self._rpc("open", params, timeout_ms=120_000)
|
|
144
|
+
|
|
145
|
+
def new_tab(self, url: str = "", force: bool = False) -> dict[str, Any]:
|
|
146
|
+
"""Create/reuse a tab and return ``TabInfo`` containing ``pageHandle``."""
|
|
147
|
+
return self._rpc("newTab", {"url": url, "force": force})
|
|
148
|
+
|
|
149
|
+
def list_tabs(self) -> list[dict[str, Any]]:
|
|
150
|
+
"""List tabs and normalize each item to a ``TabInfo`` dictionary."""
|
|
151
|
+
raw = self._rpc("listTabs", {})
|
|
152
|
+
if not isinstance(raw, list):
|
|
153
|
+
return []
|
|
154
|
+
result: list[dict[str, Any]] = []
|
|
155
|
+
for index, item in enumerate(raw):
|
|
156
|
+
if not isinstance(item, dict):
|
|
157
|
+
raise BrowserdError(f"listTabs: tab {index} is not an object")
|
|
158
|
+
page_handle = item.get("pageHandle", item)
|
|
159
|
+
if not isinstance(page_handle, dict) or not page_handle.get("pageId"):
|
|
160
|
+
raise BrowserdError(f"listTabs: tab {index} is missing pageHandle.pageId")
|
|
161
|
+
result.append(
|
|
162
|
+
{
|
|
163
|
+
**item,
|
|
164
|
+
"pageHandle": page_handle,
|
|
165
|
+
"url": item.get("url", page_handle.get("url")),
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
def reuse_tab(
|
|
171
|
+
self,
|
|
172
|
+
url: str = "",
|
|
173
|
+
url_match: str | Pattern[str] | None = None,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Reuse a matching tab, or create one when no match exists."""
|
|
176
|
+
pattern = url_match.pattern if hasattr(url_match, "pattern") else url_match
|
|
177
|
+
return self._rpc("reuseTab", _compact({"url": url, "urlMatch": pattern}))
|
|
178
|
+
|
|
179
|
+
def find_or_new_tab(
|
|
180
|
+
self,
|
|
181
|
+
url: str,
|
|
182
|
+
match: str = "includes",
|
|
183
|
+
new_if_missing: bool = True,
|
|
184
|
+
) -> dict[str, Any] | None:
|
|
185
|
+
"""Compatibility helper that returns a page handle, not ``TabInfo``."""
|
|
186
|
+
if match not in {"includes", "exact"}:
|
|
187
|
+
raise ValueError("match must be 'includes' or 'exact'")
|
|
188
|
+
tabs = self.list_tabs()
|
|
189
|
+
for tab in tabs:
|
|
190
|
+
current_url = str(tab.get("url", ""))
|
|
191
|
+
found = current_url == url if match == "exact" else url in current_url
|
|
192
|
+
if found:
|
|
193
|
+
return tab["pageHandle"]
|
|
194
|
+
if not new_if_missing:
|
|
195
|
+
return None
|
|
196
|
+
tab = self.new_tab(url)
|
|
197
|
+
return tab.get("pageHandle", tab)
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _page_handle(value: dict[str, Any]) -> dict[str, Any]:
|
|
201
|
+
"""Accept either a PageHandle or the TabInfo returned by new_tab()."""
|
|
202
|
+
if not isinstance(value, dict):
|
|
203
|
+
raise BrowserdError("invalid pageHandle: expected a dictionary")
|
|
204
|
+
page_handle = value.get("pageHandle", value)
|
|
205
|
+
if not isinstance(page_handle, dict) or not page_handle.get("pageId"):
|
|
206
|
+
raise BrowserdError("invalid pageHandle: missing pageId")
|
|
207
|
+
return page_handle
|
|
208
|
+
|
|
209
|
+
def operate(
|
|
210
|
+
self,
|
|
211
|
+
page_handle: dict[str, Any],
|
|
212
|
+
action: str,
|
|
213
|
+
timeout_ms: int | None = None,
|
|
214
|
+
**params: Any,
|
|
215
|
+
) -> Any:
|
|
216
|
+
"""Run one of browserd's page actions."""
|
|
217
|
+
return self._rpc(
|
|
218
|
+
"operate",
|
|
219
|
+
{
|
|
220
|
+
"pageHandle": self._page_handle(page_handle),
|
|
221
|
+
"action": action,
|
|
222
|
+
"params": params,
|
|
223
|
+
},
|
|
224
|
+
timeout_ms=timeout_ms,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def get_cookies(self, **filters: Any) -> Any:
|
|
228
|
+
return self._rpc("getCookies", filters)
|
|
229
|
+
|
|
230
|
+
def set_cookies(self, cookies: list[dict[str, Any]]) -> Any:
|
|
231
|
+
return self._rpc("setCookies", {"cookies": cookies})
|
|
232
|
+
|
|
233
|
+
def clear_cookies(self, **filters: Any) -> Any:
|
|
234
|
+
return self._rpc("clearCookies", filters)
|
|
235
|
+
|
|
236
|
+
def close_browser(self) -> dict[str, Any]:
|
|
237
|
+
"""Close Chrome but keep browserd running for a later warm open."""
|
|
238
|
+
return self._rpc("closeBrowser", {})
|
|
239
|
+
|
|
240
|
+
def close(self) -> dict[str, Any]:
|
|
241
|
+
"""Close Chrome and stop browserd."""
|
|
242
|
+
return self._rpc("close", {})
|
|
243
|
+
|
|
244
|
+
def list_downloads(self, limit: int = 20) -> dict[str, Any]:
|
|
245
|
+
return self._rpc("listDownloads", {"limit": limit})
|
|
246
|
+
|
|
247
|
+
def download_url(
|
|
248
|
+
self,
|
|
249
|
+
url: str,
|
|
250
|
+
filename: str | None = None,
|
|
251
|
+
conflict_action: str | None = None,
|
|
252
|
+
save_as: bool | None = None,
|
|
253
|
+
) -> dict[str, Any]:
|
|
254
|
+
return self._rpc(
|
|
255
|
+
"downloadUrl",
|
|
256
|
+
_compact(
|
|
257
|
+
{
|
|
258
|
+
"url": url,
|
|
259
|
+
"filename": filename,
|
|
260
|
+
"conflictAction": conflict_action,
|
|
261
|
+
"saveAs": save_as,
|
|
262
|
+
}
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def get_download(self, download_id: int) -> dict[str, Any]:
|
|
267
|
+
return self._rpc("getDownload", {"id": download_id})
|
|
268
|
+
|
|
269
|
+
def wait_for_download(
|
|
270
|
+
self,
|
|
271
|
+
download_id: int | None = None,
|
|
272
|
+
filename_regex: str | None = None,
|
|
273
|
+
since_ms: int | None = None,
|
|
274
|
+
timeout_ms: int = 60_000,
|
|
275
|
+
interval_ms: int | None = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
params = _compact(
|
|
278
|
+
{
|
|
279
|
+
"id": download_id,
|
|
280
|
+
"filenameRegex": filename_regex,
|
|
281
|
+
"sinceMs": since_ms,
|
|
282
|
+
"timeoutMs": timeout_ms,
|
|
283
|
+
"intervalMs": interval_ms,
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
return self._rpc("waitForDownload", params, timeout_ms=timeout_ms + 5_000)
|
|
287
|
+
|
|
288
|
+
def download_url_and_wait(
|
|
289
|
+
self,
|
|
290
|
+
url: str,
|
|
291
|
+
filename: str | None = None,
|
|
292
|
+
conflict_action: str | None = None,
|
|
293
|
+
save_as: bool | None = None,
|
|
294
|
+
timeout_ms: int = 60_000,
|
|
295
|
+
interval_ms: int | None = None,
|
|
296
|
+
) -> dict[str, Any]:
|
|
297
|
+
started = self.download_url(url, filename, conflict_action, save_as)
|
|
298
|
+
waited = self.wait_for_download(
|
|
299
|
+
download_id=started["id"],
|
|
300
|
+
timeout_ms=timeout_ms,
|
|
301
|
+
interval_ms=interval_ms,
|
|
302
|
+
)
|
|
303
|
+
return {"id": started["id"], **waited}
|
|
304
|
+
|
|
305
|
+
def allow_automatic_downloads(
|
|
306
|
+
self,
|
|
307
|
+
primary_pattern: str,
|
|
308
|
+
secondary_pattern: str | None = None,
|
|
309
|
+
scope: str | None = None,
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
return self._rpc(
|
|
312
|
+
"allowAutomaticDownloads",
|
|
313
|
+
_compact(
|
|
314
|
+
{
|
|
315
|
+
"primaryPattern": primary_pattern,
|
|
316
|
+
"secondaryPattern": secondary_pattern,
|
|
317
|
+
"scope": scope,
|
|
318
|
+
}
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def get_automatic_downloads_setting(
|
|
323
|
+
self,
|
|
324
|
+
primary_url: str | None = None,
|
|
325
|
+
primary_pattern: str | None = None,
|
|
326
|
+
secondary_url: str | None = None,
|
|
327
|
+
secondary_pattern: str | None = None,
|
|
328
|
+
incognito: bool | None = None,
|
|
329
|
+
scope: str | None = None,
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
return self._rpc(
|
|
332
|
+
"getAutomaticDownloadsSetting",
|
|
333
|
+
_compact(
|
|
334
|
+
{
|
|
335
|
+
"primaryUrl": primary_url,
|
|
336
|
+
"primaryPattern": primary_pattern,
|
|
337
|
+
"secondaryUrl": secondary_url,
|
|
338
|
+
"secondaryPattern": secondary_pattern,
|
|
339
|
+
"incognito": incognito,
|
|
340
|
+
"scope": scope,
|
|
341
|
+
}
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def reset_automatic_downloads_setting(
|
|
346
|
+
self,
|
|
347
|
+
primary_pattern: str,
|
|
348
|
+
secondary_pattern: str | None = None,
|
|
349
|
+
scope: str | None = None,
|
|
350
|
+
) -> dict[str, Any]:
|
|
351
|
+
return self._rpc(
|
|
352
|
+
"resetAutomaticDownloadsSetting",
|
|
353
|
+
_compact(
|
|
354
|
+
{
|
|
355
|
+
"primaryPattern": primary_pattern,
|
|
356
|
+
"secondaryPattern": secondary_pattern,
|
|
357
|
+
"scope": scope,
|
|
358
|
+
}
|
|
359
|
+
),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# Small action conveniences. Complex behavior remains in browserd.
|
|
363
|
+
def goto(self, page_handle: dict[str, Any], url: str) -> Any:
|
|
364
|
+
return self.operate(page_handle, "goto", url=url)
|
|
365
|
+
|
|
366
|
+
def click(
|
|
367
|
+
self,
|
|
368
|
+
page_handle: dict[str, Any],
|
|
369
|
+
selector: str,
|
|
370
|
+
x: float | None = None,
|
|
371
|
+
y: float | None = None,
|
|
372
|
+
) -> Any:
|
|
373
|
+
return self.operate(page_handle, "click", **_compact({"selector": selector, "x": x, "y": y}))
|
|
374
|
+
|
|
375
|
+
def fill(self, page_handle: dict[str, Any], selector: str, value: str) -> Any:
|
|
376
|
+
return self.operate(page_handle, "fill", selector=selector, value=value)
|
|
377
|
+
|
|
378
|
+
def press(self, page_handle: dict[str, Any], selector: str, key: str) -> Any:
|
|
379
|
+
return self.operate(page_handle, "press", selector=selector, key=key)
|
|
380
|
+
|
|
381
|
+
def status(self, page_handle: dict[str, Any]) -> dict[str, Any]:
|
|
382
|
+
return self.operate(page_handle, "status")
|
|
383
|
+
|
|
384
|
+
def snapshot(self, page_handle: dict[str, Any], **options: Any) -> Any:
|
|
385
|
+
return self.operate(page_handle, "snapshot", **options)
|
|
386
|
+
|
|
387
|
+
def evaluate(self, page_handle: dict[str, Any], source: str, args: Any = None) -> Any:
|
|
388
|
+
return self.operate(page_handle, "evaluate", **_compact({"source": source, "args": args}))
|
|
389
|
+
|
|
390
|
+
def screenshot(self, page_handle: dict[str, Any]) -> Any:
|
|
391
|
+
return self.operate(page_handle, "screenshot")
|
|
392
|
+
|
|
393
|
+
def inner_text(self, page_handle: dict[str, Any], selector: str) -> str:
|
|
394
|
+
return self.operate(page_handle, "innerText", selector=selector)
|
|
395
|
+
|
|
396
|
+
def wait_for_selector(
|
|
397
|
+
self,
|
|
398
|
+
page_handle: dict[str, Any],
|
|
399
|
+
selector: str,
|
|
400
|
+
timeout: int | None = None,
|
|
401
|
+
) -> Any:
|
|
402
|
+
return self.operate(
|
|
403
|
+
page_handle,
|
|
404
|
+
"waitForSelector",
|
|
405
|
+
**_compact({"selector": selector, "timeout": timeout}),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
_default_browser: Browser | None = None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _get_browser() -> Browser:
|
|
413
|
+
global _default_browser
|
|
414
|
+
if _default_browser is None:
|
|
415
|
+
_default_browser = Browser()
|
|
416
|
+
return _default_browser
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def configure(**options: Any) -> Browser:
|
|
420
|
+
"""Replace and return the module-level client with custom connection options."""
|
|
421
|
+
global _default_browser
|
|
422
|
+
_default_browser = Browser(**options)
|
|
423
|
+
return _default_browser
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def health(timeout_ms: int = 2_000) -> dict[str, Any] | None:
|
|
427
|
+
return _get_browser().health(timeout_ms)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def open_browser(**options: Any) -> dict[str, Any]:
|
|
431
|
+
return _get_browser().open(**options)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def new_tab(url: str = "", force: bool = False) -> dict[str, Any]:
|
|
435
|
+
return _get_browser().new_tab(url, force)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def list_tabs(browser_handle: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
439
|
+
del browser_handle
|
|
440
|
+
return _get_browser().list_tabs()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def reuse_tab(url: str = "", url_match: str | Pattern[str] | None = None) -> dict[str, Any]:
|
|
444
|
+
return _get_browser().reuse_tab(url, url_match)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def find_or_new_tab(
|
|
448
|
+
url: str,
|
|
449
|
+
match: str = "includes",
|
|
450
|
+
new_if_missing: bool = True,
|
|
451
|
+
) -> dict[str, Any] | None:
|
|
452
|
+
return _get_browser().find_or_new_tab(url, match, new_if_missing)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def operate(page_handle: dict[str, Any], params: dict[str, Any]) -> Any:
|
|
456
|
+
action = params.get("action")
|
|
457
|
+
if not action:
|
|
458
|
+
raise ValueError("operate: params is missing 'action'")
|
|
459
|
+
timeout_ms = params.get("timeoutMs")
|
|
460
|
+
kwargs = {
|
|
461
|
+
key: value
|
|
462
|
+
for key, value in params.items()
|
|
463
|
+
if key not in {"action", "timeoutMs", "_skipVisualize", "_skipHumanize", "label"}
|
|
464
|
+
}
|
|
465
|
+
return _get_browser().operate(page_handle, str(action), timeout_ms=timeout_ms, **kwargs)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def close_browser(stop_daemon: bool = True) -> dict[str, Any]:
|
|
469
|
+
return _get_browser().close() if stop_daemon else _get_browser().close_browser()
|