bb-sync 1.9.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.
@@ -0,0 +1,5 @@
1
+ """浏览器后端:Steel(独立进程 + CDP)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
@@ -0,0 +1,511 @@
1
+ """Steel 浏览器后端(本地 Node 服务端 + CDP 接入 + 会话持久化)。
2
+
3
+ - 后端以独立进程常驻(默认无窗口静默),Steel 自带 stealth/指纹伪装,规避站点反爬检测
4
+ - 通过 CDP 与 Playwright 对接,脚本完全静默运行
5
+ - 使用持久化 Chrome profile(userDataDir + persist),登录态跨次运行保留
6
+
7
+ 本模块不直接 print:所有诊断通过注入的 :class:`~bb_sync.core.output.Console`
8
+ 走 stderr,因此 ``bb-sync run --json`` 时 stdout 依然干净。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import json
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from pathlib import Path
21
+ from urllib import error, request
22
+
23
+ from bb_sync import paths
24
+ from bb_sync.core.errors import EnvironmentError_, NetworkError
25
+ from bb_sync.core.output import Console
26
+
27
+
28
+ def _steel_meta_path(root: Path | None = None) -> Path:
29
+ """部署元数据路径(默认当前安装根目录)。"""
30
+ return (root or STEEL_ROOT) / ".bb-sync.json"
31
+
32
+
33
+ def _read_steel_meta() -> dict[str, object]:
34
+ """读部署元数据;缺失/损坏时返回空字典。"""
35
+ try:
36
+ data = json.loads(_steel_meta_path().read_text(encoding="utf-8"))
37
+ except (OSError, ValueError):
38
+ return {}
39
+ return data if isinstance(data, dict) else {}
40
+
41
+
42
+ # Steel 后端装在 BB_SYNC_HOME/.steel(默认 ~/.bb-sync/.steel),跟随配置目录;
43
+ # 用户家目录里的 ~/.steel 与我们无关,任何时候都不读取、不写入、不迁移。
44
+ # 路径在模块导入时快照;需要换目录请通过 BB_SYNC_HOME 在启动前设置。
45
+ STEEL_ROOT = paths.BB_SYNC_HOME / ".steel"
46
+ STEEL_DIR = STEEL_ROOT / "api"
47
+ STEEL_LOG = STEEL_ROOT / "steel.log"
48
+ STEEL_URL = os.environ.get("STEEL_URL", "http://127.0.0.1:3000")
49
+ PROFILE_DIR = paths.BB_SYNC_HOME / ".browser-profile" / "steel-chrome"
50
+ # Steel 源码锚定到我们验证过的快照,不跟随上游 main 漂移(升级时只改 STEEL_REF)。
51
+ # 当前锚点 dacea7e2 = v0.5.4-beta 之后的 4 个修复:
52
+ # #356 /context 轮询拖慢浏览器、#361 proxy 计数、#362 存储提取越界、#366 移动端仿真信号;
53
+ # 上游 package.json 仍标 0.5.3,本机长期同步验证可用。
54
+ STEEL_REF = "dacea7e217ccded0d3886b8771a0ae52d254552c"
55
+
56
+
57
+ def _default_zip_url() -> str:
58
+ """默认源码包地址:固定到 STEEL_REF(zip 免 git 依赖,无需 clone)。"""
59
+ return f"https://codeload.github.com/steel-dev/steel-browser/zip/{STEEL_REF}"
60
+
61
+
62
+ STEEL_ZIP_URL = os.environ.get("STEEL_ZIP_URL") or _default_zip_url()
63
+
64
+ IS_WIN = sys.platform.startswith("win")
65
+ IS_MACOS = sys.platform == "darwin"
66
+
67
+ #: 默认 console,模块级函数在被命令层调用前可 keep 静默;命令层应传入自己的 console
68
+ _SILENT = Console(quiet=True)
69
+
70
+
71
+ def _node_candidates() -> tuple[Path, ...]:
72
+ """Node.js 的常见安装位置(PATH 查询失败后的兜底)。"""
73
+ if IS_WIN:
74
+ return (Path(r"C:\nvm4w\nodejs\node.exe"),)
75
+ if IS_MACOS:
76
+ return (Path("/opt/homebrew/bin/node"), Path("/usr/local/bin/node"))
77
+ return (Path("/usr/local/bin/node"), Path("/usr/bin/node"))
78
+
79
+
80
+ def _node_install_hint() -> str:
81
+ """按平台给出 Node.js 安装方式。"""
82
+ if IS_MACOS:
83
+ return "浏览器后端需要 Node.js ≥22:brew install node"
84
+ if IS_WIN:
85
+ return "浏览器后端需要 Node.js ≥22:winget install OpenJS.NodeJS.LTS"
86
+ return "浏览器后端需要 Node.js ≥22:请通过系统包管理器安装"
87
+
88
+
89
+ def _node_bin() -> str:
90
+ """定位 Node.js:优先 PATH,其次 Homebrew / nvm4w 的常见位置。"""
91
+ found = shutil.which("node")
92
+ if found:
93
+ return found
94
+ for cand in _node_candidates():
95
+ if cand.exists():
96
+ return str(cand)
97
+ raise EnvironmentError_("未找到 Node.js", _node_install_hint())
98
+
99
+
100
+ def _npm_bin() -> str | None:
101
+ """定位 npm:优先 PATH,其次查找 Node.js 同目录。"""
102
+ found = shutil.which("npm")
103
+ if found:
104
+ return found
105
+ try:
106
+ node = Path(_node_bin())
107
+ except EnvironmentError_:
108
+ return None
109
+ for name in ("npm", "npm.cmd", "npm.exe"):
110
+ candidate = node.with_name(name)
111
+ if candidate.exists():
112
+ return str(candidate)
113
+ return None
114
+
115
+
116
+ def _browser_candidates() -> tuple[str, ...]:
117
+ """按当前平台列出 Chrome / Edge 的常见安装路径。"""
118
+ if IS_WIN:
119
+ pf = os.environ.get("PROGRAMFILES", r"C:\Program Files")
120
+ pf86 = os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
121
+ return (
122
+ rf"{pf}\Google\Chrome\Application\chrome.exe",
123
+ rf"{pf86}\Google\Chrome\Application\chrome.exe",
124
+ rf"{pf86}\Microsoft\Edge\Application\msedge.exe",
125
+ rf"{pf}\Microsoft\Edge\Application\msedge.exe",
126
+ )
127
+ if IS_MACOS:
128
+ chrome = Path("Google Chrome.app/Contents/MacOS/Google Chrome")
129
+ edge = Path("Microsoft Edge.app/Contents/MacOS/Microsoft Edge")
130
+ system_apps = Path("/Applications")
131
+ user_apps = Path.home() / "Applications"
132
+ return (
133
+ str(system_apps / chrome),
134
+ str(user_apps / chrome),
135
+ str(system_apps / edge),
136
+ str(user_apps / edge),
137
+ )
138
+ return (
139
+ "/usr/bin/google-chrome",
140
+ "/usr/bin/chromium",
141
+ "/usr/bin/chromium-browser",
142
+ "/usr/bin/microsoft-edge",
143
+ )
144
+
145
+
146
+ def detect_browser() -> str | None:
147
+ """探测本机 Chromium 内核浏览器:Chrome 优先,Edge 兜底。
148
+
149
+ Steel 自带的探测只认 Chrome 标准路径,裸机(仅预装 Edge)会直接失败;
150
+ 这里提前探测,结果通过 ``CHROME_EXECUTABLE_PATH`` 传给 Steel 进程。
151
+ """
152
+ candidates = _browser_candidates()
153
+ return next((c for c in candidates if Path(c).exists()), None)
154
+
155
+
156
+ def is_deployed() -> bool:
157
+ """Steel 后端是否已就绪(源码 + tsx 依赖都在)。"""
158
+ return (STEEL_DIR / "src" / "index.ts").exists() and _find_tsx() is not None
159
+
160
+
161
+ def _write_steel_meta(console: Console, *, steel_ref: str | None = STEEL_REF) -> None:
162
+ """记录安装来源与锚点;``steel_ref=None`` 表示沿用旧安装、具体修订未知。"""
163
+ payload = {
164
+ "tool": "bb-sync",
165
+ "schema": 1,
166
+ "steel_ref": steel_ref,
167
+ "zip_url": STEEL_ZIP_URL,
168
+ "recorded_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
169
+ }
170
+ try:
171
+ _steel_meta_path().write_text(
172
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
173
+ )
174
+ except OSError as exc:
175
+ console.warn(f"[steel] 写入安装元数据失败(不影响使用): {exc}")
176
+
177
+
178
+ def _needs_upgrade() -> bool:
179
+ """bb-sync 装的旧版本(记录的源码地址与当前不一致)需要更新。"""
180
+ meta = _read_steel_meta()
181
+ return meta.get("tool") == "bb-sync" and meta.get("zip_url") != STEEL_ZIP_URL
182
+
183
+
184
+ def installed_ref() -> str | None:
185
+ """已安装源码的锚点(无元数据的旧安装返回 None)。"""
186
+ ref = _read_steel_meta().get("steel_ref")
187
+ return ref if isinstance(ref, str) and ref else None
188
+
189
+
190
+ def _unique_backup_path(kind: str) -> Path:
191
+ """为安装目录生成同级的 ``.steel.<kind>-<时间戳>`` 备份路径。"""
192
+ base = f"{STEEL_ROOT.name}.{kind}-{time.strftime('%Y%m%d-%H%M%S')}"
193
+ candidate = STEEL_ROOT.with_name(base)
194
+ seq = 2
195
+ while candidate.exists():
196
+ candidate = STEEL_ROOT.with_name(f"{base}-{seq}")
197
+ seq += 1
198
+ return candidate
199
+
200
+
201
+ def _extracted_root(tmp_dir: Path) -> Path:
202
+ """解压后的唯一顶层目录(zip 根目录名随 ref 变化:分支 / tag / commit)。"""
203
+ roots = [p for p in tmp_dir.iterdir() if p.is_dir()]
204
+ if len(roots) != 1:
205
+ raise EnvironmentError_(
206
+ "Steel 源码包结构异常(解压结果不是单一目录)",
207
+ "可能是上游仓库结构变更或下载不完整,请重试;持续出现请反馈该问题",
208
+ )
209
+ return roots[0]
210
+
211
+
212
+ def _download_source(console: Console, tmp_dir: Path) -> Path:
213
+ """下载源码 zip 并解压到 tmp_dir,返回解压出的项目根目录。"""
214
+ import zipfile
215
+
216
+ zip_path = tmp_dir / "steel-browser.zip"
217
+ console.log("[steel] 下载 steel-browser 源码 ...")
218
+ req = request.Request(STEEL_ZIP_URL, headers={"User-Agent": "bb-sync"})
219
+ try:
220
+ with request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as f:
221
+ done = 0
222
+ while chunk := resp.read(256 * 1024):
223
+ f.write(chunk)
224
+ done += len(chunk)
225
+ if done // (5 * 1024 * 1024) != (done - len(chunk)) // (5 * 1024 * 1024):
226
+ console.log(f"[steel] 已下载 {done / 1048576:.0f} MB ...")
227
+ except error.URLError as exc:
228
+ raise NetworkError(
229
+ f"下载 steel-browser 失败: {exc}",
230
+ "请检查网络/代理是否可访问 github.com(Clash 用户请确认系统代理已开启)",
231
+ ) from exc
232
+
233
+ with zipfile.ZipFile(zip_path) as zf:
234
+ zf.extractall(tmp_dir)
235
+ return _extracted_root(tmp_dir)
236
+
237
+
238
+ def _install_source_tree(source: Path) -> Path | None:
239
+ """把新源码放到 STEEL_ROOT,返回换下来的旧目录(没有则为 None)。
240
+
241
+ 已有安装先整体改名为 ``.steel.old-<时间戳>`` 再换入新版本:
242
+ 换入失败会自动回滚,旧备份由调用方在依赖装好后删除。
243
+ """
244
+ if not STEEL_ROOT.exists():
245
+ shutil.move(str(source), str(STEEL_ROOT))
246
+ return None
247
+
248
+ backup = _unique_backup_path("old")
249
+ try:
250
+ shutil.move(str(STEEL_ROOT), str(backup))
251
+ except OSError as exc:
252
+ raise EnvironmentError_(
253
+ f"更新 Steel 后端失败,旧目录被占用: {STEEL_ROOT}",
254
+ "请先关闭正在运行的 Steel/Node 进程后重试",
255
+ ) from exc
256
+ try:
257
+ shutil.move(str(source), str(STEEL_ROOT))
258
+ except OSError as exc:
259
+ _rollback_old_root(backup)
260
+ raise EnvironmentError_(f"更新 Steel 后端失败: {exc}", "旧安装已回滚到原位置") from exc
261
+ return backup
262
+
263
+
264
+ def _rollback_old_root(backup: Path) -> None:
265
+ """升级失败时用备份恢复原安装(恢复不了也不掩盖原始错误)。"""
266
+ shutil.rmtree(STEEL_ROOT, ignore_errors=True)
267
+ if STEEL_ROOT.exists():
268
+ return # 新目录被占用删不掉时保留备份,避免把旧安装嵌套进去
269
+ with contextlib.suppress(OSError):
270
+ shutil.move(str(backup), str(STEEL_ROOT))
271
+
272
+
273
+ def _install_dependencies(npm: str, console: Console) -> None:
274
+ """在 STEEL_DIR 执行 npm install;是否就绪以 tsx 为准。"""
275
+ console.log("[steel] 安装 npm 依赖(首次需几分钟,请耐心等待)...")
276
+ STEEL_LOG.parent.mkdir(parents=True, exist_ok=True)
277
+ with open(STEEL_LOG, "a", encoding="utf-8") as logf:
278
+ proc = subprocess.run(
279
+ [npm, "install", "--no-audit", "--no-fund"],
280
+ cwd=str(STEEL_DIR),
281
+ stdout=subprocess.PIPE,
282
+ stderr=subprocess.STDOUT,
283
+ text=True,
284
+ encoding="utf-8",
285
+ errors="ignore",
286
+ )
287
+ logf.write(proc.stdout or "")
288
+
289
+ if proc.returncode != 0:
290
+ console.warn(
291
+ f"[steel] npm install 退出码 {proc.returncode}(若是 husky/prepare 报错可忽略)"
292
+ )
293
+
294
+ if not _find_tsx():
295
+ tail = "\n".join((proc.stdout or "").splitlines()[-30:])
296
+ raise EnvironmentError_(
297
+ f"Steel 依赖安装失败,请查看日志: {STEEL_LOG}",
298
+ f"npm 输出末尾:\n{tail}",
299
+ )
300
+
301
+
302
+ def deploy(console: Console = _SILENT) -> None:
303
+ """自动部署 Steel 后端:下载源码包 + 安装 npm 依赖(首次运行自动触发)。
304
+
305
+ - 已就绪且锚点一致(源码 + tsx 依赖都在)→ 直接复用,跳过下载与安装
306
+ - 锚点变化(bb-sync 升级了验证过的 Steel 版本)→ 先下载,成功后再替换
307
+ - 残缺(上次安装中断等)→ 换入新目录成功后就地替换,不留残余
308
+ - 安装目录固定在 ``BB_SYNC_HOME/.steel``;用户家目录的 ``~/.steel`` 不碰
309
+
310
+ 源码走 codeload zip(不依赖 git);依赖安装在 ``.steel/api``
311
+ (npm workspaces 自动提升到 ``.steel/node_modules``)。
312
+ """
313
+ if is_deployed():
314
+ if not _needs_upgrade():
315
+ console.log(f"[steel] 复用已就绪的后端: {STEEL_ROOT}")
316
+ if not _read_steel_meta():
317
+ # 1.0.1 及更早装的没有元数据:补记归属(具体修订未知),
318
+ # 以后我们升级 STEEL_REF 时会走「先下载再替换」的平滑更新
319
+ _write_steel_meta(console, steel_ref=None)
320
+ return
321
+ console.log(f"[steel] 检测到旧的 Steel 快照,准备更新: {STEEL_ROOT}")
322
+
323
+ _node_bin() # 提前给出友好的 Node.js 缺失提示
324
+ npm = _npm_bin()
325
+ if not npm:
326
+ raise EnvironmentError_("未找到 npm", "请安装 Node.js(自带 npm)后重试")
327
+
328
+ STEEL_ROOT.parent.mkdir(parents=True, exist_ok=True)
329
+ tmp_dir = STEEL_ROOT.parent / ".steel-tmp"
330
+ shutil.rmtree(tmp_dir, ignore_errors=True)
331
+ tmp_dir.mkdir(parents=True, exist_ok=True)
332
+ old_root: Path | None = None
333
+ try:
334
+ source = _download_source(console, tmp_dir)
335
+ old_root = _install_source_tree(source)
336
+ console.log(f"[steel] 源码已就位: {STEEL_ROOT}")
337
+ _install_dependencies(npm, console)
338
+ except BaseException:
339
+ if old_root is not None:
340
+ _rollback_old_root(old_root)
341
+ raise
342
+ finally:
343
+ shutil.rmtree(tmp_dir, ignore_errors=True)
344
+
345
+ if old_root is not None:
346
+ shutil.rmtree(old_root, ignore_errors=True)
347
+ _write_steel_meta(console)
348
+ console.success("Steel 后端部署完成")
349
+
350
+
351
+ def _find_tsx() -> Path | None:
352
+ """tsx 可执行入口:npm workspaces 会把依赖提升到安装目录的 node_modules。"""
353
+ return next(
354
+ (
355
+ d / "node_modules" / "tsx" / "dist" / "cli.mjs"
356
+ for d in (STEEL_DIR, STEEL_DIR.parent)
357
+ if (d / "node_modules" / "tsx" / "dist" / "cli.mjs").exists()
358
+ ),
359
+ None,
360
+ )
361
+
362
+
363
+ # 本机回环必须绕过系统代理(用户环境有 Clash Verge),否则探测会被代理拒绝
364
+ _NO_PROXY_OPENER = request.build_opener(request.ProxyHandler({}))
365
+
366
+
367
+ def _http(method: str, path: str, body: dict | None = None, timeout: float = 10):
368
+ url = f"{STEEL_URL}{path}"
369
+ data = json.dumps(body).encode() if body is not None else None
370
+ req = request.Request(
371
+ url, data=data, method=method, headers={"Content-Type": "application/json"}
372
+ )
373
+ with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
374
+ raw = resp.read().decode("utf-8", "ignore")
375
+ return json.loads(raw) if raw else {}
376
+
377
+
378
+ def healthy() -> bool:
379
+ """Steel 服务端是否在跑。"""
380
+ try:
381
+ _http("GET", "/v1/sessions", timeout=3)
382
+ return True
383
+ except Exception:
384
+ return False
385
+
386
+
387
+ def start_server(console: Console = _SILENT, headed: bool = False, wait: int = 120) -> None:
388
+ """后台启动 Steel 服务端(独立进程,不阻塞;未部署时自动部署)。"""
389
+ if not is_deployed():
390
+ deploy(console)
391
+ tsx = _find_tsx()
392
+ if tsx is None:
393
+ raise EnvironmentError_(
394
+ f"Steel 依赖未就绪: {STEEL_LOG}",
395
+ f"重新运行会自动重装,或手动删除 {STEEL_ROOT} 后重试",
396
+ )
397
+
398
+ env = dict(os.environ)
399
+ env["CHROME_HEADLESS"] = "false" if headed else "true"
400
+ env["HOST"] = "127.0.0.1"
401
+ env["PORT"] = STEEL_URL.rsplit(":", 1)[-1]
402
+ if not env.get("CHROME_EXECUTABLE_PATH"):
403
+ browser = detect_browser()
404
+ if browser:
405
+ env["CHROME_EXECUTABLE_PATH"] = browser
406
+ if "edge" in Path(browser).name.lower():
407
+ console.log(f"[steel] 未检测到 Chrome,回退使用 Edge: {browser}")
408
+ else:
409
+ console.warn("未检测到 Chrome/Edge,Steel 可能无法启动浏览器")
410
+
411
+ STEEL_LOG.parent.mkdir(parents=True, exist_ok=True)
412
+ # 日志句柄需随后台进程长期存活,不适用 context manager
413
+ logf = open(STEEL_LOG, "a", encoding="utf-8") # noqa: SIM115
414
+ flags = 0
415
+ if IS_WIN:
416
+ # CREATE_NO_WINDOW:控制台程序但不弹窗口(DETACHED_PROCESS 下子进程
417
+ # 仍可能闪出控制台窗口);POSIX 类型存根中不存在,用 getattr 兜底
418
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
419
+ subprocess, "CREATE_NEW_PROCESS_GROUP", 0
420
+ )
421
+ # macOS/Linux 使用独立会话,避免用户关闭终端时 Steel 被 SIGHUP 结束。
422
+ subprocess.Popen(
423
+ [_node_bin(), str(tsx), "src/index.ts"],
424
+ cwd=str(STEEL_DIR),
425
+ env=env,
426
+ stdout=logf,
427
+ stderr=logf,
428
+ stdin=subprocess.DEVNULL,
429
+ creationflags=flags,
430
+ start_new_session=not IS_WIN,
431
+ )
432
+
433
+ for _ in range(wait):
434
+ time.sleep(1)
435
+ if healthy():
436
+ return
437
+ raise EnvironmentError_(
438
+ f"Steel 服务端启动超时: {STEEL_LOG}",
439
+ f"查看日志排查,或删除 {STEEL_ROOT} 后重试",
440
+ )
441
+
442
+
443
+ def ensure_server(console: Console = _SILENT, headed: bool = False) -> None:
444
+ """确保 Steel 服务端可用(已在跑则直接复用)。"""
445
+ if healthy():
446
+ console.log("[steel] 已在运行 ✓")
447
+ return
448
+ console.step("[steel] 启动服务端 ...")
449
+ start_server(console, headed)
450
+ console.success("Steel 服务端就绪")
451
+
452
+
453
+ def create_session(headed: bool = False, console: Console = _SILENT) -> dict:
454
+ """创建(或复用)浏览器会话;persist + userDataDir 保证登录态留存。"""
455
+ PROFILE_DIR.mkdir(parents=True, exist_ok=True)
456
+ payload = {
457
+ "persist": True,
458
+ "userDataDir": str(PROFILE_DIR),
459
+ "blockAds": True,
460
+ "dimensions": {"width": 1920, "height": 1080},
461
+ }
462
+ try:
463
+ data = _http("POST", "/v1/sessions", payload, timeout=60)
464
+ except error.HTTPError as exc:
465
+ detail = exc.read().decode("utf-8", "ignore")[:300]
466
+ raise EnvironmentError_(f"创建 Steel 会话失败: {exc.code} {detail}") from exc
467
+ mode = "有窗口模式" if headed else "静默无窗口"
468
+ console.log(f"[steel] 会话已创建: {data.get('id')} ({mode})")
469
+ return data
470
+
471
+
472
+ def release_session(session: dict, console: Console = _SILENT) -> None:
473
+ """释放会话(登录态保留在 profile)。"""
474
+ sid = session.get("id")
475
+ if not sid:
476
+ return
477
+ # 会话可能已被服务端自动回收,不影响持久化 profile 里的登录态
478
+ with contextlib.suppress(Exception):
479
+ _http("DELETE", f"/v1/sessions/{sid}", timeout=15)
480
+ console.log("[steel] 会话已释放(登录态保留在 profile)")
481
+
482
+
483
+ def connect(p, session: dict):
484
+ """通过 CDP 接入 Steel 浏览器,返回 ``(browser, context, page)``。"""
485
+ # Playwright 也会读取系统代理,回环地址需要显式豁免
486
+ os.environ["NO_PROXY"] = "127.0.0.1,localhost,*"
487
+ os.environ["no_proxy"] = "127.0.0.1,localhost,*"
488
+ ws = session.get("websocketUrl") or STEEL_URL.replace("http", "ws")
489
+ if not ws.endswith("/"):
490
+ ws += "/"
491
+ browser = p.chromium.connect_over_cdp(ws, timeout=60000)
492
+ ctx = browser.contexts[0] if browser.contexts else browser.new_context()
493
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
494
+ return browser, ctx, page
495
+
496
+
497
+ __all__ = [
498
+ "PROFILE_DIR",
499
+ "STEEL_DIR",
500
+ "STEEL_LOG",
501
+ "STEEL_URL",
502
+ "connect",
503
+ "create_session",
504
+ "deploy",
505
+ "detect_browser",
506
+ "ensure_server",
507
+ "healthy",
508
+ "is_deployed",
509
+ "release_session",
510
+ "start_server",
511
+ ]