sleight 0.1.0a1__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.
- sleight/__init__.py +138 -0
- sleight/_logging.py +53 -0
- sleight/core/__init__.py +1 -0
- sleight/core/_http.py +169 -0
- sleight/core/_redact.py +44 -0
- sleight/core/element.py +219 -0
- sleight/core/errors.py +102 -0
- sleight/core/human/__init__.py +64 -0
- sleight/core/human/curves.py +297 -0
- sleight/core/human/engine.py +435 -0
- sleight/core/human/presets.py +160 -0
- sleight/core/human/timing.py +185 -0
- sleight/core/human/wind.py +145 -0
- sleight/core/input.py +363 -0
- sleight/core/keymap.py +285 -0
- sleight/core/netidle.py +131 -0
- sleight/core/protocol.py +100 -0
- sleight/core/session.py +557 -0
- sleight/core/transport.py +360 -0
- sleight/core/types.py +191 -0
- sleight/lease/__init__.py +11 -0
- sleight/lease/base.py +81 -0
- sleight/lease/memory.py +78 -0
- sleight/pool.py +506 -0
- sleight/providers/__init__.py +14 -0
- sleight/providers/base.py +245 -0
- sleight/providers/cloakbrowser.py +490 -0
- sleight/providers/plain.py +115 -0
- sleight/py.typed +0 -0
- sleight-0.1.0a1.dist-info/METADATA +158 -0
- sleight-0.1.0a1.dist-info/RECORD +33 -0
- sleight-0.1.0a1.dist-info/WHEEL +4 -0
- sleight-0.1.0a1.dist-info/licenses/LICENSE +21 -0
sleight/__init__.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""sleight —— 通过 CDP 驱动真实浏览器,自带拟人交互与实例池管理。
|
|
2
|
+
|
|
3
|
+
>>> from sleight import connect
|
|
4
|
+
>>> with connect("http://127.0.0.1:9222") as s:
|
|
5
|
+
... s.open("https://example.com")
|
|
6
|
+
... print(s.title())
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as _installed_version
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._logging import enable_debug_logging
|
|
16
|
+
from .core import errors
|
|
17
|
+
from .core.element import Element
|
|
18
|
+
from .core.errors import (
|
|
19
|
+
AuthError,
|
|
20
|
+
Busy,
|
|
21
|
+
Crashed,
|
|
22
|
+
ElementError,
|
|
23
|
+
InstanceError,
|
|
24
|
+
LeaseLost,
|
|
25
|
+
NotFound,
|
|
26
|
+
NotReady,
|
|
27
|
+
SessionLost,
|
|
28
|
+
SleightError,
|
|
29
|
+
)
|
|
30
|
+
from .core.human import CAREFUL, DEFAULT, FAST, HumanProfile
|
|
31
|
+
from .core.session import Selectable, Session
|
|
32
|
+
from .core.transport import Transport
|
|
33
|
+
from .core.types import (
|
|
34
|
+
Box,
|
|
35
|
+
Condition,
|
|
36
|
+
DomReady,
|
|
37
|
+
Endpoint,
|
|
38
|
+
Gone,
|
|
39
|
+
InstanceInfo,
|
|
40
|
+
InstanceStatus,
|
|
41
|
+
Load,
|
|
42
|
+
NetworkIdle,
|
|
43
|
+
Point,
|
|
44
|
+
Selector,
|
|
45
|
+
Text,
|
|
46
|
+
)
|
|
47
|
+
from .pool import InstanceHandle, Pool
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
__version__ = _installed_version("sleight")
|
|
51
|
+
except PackageNotFoundError: # 从源码目录直接 import,没装进环境
|
|
52
|
+
__version__ = "0.0.0.dev0"
|
|
53
|
+
|
|
54
|
+
__all__ = [ # noqa: RUF022 - 按语义分组,不按字母序
|
|
55
|
+
"__version__",
|
|
56
|
+
"connect",
|
|
57
|
+
"Pool",
|
|
58
|
+
"InstanceHandle",
|
|
59
|
+
"Session",
|
|
60
|
+
"Selectable",
|
|
61
|
+
"Element",
|
|
62
|
+
"Transport",
|
|
63
|
+
"enable_debug_logging",
|
|
64
|
+
# 拟人预设
|
|
65
|
+
"HumanProfile",
|
|
66
|
+
"FAST",
|
|
67
|
+
"DEFAULT",
|
|
68
|
+
"CAREFUL",
|
|
69
|
+
# 等待条件
|
|
70
|
+
"DomReady",
|
|
71
|
+
"Load",
|
|
72
|
+
"Text",
|
|
73
|
+
"Selector",
|
|
74
|
+
"Gone",
|
|
75
|
+
"NetworkIdle",
|
|
76
|
+
"Condition",
|
|
77
|
+
# 数据类型
|
|
78
|
+
"Point",
|
|
79
|
+
"Box",
|
|
80
|
+
"Endpoint",
|
|
81
|
+
"InstanceInfo",
|
|
82
|
+
"InstanceStatus",
|
|
83
|
+
# 异常
|
|
84
|
+
"errors",
|
|
85
|
+
"SleightError",
|
|
86
|
+
"AuthError",
|
|
87
|
+
"InstanceError",
|
|
88
|
+
"NotFound",
|
|
89
|
+
"NotReady",
|
|
90
|
+
"Busy",
|
|
91
|
+
"Crashed",
|
|
92
|
+
"SessionLost",
|
|
93
|
+
"LeaseLost",
|
|
94
|
+
"ElementError",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Connection:
|
|
99
|
+
"""``connect()`` 的返回:一个 Session,退出时把 transport 也带走。"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, transport: Transport, session: Session) -> None:
|
|
102
|
+
self._t = transport
|
|
103
|
+
self.session = session
|
|
104
|
+
|
|
105
|
+
def __enter__(self) -> Session:
|
|
106
|
+
return self.session
|
|
107
|
+
|
|
108
|
+
def __exit__(self, *exc: object) -> None:
|
|
109
|
+
try:
|
|
110
|
+
self.session.close()
|
|
111
|
+
finally:
|
|
112
|
+
self._t.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def connect(url: str, *, headers: dict[str, str] | None = None, **kw: Any) -> _Connection:
|
|
116
|
+
"""连一个裸 CDP 端点,**新建自有 tab**。
|
|
117
|
+
|
|
118
|
+
``url`` 可以是 ``http://host:port``(走 ``/json/version`` 发现)或直接的
|
|
119
|
+
``ws://…`` 浏览器级端点。
|
|
120
|
+
|
|
121
|
+
自建 tab 而不是接管既有的 —— target 顺序没有业务语义,接管等于随机修改一个
|
|
122
|
+
别人的页面。
|
|
123
|
+
"""
|
|
124
|
+
if url.startswith(("ws://", "wss://")):
|
|
125
|
+
ws_url = url
|
|
126
|
+
else:
|
|
127
|
+
from .providers.plain import Plain
|
|
128
|
+
|
|
129
|
+
ep: Endpoint = Plain(url, headers=headers).endpoint()
|
|
130
|
+
ws_url = ep.ws_url
|
|
131
|
+
headers = dict(ep.headers) or headers
|
|
132
|
+
|
|
133
|
+
transport = Transport.connect(ws_url, headers=headers)
|
|
134
|
+
try:
|
|
135
|
+
return _Connection(transport, Session.create(transport, **kw))
|
|
136
|
+
except BaseException:
|
|
137
|
+
transport.close()
|
|
138
|
+
raise
|
sleight/_logging.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""标准 ``logging``,不引入 loguru。
|
|
2
|
+
|
|
3
|
+
库不配置根 logger —— 那是应用的事。这里只挂一个 ``NullHandler``,免得用户没配日志时
|
|
4
|
+
Python 打印 "No handlers could be found"。
|
|
5
|
+
|
|
6
|
+
调试时:
|
|
7
|
+
|
|
8
|
+
>>> import sleight
|
|
9
|
+
>>> sleight.enable_debug_logging()
|
|
10
|
+
|
|
11
|
+
Logger 层级:``sleight.transport`` / ``sleight.session`` / ``sleight.input`` /
|
|
12
|
+
``sleight.pool`` / ``sleight.lease`` / ``sleight.provider`` / ``sleight.cloakbrowser`` /
|
|
13
|
+
``sleight.http``。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
__all__ = ["enable_debug_logging"]
|
|
22
|
+
|
|
23
|
+
ROOT = "sleight"
|
|
24
|
+
_HANDLER_TAG = "sleight-debug"
|
|
25
|
+
|
|
26
|
+
logging.getLogger(ROOT).addHandler(logging.NullHandler())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def enable_debug_logging(level: int = logging.DEBUG, stream: TextIO | None = None) -> None:
|
|
30
|
+
"""给 ``sleight.*`` 挂一个 stderr handler。仅供调试与开发使用。
|
|
31
|
+
|
|
32
|
+
**幂等** —— 重复调用只会调整级别,不会再挂一个 handler。挂两个的话每行日志
|
|
33
|
+
都会打印两遍,而这种事在 notebook 和被重复 import 的模块里太容易发生了。
|
|
34
|
+
|
|
35
|
+
凭据由 :mod:`sleight.core._redact` 在生成消息时就已脱敏,但 DEBUG 级别会打印
|
|
36
|
+
完整的 CDP 流量 —— 不要把它开在生产日志里。
|
|
37
|
+
|
|
38
|
+
:param level: 日志级别,默认 ``logging.DEBUG``
|
|
39
|
+
:param stream: 输出流。``None`` 表示 ``sys.stderr``
|
|
40
|
+
"""
|
|
41
|
+
logger = logging.getLogger(ROOT)
|
|
42
|
+
logger.setLevel(level)
|
|
43
|
+
for existing in logger.handlers:
|
|
44
|
+
if getattr(existing, "name", None) == _HANDLER_TAG:
|
|
45
|
+
existing.setLevel(level)
|
|
46
|
+
return
|
|
47
|
+
handler = logging.StreamHandler(stream)
|
|
48
|
+
handler.name = _HANDLER_TAG
|
|
49
|
+
handler.setLevel(level)
|
|
50
|
+
handler.setFormatter(
|
|
51
|
+
logging.Formatter("%(asctime)s %(levelname)-7s %(name)-18s %(message)s", "%H:%M:%S")
|
|
52
|
+
)
|
|
53
|
+
logger.addHandler(handler)
|
sleight/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""核心:协议、传输、会话、类型。不依赖任何 provider、不依赖 lease。"""
|
sleight/core/_http.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""标准库 HTTP 客户端。
|
|
2
|
+
|
|
3
|
+
为什么不用 httpx / niquests:Provider 只在 launch/status/list 时发 HTTP,频率极低,
|
|
4
|
+
不需要连接池、不需要 HTTP/2。实测依赖代价 —— urllib 0 个包、httpx 6 个、niquests 7 个
|
|
5
|
+
(含两个 Rust 编译扩展)。
|
|
6
|
+
|
|
7
|
+
证书:httpx 并不"管理证书",它只是捆一份 certifi 的 CA bundle。标准库的
|
|
8
|
+
``ssl.create_default_context()`` 在 Windows 走系统证书存储、Linux 走 OpenSSL 默认路径,
|
|
9
|
+
对公网 CA 一样能验。只有连自签证书的远程 Manager 才需要 ``ca_bundle``。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import builtins
|
|
15
|
+
import http.client
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import ssl
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ._redact import redact_url
|
|
26
|
+
from .errors import AuthError, ConnectionError
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger("sleight.http")
|
|
29
|
+
|
|
30
|
+
__all__ = ["HttpClient", "HttpResponse"]
|
|
31
|
+
|
|
32
|
+
DEFAULT_TIMEOUT = 15.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class HttpResponse:
|
|
37
|
+
status: int
|
|
38
|
+
body: Any
|
|
39
|
+
url: str = field(repr=False, default="")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def ok(self) -> bool:
|
|
43
|
+
return 200 <= self.status < 300
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def detail(self) -> str:
|
|
47
|
+
"""FastAPI 风格的 ``{"detail": "..."}``。
|
|
48
|
+
|
|
49
|
+
CloakBrowser 的 stop 对「已停止」和「id 不存在」都返回 404,**必须靠 detail
|
|
50
|
+
或 status() 才能区分**。
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(self.body, dict):
|
|
53
|
+
d = self.body.get("detail")
|
|
54
|
+
if isinstance(d, str):
|
|
55
|
+
return d
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HttpClient:
|
|
60
|
+
"""极简 JSON over HTTP 客户端。
|
|
61
|
+
|
|
62
|
+
非 2xx **不抛异常**(401/403 除外)—— provider 需要自己解读 409/404,
|
|
63
|
+
生命周期语义无法从状态码通用推导。
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
base_url: str,
|
|
69
|
+
*,
|
|
70
|
+
headers: dict[str, str] | None = None,
|
|
71
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
72
|
+
ca_bundle: str | None = None,
|
|
73
|
+
verify: bool = True,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self.headers = dict(headers or {})
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self._ctx = self._build_ssl_context(ca_bundle, verify)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _build_ssl_context(ca_bundle: str | None, verify: bool) -> ssl.SSLContext | None:
|
|
82
|
+
if not verify:
|
|
83
|
+
ctx = ssl.create_default_context()
|
|
84
|
+
ctx.check_hostname = False
|
|
85
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
86
|
+
return ctx
|
|
87
|
+
if ca_bundle:
|
|
88
|
+
return ssl.create_default_context(cafile=ca_bundle)
|
|
89
|
+
return None # urllib 用默认上下文
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------ #
|
|
92
|
+
|
|
93
|
+
def request(
|
|
94
|
+
self,
|
|
95
|
+
method: str,
|
|
96
|
+
path: str,
|
|
97
|
+
*,
|
|
98
|
+
json_body: Any = None,
|
|
99
|
+
params: dict[str, Any] | None = None,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
) -> HttpResponse:
|
|
102
|
+
"""发一个请求。**所有出站 HTTP 都从这里走** —— 打桩只需要盯这一个入口。
|
|
103
|
+
|
|
104
|
+
:param method: HTTP 方法,大小写不敏感
|
|
105
|
+
:param path: 相对 :attr:`base_url` 的路径,要带前导 ``/``
|
|
106
|
+
:param json_body: 给了就序列化成 JSON body 并加 ``Content-Type``
|
|
107
|
+
:param params: query 参数
|
|
108
|
+
:param timeout: 覆盖本次请求的超时,秒。``None`` 用构造时的默认值
|
|
109
|
+
:returns: :class:`HttpResponse`。**非 2xx 不抛异常**(401/403 除外)
|
|
110
|
+
:raises AuthError: 401 / 403
|
|
111
|
+
:raises ConnectionError: 连不上、超时、传输中断
|
|
112
|
+
"""
|
|
113
|
+
url = self.base_url + path
|
|
114
|
+
if params:
|
|
115
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
116
|
+
|
|
117
|
+
data: bytes | None = None
|
|
118
|
+
headers = dict(self.headers)
|
|
119
|
+
if json_body is not None:
|
|
120
|
+
data = json.dumps(json_body).encode()
|
|
121
|
+
headers["Content-Type"] = "application/json"
|
|
122
|
+
headers.setdefault("Accept", "application/json")
|
|
123
|
+
|
|
124
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
|
|
125
|
+
try:
|
|
126
|
+
with urllib.request.urlopen(
|
|
127
|
+
req, timeout=timeout or self.timeout, context=self._ctx
|
|
128
|
+
) as resp:
|
|
129
|
+
return HttpResponse(resp.status, _parse(resp.read(), resp.headers), url)
|
|
130
|
+
except urllib.error.HTTPError as exc:
|
|
131
|
+
resp = HttpResponse(exc.code, _parse(exc.read(), exc.headers), url)
|
|
132
|
+
if exc.code in (401, 403):
|
|
133
|
+
suffix = f": {resp.detail}" if resp.detail else ""
|
|
134
|
+
raise AuthError(f"{exc.code} from {redact_url(url)}{suffix}") from exc
|
|
135
|
+
return resp
|
|
136
|
+
except urllib.error.URLError as exc:
|
|
137
|
+
raise ConnectionError(f"cannot reach {redact_url(url)}: {exc.reason}") from exc
|
|
138
|
+
except builtins.TimeoutError as exc: # socket timeout(注意不是本包的 TimeoutError)
|
|
139
|
+
raise ConnectionError(f"timed out talking to {redact_url(url)}") from exc
|
|
140
|
+
except (http.client.HTTPException, OSError) as exc:
|
|
141
|
+
# urllib 只在它自己的内层 try 里包 URLError;`h.getresponse()` 期间断连会原样
|
|
142
|
+
# 逃逸成 http.client.RemoteDisconnected / ConnectionResetError,绕过整个
|
|
143
|
+
# SleightError 体系,调用方的 except 一个都接不住。
|
|
144
|
+
raise ConnectionError(f"lost connection to {redact_url(url)}: {exc}") from exc
|
|
145
|
+
|
|
146
|
+
def get(self, path: str, **kw: Any) -> HttpResponse:
|
|
147
|
+
return self.request("GET", path, **kw)
|
|
148
|
+
|
|
149
|
+
def post(self, path: str, **kw: Any) -> HttpResponse:
|
|
150
|
+
return self.request("POST", path, **kw)
|
|
151
|
+
|
|
152
|
+
def put(self, path: str, **kw: Any) -> HttpResponse:
|
|
153
|
+
return self.request("PUT", path, **kw)
|
|
154
|
+
|
|
155
|
+
def delete(self, path: str, **kw: Any) -> HttpResponse:
|
|
156
|
+
return self.request("DELETE", path, **kw)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse(raw: bytes, headers: Any) -> Any:
|
|
160
|
+
if not raw:
|
|
161
|
+
return None
|
|
162
|
+
ctype = (headers.get("Content-Type") or "").lower() if headers else ""
|
|
163
|
+
text = raw.decode("utf-8", errors="replace")
|
|
164
|
+
if "json" in ctype:
|
|
165
|
+
try:
|
|
166
|
+
return json.loads(text)
|
|
167
|
+
except ValueError:
|
|
168
|
+
return text
|
|
169
|
+
return text
|
sleight/core/_redact.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""日志与异常里的凭据脱敏。
|
|
2
|
+
|
|
3
|
+
dataclass 的默认 repr 会把 ``Authorization: Bearer ...`` 原样打进 traceback,
|
|
4
|
+
所有携带凭据的字段必须 ``repr=False``,所有对外可见的字符串走这里过一遍。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
__all__ = ["redact", "redact_url"]
|
|
12
|
+
|
|
13
|
+
_SENSITIVE_QUERY = re.compile(r"([?&](?:token|api_?key|access_token|auth|password|secret)=)([^&#]+)", re.I)
|
|
14
|
+
_BEARER = re.compile(r"(Bearer\s+)([A-Za-z0-9._\-]{8,})", re.I)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mask(value: str) -> str:
|
|
18
|
+
"""保留头尾各 3 个字符,够人肉比对,不够复用。"""
|
|
19
|
+
if len(value) <= 8:
|
|
20
|
+
return "***"
|
|
21
|
+
return f"{value[:3]}…{value[-3:]}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def redact(text: str) -> str:
|
|
25
|
+
"""对任意字符串做尽力脱敏(Bearer token + URL query 里的凭据)。
|
|
26
|
+
|
|
27
|
+
:param text: 任意文本,通常是即将进日志或异常消息的字符串
|
|
28
|
+
:returns: 敏感片段替换成 ``头3…尾3``(长度 ≤ 8 的整个换成 ``***``)
|
|
29
|
+
"""
|
|
30
|
+
text = _BEARER.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
|
|
31
|
+
return _SENSITIVE_QUERY.sub(lambda m: m.group(1) + _mask(m.group(2)), text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact_url(url: str) -> str:
|
|
35
|
+
"""URL 专用:去掉 userinfo,遮蔽 query 里的凭据。
|
|
36
|
+
|
|
37
|
+
异常消息里不出现完整 WS URL —— 它可能整条就是凭据。
|
|
38
|
+
|
|
39
|
+
:param url: 完整 URL
|
|
40
|
+
:returns: userinfo 段换成 ``***``,query 里的 token/api_key 等遮蔽掉
|
|
41
|
+
"""
|
|
42
|
+
url = re.sub(r"://([^/@]+)@", "://***@", url)
|
|
43
|
+
return _SENSITIVE_QUERY.sub(lambda m: m.group(1) + _mask(m.group(2)), url)
|
|
44
|
+
|
sleight/core/element.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Element:几何 + 命中校验。
|
|
2
|
+
|
|
3
|
+
作用域限制(有意为之):**只支持主 frame 的普通 DOM**。不支持 iframe、OOPIF,
|
|
4
|
+
不穿透 Shadow DOM。需要这些就用 Playwright。
|
|
5
|
+
|
|
6
|
+
定位方式是 selector + index 而不是 CDP 的 ``nodeId``/``objectId``:省掉一整套节点
|
|
7
|
+
生命周期管理,代价是 DOM 变动后同一个 Element 可能指向不同节点。对驱动层够用;
|
|
8
|
+
每次取几何都会重新解析,所以拿到的 box 一定是当下的。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from .errors import ElementError
|
|
17
|
+
from .types import Box
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .session import Session
|
|
21
|
+
|
|
22
|
+
__all__ = ["Element"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Element:
|
|
26
|
+
__slots__ = ("_session", "index", "selector")
|
|
27
|
+
|
|
28
|
+
def __init__(self, session: Session, selector: str, index: int = 0) -> None:
|
|
29
|
+
self._session = session
|
|
30
|
+
self.selector = selector
|
|
31
|
+
self.index = index
|
|
32
|
+
|
|
33
|
+
def __repr__(self) -> str:
|
|
34
|
+
at = f"[{self.index}]" if self.index else ""
|
|
35
|
+
return f"<Element {self.selector!r}{at}>"
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------ #
|
|
38
|
+
# JS 侧的元素引用
|
|
39
|
+
# ------------------------------------------------------------------ #
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def js_ref(self) -> str:
|
|
43
|
+
"""解析到该元素的 JS 表达式。
|
|
44
|
+
|
|
45
|
+
用 ``json.dumps`` 而不是 Python 的 ``repr`` —— repr 走 Python 的引号与转义
|
|
46
|
+
规则,遇到反斜杠、引号、非 ASCII 会产出不合法或语义不同的 JS 字面量。
|
|
47
|
+
``json.dumps`` 产出的恰好是合法 JS 字符串字面量。
|
|
48
|
+
"""
|
|
49
|
+
return f"document.querySelectorAll({json.dumps(self.selector)})[{self.index}]"
|
|
50
|
+
|
|
51
|
+
def _eval(self, body: str) -> Any:
|
|
52
|
+
"""在 ``el`` 绑定到本元素的作用域里求值。元素不存在时 body 不执行。"""
|
|
53
|
+
return self._session.eval(
|
|
54
|
+
f"(() => {{ const el = {self.js_ref}; if (!el) return null; {body} }})()"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------ #
|
|
58
|
+
# 查询
|
|
59
|
+
# ------------------------------------------------------------------ #
|
|
60
|
+
|
|
61
|
+
def exists(self) -> bool:
|
|
62
|
+
"""选择器现在还能命中这个元素吗?"""
|
|
63
|
+
return bool(self._eval("return true;"))
|
|
64
|
+
|
|
65
|
+
def box(self) -> Box | None:
|
|
66
|
+
"""``getBoundingClientRect()`` 的原值,viewport CSS 像素。
|
|
67
|
+
|
|
68
|
+
不乘 devicePixelRatio,不加 scroll 偏移 —— CDP Input 用的就是这个坐标系。
|
|
69
|
+
|
|
70
|
+
:returns: 元素几何;元素已经不在了返回 ``None``
|
|
71
|
+
"""
|
|
72
|
+
raw = self._eval(
|
|
73
|
+
"const r = el.getBoundingClientRect();"
|
|
74
|
+
" return {x: r.x, y: r.y, w: r.width, h: r.height};"
|
|
75
|
+
)
|
|
76
|
+
return Box(**raw) if raw else None
|
|
77
|
+
|
|
78
|
+
def require_box(self) -> Box:
|
|
79
|
+
"""同 :meth:`box`,但拿不到就抛。
|
|
80
|
+
|
|
81
|
+
:raises ElementError: 元素不存在,或宽高为 0(多半是被 CSS 隐藏了)
|
|
82
|
+
"""
|
|
83
|
+
box = self.box()
|
|
84
|
+
if box is None:
|
|
85
|
+
raise ElementError(f"no element matches {self.selector!r}[{self.index}]")
|
|
86
|
+
if box.empty:
|
|
87
|
+
raise ElementError(f"{self!r} has zero size ({box.w}x{box.h}); is it hidden?")
|
|
88
|
+
return box
|
|
89
|
+
|
|
90
|
+
def text(self) -> str:
|
|
91
|
+
"""元素的 ``innerText``。元素不在了返回空串。"""
|
|
92
|
+
return self._eval("return el.innerText;") or ""
|
|
93
|
+
|
|
94
|
+
def attr(self, name: str) -> str | None:
|
|
95
|
+
"""读一个 HTML 属性。
|
|
96
|
+
|
|
97
|
+
:param name: 属性名,会经 ``json.dumps`` 转义后拼进 JS
|
|
98
|
+
:returns: 属性值;属性不存在或元素不在了返回 ``None``
|
|
99
|
+
"""
|
|
100
|
+
return self._eval(f"return el.getAttribute({json.dumps(name)});")
|
|
101
|
+
|
|
102
|
+
def object_id(self) -> str:
|
|
103
|
+
"""拿一个 CDP ``Runtime.RemoteObject`` 句柄,给 ``DOM.*`` 命令用。
|
|
104
|
+
|
|
105
|
+
**调用方负责 ``Runtime.releaseObject``** —— 不释放会把节点钉在内存里。
|
|
106
|
+
平时不需要它:读几何走 ``Runtime.evaluate`` 更简单,也不用维护 nodeId 生命周期。
|
|
107
|
+
|
|
108
|
+
:returns: 可传给 ``DOM.*`` 命令的 ``objectId``
|
|
109
|
+
:raises ElementError: 元素不存在
|
|
110
|
+
"""
|
|
111
|
+
result = self._session.call(
|
|
112
|
+
"Runtime.evaluate", {"expression": self.js_ref, "returnByValue": False}
|
|
113
|
+
)
|
|
114
|
+
remote = result.get("result") or {}
|
|
115
|
+
if not (object_id := remote.get("objectId")):
|
|
116
|
+
raise ElementError(f"no element matches {self.selector!r}[{self.index}]")
|
|
117
|
+
return object_id
|
|
118
|
+
|
|
119
|
+
def in_viewport(self) -> bool:
|
|
120
|
+
"""元素当前是否有一部分落在视口内,且宽高都不为 0。
|
|
121
|
+
|
|
122
|
+
滚动是否到位的判据就是它 —— 不能用"垂直偏移为 0",那对横向出界的元素
|
|
123
|
+
会误判成成功。
|
|
124
|
+
"""
|
|
125
|
+
return bool(
|
|
126
|
+
self._eval(
|
|
127
|
+
"const r = el.getBoundingClientRect();"
|
|
128
|
+
" return r.bottom > 0 && r.right > 0"
|
|
129
|
+
" && r.top < innerHeight && r.left < innerWidth"
|
|
130
|
+
" && r.width > 0 && r.height > 0;"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def scroll_metrics(self) -> dict[str, float]:
|
|
135
|
+
"""``{top, bottom, height}``,供 :class:`~sleight.core.input.InputDriver`
|
|
136
|
+
计算还要滚多远。``height`` 是视口高度,不是元素高度。
|
|
137
|
+
|
|
138
|
+
:raises ElementError: 元素不存在
|
|
139
|
+
"""
|
|
140
|
+
raw = self._eval(
|
|
141
|
+
"const r = el.getBoundingClientRect();"
|
|
142
|
+
" return {top: r.top, bottom: r.bottom, height: innerHeight};"
|
|
143
|
+
)
|
|
144
|
+
if raw is None:
|
|
145
|
+
raise ElementError(f"{self!r} no longer exists")
|
|
146
|
+
return raw
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
# 命中校验 —— 每次点击前后各做一次
|
|
150
|
+
# ------------------------------------------------------------------ #
|
|
151
|
+
|
|
152
|
+
def hit_test(self, x: int, y: int) -> bool:
|
|
153
|
+
"""(x, y) 处最上层的元素是不是本元素或其后代。
|
|
154
|
+
|
|
155
|
+
false 说明被遮挡(cookie 弹窗、fixed 头部、遮罩层)。不做这步的症状是
|
|
156
|
+
"点了但没反应",排查极费时间。
|
|
157
|
+
|
|
158
|
+
:param x: viewport CSS 像素
|
|
159
|
+
:param y: viewport CSS 像素
|
|
160
|
+
"""
|
|
161
|
+
return bool(
|
|
162
|
+
self._eval(
|
|
163
|
+
f"const hit = document.elementFromPoint({x}, {y});"
|
|
164
|
+
" return !!hit && (hit === el || el.contains(hit));"
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def has_focus(self) -> bool:
|
|
169
|
+
"""本元素或其后代是不是 ``document.activeElement``。"""
|
|
170
|
+
return bool(
|
|
171
|
+
self._eval(
|
|
172
|
+
"return document.activeElement === el"
|
|
173
|
+
" || el.contains(document.activeElement);"
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def require_focus(self, *, after: str) -> None:
|
|
178
|
+
"""确认焦点真的落在本元素上。
|
|
179
|
+
|
|
180
|
+
点击只保证**几何命中**(``elementFromPoint``),不保证元素可聚焦:点一个
|
|
181
|
+
``<div>`` 会让焦点留在原处,随后的键盘事件全打给上一个焦点元素。这在
|
|
182
|
+
``type(clear=True)`` 下尤其危险 —— Ctrl+A + Backspace 会清空**别的**输入框。
|
|
183
|
+
|
|
184
|
+
:param after: 出现在报错消息里的一句话,说明是在哪一步之后检查的,
|
|
185
|
+
例如 ``"the focusing click"``
|
|
186
|
+
:raises ElementError: 焦点不在本元素上;消息里会带上真正的 activeElement
|
|
187
|
+
"""
|
|
188
|
+
if self.has_focus():
|
|
189
|
+
return
|
|
190
|
+
active = self._session.eval(
|
|
191
|
+
"(() => { const a = document.activeElement;"
|
|
192
|
+
" return a ? a.tagName.toLowerCase() + (a.id ? '#' + a.id : '') : null; })()"
|
|
193
|
+
)
|
|
194
|
+
raise ElementError(
|
|
195
|
+
f"{self!r} does not have focus {after}"
|
|
196
|
+
f"{f'; document.activeElement is <{active}>' if active else ''}"
|
|
197
|
+
" — is it focusable?"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def require_hit(self, x: int, y: int, *, when: str) -> None:
|
|
201
|
+
"""同 :meth:`hit_test`,但没命中就抛,并在消息里点名挡住它的元素。
|
|
202
|
+
|
|
203
|
+
:param x: 要探的 viewport 坐标
|
|
204
|
+
:param y: 要探的 viewport 坐标
|
|
205
|
+
:param when: 出现在报错消息里的时机描述,例如 ``"before moving there"``
|
|
206
|
+
:raises ElementError: 该点最上层的不是本元素
|
|
207
|
+
"""
|
|
208
|
+
if not self.hit_test(x, y):
|
|
209
|
+
blocker = self._session.eval(
|
|
210
|
+
f"(() => {{ const h = document.elementFromPoint({x}, {y});"
|
|
211
|
+
" return h ? (h.tagName.toLowerCase()"
|
|
212
|
+
" + (h.id ? '#' + h.id : '')"
|
|
213
|
+
" + (h.className && typeof h.className === 'string'"
|
|
214
|
+
" ? '.' + h.className.trim().split(/\\s+/).join('.') : '')) : null; })()"
|
|
215
|
+
)
|
|
216
|
+
raise ElementError(
|
|
217
|
+
f"{self!r} is covered at ({x}, {y}) {when}"
|
|
218
|
+
f"{f'; topmost element is <{blocker}>' if blocker else ''}"
|
|
219
|
+
)
|