myagent-ai 1.33.0 → 1.33.1
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.
- package/aiskills/browser_stealth.py +217 -5
- package/aiskills/chromedev_mcp.py +145 -3
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@ import asyncio
|
|
|
33
33
|
import json
|
|
34
34
|
import os
|
|
35
35
|
import shutil
|
|
36
|
+
import subprocess
|
|
36
37
|
import threading
|
|
37
38
|
import time
|
|
38
39
|
from pathlib import Path
|
|
@@ -44,6 +45,139 @@ from aiskills.base import Skill, SkillResult, SkillParameter
|
|
|
44
45
|
logger = get_logger("myagent.skills.browser_stealth")
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
# ── Xvfb 虚拟显示管理 ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
_xvfb_process: Optional[subprocess.Popen] = None
|
|
51
|
+
_xvfb_display: Optional[str] = None
|
|
52
|
+
_xvfb_lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_display() -> bool:
|
|
56
|
+
"""检查当前环境是否有可用的 X11 显示"""
|
|
57
|
+
display = os.environ.get("DISPLAY", "").strip()
|
|
58
|
+
if not display:
|
|
59
|
+
return False
|
|
60
|
+
# DISPLAY 已设置,尝试检测是否真的可用
|
|
61
|
+
# 方法1: xdpyinfo
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["xdpyinfo"],
|
|
65
|
+
capture_output=True, timeout=3,
|
|
66
|
+
env={**os.environ},
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
# 方法2: xdpyinfo 不可用时,尝试用 Xvfb 的 -displayfd 验证
|
|
73
|
+
# 如果 DISPLAY 变量指向一个活跃的 X server,即使 xdpyinfo 不可用,
|
|
74
|
+
# 我们也可以认为有可用显示(例如 Xvfb 已经启动过了)
|
|
75
|
+
try:
|
|
76
|
+
# 尝试连接 X server(使用 xdotool 或 xprop 等轻量工具)
|
|
77
|
+
for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
|
|
78
|
+
["xdotool", "getdisplaywidth"]):
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
cmd, capture_output=True, timeout=3,
|
|
81
|
+
env={**os.environ},
|
|
82
|
+
)
|
|
83
|
+
if result.returncode == 0:
|
|
84
|
+
return True
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
# 方法3: 如果 DISPLAY 格式正确且 /tmp/.X11-unix/ 下有对应 socket,认为可用
|
|
88
|
+
try:
|
|
89
|
+
if display.startswith(":"):
|
|
90
|
+
num = display[1:].split(".")[0]
|
|
91
|
+
socket_path = f"/tmp/.X11-unix/X{num}"
|
|
92
|
+
if os.path.exists(socket_path):
|
|
93
|
+
return True
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _start_xvfb(display_num: int = 99) -> Optional[str]:
|
|
100
|
+
"""
|
|
101
|
+
启动 Xvfb 虚拟显示服务器。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
DISPLAY 环境变量值(如 :99),失败返回 None
|
|
105
|
+
"""
|
|
106
|
+
global _xvfb_process, _xvfb_display
|
|
107
|
+
|
|
108
|
+
with _xvfb_lock:
|
|
109
|
+
# 如果已经启动,直接返回
|
|
110
|
+
if _xvfb_process and _xvfb_process.poll() is None:
|
|
111
|
+
return _xvfb_display
|
|
112
|
+
|
|
113
|
+
# 清理旧的
|
|
114
|
+
if _xvfb_process:
|
|
115
|
+
try:
|
|
116
|
+
_xvfb_process.terminate()
|
|
117
|
+
_xvfb_process.wait(timeout=3)
|
|
118
|
+
except Exception:
|
|
119
|
+
try:
|
|
120
|
+
_xvfb_process.kill()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
_xvfb_process = None
|
|
124
|
+
|
|
125
|
+
xvfb_path = shutil.which("Xvfb")
|
|
126
|
+
if not xvfb_path:
|
|
127
|
+
logger.warning("Xvfb 未安装,无法启动虚拟显示")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
display_str = f":{display_num}"
|
|
131
|
+
try:
|
|
132
|
+
# 先清理可能残留的 X11 锁文件
|
|
133
|
+
for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
|
|
134
|
+
if os.path.exists(lock_file):
|
|
135
|
+
try:
|
|
136
|
+
os.unlink(lock_file)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
_xvfb_process = subprocess.Popen(
|
|
141
|
+
[xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
|
|
142
|
+
stdout=subprocess.DEVNULL,
|
|
143
|
+
stderr=subprocess.DEVNULL,
|
|
144
|
+
)
|
|
145
|
+
# 等待 Xvfb 启动
|
|
146
|
+
time.sleep(0.5)
|
|
147
|
+
if _xvfb_process.poll() is not None:
|
|
148
|
+
logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
|
|
149
|
+
_xvfb_process = None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
_xvfb_display = display_str
|
|
153
|
+
os.environ["DISPLAY"] = display_str
|
|
154
|
+
logger.info(f"Xvfb 虚拟显示已启动: {display_str}")
|
|
155
|
+
return display_str
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"启动 Xvfb 失败: {e}")
|
|
158
|
+
_xvfb_process = None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stop_xvfb() -> None:
|
|
163
|
+
"""停止 Xvfb 虚拟显示服务器"""
|
|
164
|
+
global _xvfb_process, _xvfb_display
|
|
165
|
+
|
|
166
|
+
with _xvfb_lock:
|
|
167
|
+
if _xvfb_process:
|
|
168
|
+
try:
|
|
169
|
+
_xvfb_process.terminate()
|
|
170
|
+
_xvfb_process.wait(timeout=5)
|
|
171
|
+
except Exception:
|
|
172
|
+
try:
|
|
173
|
+
_xvfb_process.kill()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
_xvfb_process = None
|
|
177
|
+
_xvfb_display = None
|
|
178
|
+
logger.info("Xvfb 虚拟显示已停止")
|
|
179
|
+
|
|
180
|
+
|
|
47
181
|
# ── 反检测浏览器管理器 ──────────────────────────────────────
|
|
48
182
|
|
|
49
183
|
|
|
@@ -80,6 +214,7 @@ class StealthBrowser:
|
|
|
80
214
|
self._browser = None # DrissionPage Chromium 实例
|
|
81
215
|
self._started = False
|
|
82
216
|
self._user_data_dir = ""
|
|
217
|
+
self._xvfb_started_by_us = False # 是否由本实例启动的 Xvfb
|
|
83
218
|
|
|
84
219
|
def _get_user_data_dir(self) -> str:
|
|
85
220
|
"""获取用户数据目录路径"""
|
|
@@ -130,7 +265,21 @@ class StealthBrowser:
|
|
|
130
265
|
co.set_argument("--disable-dev-shm-usage")
|
|
131
266
|
co.set_argument("--disable-gpu")
|
|
132
267
|
|
|
133
|
-
#
|
|
268
|
+
# ── 无 DISPLAY 环境处理 ──
|
|
269
|
+
# 当 headless=False 但没有可用显示时,尝试启动 Xvfb 虚拟显示;
|
|
270
|
+
# 如果 Xvfb 不可用则自动降级为 headless 模式
|
|
271
|
+
if not self._headless and not _has_display():
|
|
272
|
+
xvfb_display = _start_xvfb()
|
|
273
|
+
if xvfb_display:
|
|
274
|
+
self._xvfb_started_by_us = True
|
|
275
|
+
logger.info(f"无 DISPLAY 环境,已启动 Xvfb 虚拟显示 ({xvfb_display})")
|
|
276
|
+
else:
|
|
277
|
+
self._headless = True
|
|
278
|
+
logger.warning(
|
|
279
|
+
"无 DISPLAY 环境且 Xvfb 不可用,自动降级为 headless 模式"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 无头模式(co.headless() 内部设置 --headless=new)
|
|
134
283
|
if self._headless:
|
|
135
284
|
co.headless()
|
|
136
285
|
|
|
@@ -138,13 +287,33 @@ class StealthBrowser:
|
|
|
138
287
|
browser_path = self._detect_browser()
|
|
139
288
|
if browser_path:
|
|
140
289
|
co.set_browser_path(browser_path)
|
|
290
|
+
else:
|
|
291
|
+
return SkillResult(
|
|
292
|
+
success=False,
|
|
293
|
+
error=(
|
|
294
|
+
"未找到 Chrome/Chromium 浏览器。"
|
|
295
|
+
"请安装 Chrome 或设置 CHROME_PATH 环境变量。"
|
|
296
|
+
),
|
|
297
|
+
)
|
|
141
298
|
|
|
142
299
|
# 设置窗口大小
|
|
143
300
|
co.set_argument("--window-size=1920,1080")
|
|
144
301
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
302
|
+
# 创建浏览器实例(增加重试逻辑)
|
|
303
|
+
last_error = None
|
|
304
|
+
for attempt in range(3):
|
|
305
|
+
try:
|
|
306
|
+
self._browser = Chromium(co)
|
|
307
|
+
self._page = self._browser.latest_tab
|
|
308
|
+
break
|
|
309
|
+
except Exception as e:
|
|
310
|
+
last_error = e
|
|
311
|
+
logger.warning(f"浏览器启动第 {attempt + 1}/3 次尝试失败: {e}")
|
|
312
|
+
# 清理可能残留的 Chrome 进程
|
|
313
|
+
self._kill_stale_chrome(user_data)
|
|
314
|
+
time.sleep(2 * (attempt + 1))
|
|
315
|
+
else:
|
|
316
|
+
raise last_error
|
|
148
317
|
|
|
149
318
|
# 如果浏览器已崩溃或无法获取 tab
|
|
150
319
|
if not self._page:
|
|
@@ -195,11 +364,23 @@ class StealthBrowser:
|
|
|
195
364
|
self._browser = None
|
|
196
365
|
self._page = None
|
|
197
366
|
logger.info("反检测浏览器已关闭")
|
|
198
|
-
return SkillResult(success=True, message="浏览器已关闭")
|
|
199
367
|
except Exception as e:
|
|
200
368
|
logger.error(f"关闭浏览器异常: {e}")
|
|
201
369
|
self._browser = None
|
|
202
370
|
self._page = None
|
|
371
|
+
|
|
372
|
+
# 如果由本实例启动了 Xvfb,关闭浏览器后停止虚拟显示
|
|
373
|
+
if self._xvfb_started_by_us:
|
|
374
|
+
self._xvfb_started_by_us = False
|
|
375
|
+
# 检查是否还有其他浏览器实例在使用 Xvfb
|
|
376
|
+
with _browser_lock:
|
|
377
|
+
other_active = any(
|
|
378
|
+
b._started and b is not self
|
|
379
|
+
for b in _browsers.values()
|
|
380
|
+
)
|
|
381
|
+
if not other_active:
|
|
382
|
+
_stop_xvfb()
|
|
383
|
+
|
|
203
384
|
return SkillResult(success=True, message="浏览器已关闭")
|
|
204
385
|
|
|
205
386
|
async def navigate(self, url: str, wait: float = 2.0) -> SkillResult:
|
|
@@ -559,6 +740,34 @@ class StealthBrowser:
|
|
|
559
740
|
logger.debug(f"页面检查失败 (profile={self.profile_name}),浏览器可能已关闭")
|
|
560
741
|
return False
|
|
561
742
|
|
|
743
|
+
@staticmethod
|
|
744
|
+
def _kill_stale_chrome(user_data_dir: str = "") -> None:
|
|
745
|
+
"""清理可能残留的 Chrome/Chromium 进程"""
|
|
746
|
+
try:
|
|
747
|
+
# 查找使用相同 user-data-dir 的残留 Chrome 进程
|
|
748
|
+
patterns = [
|
|
749
|
+
"chromium-browser.*--remote-debugging-port",
|
|
750
|
+
"chromium.*--remote-debugging-port",
|
|
751
|
+
"chrome.*--remote-debugging-port",
|
|
752
|
+
"google-chrome.*--remote-debugging-port",
|
|
753
|
+
"headless_shell.*--remote-debugging-port",
|
|
754
|
+
]
|
|
755
|
+
for pattern in patterns:
|
|
756
|
+
result = subprocess.run(
|
|
757
|
+
["pgrep", "-f", pattern],
|
|
758
|
+
capture_output=True, text=True, timeout=5,
|
|
759
|
+
)
|
|
760
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
761
|
+
for pid_str in result.stdout.strip().split("\n"):
|
|
762
|
+
try:
|
|
763
|
+
pid = int(pid_str.strip())
|
|
764
|
+
os.kill(pid, 9) # SIGKILL
|
|
765
|
+
logger.info(f"已清理残留 Chrome 进程: PID {pid}")
|
|
766
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
767
|
+
pass
|
|
768
|
+
except Exception as e:
|
|
769
|
+
logger.debug(f"清理残留 Chrome 进程时出错: {e}")
|
|
770
|
+
|
|
562
771
|
@staticmethod
|
|
563
772
|
def _detect_browser() -> Optional[str]:
|
|
564
773
|
"""自动检测 Chromium/Chrome 浏览器路径"""
|
|
@@ -693,6 +902,9 @@ def close_stealth_browser(profile_name: str = "") -> None:
|
|
|
693
902
|
pass
|
|
694
903
|
_browsers.clear()
|
|
695
904
|
|
|
905
|
+
# 所有浏览器关闭后,停止 Xvfb(如果有)
|
|
906
|
+
_stop_xvfb()
|
|
907
|
+
|
|
696
908
|
|
|
697
909
|
async def close_stealth_browser_async(profile_name: str = "") -> None:
|
|
698
910
|
"""关闭浏览器实例(异步版本,供 async 上下文调用)"""
|
|
@@ -39,6 +39,132 @@ from aiskills.base import Skill, SkillResult, SkillParameter
|
|
|
39
39
|
|
|
40
40
|
logger = get_logger("myagent.skills.chromedev_mcp")
|
|
41
41
|
|
|
42
|
+
|
|
43
|
+
# ── Xvfb 虚拟显示管理 ──────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
_xvfb_process: Optional[subprocess.Popen] = None
|
|
46
|
+
_xvfb_display: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _has_display() -> bool:
|
|
50
|
+
"""检查当前环境是否有可用的 X11 显示"""
|
|
51
|
+
display = os.environ.get("DISPLAY", "").strip()
|
|
52
|
+
if not display:
|
|
53
|
+
return False
|
|
54
|
+
# DISPLAY 已设置,尝试检测是否真的可用
|
|
55
|
+
# 方法1: xdpyinfo
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["xdpyinfo"], capture_output=True, timeout=3,
|
|
59
|
+
env={**os.environ},
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
# 方法2: 用 xprop/xdotool 等轻量工具验证
|
|
66
|
+
try:
|
|
67
|
+
for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
|
|
68
|
+
["xdotool", "getdisplaywidth"]):
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
cmd, capture_output=True, timeout=3,
|
|
71
|
+
env={**os.environ},
|
|
72
|
+
)
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
# 方法3: 检查 /tmp/.X11-unix/ 下的 socket 文件
|
|
78
|
+
try:
|
|
79
|
+
if display.startswith(":"):
|
|
80
|
+
num = display[1:].split(".")[0]
|
|
81
|
+
socket_path = f"/tmp/.X11-unix/X{num}"
|
|
82
|
+
if os.path.exists(socket_path):
|
|
83
|
+
return True
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _start_xvfb(display_num: int = 98) -> Optional[str]:
|
|
90
|
+
"""
|
|
91
|
+
启动 Xvfb 虚拟显示服务器。
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
DISPLAY 环境变量值(如 :98),失败返回 None
|
|
95
|
+
"""
|
|
96
|
+
global _xvfb_process, _xvfb_display
|
|
97
|
+
|
|
98
|
+
# 如果已经启动,直接返回
|
|
99
|
+
if _xvfb_process and _xvfb_process.poll() is None:
|
|
100
|
+
return _xvfb_display
|
|
101
|
+
|
|
102
|
+
# 清理旧的
|
|
103
|
+
if _xvfb_process:
|
|
104
|
+
try:
|
|
105
|
+
_xvfb_process.terminate()
|
|
106
|
+
_xvfb_process.wait(timeout=3)
|
|
107
|
+
except Exception:
|
|
108
|
+
try:
|
|
109
|
+
_xvfb_process.kill()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
_xvfb_process = None
|
|
113
|
+
|
|
114
|
+
xvfb_path = shutil.which("Xvfb")
|
|
115
|
+
if not xvfb_path:
|
|
116
|
+
logger.warning("Xvfb 未安装,无法启动虚拟显示")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
display_str = f":{display_num}"
|
|
120
|
+
try:
|
|
121
|
+
# 清理可能残留的 X11 锁文件
|
|
122
|
+
for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
|
|
123
|
+
if os.path.exists(lock_file):
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(lock_file)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
_xvfb_process = subprocess.Popen(
|
|
130
|
+
[xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
|
|
131
|
+
stdout=subprocess.DEVNULL,
|
|
132
|
+
stderr=subprocess.DEVNULL,
|
|
133
|
+
)
|
|
134
|
+
time.sleep(0.5)
|
|
135
|
+
if _xvfb_process.poll() is not None:
|
|
136
|
+
logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
|
|
137
|
+
_xvfb_process = None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
_xvfb_display = display_str
|
|
141
|
+
os.environ["DISPLAY"] = display_str
|
|
142
|
+
logger.info(f"Xvfb 虚拟显示已启动 (chromedev_mcp): {display_str}")
|
|
143
|
+
return display_str
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"启动 Xvfb 失败: {e}")
|
|
146
|
+
_xvfb_process = None
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _stop_xvfb() -> None:
|
|
151
|
+
"""停止 Xvfb 虚拟显示服务器"""
|
|
152
|
+
global _xvfb_process, _xvfb_display
|
|
153
|
+
|
|
154
|
+
if _xvfb_process:
|
|
155
|
+
try:
|
|
156
|
+
_xvfb_process.terminate()
|
|
157
|
+
_xvfb_process.wait(timeout=5)
|
|
158
|
+
except Exception:
|
|
159
|
+
try:
|
|
160
|
+
_xvfb_process.kill()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
_xvfb_process = None
|
|
164
|
+
_xvfb_display = None
|
|
165
|
+
logger.info("Xvfb 虚拟显示已停止 (chromedev_mcp)")
|
|
166
|
+
|
|
167
|
+
|
|
42
168
|
# ── MCP 通信常量 ──────────────────────────────────────────────
|
|
43
169
|
|
|
44
170
|
# chrome-devtools-mcp 支持的完整工具列表 (v0.21.0)
|
|
@@ -154,9 +280,22 @@ class MCPClient:
|
|
|
154
280
|
args.extend(["--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox"])
|
|
155
281
|
|
|
156
282
|
# [v1.17.0] 有头模式: 设置 DISPLAY 环境变量
|
|
157
|
-
if not self._headless
|
|
158
|
-
|
|
159
|
-
|
|
283
|
+
if not self._headless:
|
|
284
|
+
# 优先使用用户指定的 DISPLAY
|
|
285
|
+
if self._display_override:
|
|
286
|
+
env["DISPLAY"] = self._display_override
|
|
287
|
+
logger.info(f"有头模式: DISPLAY={self._display_override}")
|
|
288
|
+
# 无可用显示时自动启动 Xvfb
|
|
289
|
+
elif not _has_display():
|
|
290
|
+
xvfb_display = _start_xvfb()
|
|
291
|
+
if xvfb_display:
|
|
292
|
+
env["DISPLAY"] = xvfb_display
|
|
293
|
+
logger.info(f"无 DISPLAY 环境,已启动 Xvfb: {xvfb_display}")
|
|
294
|
+
else:
|
|
295
|
+
# Xvfb 不可用,降级为 headless 模式
|
|
296
|
+
self._headless = True
|
|
297
|
+
args.append("--headless")
|
|
298
|
+
logger.warning("无 DISPLAY 环境且 Xvfb 不可用,自动降级为 headless 模式")
|
|
160
299
|
|
|
161
300
|
logger.info(f"启动 chrome-devtools-mcp: {' '.join(args)}")
|
|
162
301
|
|
|
@@ -629,6 +768,9 @@ class MCPClient:
|
|
|
629
768
|
# chrome-devtools-mcp 启动的 Chrome 可能不会随 MCP 进程一起退出
|
|
630
769
|
self._kill_stale_chrome()
|
|
631
770
|
|
|
771
|
+
# 清理 Xvfb 虚拟显示
|
|
772
|
+
_stop_xvfb()
|
|
773
|
+
|
|
632
774
|
def _kill_stale_chrome(self):
|
|
633
775
|
"""清理残留的 Chrome/Chromium 进程
|
|
634
776
|
|