diff --git a/README.md b/README.md index a7921cd..3ae624e 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,30 @@ | 依赖 | 说明 | |------|------| -| Linux / macOS | 协议 mint **不需要**浏览器;回退浏览器时需要桌面 `DISPLAY` | +| Linux / macOS | 协议 mint **不需要**浏览器;注册 / 回退浏览器需要 Chromium | +| 无桌面服务器 | **`./start.sh` 自动 Xvfb**(无 DISPLAY 时);失败再回退 Python headless | | `uv` + Python 3.13 | 见 `pyproject.toml` / `uv.lock`;可选 `mise` | -| Chromium | 注册流程 + 协议失败回退时需要 | +| Chromium / Chrome | 注册流程 + 协议失败回退时需要 | | 代理(按需) | 访问 xAI / accounts.x.ai 不通时配置 `proxy` / `cpa_proxy` | | 可选 | grok2api `:8000`、CLIProxyAPI(CPA) `:8317` | +无头服务器(推荐直接 `./start.sh`): + +```bash +# 一键:缺啥装啥 + 无 DISPLAY 自动 xvfb-run / 常驻 Xvfb +./start.sh --extra 1 --threads 1 + +# 跳过 Xvfb,强制走 Python 自动 headless +SKIP_XVFB=1 ./start.sh --extra 1 + +# 即使已有 DISPLAY 也包一层 xvfb-run +FORCE_XVFB=1 ./start.sh --extra 1 + +# 手动(不经过 start.sh) +sudo apt install -y xvfb google-chrome-stable +xvfb-run -a uv run python -u register_cli.py --extra 1 --threads 1 +``` + ```bash cd /path/to/grok-reg-protocol-cpa uv sync @@ -305,7 +323,8 @@ cpa_proxy > proxy > 环境变量 https_proxy/http_proxy | `cpa_copy_to_hotload` | `false` | 是否复制到 CPA 热加载目录 | | `cpa_hotload_dir` | `""` | CPA `auth-dir`(仅 copy 时) | | `cpa_base_url` | `https://cli-chat-proxy.grok.com/v1` | 免费 Build **必须**此上游 | -| `cpa_headless` | `false` | 回退浏览器建议有头 | +| `browser_headless` | `auto` | 注册浏览器:`auto`=无 DISPLAY 自动 headless;`true`/`false` 强制 | +| `cpa_headless` | `false` | 回退浏览器:有 DISPLAY 时建议有头;无 DISPLAY 时强制 headless | | `cpa_force_standalone` | `true` | 回退时独立 Chromium | | `cpa_mint_cookie_inject` | `true` | 回退时注入注册 cookie | | `cpa_mint_workers` | `-1` | mint 并发:`-1` 自动;`0` 内联;`1-10` 固定 | @@ -463,7 +482,8 @@ uv run python -u scripts/backfill_cpa_xai_from_accounts.py \ | `--out-dir` | 主导出目录 | | `--cpa-dir` | 成功后复制到 CPA 热加载目录 | | `--probe` | 检查是否列出 `grok-4.5` | -| `--headless` | 回退浏览器时无头(不推荐) | +| `--headless` | 强制无头浏览器(无 DISPLAY 时默认也会自动 headless) | +| `--headed` | 强制有头(需 DISPLAY / xvfb-run;无显示仍回退 headless) | ### C. 从 `~/.grok/auth.json` 导出 @@ -523,7 +543,8 @@ curl -sS http://127.0.0.1:8317/v1/chat/completions \ | 协议 `sso invalid` | SSO 过期/无效;会回退浏览器;检查账本第三段 | | 协议 verify/approve 失败 | 会话态变化 / 风控;看日志后自动回退浏览器 | | 一直 `authorization_pending` | 浏览器路径未完成 consent;需到「设备已授权」且 token 200 | -| Cloudflare / Turnstile | 回退浏览器时关 headless、开 turnstilePatch、检查代理 | +| Cloudflare / Turnstile | 优先 `xvfb-run` 有头;纯 headless 易拦;Chromium+turnstilePatch、检查代理 | +| 浏览器 connection fails / no interface | 无 DISPLAY:已自动 headless;仍失败则装 Chrome 并用 `xvfb-run -a ...` | | Hotmail 收不到码 | 检查四段凭证、ClientID/Token、IMAP 主机与 alias 计数 | | 有 token 但无 grok-4.5 | `cpa_base_url` 是否为 `cli-chat-proxy` | | 注册成功但无 `cpa_auths` | `cpa_export_enabled`?看 `cpa_auths/cpa_auth_failed.txt` | diff --git a/auto_register.py b/auto_register.py index 40d5ad2..20b3e63 100755 --- a/auto_register.py +++ b/auto_register.py @@ -196,6 +196,13 @@ def resolve_browser_path() -> str | None: return None +def has_display() -> bool: + """True if Chromium can open a real GUI window (Windows always; else DISPLAY/WAYLAND).""" + if sys.platform == "win32": + return True + return bool((os.environ.get("DISPLAY") or "").strip() or (os.environ.get("WAYLAND_DISPLAY") or "").strip()) + + def create_browser_options(headless: bool = False, proxy: str = "") -> ChromiumOptions: opts = ChromiumOptions() opts.auto_port() @@ -217,8 +224,26 @@ def create_browser_options(headless: bool = False, proxy: str = "") -> ChromiumO print("[!] skip turnstilePatch: Google Chrome disallows --load-extension; use Chromium") else: opts.add_extension(EXTENSION_PATH) - if headless: - opts.headless() + # Auto headless when no DISPLAY (unless caller already requested headless) + use_headless = bool(headless) or not has_display() + if use_headless: + try: + opts.headless() + except Exception: + opts.set_argument("--headless=new") + for flag in ("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"): + try: + opts.set_argument(flag) + except Exception: + pass + if not headless and not has_display(): + print( + f"[*] 无图形界面 (DISPLAY={os.environ.get('DISPLAY', '')!r}),已自动启用 headless。\n" + " 推荐: xvfb-run -a python auto_register.py ... (过 CF 更稳)", + flush=True, + ) + else: + print("[*] browser headless=True", flush=True) if proxy: try: from urllib.parse import urlparse diff --git a/config.example.json b/config.example.json index 00dcb71..43beb0e 100644 --- a/config.example.json +++ b/config.example.json @@ -56,6 +56,10 @@ "// ===== 网络 / 浏览器 =====": "", "// proxy": "注册机主代理:Chromium + 邮箱 HTTP。可带账号 http://user:pass@host:port。优先于系统 https_proxy。", "proxy": "http://127.0.0.1:7890", + "// browser_headless": "注册 Chromium:true=强制无头,false=强制有头,auto/省略=无 DISPLAY 时自动 headless。也可用 env BROWSER_HEADLESS / CLI --headless|--headed。无头服务器建议 xvfb-run。", + "browser_headless": "auto", + "// browser_path": "可选,强制 Chromium/Chrome 可执行文件路径;默认自动探测 google-chrome-stable / chromium。", + "browser_path": "", "// user_agent": "Chromium UA;一般保持默认 Chrome 桌面串即可。", "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", "// enable_nsfw": "注册成功后是否尝试打开 NSFW(历史字段;与 CPA 无关)", @@ -145,7 +149,7 @@ "cpa_base_url": "https://cli-chat-proxy.grok.com/v1", "// cpa_proxy": "OIDC mint 专用代理(device-code/token/probe/浏览器)。优先级: cpa_proxy > proxy > 环境 https_proxy。空=用 proxy。", "cpa_proxy": "http://127.0.0.1:7890", - "// cpa_headless": "device 确认浏览器是否无头。accounts.x.ai 建议 false(有头)", + "// cpa_headless": "回退浏览器是否无头。false=有头(推荐,过 CF 更稳);true=无头;省略/auto 时无 DISPLAY 自动无头。无显示时即使 false 也会强制 headless 以免起不来。", "cpa_headless": false, "// cpa_force_standalone": "true=新开 Chromium+turnstilePatch 做 OIDC;勿复用注册 tab", "cpa_force_standalone": true, diff --git a/cpa_export.py b/cpa_export.py index 561dcde..84fbe6f 100644 --- a/cpa_export.py +++ b/cpa_export.py @@ -351,8 +351,38 @@ def export_cpa_xai_for_account( or os.environ.get("http_proxy") or "" ).strip() - # Default headed: headless is frequently Cloudflare-blocked on accounts.x.ai - headless = bool(cfg.get("cpa_headless", False)) + # Default headed when a display exists; headless is frequently Cloudflare-blocked. + # On servers without DISPLAY, always force headless so Chromium can start. + has_disp = sys.platform == "win32" or bool( + (os.environ.get("DISPLAY") or "").strip() + or (os.environ.get("WAYLAND_DISPLAY") or "").strip() + ) + cpa_hl_raw = cfg.get("cpa_headless", None) + if cpa_hl_raw is None: + # inherit browser_headless auto / explicit if set + cpa_hl_raw = cfg.get("browser_headless", "auto") + if isinstance(cpa_hl_raw, bool): + headless = cpa_hl_raw + else: + s = str(cpa_hl_raw or "auto").strip().lower() + if s in ("1", "true", "yes", "on", "headless"): + headless = True + elif s in ("0", "false", "no", "off", "headed", "gui"): + headless = False + else: + headless = not has_disp + if not has_disp and not headless: + headless = True + _log( + f"cpa browser forced headless (no DISPLAY={os.environ.get('DISPLAY', '')!r}); " + "prefer xvfb-run for Turnstile; protocol mint still preferred first" + ) + elif headless and not bool(cfg.get("cpa_headless", False)): + _log( + "cpa browser headless auto/on " + f"(DISPLAY={os.environ.get('DISPLAY', '')!r}); " + "protocol mint still preferred first" + ) probe = bool(cfg.get("cpa_probe_after_write", True)) probe_chat = bool(cfg.get("cpa_probe_chat", False)) timeout = float(cfg.get("cpa_mint_timeout_sec", 240)) diff --git a/cpa_xai/browser_confirm.py b/cpa_xai/browser_confirm.py index 13aad05..75370f9 100644 --- a/cpa_xai/browser_confirm.py +++ b/cpa_xai/browser_confirm.py @@ -203,18 +203,33 @@ def create_standalone_page( except Exception as e: # noqa: BLE001 log(f"extension add failed: {e}") - if headless: + # Auto-headless when no display (Linux server). Prefer register helpers if present. + display = (os.environ.get("DISPLAY") or "").strip() + wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() + has_disp = sys.platform == "win32" or bool(display or wayland) + effective_headless = bool(headless) or not has_disp + if effective_headless and not headless and not has_disp: + log( + f"no DISPLAY/WAYLAND (DISPLAY={display!r}) — auto headless; " + "prefer: xvfb-run -a ... for better Turnstile success" + ) + if effective_headless: try: opts.headless(True) except Exception: opts.set_argument("--headless=new") + for flag in ("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"): + try: + opts.set_argument(flag) + except Exception: + pass log("headless=True (may hit Cloudflare / break real clicks)") else: try: opts.headless(False) except Exception: pass - log(f"headed browser DISPLAY={os.environ.get('DISPLAY', '')!r}") + log(f"headed browser DISPLAY={display!r}") # Prefer Chromium: Google Chrome blocks --load-extension (turnstilePatch). for cand in ( diff --git a/grok_register_ttk.py b/grok_register_ttk.py index 4deeced..e155e4e 100644 --- a/grok_register_ttk.py +++ b/grok_register_ttk.py @@ -57,6 +57,8 @@ DEFAULT_CONFIG = { "cloudmail_admin_email": "", "cloudmail_password": "", "cpa_gui_close_mint_browser": True, + "browser_headless": "auto", + "browser_path": "", "hotmail_accounts_file": "mail_credentials.txt", "hotmail_alias_mode": "random", "hotmail_alias_random_length": 8, @@ -622,6 +624,154 @@ CHROMIUM_SLIM_FLAGS = [ "--disable-blink-features=AutomationControlled", ] +# Headless / display helpers (Linux servers without desktop) +_HEADLESS_HINT_SHOWN = False +_HEADLESS_HINT_LOCK = threading.Lock() + + +def has_display() -> bool: + """Return True if a GUI display is likely available for Chromium. + + - Windows: always True (Win32 GUI subsystem). + - Linux/macOS: need DISPLAY or WAYLAND_DISPLAY (Xvfb counts as DISPLAY). + """ + if sys.platform == "win32": + return True + display = (os.environ.get("DISPLAY") or "").strip() + wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() + return bool(display or wayland) + + +def _truthy_headless(val) -> bool | None: + """Parse headless preference. True/False explicit; None means auto.""" + if val is None: + return None + if isinstance(val, bool): + return val + s = str(val).strip().lower() + if s in ("", "auto", "default", "detect"): + return None + if s in ("1", "true", "yes", "on", "headless"): + return True + if s in ("0", "false", "no", "off", "headed", "gui"): + return False + return None + + +def resolve_headless_preference(preference=None) -> bool | None: + """Resolve headless preference from arg → env → config → auto. + + Returns: + True = force headless + False = force headed + None = auto (headless when no DISPLAY) + """ + if preference is not None: + parsed = _truthy_headless(preference) + if parsed is not None or str(preference).strip().lower() in ("", "auto", "default", "detect"): + return parsed + env_raw = (os.environ.get("BROWSER_HEADLESS") or os.environ.get("GROK_HEADLESS") or "").strip() + if env_raw: + return _truthy_headless(env_raw) + try: + cfg_val = config.get("browser_headless", "auto") if isinstance(config, dict) else "auto" + except Exception: + cfg_val = "auto" + return _truthy_headless(cfg_val) + + +def should_use_headless(preference=None, *, log_hint: bool = True) -> bool: + """Decide whether Chromium should start headless. + + Auto (default): headless when no DISPLAY/WAYLAND_DISPLAY. + Force headed without display is impossible — still headless + warn. + """ + pref = resolve_headless_preference(preference) + display_ok = has_display() + if pref is True: + if log_hint: + log_headless_mode(forced=True, auto=False, display_ok=display_ok) + return True + if pref is False: + if display_ok: + return False + # Cannot start headed without display — force headless + if log_hint: + log_headless_mode(forced=True, auto=True, display_ok=False, wanted_headed=True) + return True + # auto + use = not display_ok + if use and log_hint: + log_headless_mode(forced=False, auto=True, display_ok=False) + return use + + +def log_headless_mode( + *, + forced: bool = False, + auto: bool = False, + display_ok: bool = False, + wanted_headed: bool = False, +) -> None: + """Print one-shot guidance for headless / Xvfb on servers.""" + global _HEADLESS_HINT_SHOWN + with _HEADLESS_HINT_LOCK: + if _HEADLESS_HINT_SHOWN: + return + _HEADLESS_HINT_SHOWN = True + disp = (os.environ.get("DISPLAY") or "").strip() or "(unset)" + wayland = (os.environ.get("WAYLAND_DISPLAY") or "").strip() or "(unset)" + if wanted_headed and not display_ok: + print( + f"[!] browser_headless=false/--headed 但无图形界面 " + f"(DISPLAY={disp}, WAYLAND_DISPLAY={wayland}),仍强制 headless,否则无法启动。", + flush=True, + ) + elif auto and not display_ok: + print( + f"[*] 无图形界面 (DISPLAY={disp}),已自动启用 --headless=new。", + flush=True, + ) + elif forced: + print("[*] 已强制启用 headless 浏览器。", flush=True) + else: + return + print( + " 提示: headless 更容易被 Cloudflare/Turnstile 拦截;无头服务器推荐虚拟显示:\n" + " sudo apt install -y xvfb\n" + " xvfb-run -a uv run python -u register_cli.py --extra 1 --threads 1\n" + " 或常驻: Xvfb :99 -screen 0 1920x1080x24 & export DISPLAY=:99\n" + " 强制无头: config browser_headless=true / env BROWSER_HEADLESS=1 / CLI --headless\n" + " 强制有头: config browser_headless=false / CLI --headed(需 DISPLAY 或 xvfb-run)", + flush=True, + ) + + +def apply_headless_to_options(options, headless: bool = True) -> None: + """Apply headless flags to ChromiumOptions (DrissionPage).""" + if not headless: + try: + options.headless(False) + except Exception: + pass + return + try: + options.headless(True) + except Exception: + try: + options.set_argument("--headless=new") + except Exception: + try: + options.set_argument("--headless") + except Exception: + pass + # Extra flags commonly needed on Linux servers + for flag in ("--headless=new", "--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"): + try: + options.set_argument(flag) + except Exception: + pass + # Prefer Google Chrome for registration reliability (email delivery path). # Chromium is fallback; turnstilePatch extension only loads on Chromium. # On Chrome we inject the same patch via CDP add_init_js (see apply_stealth_patches). @@ -723,7 +873,14 @@ def apply_stealth_patches(page=None, log_callback=None) -> bool: return ok -def create_browser_options(): +def create_browser_options(headless=None): + """Build ChromiumOptions for register / TabPool. + + headless: + None → resolve from config/env/auto (no DISPLAY → headless) + True → force headless + False → prefer headed (still headless if no DISPLAY) + """ options = ChromiumOptions() options.auto_port() options.set_timeouts(base=1) @@ -766,6 +923,16 @@ def create_browser_options(): options.set_argument(f"--proxy-server={scheme}://{host}:{port}") except Exception as e: print(f" [proxy] set browser proxy failed: {e}") + + use_hl = should_use_headless(headless, log_hint=True) + apply_headless_to_options(options, use_hl) + if use_hl: + print(" [browser] headless=True", flush=True) + else: + print( + f" [browser] headed DISPLAY={os.environ.get('DISPLAY', '')!r}", + flush=True, + ) return options diff --git a/register_cli.py b/register_cli.py index 859fe95..f2d8fc7 100644 --- a/register_cli.py +++ b/register_cli.py @@ -33,9 +33,11 @@ _orig_create_browser_options = reg.create_browser_options def _patched_create_browser_options(): - # Prefer original factory (proxy + CHROMIUM_SLIM_FLAGS + extension) + # Prefer original factory (proxy + CHROMIUM_SLIM_FLAGS + extension + auto-headless) + used_orig = False try: opts = _orig_create_browser_options() + used_orig = True except Exception: from DrissionPage import ChromiumOptions @@ -107,6 +109,30 @@ def _patched_create_browser_options(): opts.add_extension(ext_path) except Exception: pass + + # Fallback path only: original factory already applied headless when used_orig. + if not used_orig: + try: + apply = getattr(reg, "apply_headless_to_options", None) + should = getattr(reg, "should_use_headless", None) + if callable(apply) and callable(should): + use_hl = should(None, log_hint=True) + apply(opts, use_hl) + print(f"[browser] headless={use_hl} (fallback options)", flush=True) + else: + # Minimal fallback without helpers + has_disp = sys.platform == "win32" or bool( + (os.environ.get("DISPLAY") or "").strip() + or (os.environ.get("WAYLAND_DISPLAY") or "").strip() + ) + if not has_disp: + try: + opts.headless(True) + except Exception: + opts.set_argument("--headless=new") + print("[browser] headless=True (no DISPLAY, minimal fallback)", flush=True) + except Exception as exc: + print(f"[browser] headless apply failed: {exc}", flush=True) return opts @@ -573,6 +599,16 @@ def main() -> int: parser.add_argument("--browser-recycle-every", type=int, default=25, help="复用 N 次后完整回收") parser.add_argument("--cookie-snapshot", action="store_true", help="注册成功写 cookie 快照(默认关,fast)") parser.add_argument("--inline-mint", action="store_true", help="强制注册线程内联 mint(调试用)") + parser.add_argument( + "--headless", + action="store_true", + help="强制无头浏览器(无 DISPLAY 时默认也会自动 headless)", + ) + parser.add_argument( + "--headed", + action="store_true", + help="强制有头浏览器(需 DISPLAY;无显示时仍会回退 headless 并提示 Xvfb)", + ) args = parser.parse_args() reg.load_config() @@ -580,6 +616,19 @@ def main() -> int: threads = max(1, min(args.threads, 10)) fast = bool(args.fast) and not bool(args.no_fast) + # Headless preference: CLI > config/env/auto (no DISPLAY → headless) + if args.headless and args.headed: + print("[!] --headless 与 --headed 互斥,忽略 --headed", flush=True) + args.headed = False + if args.headless: + cfg0["browser_headless"] = True + if isinstance(getattr(reg, "config", None), dict): + reg.config["browser_headless"] = True + elif args.headed: + cfg0["browser_headless"] = False + if isinstance(getattr(reg, "config", None), dict): + reg.config["browser_headless"] = False + mint_workers = resolve_mint_workers( cli_value=args.mint_workers, threads=threads, @@ -639,6 +688,28 @@ def main() -> int: print("[*] 所有账号已完成,无需继续(可用 --extra N 再注册)", flush=True) return 0 + # Surface display/headless decision early (before TabPool cold-start) + try: + will_hl = reg.should_use_headless(None, log_hint=True) + has_disp = reg.has_display() + print( + f"[*] browser mode: {'headless' if will_hl else 'headed'} " + f"(DISPLAY={os.environ.get('DISPLAY', '')!r} has_display={has_disp})", + flush=True, + ) + # Keep cpa browser fallback aligned when config left cpa_headless unset/false + # and we are on a headless host — only auto-enable if user did not force headed. + if will_hl and not args.headed: + if "cpa_headless" not in cfg0 or cfg0.get("cpa_headless") is False: + # Auto-headless host: enable cpa headless unless explicitly headed CLI + if not has_disp: + cfg0["cpa_headless"] = True + if isinstance(getattr(reg, "config", None), dict): + reg.config["cpa_headless"] = True + print("[*] cpa_headless 自动=true(无 DISPLAY;协议 mint 仍优先纯 HTTP)", flush=True) + except Exception as exc: + print(f"[!] headless probe failed: {exc}", flush=True) + log_thread = threading.Thread(target=_log_writer, daemon=True) log_thread.start() @@ -646,6 +717,12 @@ def main() -> int: reg.TabPool.init(reg.create_browser_options, log_callback=lambda m: log(0, m)) except Exception as exc: print(f"[!] 浏览器初始化失败: {exc}", flush=True) + if not reg.has_display(): + print( + "[!] 无 DISPLAY 时若仍失败,请检查 Chrome/Chromium 是否安装,并尝试:\n" + " xvfb-run -a uv run python -u register_cli.py --extra 1 --threads 1", + flush=True, + ) return 1 task_queue: queue.Queue = queue.Queue() diff --git a/start.sh b/start.sh index c191d29..21460a5 100755 --- a/start.sh +++ b/start.sh @@ -15,6 +15,12 @@ # 3. 同步 Python 依赖 # 4. 尝试安装 Chrome/Chromium(如缺失且有权限) # 5. 从模板复制 .env / config.json(如缺失) +# 6. 无 DISPLAY 时自动装/启 Xvfb(虚拟显示,过 CF 比纯 headless 稳) +# +# 环境变量: +# SKIP_XVFB=1 # 强制不用 Xvfb(走 Python 侧自动 headless) +# FORCE_XVFB=1 # 即使已有 DISPLAY 也用 xvfb-run 包一层 +# XVFB_DISPLAY=:99 # 常驻 Xvfb 使用的 display(默认 :99) # ============================================================================ set -euo pipefail @@ -32,6 +38,11 @@ cd "$PROJECT_DIR" # uv 常见安装路径,确保后续命令能找到 export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" +# 启动包装:默认直接 exec;无桌面时改为 xvfb-run / 自启 Xvfb +RUN_PREFIX=() +USE_XVFB=0 +XVFB_MODE="" # xvfb-run | persistent | none + echo -e "${CYAN}${BOLD}============================================${NC}" echo -e "${CYAN}${BOLD} Grok 注册机 - 一键启动 (Linux/macOS)${NC}" echo -e "${CYAN}${BOLD}============================================${NC}" @@ -41,9 +52,159 @@ have_cmd() { command -v "$1" &>/dev/null } +# 是否已有可用图形显示(含用户已 export 的 DISPLAY / Wayland) +has_display() { + [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ] +} + +# 尝试用包管理器安装 xvfb(仅免密 sudo 时自动装) +try_install_xvfb() { + if have_cmd xvfb-run || have_cmd Xvfb; then + return 0 + fi + + echo -e " ${YELLOW}⚠${NC} 未检测到 Xvfb,尝试自动安装..." + + if have_cmd apt-get && have_cmd sudo && sudo -n true 2>/dev/null; then + echo " → apt 安装 xvfb..." + if sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq \ + && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq xvfb 2>/dev/null; then + echo -e " ${GREEN}✓${NC} xvfb 安装成功" + return 0 + fi + elif have_cmd dnf && have_cmd sudo && sudo -n true 2>/dev/null; then + echo " → dnf 安装 xorg-x11-server-Xvfb..." + if sudo dnf install -y xorg-x11-server-Xvfb 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Xvfb 安装成功" + return 0 + fi + elif have_cmd pacman && have_cmd sudo && sudo -n true 2>/dev/null; then + echo " → pacman 安装 xorg-server-xvfb..." + if sudo pacman -Sy --noconfirm xorg-server-xvfb 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Xvfb 安装成功" + return 0 + fi + elif have_cmd yum && have_cmd sudo && sudo -n true 2>/dev/null; then + echo " → yum 安装 xorg-x11-server-Xvfb..." + if sudo yum install -y xorg-x11-server-Xvfb 2>/dev/null; then + echo -e " ${GREEN}✓${NC} Xvfb 安装成功" + return 0 + fi + else + echo -e " ${YELLOW}⚠${NC} 无法自动安装(需要免密 sudo 或手动装)" + echo " Ubuntu/Debian: sudo apt install -y xvfb" + echo " Fedora: sudo dnf install -y xorg-x11-server-Xvfb" + echo " Arch: sudo pacman -S xorg-server-xvfb" + fi + + if have_cmd xvfb-run || have_cmd Xvfb; then + return 0 + fi + return 1 +} + +# 启动常驻 Xvfb 并 export DISPLAY +start_persistent_xvfb() { + local disp="${XVFB_DISPLAY:-:99}" + local sock="/tmp/.X11-unix/X${disp#:}" + + # 已有该 display 的 X socket 则直接复用 + if [ -S "$sock" ] 2>/dev/null; then + export DISPLAY="$disp" + echo -e " ${GREEN}✓${NC} 复用已有 Xvfb DISPLAY=$DISPLAY" + return 0 + fi + if ! have_cmd Xvfb; then + return 1 + fi + echo " → 启动常驻 Xvfb $disp ..." + # 后台起;失败不阻断(上层会回退 headless) + Xvfb "$disp" -screen 0 1920x1080x24 -ac +extension GLX +render -noreset \ + >/tmp/grok-xvfb.log 2>&1 & + local xvfb_pid=$! + sleep 0.5 + if [ -S "$sock" ] 2>/dev/null || kill -0 "$xvfb_pid" 2>/dev/null; then + export DISPLAY="$disp" + echo -e " ${GREEN}✓${NC} Xvfb 已启动 DISPLAY=$DISPLAY pid=$xvfb_pid (log: /tmp/grok-xvfb.log)" + return 0 + fi + echo -e " ${YELLOW}⚠${NC} 常驻 Xvfb 启动失败,见 /tmp/grok-xvfb.log" + return 1 +} + +# 无桌面时准备虚拟显示;成功则 RUN_PREFIX / DISPLAY 已设好 +step6_ensure_display() { + echo -e "${CYAN}[6/6] 检测图形显示 / 自动 Xvfb...${NC}" + + # macOS 一般有 Aqua,不强制 Xvfb + if [ "$(uname -s 2>/dev/null || echo unknown)" = "Darwin" ]; then + if has_display; then + echo -e " ${GREEN}✓${NC} macOS 已有显示 DISPLAY=${DISPLAY:-} WAYLAND=${WAYLAND_DISPLAY:-}" + else + echo -e " ${GREEN}✓${NC} macOS:不强制 Xvfb(Python 侧可 headless)" + fi + USE_XVFB=0 + XVFB_MODE="none" + return 0 + fi + + if [ "${SKIP_XVFB:-0}" = "1" ]; then + echo -e " ${YELLOW}⚠${NC} SKIP_XVFB=1:跳过虚拟显示,走 Python 自动 headless" + USE_XVFB=0 + XVFB_MODE="none" + return 0 + fi + + local need_xvfb=0 + if [ "${FORCE_XVFB:-0}" = "1" ]; then + need_xvfb=1 + echo -e " ${CYAN}→${NC} FORCE_XVFB=1,将使用虚拟显示" + elif ! has_display; then + need_xvfb=1 + echo -e " ${YELLOW}⚠${NC} 无 DISPLAY/WAYLAND(无头服务器),准备自动启用 Xvfb" + else + echo -e " ${GREEN}✓${NC} 已有显示 DISPLAY=${DISPLAY:-} WAYLAND=${WAYLAND_DISPLAY:-}" + USE_XVFB=0 + XVFB_MODE="none" + return 0 + fi + + if [ "$need_xvfb" -ne 1 ]; then + return 0 + fi + + try_install_xvfb || true + + # 优先 xvfb-run(自动分配 display,最省心) + if have_cmd xvfb-run; then + RUN_PREFIX=(xvfb-run -a -s "-screen 0 1920x1080x24") + USE_XVFB=1 + XVFB_MODE="xvfb-run" + echo -e " ${GREEN}✓${NC} 将用 xvfb-run 启动(虚拟有头,利于过 CF/Turnstile)" + echo " 若需纯 headless: SKIP_XVFB=1 ./start.sh ..." + return 0 + fi + + # 其次常驻 Xvfb + export DISPLAY + if start_persistent_xvfb; then + RUN_PREFIX=() + USE_XVFB=1 + XVFB_MODE="persistent" + echo -e " ${GREEN}✓${NC} 使用常驻 Xvfb DISPLAY=$DISPLAY" + return 0 + fi + + # 都失败 → Python 侧 create_browser_options 会自动 headless + echo -e " ${YELLOW}⚠${NC} 未能启用 Xvfb,将依赖 Python 自动 headless(可能更易被 CF 拦)" + echo " 手动: sudo apt install -y xvfb && xvfb-run -a ./start.sh" + USE_XVFB=0 + XVFB_MODE="none" + return 0 +} + # ── 0. 基础工具 ── step0_basics() { - echo -e "${CYAN}[0/5] 检查基础工具...${NC}" + echo -e "${CYAN}[0/6] 检查基础工具...${NC}" if ! have_cmd curl && ! have_cmd wget; then echo -e " ${RED}✗${NC} 需要 curl 或 wget 才能自动安装环境" echo " 请先安装: sudo apt install -y curl 或 sudo yum install -y curl" @@ -58,7 +219,7 @@ step0_basics() { # ── 1. 检测/安装 uv ── step1_install_uv() { - echo -e "${CYAN}[1/5] 检测/安装 uv 包管理器...${NC}" + echo -e "${CYAN}[1/6] 检测/安装 uv 包管理器...${NC}" if have_cmd uv; then echo -e " ${GREEN}✓${NC} uv: $(uv --version 2>&1)" @@ -92,7 +253,7 @@ step1_install_uv() { # ── 2. 检测/安装 Python 3.13 ── # 策略: 优先系统/mise 已有 3.13;否则用 uv 下载管理 Python step2_install_python() { - echo -e "${CYAN}[2/5] 检测/安装 Python 3.13...${NC}" + echo -e "${CYAN}[2/6] 检测/安装 Python 3.13...${NC}" PYTHON_BIN="" # mise 托管的 python @@ -160,7 +321,7 @@ step2_install_python() { # ── 3. 同步依赖 ── step3_sync_deps() { - echo -e "${CYAN}[3/5] 同步 Python 依赖...${NC}" + echo -e "${CYAN}[3/6] 同步 Python 依赖...${NC}" # 固定使用 3.13,避免系统默认 python 过旧 if ! uv sync --python 3.13; then @@ -176,7 +337,7 @@ step3_sync_deps() { # ── 4. 检测/尝试安装浏览器 ── step4_check_browser() { - echo -e "${CYAN}[4/5] 检测/安装浏览器...${NC}" + echo -e "${CYAN}[4/6] 检测/安装浏览器...${NC}" local candidate for candidate in \ @@ -262,7 +423,7 @@ step4_check_browser() { # ── 5. 配置文件模板 ── step5_config_templates() { - echo -e "${CYAN}[5/5] 检查配置文件...${NC}" + echo -e "${CYAN}[5/6] 检查配置文件...${NC}" if [ ! -f "$PROJECT_DIR/.env" ] && [ -f "$PROJECT_DIR/.env.example" ]; then cp "$PROJECT_DIR/.env.example" "$PROJECT_DIR/.env" @@ -298,10 +459,19 @@ main() { echo "" step5_config_templates || exit 1 echo "" + step6_ensure_display + echo "" echo -e "${CYAN}${BOLD}============================================${NC}" echo -e "${GREEN}${BOLD} 环境就绪,启动 register_cli.py${NC}" echo -e "${CYAN}${BOLD}============================================${NC}" + if [ "$USE_XVFB" = "1" ]; then + echo -e " 显示模式: ${GREEN}Xvfb (${XVFB_MODE})${NC} DISPLAY=${DISPLAY:-(xvfb-run 自动分配)}" + elif has_display; then + echo -e " 显示模式: ${GREEN}headed${NC} DISPLAY=${DISPLAY:-} WAYLAND=${WAYLAND_DISPLAY:-}" + else + echo -e " 显示模式: ${YELLOW}headless (Python 自动)${NC}" + fi echo "" # 默认不限数量(--count 0),除非用户显式传参 @@ -320,7 +490,13 @@ main() { mise install >/dev/null 2>&1 || true fi - exec uv run --python 3.13 python -u register_cli.py "${args[@]}" + if [ ${#RUN_PREFIX[@]} -gt 0 ]; then + echo -e "${CYAN}[*] 启动命令: ${RUN_PREFIX[*]} uv run --python 3.13 python -u register_cli.py ${args[*]}${NC}" + echo "" + exec "${RUN_PREFIX[@]}" uv run --python 3.13 python -u register_cli.py "${args[@]}" + else + exec uv run --python 3.13 python -u register_cli.py "${args[@]}" + fi } main "$@"