mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 19:27:11 +08:00
429 lines
17 KiB
Python
429 lines
17 KiB
Python
"""cow install-browser - Install Playwright + Chromium for the browser tool."""
|
||
|
||
import os
|
||
import sys
|
||
import subprocess
|
||
from typing import Callable, Optional
|
||
|
||
import click
|
||
|
||
PLAYWRIGHT_VERSION = "1.52.0"
|
||
PLAYWRIGHT_LEGACY_VERSION = "1.28.0"
|
||
GLIBC_THRESHOLD = (2, 28)
|
||
CHINA_MIRROR = "https://registry.npmmirror.com/-/binary/playwright"
|
||
|
||
# stream(msg, fg=None) — fg is "yellow" | "green" | "red" | None
|
||
StreamFn = Callable[[str, Optional[str]], None]
|
||
# on_phase(msg) — coarse-grained progress for chat channels (localized via i18n)
|
||
PhaseFn = Callable[[str], None]
|
||
|
||
|
||
def _phase(cb: Optional[PhaseFn], msg: str) -> None:
|
||
if cb:
|
||
cb(msg)
|
||
|
||
|
||
def _has_display() -> bool:
|
||
"""Check if a graphical display is available (Linux only)."""
|
||
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||
|
||
|
||
def _is_headless_linux() -> bool:
|
||
return sys.platform == "linux" and not _has_display()
|
||
|
||
|
||
def _get_installed_version() -> str:
|
||
try:
|
||
out = subprocess.check_output(
|
||
[sys.executable, "-c", "import playwright; print(playwright.__version__)"],
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
return out.decode().strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _version_tuple(v: str):
|
||
try:
|
||
return tuple(int(x) for x in v.split(".")[:3])
|
||
except (ValueError, AttributeError):
|
||
return (0, 0, 0)
|
||
|
||
|
||
def _get_glibc_version():
|
||
if sys.platform != "linux":
|
||
return None
|
||
try:
|
||
import ctypes
|
||
libc = ctypes.CDLL("libc.so.6")
|
||
gnu_get_libc_version = libc.gnu_get_libc_version
|
||
gnu_get_libc_version.restype = ctypes.c_char_p
|
||
ver = gnu_get_libc_version().decode()
|
||
parts = ver.split(".")
|
||
return (int(parts[0]), int(parts[1]))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _is_china_network() -> bool:
|
||
try:
|
||
out = subprocess.check_output(
|
||
[sys.executable, "-m", "pip", "config", "get", "global.index-url"],
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
url = out.decode().strip().lower()
|
||
return any(kw in url for kw in ("tsinghua", "aliyun", "npmmirror", "douban", "ustc", "huawei", "tencentyun"))
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
|
||
python = sys.executable
|
||
base = [python, "-m", "pip", "install", "--prefer-binary"]
|
||
ret = subprocess.call(base + [package_spec])
|
||
if ret != 0:
|
||
stream(" Retrying with --user flag...", "yellow")
|
||
ret = subprocess.call(base + ["--user", package_spec])
|
||
return ret
|
||
|
||
|
||
def _is_frozen() -> bool:
|
||
"""True when running inside a PyInstaller-frozen bundle (desktop backend).
|
||
|
||
In this mode ``sys.executable`` is the frozen exe (no pip / no ``-m``), so
|
||
playwright is already bundled and we only need to download the browser
|
||
binary in-process rather than pip-installing anything.
|
||
"""
|
||
return bool(getattr(sys, "frozen", False))
|
||
|
||
|
||
def _playwright_cli(args: list, env: Optional[dict] = None) -> int:
|
||
"""Invoke the Playwright CLI, working in both source and frozen builds.
|
||
|
||
Source builds shell out to ``python -m playwright <args>``. Frozen builds
|
||
can't use ``-m`` (the exe isn't a Python interpreter), so we call
|
||
Playwright's driver entrypoint in-process instead. ``env`` overrides are
|
||
applied to os.environ for the duration of the call (frozen path) or passed
|
||
through to the subprocess (source path).
|
||
"""
|
||
if not _is_frozen():
|
||
cmd = [sys.executable, "-m", "playwright"] + args
|
||
return subprocess.call(cmd, env=env)
|
||
|
||
# Frozen: run the bundled Playwright driver in-process. compute_driver_executable
|
||
# returns the Node driver shipped inside the bundle; we spawn it directly.
|
||
prev_env = {}
|
||
if env:
|
||
for k, v in env.items():
|
||
prev_env[k] = os.environ.get(k)
|
||
os.environ[k] = v
|
||
try:
|
||
from playwright._impl._driver import compute_driver_executable, get_driver_env
|
||
driver = compute_driver_executable()
|
||
# compute_driver_executable may return a tuple (node, cli.js) on newer
|
||
# Playwright, or a single path on older ones.
|
||
if isinstance(driver, (list, tuple)):
|
||
cmd = list(driver) + args
|
||
else:
|
||
cmd = [str(driver)] + args
|
||
# get_driver_env() snapshots os.environ, which we've already patched with
|
||
# the caller's overrides (PLAYWRIGHT_BROWSERS_PATH / DOWNLOAD_HOST) above,
|
||
# so mirror + pinned browsers dir are honored here too.
|
||
return subprocess.call(cmd, env=get_driver_env())
|
||
except Exception as e:
|
||
# Last resort: try the module main via runpy (works if the frozen build
|
||
# kept playwright.__main__ importable).
|
||
try:
|
||
import runpy
|
||
sys.argv = ["playwright"] + args
|
||
runpy.run_module("playwright", run_name="__main__")
|
||
return 0
|
||
except SystemExit as se:
|
||
return int(se.code or 0)
|
||
except Exception:
|
||
return 1
|
||
finally:
|
||
for k, v in prev_env.items():
|
||
if v is None:
|
||
os.environ.pop(k, None)
|
||
else:
|
||
os.environ[k] = v
|
||
|
||
|
||
def _default_stream(msg: str, fg: Optional[str] = None) -> None:
|
||
"""CLI: colored click output."""
|
||
if fg == "yellow":
|
||
click.echo(click.style(msg, fg="yellow"))
|
||
elif fg == "green":
|
||
click.echo(click.style(msg, fg="green"))
|
||
elif fg == "red":
|
||
click.echo(click.style(msg, fg="red"))
|
||
else:
|
||
click.echo(msg)
|
||
|
||
|
||
def run_install_browser(
|
||
stream: Optional[StreamFn] = None,
|
||
on_phase: Optional[PhaseFn] = None,
|
||
) -> int:
|
||
"""
|
||
Install Playwright Python package, optional Linux deps, and Chromium.
|
||
|
||
Reused by ``cow install-browser`` CLI and chat ``/install-browser``.
|
||
|
||
Args:
|
||
stream: Optional callback ``(message, fg)`` for each line. ``fg`` is
|
||
``yellow`` / ``green`` / ``red`` or None. Defaults to colored click output.
|
||
on_phase: Optional callback for coarse progress (e.g. push to chat);
|
||
messages are short status lines localized via i18n.
|
||
|
||
Returns:
|
||
0 on success, 1 on fatal failure (pip or chromium install failed).
|
||
"""
|
||
from cli.utils import get_cli_language
|
||
|
||
# Import `common` only after get_cli_language() runs ensure_sys_path(),
|
||
# so it works when `cow` is invoked from outside the project directory.
|
||
get_cli_language() # resolve cow_lang so i18n.t reflects config
|
||
from common import i18n
|
||
_t = i18n.t
|
||
|
||
stream = stream or _default_stream
|
||
python = sys.executable
|
||
legacy_mode = False
|
||
frozen = _is_frozen()
|
||
|
||
_phase(on_phase, _t(
|
||
"🔧 开始安装浏览器工具依赖(约几分钟,请耐心等待)…",
|
||
"🔧 Installing browser tool dependencies (a few minutes, please wait)…",
|
||
))
|
||
|
||
glibc = _get_glibc_version()
|
||
if glibc and glibc < GLIBC_THRESHOLD:
|
||
legacy_mode = True
|
||
glibc_str = f"{glibc[0]}.{glibc[1]}"
|
||
stream(
|
||
f"glibc {glibc_str} detected (< 2.28). "
|
||
f"Will install playwright {PLAYWRIGHT_LEGACY_VERSION} for compatibility.",
|
||
"yellow",
|
||
)
|
||
stream(" Note: upgrade your OS for full browser tool support.", "yellow")
|
||
stream("")
|
||
_phase(
|
||
on_phase,
|
||
_t(
|
||
f"ℹ️ 检测到 glibc {glibc_str}(较旧),将安装兼容版 Playwright {PLAYWRIGHT_LEGACY_VERSION}。",
|
||
f"ℹ️ Detected glibc {glibc_str} (older); installing compatible Playwright {PLAYWRIGHT_LEGACY_VERSION}.",
|
||
),
|
||
)
|
||
|
||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
||
|
||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||
if sys.platform == "win32" and not frozen:
|
||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||
ret = subprocess.call(
|
||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||
)
|
||
if ret != 0:
|
||
stream(
|
||
" Could not pre-install a prebuilt greenlet wheel.\n"
|
||
" playwright may try to build greenlet from source, which needs\n"
|
||
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
|
||
"yellow",
|
||
)
|
||
|
||
if frozen:
|
||
# Desktop bundle: playwright is already shipped inside the app; there is
|
||
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||
installed = _get_installed_version()
|
||
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||
_phase(on_phase, _t(
|
||
"✅ [1/3] Playwright 已内置于客户端,跳过安装。",
|
||
"✅ [1/3] Playwright is bundled in the app; skipping install.",
|
||
))
|
||
else:
|
||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||
if ret != 0:
|
||
stream("Failed to install playwright package.", "red")
|
||
_phase(on_phase, _t("❌ [1/3] Playwright Python 包安装失败。", "❌ [1/3] Failed to install Playwright Python package."))
|
||
return 1
|
||
|
||
installed = _get_installed_version()
|
||
if installed:
|
||
stream(f" playwright {installed} installed.", "green")
|
||
stream("")
|
||
_phase(on_phase, _t(
|
||
f"✅ [1/3] Playwright 包已安装({installed or target_version})。",
|
||
f"✅ [1/3] Playwright package installed ({installed or target_version}).",
|
||
))
|
||
|
||
# With playwright available, prefer the user's system Chrome/Edge: the browser
|
||
# tool drives it directly (channel="chrome"/"msedge"), so we can skip the heavy
|
||
# ~150MB Chromium download entirely. Applies to every runtime (desktop, web,
|
||
# source) — only headless Linux servers, which usually lack a system browser,
|
||
# fall through to the download below. Honors prefer_system_browser via
|
||
# resolve_engine, so users who force downloaded Chromium still get it.
|
||
try:
|
||
from agent.tools.browser import browser_env
|
||
summary = browser_env.capability_summary()
|
||
if summary.get("ready") and summary.get("engine", {}).get("mode") == "system-chrome":
|
||
sc = summary.get("system_chrome") or {}
|
||
stream(f"System browser detected ({sc.get('channel')}), skipping Chromium download.", "green")
|
||
_phase(on_phase, _t(
|
||
f"✅ 检测到系统浏览器({sc.get('channel')}),无需下载 Chromium,浏览器工具已就绪。",
|
||
f"✅ Detected system browser ({sc.get('channel')}); no Chromium download needed, browser tool is ready.",
|
||
))
|
||
return 0
|
||
except Exception as e:
|
||
stream(f" (system browser probe skipped: {e})", None)
|
||
|
||
if sys.platform == "linux":
|
||
_phase(on_phase, _t(
|
||
"🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 sudo)…",
|
||
"🔧 [2/3] Installing Linux system deps and a lightweight CJK font (WenQuanYi Zen Hei; some steps may need sudo)…",
|
||
))
|
||
stream("[2/3] Installing system dependencies (Linux)...", "yellow")
|
||
ret = _playwright_cli(["install-deps", "chromium"])
|
||
if ret != 0:
|
||
stream(
|
||
" Could not auto-install system deps (may need sudo).\n"
|
||
f" Run manually: sudo {python} -m playwright install-deps chromium",
|
||
"yellow",
|
||
)
|
||
# Prefer fonts-wqy-zenhei only (~few MB). fonts-noto-cjk is much larger (~150MB+).
|
||
stream(" Installing CJK font (fonts-wqy-zenhei, lightweight)...")
|
||
font_ret = subprocess.call(
|
||
["sudo", "apt-get", "install", "-y", "--no-install-recommends", "fonts-wqy-zenhei"],
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
if font_ret != 0:
|
||
stream(
|
||
" Could not auto-install CJK font.\n"
|
||
" Run manually: sudo apt-get install -y fonts-wqy-zenhei\n"
|
||
" (Optional, larger full coverage: sudo apt-get install -y fonts-noto-cjk)",
|
||
"yellow",
|
||
)
|
||
else:
|
||
subprocess.call(["fc-cache", "-fv"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
stream(" CJK font (wqy-zenhei) installed.", "green")
|
||
_phase(
|
||
on_phase,
|
||
_t(
|
||
"✅ [2/3] Linux 依赖与字体步骤已执行(若有权限问题请查看服务器日志或手动执行提示命令)。",
|
||
"✅ [2/3] Linux deps and font steps executed (on permission issues, check the server log or run the suggested commands manually).",
|
||
),
|
||
)
|
||
else:
|
||
stream(f"[2/3] Skipping system deps (not needed on {sys.platform}).", "yellow")
|
||
_phase(on_phase, _t(
|
||
f"ℹ️ [2/3] 当前系统({sys.platform})跳过 Linux 专用依赖。",
|
||
f"ℹ️ [2/3] Skipping Linux-specific deps on this platform ({sys.platform}).",
|
||
))
|
||
stream("")
|
||
|
||
_phase(on_phase, _t(
|
||
"🌐 [3/3] 正在下载并安装 Chromium(体积较大,请耐心等待)…",
|
||
"🌐 [3/3] Downloading and installing Chromium (large download, please wait)…",
|
||
))
|
||
stream("[3/3] Installing Chromium browser...", "yellow")
|
||
pw_args = ["install", "chromium"]
|
||
|
||
if _is_headless_linux() and not legacy_mode:
|
||
ver = _version_tuple(installed or "")
|
||
if ver >= (1, 57, 0):
|
||
pw_args.append("--only-shell")
|
||
stream(" (headless shell for Linux server)", None)
|
||
else:
|
||
stream(" (full Chromium)", None)
|
||
elif sys.platform == "linux" and _has_display():
|
||
stream(" (full browser for Linux desktop)", None)
|
||
|
||
env = os.environ.copy()
|
||
# Pin the download location so it survives desktop app updates and matches
|
||
# what the runtime looks up (see browser_env.browsers_download_dir()).
|
||
try:
|
||
from agent.tools.browser.browser_env import browsers_download_dir
|
||
env["PLAYWRIGHT_BROWSERS_PATH"] = browsers_download_dir()
|
||
stream(f" (browsers dir: {env['PLAYWRIGHT_BROWSERS_PATH']})", None)
|
||
except Exception:
|
||
pass
|
||
|
||
use_mirror = _is_china_network()
|
||
if use_mirror:
|
||
env["PLAYWRIGHT_DOWNLOAD_HOST"] = CHINA_MIRROR
|
||
stream(f" (using China mirror: {CHINA_MIRROR})", None)
|
||
_phase(on_phase, _t(
|
||
"📡 检测到国内 pip 源配置,Chromium 将优先走国内镜像下载。",
|
||
"📡 Detected a China pip mirror; Chromium will be downloaded from the China mirror first.",
|
||
))
|
||
|
||
ret = _playwright_cli(pw_args, env=env)
|
||
|
||
if ret != 0 and use_mirror:
|
||
stream(" Mirror download failed, retrying with official CDN...", "yellow")
|
||
_phase(on_phase, _t(
|
||
"⚠️ 镜像下载失败,正在改用官方源重试…",
|
||
"⚠️ Mirror download failed; retrying with the official CDN…",
|
||
))
|
||
env_no_mirror = dict(env)
|
||
env_no_mirror.pop("PLAYWRIGHT_DOWNLOAD_HOST", None)
|
||
ret = _playwright_cli(pw_args, env=env_no_mirror)
|
||
|
||
if ret != 0:
|
||
stream("Failed to install Chromium.", "red")
|
||
_phase(on_phase, _t("❌ [3/3] Chromium 安装失败。", "❌ [3/3] Failed to install Chromium."))
|
||
return 1
|
||
|
||
stream("")
|
||
_phase(on_phase, _t("✅ [3/3] Chromium 已安装。", "✅ [3/3] Chromium installed."))
|
||
|
||
stream("Verifying browser installation...", None)
|
||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||
if frozen:
|
||
# Frozen: no child interpreter to spawn; import in-process instead.
|
||
try:
|
||
from playwright.sync_api import sync_playwright # noqa: F401
|
||
ret = 0
|
||
except Exception:
|
||
ret = 1
|
||
else:
|
||
ret = subprocess.call(
|
||
[python, "-c", "from playwright.sync_api import sync_playwright; print('OK')"],
|
||
stderr=subprocess.DEVNULL,
|
||
)
|
||
if ret != 0:
|
||
stream(
|
||
" Warning: playwright import failed. Browser tool may not work on this system.\n"
|
||
" Consider upgrading your OS or using Docker.",
|
||
"yellow",
|
||
)
|
||
_phase(on_phase, _t(
|
||
"⚠️ 验证未完全通过:本机可能仍无法使用浏览器工具,请查看日志或升级系统。",
|
||
"⚠️ Verification did not fully pass: the browser tool may still not work here; check the log or upgrade your system.",
|
||
))
|
||
else:
|
||
stream(" Verification passed.", "green")
|
||
_phase(on_phase, _t("✅ 验证通过。", "✅ Verification passed."))
|
||
|
||
stream("")
|
||
stream("Browser tool ready! Restart CowAgent to enable it.", "green")
|
||
_phase(on_phase, _t(
|
||
"🎉 全部步骤结束。请重启 CowAgent 后使用 browser 工具。",
|
||
"🎉 All steps finished. Restart CowAgent to use the browser tool.",
|
||
))
|
||
return 0
|
||
|
||
|
||
@click.command("install-browser")
|
||
def install_browser():
|
||
"""Install browser tool dependencies (Playwright + Chromium)."""
|
||
code = run_install_browser()
|
||
if code != 0:
|
||
raise SystemExit(code)
|