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.
- bb_sync/__init__.py +23 -0
- bb_sync/__main__.py +8 -0
- bb_sync/blackboard/__init__.py +5 -0
- bb_sync/blackboard/downloader.py +348 -0
- bb_sync/blackboard/login.py +190 -0
- bb_sync/blackboard/models.py +143 -0
- bb_sync/blackboard/scraper.py +695 -0
- bb_sync/browser/__init__.py +5 -0
- bb_sync/browser/steel.py +511 -0
- bb_sync/cli.py +302 -0
- bb_sync/commands/__init__.py +5 -0
- bb_sync/commands/auth.py +80 -0
- bb_sync/commands/config.py +239 -0
- bb_sync/commands/course.py +45 -0
- bb_sync/commands/doctor.py +65 -0
- bb_sync/commands/due.py +66 -0
- bb_sync/commands/run.py +74 -0
- bb_sync/context.py +69 -0
- bb_sync/core/__init__.py +5 -0
- bb_sync/core/config.py +205 -0
- bb_sync/core/config_store.py +190 -0
- bb_sync/core/doctor.py +230 -0
- bb_sync/core/due.py +192 -0
- bb_sync/core/errors.py +107 -0
- bb_sync/core/output.py +151 -0
- bb_sync/core/service.py +446 -0
- bb_sync/core/update.py +286 -0
- bb_sync/creds.py +91 -0
- bb_sync/paths.py +26 -0
- bb_sync-1.9.0.dist-info/METADATA +265 -0
- bb_sync-1.9.0.dist-info/RECORD +34 -0
- bb_sync-1.9.0.dist-info/WHEEL +4 -0
- bb_sync-1.9.0.dist-info/entry_points.txt +2 -0
- bb_sync-1.9.0.dist-info/licenses/LICENSE +21 -0
bb_sync/browser/steel.py
ADDED
|
@@ -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
|
+
]
|