easybrowser-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: easybrowser-sdk
3
+ Version: 0.1.0
4
+ Summary: EasyBrowser local API SDK
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31
8
+ Requires-Dist: loguru>=0.7
9
+ Requires-Dist: patchright
10
+ Requires-Dist: PySocks>=1.7
11
+
12
+ # EasyBrowser SDK
13
+
14
+ EasyBrowser SDK is a small Python helper for the EasyBrowser local API. It can create or reuse browser environments, start the local browser, open tabs, connect through CDP, and provide simple human-like browser actions for test scripts.
15
+
16
+ Website: https://easybrowser.pages.dev/
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install easybrowser-sdk
22
+ ```
23
+
24
+ For local development:
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```python
33
+ from easybrowser_sdk import EasyBrowserAPI
34
+
35
+ api = EasyBrowserAPI()
36
+ api.ensure_local_api_available()
37
+
38
+ env = api.ensure_container(
39
+ name="TestEnv",
40
+ defaultUrl="https://example.com",
41
+ )
42
+ print(env["id"])
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.10+
48
+ - EasyLauncher installed and running
49
+ - Patchright browser runtime installed when CDP automation is used
50
+
51
+ ```bash
52
+ patchright install chromium
53
+ ```
54
+
55
+ ## Publish
56
+
57
+ ```bash
58
+ python -m pip install build twine
59
+ python -m build
60
+ python -m twine upload dist/*
61
+ ```
62
+
63
+ Use `__token__` as the username and your PyPI API token as the password.
64
+
65
+ ---
66
+
67
+ # EasyBrowser SDK 中文说明
68
+
69
+ EasyBrowser SDK 是一个用于 EasyBrowser 本地 API 的 Python 小工具。它可以创建或复用浏览器环境、启动本地浏览器、打开标签页、通过 CDP 连接浏览器,并提供简单的拟人化浏览器操作能力,方便编写测试脚本。
70
+
71
+ 官网:https://easybrowser.pages.dev/
72
+
73
+ ## 安装
74
+
75
+ ```bash
76
+ pip install easybrowser-sdk
77
+ ```
78
+
79
+ 本地开发安装:
80
+
81
+ ```bash
82
+ pip install -e .
83
+ ```
84
+
85
+ ## 快速使用
86
+
87
+ ```python
88
+ from easybrowser_sdk import EasyBrowserAPI
89
+
90
+ api = EasyBrowserAPI()
91
+ api.ensure_local_api_available()
92
+
93
+ env = api.ensure_container(
94
+ name="TestEnv",
95
+ defaultUrl="https://example.com",
96
+ )
97
+ print(env["id"])
98
+ ```
99
+
100
+ ## 依赖
101
+
102
+ - Python 3.10+
103
+ - 已安装并启动 EasyLauncher
104
+ - 使用 CDP 自动化时需要安装 Patchright 浏览器运行时
105
+
106
+ ```bash
107
+ patchright install chromium
108
+ ```
109
+
110
+ ## 发布到 PyPI
111
+
112
+ ```bash
113
+ python -m pip install build twine
114
+ python -m build
115
+ python -m twine upload dist/*
116
+ ```
117
+
118
+ 上传时用户名填写 `__token__`,密码填写 PyPI API token。
@@ -0,0 +1,107 @@
1
+ # EasyBrowser SDK
2
+
3
+ EasyBrowser SDK is a small Python helper for the EasyBrowser local API. It can create or reuse browser environments, start the local browser, open tabs, connect through CDP, and provide simple human-like browser actions for test scripts.
4
+
5
+ Website: https://easybrowser.pages.dev/
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install easybrowser-sdk
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ pip install -e .
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from easybrowser_sdk import EasyBrowserAPI
23
+
24
+ api = EasyBrowserAPI()
25
+ api.ensure_local_api_available()
26
+
27
+ env = api.ensure_container(
28
+ name="TestEnv",
29
+ defaultUrl="https://example.com",
30
+ )
31
+ print(env["id"])
32
+ ```
33
+
34
+ ## Requirements
35
+
36
+ - Python 3.10+
37
+ - EasyLauncher installed and running
38
+ - Patchright browser runtime installed when CDP automation is used
39
+
40
+ ```bash
41
+ patchright install chromium
42
+ ```
43
+
44
+ ## Publish
45
+
46
+ ```bash
47
+ python -m pip install build twine
48
+ python -m build
49
+ python -m twine upload dist/*
50
+ ```
51
+
52
+ Use `__token__` as the username and your PyPI API token as the password.
53
+
54
+ ---
55
+
56
+ # EasyBrowser SDK 中文说明
57
+
58
+ EasyBrowser SDK 是一个用于 EasyBrowser 本地 API 的 Python 小工具。它可以创建或复用浏览器环境、启动本地浏览器、打开标签页、通过 CDP 连接浏览器,并提供简单的拟人化浏览器操作能力,方便编写测试脚本。
59
+
60
+ 官网:https://easybrowser.pages.dev/
61
+
62
+ ## 安装
63
+
64
+ ```bash
65
+ pip install easybrowser-sdk
66
+ ```
67
+
68
+ 本地开发安装:
69
+
70
+ ```bash
71
+ pip install -e .
72
+ ```
73
+
74
+ ## 快速使用
75
+
76
+ ```python
77
+ from easybrowser_sdk import EasyBrowserAPI
78
+
79
+ api = EasyBrowserAPI()
80
+ api.ensure_local_api_available()
81
+
82
+ env = api.ensure_container(
83
+ name="TestEnv",
84
+ defaultUrl="https://example.com",
85
+ )
86
+ print(env["id"])
87
+ ```
88
+
89
+ ## 依赖
90
+
91
+ - Python 3.10+
92
+ - 已安装并启动 EasyLauncher
93
+ - 使用 CDP 自动化时需要安装 Patchright 浏览器运行时
94
+
95
+ ```bash
96
+ patchright install chromium
97
+ ```
98
+
99
+ ## 发布到 PyPI
100
+
101
+ ```bash
102
+ python -m pip install build twine
103
+ python -m build
104
+ python -m twine upload dist/*
105
+ ```
106
+
107
+ 上传时用户名填写 `__token__`,密码填写 PyPI API token。
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: easybrowser-sdk
3
+ Version: 0.1.0
4
+ Summary: EasyBrowser local API SDK
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31
8
+ Requires-Dist: loguru>=0.7
9
+ Requires-Dist: patchright
10
+ Requires-Dist: PySocks>=1.7
11
+
12
+ # EasyBrowser SDK
13
+
14
+ EasyBrowser SDK is a small Python helper for the EasyBrowser local API. It can create or reuse browser environments, start the local browser, open tabs, connect through CDP, and provide simple human-like browser actions for test scripts.
15
+
16
+ Website: https://easybrowser.pages.dev/
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install easybrowser-sdk
22
+ ```
23
+
24
+ For local development:
25
+
26
+ ```bash
27
+ pip install -e .
28
+ ```
29
+
30
+ ## Quick Start
31
+
32
+ ```python
33
+ from easybrowser_sdk import EasyBrowserAPI
34
+
35
+ api = EasyBrowserAPI()
36
+ api.ensure_local_api_available()
37
+
38
+ env = api.ensure_container(
39
+ name="TestEnv",
40
+ defaultUrl="https://example.com",
41
+ )
42
+ print(env["id"])
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ - Python 3.10+
48
+ - EasyLauncher installed and running
49
+ - Patchright browser runtime installed when CDP automation is used
50
+
51
+ ```bash
52
+ patchright install chromium
53
+ ```
54
+
55
+ ## Publish
56
+
57
+ ```bash
58
+ python -m pip install build twine
59
+ python -m build
60
+ python -m twine upload dist/*
61
+ ```
62
+
63
+ Use `__token__` as the username and your PyPI API token as the password.
64
+
65
+ ---
66
+
67
+ # EasyBrowser SDK 中文说明
68
+
69
+ EasyBrowser SDK 是一个用于 EasyBrowser 本地 API 的 Python 小工具。它可以创建或复用浏览器环境、启动本地浏览器、打开标签页、通过 CDP 连接浏览器,并提供简单的拟人化浏览器操作能力,方便编写测试脚本。
70
+
71
+ 官网:https://easybrowser.pages.dev/
72
+
73
+ ## 安装
74
+
75
+ ```bash
76
+ pip install easybrowser-sdk
77
+ ```
78
+
79
+ 本地开发安装:
80
+
81
+ ```bash
82
+ pip install -e .
83
+ ```
84
+
85
+ ## 快速使用
86
+
87
+ ```python
88
+ from easybrowser_sdk import EasyBrowserAPI
89
+
90
+ api = EasyBrowserAPI()
91
+ api.ensure_local_api_available()
92
+
93
+ env = api.ensure_container(
94
+ name="TestEnv",
95
+ defaultUrl="https://example.com",
96
+ )
97
+ print(env["id"])
98
+ ```
99
+
100
+ ## 依赖
101
+
102
+ - Python 3.10+
103
+ - 已安装并启动 EasyLauncher
104
+ - 使用 CDP 自动化时需要安装 Patchright 浏览器运行时
105
+
106
+ ```bash
107
+ patchright install chromium
108
+ ```
109
+
110
+ ## 发布到 PyPI
111
+
112
+ ```bash
113
+ python -m pip install build twine
114
+ python -m build
115
+ python -m twine upload dist/*
116
+ ```
117
+
118
+ 上传时用户名填写 `__token__`,密码填写 PyPI API token。
@@ -0,0 +1,8 @@
1
+ README.md
2
+ easybrowser_sdk.py
3
+ pyproject.toml
4
+ easybrowser_sdk.egg-info/PKG-INFO
5
+ easybrowser_sdk.egg-info/SOURCES.txt
6
+ easybrowser_sdk.egg-info/dependency_links.txt
7
+ easybrowser_sdk.egg-info/requires.txt
8
+ easybrowser_sdk.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ requests>=2.31
2
+ loguru>=0.7
3
+ patchright
4
+ PySocks>=1.7
@@ -0,0 +1 @@
1
+ easybrowser_sdk
@@ -0,0 +1,791 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ EasyBrowser 本地 API 的可复用 SDK(公共部分抽出来放在外面)。
4
+
5
+ 从示例脚本抽出的「与业务无关」的公共能力,供各例子直接 import:
6
+ - EasyBrowserAPI : 环境 create/update、浏览器 start/stop、标签页 new_tab/close_tab
7
+ - probe_proxy : 本地探测代理出口 IP/时区,产出 create/update 用的 ip_info
8
+ - connect_browser_with_retry / find_page_by_target / wait_for_page_ready
9
+ : patchright 通过 CDP 接入,并按 target_id 精确定位页面
10
+ - HumanBehavior : 贝塞尔鼠标轨迹 + 逐字输入 + 随机停顿的拟人操作
11
+ - click_first / fill_first / visible_selector / get_visible_text : 通用选择器辅助
12
+
13
+ 依赖:
14
+ pip install patchright requests loguru
15
+ patchright install chromium
16
+ 本模块只用 loguru 打日志、不配置 handler;handler 由使用方(示例脚本)配置。
17
+ """
18
+
19
+ import asyncio
20
+ import random
21
+ import time
22
+ from urllib.parse import urlsplit
23
+
24
+ import requests
25
+ from loguru import logger
26
+ from patchright.async_api import async_playwright
27
+
28
+ # EasyBrowser 客户端默认监听的本地 API 地址
29
+ DEFAULT_BASE = "http://127.0.0.1:50325"
30
+ EASYLAUNCHER_URL = "https://easybrowser.pages.dev/"
31
+
32
+ # ASCII banner shown when the local API is missing.
33
+ EASYBROWSER_BANNER = r"""
34
+ EEEEEE AAAAA SSSSS Y Y BBBBB RRRRR OOOOO W W SSSSS EEEEE RRRRR
35
+ E A A S Y Y B B R R O O W W S E R R
36
+ EEE AAAAA SSS Y BBBBB RRRRR O O W W W SSS EEE RRRRR
37
+ E A A S Y B B R R O O W W W S E R R
38
+ EEEEEE A A SSSSS Y BBBBB R R OOOOO W W SSSSS EEEEE R R
39
+ """
40
+
41
+ # EasyBrowser 客户端探测代理出口 IP/时区用的接口(见 localapi/server.js)
42
+ VISITOR_IP_URL = "https://ip-scan.browserscan.net/sys/config/ip/get-visitor-ip"
43
+
44
+
45
+ def _print_startup_banner():
46
+ for line in (
47
+ "========================================",
48
+ "EasyBrowser",
49
+ f"Website: {EASYLAUNCHER_URL}",
50
+ "Download EasyLauncher to use the local API.",
51
+ "========================================",
52
+ ):
53
+ print(line)
54
+
55
+
56
+ _print_startup_banner()
57
+
58
+
59
+ def mask_secret(value, keep=2):
60
+ """把密码等敏感串打码,只留首尾各 keep 位,用于日志。"""
61
+ if not value:
62
+ return ""
63
+ if len(value) <= keep * 2:
64
+ return "*" * len(value)
65
+ return f"{value[:keep]}{'*' * (len(value) - keep * 2)}{value[-keep:]}"
66
+
67
+
68
+ def is_local_base_url(base_url):
69
+ host = (urlsplit(base_url or "").hostname or "").lower()
70
+ return host in {"127.0.0.1", "localhost", "::1"}
71
+
72
+
73
+ def format_api_unavailable_error(base_url, exc=None):
74
+ if is_local_base_url(base_url):
75
+ message = (
76
+ f"EasyBrowser local API is not available at {base_url}. "
77
+ f"Please install and start EasyLauncher: {EASYLAUNCHER_URL}"
78
+ )
79
+ else:
80
+ message = f"Cannot connect to EasyBrowser API at {base_url}"
81
+ if exc is not None:
82
+ message = f"{message} ({exc})"
83
+ return message
84
+
85
+
86
+ def probe_local_api(base_url=DEFAULT_BASE, headers=None, timeout=3):
87
+ headers = headers or {}
88
+ try:
89
+ r = requests.get(
90
+ f"{base_url.rstrip('/')}/api/v1/container/list",
91
+ params={"page": 1, "page_size": 1},
92
+ headers=headers,
93
+ timeout=timeout,
94
+ )
95
+ except requests.RequestException as exc:
96
+ return {
97
+ "ok": False,
98
+ "reachable": False,
99
+ "auth_required": False,
100
+ "error": exc,
101
+ }
102
+
103
+ if r.status_code in (401, 403):
104
+ return {
105
+ "ok": False,
106
+ "reachable": True,
107
+ "auth_required": True,
108
+ "status_code": r.status_code,
109
+ "error": None,
110
+ }
111
+
112
+ try:
113
+ payload = r.json()
114
+ except ValueError as exc:
115
+ return {
116
+ "ok": False,
117
+ "reachable": True,
118
+ "auth_required": False,
119
+ "status_code": r.status_code,
120
+ "error": exc,
121
+ }
122
+
123
+ if r.status_code != 200 or payload.get("code") != 0:
124
+ return {
125
+ "ok": False,
126
+ "reachable": True,
127
+ "auth_required": False,
128
+ "status_code": r.status_code,
129
+ "payload": payload,
130
+ }
131
+
132
+ return {
133
+ "ok": True,
134
+ "reachable": True,
135
+ "auth_required": False,
136
+ "status_code": r.status_code,
137
+ "payload": payload,
138
+ }
139
+
140
+
141
+ def ensure_local_api_available(base_url=DEFAULT_BASE, headers=None, timeout=3):
142
+ return EasyBrowserAPI(
143
+ base_url=base_url,
144
+ headers=headers,
145
+ timeout=timeout,
146
+ ).ensure_local_api_available(timeout=timeout)
147
+
148
+
149
+ # ------------------------- 本地代理测试 -------------------------
150
+ def probe_proxy(proxy_url, timeout=15):
151
+ """通过代理(留空则直连)访问 browserscan,返回归一化后的 ip_info dict。
152
+
153
+ 返回的字段名与服务端 enrichIpInfo 读取的保持一致:
154
+ ip / query / countryCode / country / state / regionName / city / timezone。
155
+ 代理不通会抛异常,交由调用方决定是否中止。
156
+ """
157
+ proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
158
+ try:
159
+ r = requests.get(
160
+ VISITOR_IP_URL,
161
+ proxies=proxies,
162
+ timeout=timeout,
163
+ headers={"User-Agent": "EasyBrowser", "Accept-Encoding": "identity"},
164
+ )
165
+ except requests.exceptions.InvalidSchema as e:
166
+ # socks 代理需要额外依赖:pip install "requests[socks]"
167
+ raise RuntimeError(f"缺少 SOCKS 支持,请先 pip install \"requests[socks]\": {e}") from e
168
+ r.raise_for_status()
169
+ payload = r.json()
170
+ if payload.get("code") != 0 or not payload.get("data", {}).get("ip_data"):
171
+ raise RuntimeError(f"代理返回数据异常: {payload.get('msg') or payload}")
172
+
173
+ data = payload["data"]
174
+ ip_data = data.get("ip_data") or {}
175
+ country_code = str(ip_data.get("country") or "").upper()
176
+ return {
177
+ "ip": data.get("ip") or "",
178
+ "query": data.get("ip") or "",
179
+ "countryCode": country_code,
180
+ "country": country_code,
181
+ "state": ip_data.get("region") or "",
182
+ "regionName": ip_data.get("region") or "",
183
+ "city": ip_data.get("city") or "",
184
+ "timezone": ip_data.get("timezone") or "",
185
+ "isp": ip_data.get("isp") or "",
186
+ }
187
+ # ---------------------------------------------------------------
188
+
189
+
190
+ class EasyBrowserAPI:
191
+ """EasyBrowser 本地 API 的最小封装。"""
192
+
193
+ def __init__(self, base_url=DEFAULT_BASE, headers=None, timeout=30):
194
+ self.base = base_url.rstrip("/")
195
+ self.headers = headers or {}
196
+ self.timeout = timeout
197
+
198
+ def probe_local_api(self, timeout=None):
199
+ return probe_local_api(
200
+ base_url=self.base,
201
+ headers=self.headers,
202
+ timeout=self.timeout if timeout is None else timeout,
203
+ )
204
+
205
+ @staticmethod
206
+ def _launcher_ad_message(detail=None):
207
+ lines = [
208
+ "========================================",
209
+ "EasyBrowser local API is not available.",
210
+ f"Download EasyLauncher: {EASYLAUNCHER_URL}",
211
+ "========================================",
212
+ ]
213
+ if detail:
214
+ lines.extend(["", str(detail)])
215
+ return "\n".join(lines)
216
+
217
+ def ensure_local_api_available(self, timeout=None):
218
+ status = self.probe_local_api(timeout=timeout)
219
+ if status["ok"]:
220
+ return status
221
+ if status.get("auth_required"):
222
+ raise RuntimeError("EasyBrowser local API is reachable but requires an API key.")
223
+ raise RuntimeError(self._launcher_ad_message(status.get("error")))
224
+
225
+ def _check(self, res):
226
+ if res.get("code") != 0:
227
+ raise RuntimeError(f"API error: {res.get('msg')}")
228
+ return res.get("data")
229
+
230
+ def _get(self, path, params=None):
231
+ try:
232
+ r = requests.get(
233
+ f"{self.base}{path}",
234
+ params=params,
235
+ headers=self.headers,
236
+ timeout=self.timeout,
237
+ )
238
+ except requests.RequestException as exc:
239
+ raise RuntimeError(format_api_unavailable_error(self.base, exc)) from exc
240
+ try:
241
+ return self._check(r.json())
242
+ except ValueError as exc:
243
+ raise RuntimeError(f"Invalid JSON response from {self.base}{path}: {exc}") from exc
244
+
245
+ def _post(self, path, data=None):
246
+ try:
247
+ r = requests.post(
248
+ f"{self.base}{path}",
249
+ json=data,
250
+ headers=self.headers,
251
+ timeout=self.timeout,
252
+ )
253
+ except requests.RequestException as exc:
254
+ raise RuntimeError(format_api_unavailable_error(self.base, exc)) from exc
255
+ try:
256
+ return self._check(r.json())
257
+ except ValueError as exc:
258
+ raise RuntimeError(f"Invalid JSON response from {self.base}{path}: {exc}") from exc
259
+
260
+ def create_container(self, name, **kw):
261
+ return self._post("/api/v1/container/create", {"name": name, **kw})
262
+
263
+ def ensure_container(self, name, **kw):
264
+ def find_existing():
265
+ containers = self.list_containers(page=1, page_size=1000)
266
+ items = containers if isinstance(containers, list) else []
267
+ if isinstance(containers, dict):
268
+ for key in ("list", "items", "records", "rows", "data"):
269
+ value = containers.get(key)
270
+ if isinstance(value, list):
271
+ items = value
272
+ break
273
+ for item in items:
274
+ if isinstance(item, dict) and item.get("name") == name:
275
+ return item
276
+ return None
277
+
278
+ existing = find_existing()
279
+ if existing:
280
+ logger.info("Reusing container: {} ({})", name, existing.get("id"))
281
+ return existing
282
+ try:
283
+ return self.create_container(name, **kw)
284
+ except RuntimeError as exc:
285
+ if "already exists" not in str(exc).lower():
286
+ raise
287
+ existing = find_existing()
288
+ if existing:
289
+ logger.info("Reusing container: {} ({})", name, existing.get("id"))
290
+ return existing
291
+ raise
292
+
293
+ def update_container(self, env_id, **kw):
294
+ return self._post("/api/v1/container/update", {"id": env_id, **kw})
295
+
296
+ def list_containers(self, page=1, page_size=100):
297
+ return self._get(
298
+ "/api/v1/container/list",
299
+ {"page": page, "page_size": page_size},
300
+ )
301
+
302
+ def browser_start(self):
303
+ return self._get("/api/v1/browser/start")
304
+
305
+ def browser_stop(self):
306
+ return self._get("/api/v1/browser/stop")
307
+
308
+ def new_tab(self, env_id, url=None):
309
+ params = {"id": env_id}
310
+ if url:
311
+ params["url"] = url
312
+ return self._get("/api/v1/browser/new_tab", params)
313
+
314
+ def close_tab(self, env_id, target_id=None):
315
+ params = {"id": env_id}
316
+ if target_id:
317
+ params["target_id"] = target_id
318
+ return self._get("/api/v1/browser/close_tab", params)
319
+
320
+
321
+ # ------------------------- CDP 接入 / 页面定位 -------------------------
322
+ async def connect_browser_with_retry(debug_port, retries=30, delay=2.0,
323
+ timeout_seconds=120):
324
+ """启动 playwright 并用 connect_over_cdp 接入调试端口,带重试。
325
+
326
+ 返回 (playwright, browser);调用方负责最后 browser.close() + playwright.stop()。
327
+ """
328
+ last_error = None
329
+ playwright = await async_playwright().start()
330
+ try:
331
+ deadline = time.monotonic() + timeout_seconds
332
+ for attempt in range(1, retries + 1):
333
+ if time.monotonic() >= deadline:
334
+ break
335
+ try:
336
+ browser = await playwright.chromium.connect_over_cdp(
337
+ f"http://127.0.0.1:{debug_port}")
338
+ return playwright, browser
339
+ except Exception as e:
340
+ last_error = e
341
+ logger.warning("CDP 连接失败 {}/{}: {}", attempt, retries, e)
342
+ remaining = deadline - time.monotonic()
343
+ if remaining <= 0:
344
+ break
345
+ await asyncio.sleep(min(delay * attempt, 5.0, remaining))
346
+ except Exception:
347
+ try:
348
+ await playwright.stop()
349
+ except Exception:
350
+ pass
351
+ raise
352
+ try:
353
+ await playwright.stop()
354
+ except Exception:
355
+ pass
356
+ raise RuntimeError(f"CDP 连接失败,重试耗尽: {last_error}")
357
+
358
+
359
+ async def find_page_by_target(browser, target_id, timeout=30, poll_interval=0.5):
360
+ """在已连接的浏览器里,按 target_id 匹配出对应的 page。"""
361
+ deadline = time.monotonic() + timeout
362
+ while time.monotonic() < deadline:
363
+ for ctx in browser.contexts:
364
+ for pg in ctx.pages:
365
+ cdp = None
366
+ try:
367
+ cdp = await ctx.new_cdp_session(pg)
368
+ info = await cdp.send("Target.getTargetInfo")
369
+ except Exception:
370
+ continue
371
+ finally:
372
+ if cdp is not None:
373
+ try:
374
+ await cdp.detach()
375
+ except Exception:
376
+ pass
377
+ if info.get("targetInfo", {}).get("targetId") == target_id:
378
+ return pg
379
+ await asyncio.sleep(poll_interval)
380
+ return None
381
+
382
+
383
+ async def wait_for_page_ready(page, timeout=30000):
384
+ await page.wait_for_load_state("domcontentloaded", timeout=timeout)
385
+ try:
386
+ await page.wait_for_load_state("networkidle", timeout=timeout // 2)
387
+ except Exception:
388
+ pass
389
+
390
+
391
+ async def get_visible_text(page, selector="body"):
392
+ try:
393
+ return (await page.inner_text(selector)).strip()
394
+ except Exception:
395
+ return ""
396
+
397
+
398
+ async def visible_selector(page, selectors):
399
+ """依次尝试选择器,返回第一个可见的;都不可见返回 None。"""
400
+ for sel in selectors:
401
+ try:
402
+ el = await page.query_selector(sel)
403
+ if el and await el.is_visible():
404
+ return sel
405
+ except Exception:
406
+ continue
407
+ return None
408
+ # ---------------------------------------------------------------------
409
+
410
+
411
+ # ------------------------- 拟人行为(鼠标轨迹/输入/停顿) -------------------------
412
+ class HumanBehavior:
413
+ """把「像人一样操作」集中到一处:贝塞尔鼠标轨迹、逐字输入、随机停顿。
414
+
415
+ - 维护一个虚拟鼠标坐标 (x, y),每次移动都从当前点用三次贝塞尔曲线
416
+ 平滑走到目标点(带缓入缓出 + 轻微抖动),而不是瞬移。
417
+ - 点击前先「移动过去」,点击/按下都带毫秒级微停顿。
418
+ - 输入逐字符敲,字符间、字段间都有随机停顿。
419
+ 参数都留了区间,方便统一调节整体节奏。
420
+ """
421
+
422
+ def __init__(self, page, key_delay=(90, 220), field_pause=(0.4, 0.9)):
423
+ self.page = page
424
+ self.key_delay = key_delay # 字符之间停顿(毫秒)
425
+ self.field_pause = field_pause # 一个字段填完后的停顿(秒)
426
+ # 初始鼠标位置未知,给个视口内的随机起点
427
+ self.x = random.uniform(200, 700)
428
+ self.y = random.uniform(200, 500)
429
+
430
+ @staticmethod
431
+ def _cubic(a, b, c, d, t):
432
+ """三次贝塞尔在参数 t 处的取值(单维度)。"""
433
+ mt = 1 - t
434
+ return (mt ** 3) * a + 3 * (mt ** 2) * t * b + 3 * mt * (t ** 2) * c + (t ** 3) * d
435
+
436
+ async def move_to(self, tx, ty):
437
+ """从当前点沿贝塞尔曲线移动到 (tx, ty)。步数/弧度随距离变化。"""
438
+ sx, sy = self.x, self.y
439
+ dist = ((tx - sx) ** 2 + (ty - sy) ** 2) ** 0.5
440
+ steps = max(12, min(60, int(dist / 6)))
441
+ # 两个控制点:主方向上分别落在 ~30% / ~70% 处,再加垂直方向的随机偏移形成弧线
442
+ off = min(dist * 0.25, 120)
443
+ cx1 = sx + (tx - sx) * random.uniform(0.2, 0.4) + random.uniform(-off, off)
444
+ cy1 = sy + (ty - sy) * random.uniform(0.2, 0.4) + random.uniform(-off, off)
445
+ cx2 = sx + (tx - sx) * random.uniform(0.6, 0.8) + random.uniform(-off, off)
446
+ cy2 = sy + (ty - sy) * random.uniform(0.6, 0.8) + random.uniform(-off, off)
447
+ for i in range(1, steps + 1):
448
+ t = i / steps
449
+ te = t * t * (3 - 2 * t) # smoothstep 缓入缓出
450
+ x = self._cubic(sx, cx1, cx2, tx, te) + random.uniform(-0.7, 0.7)
451
+ y = self._cubic(sy, cy1, cy2, ty, te) + random.uniform(-0.7, 0.7)
452
+ await self.page.mouse.move(x, y)
453
+ await self.page.wait_for_timeout(random.randint(6, 16))
454
+ self.x, self.y = tx, ty
455
+ return tx, ty
456
+
457
+ async def move_to_element(self, el):
458
+ """移动到元素内部一个随机点(避开正中心和边缘,更自然)。"""
459
+ box = await el.bounding_box()
460
+ if not box:
461
+ return None
462
+ tx = box["x"] + box["width"] * random.uniform(0.3, 0.7)
463
+ ty = box["y"] + box["height"] * random.uniform(0.35, 0.65)
464
+ await self.move_to(tx, ty)
465
+ return self.x, self.y
466
+
467
+ async def click(self, el):
468
+ """移动到元素上,停顿一下再按下-抬起(带微小按住时长)。"""
469
+ moved = await self.move_to_element(el)
470
+ await self.page.wait_for_timeout(random.randint(60, 180))
471
+ if moved:
472
+ await self.page.mouse.down()
473
+ await self.page.wait_for_timeout(random.randint(40, 110))
474
+ await self.page.mouse.up()
475
+ else:
476
+ # 拿不到坐标(隐藏/跨域等),退回元素自身点击
477
+ await el.click()
478
+ await self.page.wait_for_timeout(random.randint(120, 300))
479
+
480
+ async def type_text(self, el, value):
481
+ """聚焦后逐字符输入:字符间随机停顿,填完再停顿一个字段间隔。"""
482
+ await self.click(el) # 先拟人点击聚焦
483
+ try:
484
+ await el.fill("") # 清掉预填,避免逐字追加到旧值后
485
+ except Exception:
486
+ pass
487
+ for ch in value:
488
+ await el.type(ch, delay=random.uniform(30, 80))
489
+ await self.page.wait_for_timeout(int(random.uniform(*self.key_delay)))
490
+ await self.page.wait_for_timeout(int(random.uniform(*self.field_pause) * 1000))
491
+
492
+ async def pause(self, lo=0.3, hi=0.8):
493
+ """一段随机停顿(秒),用于步骤之间。"""
494
+ await self.page.wait_for_timeout(int(random.uniform(lo, hi) * 1000))
495
+ # ---------------------------------------------------------------------------------
496
+
497
+
498
+ async def click_first(human, selectors, timeout=5000, require_enabled=False):
499
+ """依次尝试选择器,拟人移动并点中第一个可见的就返回。"""
500
+ deadline = time.monotonic() + timeout / 1000
501
+ while time.monotonic() < deadline:
502
+ for sel in selectors:
503
+ try:
504
+ el = await human.page.query_selector(sel)
505
+ if not el:
506
+ continue
507
+ if not await el.is_visible():
508
+ continue
509
+ if require_enabled:
510
+ try:
511
+ if not await el.is_enabled():
512
+ continue
513
+ except Exception:
514
+ disabled = await el.get_attribute("disabled")
515
+ aria_disabled = await el.get_attribute("aria-disabled")
516
+ if disabled is not None or (aria_disabled or "").lower() == "true":
517
+ continue
518
+ await human.click(el)
519
+ logger.trace("click 命中选择器: {}", sel)
520
+ return True
521
+ except Exception:
522
+ continue
523
+ await human.page.wait_for_timeout(250)
524
+ logger.trace("click 未命中任何选择器: {}", selectors)
525
+ return False
526
+
527
+
528
+ async def fill_first(human, selectors, value):
529
+ """依次尝试选择器,往第一个可见的输入框里拟人逐字输入。"""
530
+ for sel in selectors:
531
+ try:
532
+ el = await human.page.wait_for_selector(sel, timeout=5000, state="visible")
533
+ if not el:
534
+ continue
535
+ await human.type_text(el, value)
536
+ logger.trace("type 命中选择器: {}", sel)
537
+ return True
538
+ except Exception:
539
+ continue
540
+ logger.trace("type 未命中任何选择器: {}", selectors)
541
+ return False
542
+
543
+
544
+ def contains_any(value, needles):
545
+ """value(允许 None)转小写后,是否包含 needles 里任意一个(大小写不敏感)。"""
546
+ value = (value or "").lower()
547
+ return any(n.lower() in value for n in needles)
548
+
549
+
550
+ # ------------------------- 下拉 / 选择 / 精确填写 -------------------------
551
+ async def pick_random_option(human, dropdown_sel):
552
+ """Fluent 按钮下拉:拟人点开 -> 读真实 [role=option] -> 随机拟人点一个。
553
+
554
+ 不硬编码选项文本(月是「N月」、日是「N日」这类格式会变),
555
+ 直接从 DOM 现有选项里随机挑,返回被选中的文本。
556
+ """
557
+ page = human.page
558
+ dropdown = await page.wait_for_selector(dropdown_sel, timeout=8000, state="visible")
559
+ await human.click(dropdown)
560
+ # 等选项浮层渲染出来
561
+ await page.wait_for_selector("[role=option]", timeout=8000, state="visible")
562
+ options = await page.query_selector_all("[role=option]")
563
+ options = [o for o in options if await o.is_visible()]
564
+ if not options:
565
+ raise RuntimeError(f"{dropdown_sel} 展开后没有可见选项")
566
+ choice = random.choice(options)
567
+ text = (await choice.inner_text()).strip()
568
+ await human.click(choice)
569
+ logger.trace("下拉 {} 随机选中: {}", dropdown_sel, text)
570
+ return text
571
+
572
+
573
+ async def pick_random_option_first(human, selectors):
574
+ """多候选下拉:原生 select 直接随机 select_option;否则退回 pick_random_option。"""
575
+ for sel in selectors:
576
+ try:
577
+ el = await human.page.wait_for_selector(sel, timeout=3000, state="visible")
578
+ if not el:
579
+ continue
580
+ tag_name = (await el.evaluate("node => node.tagName")).lower()
581
+ if tag_name == "select":
582
+ options = await el.query_selector_all("option")
583
+ candidates = []
584
+ for option in options:
585
+ value = await option.get_attribute("value")
586
+ text = (await option.inner_text()).strip()
587
+ if value and text:
588
+ candidates.append((value, text))
589
+ if not candidates:
590
+ continue
591
+ value, text = random.choice(candidates)
592
+ await el.select_option(value=value)
593
+ logger.trace("select {} 随机选中: {}", sel, text)
594
+ return text
595
+ return await pick_random_option(human, sel)
596
+ except Exception:
597
+ continue
598
+ raise RuntimeError(f"没有找到可用下拉: {selectors}")
599
+
600
+
601
+ async def set_select_value(page, selectors, value_text):
602
+ """把下拉/按钮/输入框设置成指定文本;已是该值则直接返回 True。"""
603
+ for sel in selectors:
604
+ try:
605
+ el = await page.wait_for_selector(sel, timeout=3000, state="visible")
606
+ if not el:
607
+ continue
608
+ tag_name = (await el.evaluate("node => node.tagName")).lower()
609
+ if tag_name == "select":
610
+ try:
611
+ selected = await el.query_selector("option:checked")
612
+ if selected:
613
+ current_text = " ".join((await selected.inner_text()).split())
614
+ if current_text == value_text or value_text in current_text:
615
+ return True
616
+ except Exception:
617
+ pass
618
+ options = await el.query_selector_all("option")
619
+ for option in options:
620
+ text = (await option.inner_text()).strip()
621
+ if text == value_text or value_text in text:
622
+ value = await option.get_attribute("value")
623
+ if value is not None:
624
+ await el.select_option(value=value)
625
+ return True
626
+ if tag_name == "button":
627
+ current_text = " ".join((await el.inner_text()).split())
628
+ if current_text == value_text or value_text in current_text:
629
+ return True
630
+ await el.click()
631
+ await page.wait_for_timeout(500)
632
+ candidates = await page.query_selector_all("[role=option]")
633
+ for opt in candidates:
634
+ try:
635
+ text = (await opt.inner_text()).strip()
636
+ if text == value_text or value_text in text:
637
+ await opt.click()
638
+ return True
639
+ except Exception:
640
+ continue
641
+ if tag_name == "input":
642
+ current = (await el.input_value()).strip()
643
+ if current == value_text or value_text in current:
644
+ return True
645
+ await el.fill(value_text)
646
+ await el.press("Tab")
647
+ return True
648
+ except Exception:
649
+ continue
650
+ return False
651
+
652
+
653
+ async def fill_input_exact(page, selectors, value_text):
654
+ """往第一个可见 input/textarea 精确填入 value_text 并回读校验。"""
655
+ for sel in selectors:
656
+ try:
657
+ el = await page.wait_for_selector(sel, timeout=3000, state="visible")
658
+ if not el:
659
+ continue
660
+ tag_name = (await el.evaluate("node => node.tagName")).lower()
661
+ if tag_name != "input" and tag_name != "textarea":
662
+ continue
663
+ await el.fill(value_text)
664
+ await el.press("Tab")
665
+ current = ""
666
+ try:
667
+ current = (await el.input_value()).strip()
668
+ except Exception:
669
+ pass
670
+ if current == value_text or value_text in current:
671
+ return True
672
+ except Exception:
673
+ continue
674
+ return False
675
+ # ---------------------------------------------------------------------
676
+
677
+
678
+ # ------------------------- 按住(Press & Hold)人机验证 -------------------------
679
+ # Arkose / Cloudflare 等常有「按住直到通过」验证,按钮通常嵌在跨域 iframe 里。
680
+ # 不写死单一标记:候选选择器 + 遍历所有 frame 兜底(沿用「读真实 DOM」的原则)。
681
+ HOLD_BUTTON_SELECTORS = [
682
+ "#game-core-bounding-box #hold-button", # Arkose 经典结构
683
+ "#hold-button",
684
+ "button[aria-label*='hold' i]",
685
+ "button[aria-label*='press' i]",
686
+ "[id*='hold' i][role=button]",
687
+ "button:has-text('按住')",
688
+ "button:has-text('Press & Hold')",
689
+ "button:has-text('Press and Hold')",
690
+ "button:has-text('Hold')",
691
+ ]
692
+ # 出现这些文案基本说明验证已通过 / 页面已跳过验证
693
+ HOLD_DONE_HINTS = ["成功", "success", "verified", "完成", "done"]
694
+
695
+
696
+ async def find_hold_button(browser_page, timeout=45000):
697
+ """在所有 frame 里轮询查找「按住」按钮,拿到就返回元素句柄。
698
+
699
+ 按钮常在动态注入的跨域 iframe 内,主页 DOM 查不到,必须遍历 page.frames。
700
+ 用 query_selector 快速扫描(不阻塞),外层用总超时轮询,避免逐 selector 死等。
701
+ """
702
+ deadline = time.monotonic() + timeout / 1000
703
+ while time.monotonic() < deadline:
704
+ for frame in browser_page.frames:
705
+ for sel in HOLD_BUTTON_SELECTORS:
706
+ try:
707
+ el = await frame.query_selector(sel)
708
+ except Exception:
709
+ continue # frame 可能正在导航/被销毁
710
+ if el and await el.is_visible():
711
+ logger.trace("找到按住按钮: frame={} selector={}",
712
+ frame.url or "<main>", sel)
713
+ return el
714
+ await browser_page.wait_for_timeout(800)
715
+ return None
716
+
717
+
718
+ async def is_hold_solved(page, hold_el):
719
+ """判断按住验证是否已通过:按钮消失/不可见,或页面出现成功文案。"""
720
+ try:
721
+ if hold_el and await hold_el.is_visible():
722
+ return False
723
+ except Exception:
724
+ # 元素已从 DOM 脱离(frame 被回收),通常意味着验证已过
725
+ return True
726
+ try:
727
+ body_text = (await page.inner_text("body")).lower()
728
+ return any(h in body_text for h in HOLD_DONE_HINTS)
729
+ except Exception:
730
+ return True
731
+
732
+
733
+ async def press_and_hold(page, hold_seconds=(6.0, 9.0), max_attempts=3, human=None):
734
+ """完成「按住」验证:移到按钮中心 -> 按下保持 -> 松开 -> 检测。
735
+
736
+ - 传了 human 就沿贝塞尔轨迹移动过去(而不是瞬移到按钮),更像人手。
737
+ - 按住时长在区间内随机,期间做几次微小抖动(±2px)模拟真人手抖,
738
+ 纯静止按压容易被判定为机器人。
739
+ - 单次没过就重试,最多 max_attempts 次;全失败返回 False 交人工。
740
+ """
741
+ for attempt in range(1, max_attempts + 1):
742
+ hold_el = await find_hold_button(page)
743
+ if hold_el is None:
744
+ logger.info("未发现「按住」验证按钮(可能本次没触发该验证)")
745
+ return False
746
+
747
+ try:
748
+ await hold_el.scroll_into_view_if_needed()
749
+ except Exception:
750
+ pass
751
+ box = await hold_el.bounding_box()
752
+ if not box:
753
+ logger.warning("按住按钮拿不到坐标,第 {} 次跳过", attempt)
754
+ continue
755
+
756
+ # bounding_box 已换算到主页面坐标系,可直接喂给 page.mouse
757
+ cx = box["x"] + box["width"] / 2
758
+ cy = box["y"] + box["height"] / 2
759
+ duration = random.uniform(*hold_seconds)
760
+ logger.info("第 {}/{} 次尝试按住 {:.1f}s @ ({:.0f},{:.0f})",
761
+ attempt, max_attempts, duration, cx, cy)
762
+
763
+ if human is not None:
764
+ await human.move_to(cx, cy) # 贝塞尔轨迹靠近
765
+ await page.wait_for_timeout(random.randint(80, 200))
766
+ else:
767
+ await page.mouse.move(cx, cy)
768
+ await page.mouse.down()
769
+ try:
770
+ # 分段保持,期间轻微抖动,更像人手
771
+ elapsed = 0.0
772
+ while elapsed < duration:
773
+ step = min(random.uniform(0.4, 0.8), duration - elapsed)
774
+ await page.wait_for_timeout(int(step * 1000))
775
+ elapsed += step
776
+ await page.mouse.move(
777
+ cx + random.uniform(-2, 2),
778
+ cy + random.uniform(-2, 2),
779
+ )
780
+ finally:
781
+ await page.mouse.up() # 无论如何都要松开,避免卡在按下态
782
+
783
+ await page.wait_for_timeout(2500) # 等验证结算/页面跳转
784
+ if await is_hold_solved(page, hold_el):
785
+ logger.success("按住验证已通过(第 {} 次)", attempt)
786
+ return True
787
+ logger.warning("第 {} 次按住后仍未通过,准备重试", attempt)
788
+
789
+ logger.warning("按住验证多次未通过,请在浏览器里人工完成")
790
+ return False
791
+ # -----------------------------------------------------------------------------
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "easybrowser-sdk"
7
+ version = "0.1.0"
8
+ description = "EasyBrowser local API SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "requests>=2.31",
13
+ "loguru>=0.7",
14
+ "patchright",
15
+ "PySocks>=1.7",
16
+ ]
17
+
18
+ [tool.setuptools]
19
+ py-modules = ["easybrowser_sdk"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+