Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| # launch_camoufox.py | |
| import sys | |
| import subprocess | |
| import time | |
| import re | |
| import os | |
| import signal | |
| import atexit | |
| import argparse | |
| import select | |
| import traceback | |
| import json | |
| import threading | |
| import queue | |
| import logging | |
| import logging.handlers | |
| import socket | |
| import platform | |
| import shutil | |
| # --- 新的导入 --- | |
| import uvicorn | |
| from server import app # 从 server.py 导入 FastAPI app 对象 | |
| from dotenv import load_dotenv | |
| # 加载 .env 文件 | |
| load_dotenv() | |
| # ----------------- | |
| # 尝试导入 launch_server (用于内部启动模式,模拟 Camoufox 行为) | |
| try: | |
| from camoufox.server import launch_server | |
| from camoufox import DefaultAddons # 假设 DefaultAddons 包含 AntiFingerprint | |
| except ImportError: | |
| if '--internal-launch' in sys.argv or any(arg.startswith('--internal-') for arg in sys.argv): # 更广泛地检查内部参数 | |
| print("❌ 致命错误:内部启动模式需要 'camoufox.server.launch_server' 和 'camoufox.DefaultAddons' 但无法导入。", file=sys.stderr) | |
| print(" 这通常意味着 'camoufox' 包未正确安装或不在 PYTHONPATH 中。", file=sys.stderr) | |
| sys.exit(1) | |
| else: | |
| launch_server = None | |
| DefaultAddons = None | |
| # --- 配置常量 --- | |
| PYTHON_EXECUTABLE = sys.executable | |
| ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get('ENDPOINT_CAPTURE_TIMEOUT', '45')) # 秒 (from dev) | |
| DEFAULT_SERVER_PORT = int(os.environ.get('DEFAULT_FASTAPI_PORT', '2048')) # FastAPI 服务器端口 | |
| DEFAULT_CAMOUFOX_PORT = int(os.environ.get('DEFAULT_CAMOUFOX_PORT', '9222')) # Camoufox 调试端口 (如果内部启动需要) | |
| DEFAULT_STREAM_PORT = int(os.environ.get('STREAM_PORT', '3120')) # 流式代理服务器端口 | |
| DEFAULT_HELPER_ENDPOINT = os.environ.get('GUI_DEFAULT_HELPER_ENDPOINT', '') # 外部 Helper 端点 | |
| DEFAULT_AUTH_SAVE_TIMEOUT = int(os.environ.get('AUTH_SAVE_TIMEOUT', '30')) # 认证保存超时时间 | |
| DEFAULT_SERVER_LOG_LEVEL = os.environ.get('SERVER_LOG_LEVEL', 'INFO') # 服务器日志级别 | |
| AUTH_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "/tmp/auth_profiles") | |
| ACTIVE_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "active") | |
| SAVED_AUTH_DIR = os.path.join(AUTH_PROFILES_DIR, "saved") | |
| HTTP_PROXY = os.environ.get('HTTP_PROXY', '') | |
| HTTPS_PROXY = os.environ.get('HTTPS_PROXY', '') | |
| LOG_DIR = os.path.join(os.path.dirname(__file__), '/tmp/logs') | |
| LAUNCHER_LOG_FILE_PATH = os.path.join(LOG_DIR, 'launch_app.log') | |
| # --- 全局进程句柄 --- | |
| camoufox_proc = None | |
| # --- 日志记录器实例 --- | |
| logger = logging.getLogger("CamoufoxLauncher") | |
| # --- WebSocket 端点正则表达式 --- | |
| ws_regex = re.compile(r"(ws://\S+)") | |
| # --- 线程安全的输出队列处理函数 (_enqueue_output) (from dev - more robust error handling) --- | |
| def _enqueue_output(stream, stream_name, output_queue, process_pid_for_log="<未知PID>"): | |
| log_prefix = f"[读取线程-{stream_name}-PID:{process_pid_for_log}]" | |
| try: | |
| for line_bytes in iter(stream.readline, b''): | |
| if not line_bytes: | |
| break | |
| try: | |
| line_str = line_bytes.decode('utf-8', errors='replace') | |
| output_queue.put((stream_name, line_str)) | |
| except Exception as decode_err: | |
| logger.warning(f"{log_prefix} 解码错误: {decode_err}。原始数据 (前100字节): {line_bytes[:100]}") | |
| output_queue.put((stream_name, f"[解码错误: {decode_err}] {line_bytes[:100]}...\n")) | |
| except ValueError: | |
| logger.debug(f"{log_prefix} ValueError (流可能已关闭)。") | |
| except Exception as e: | |
| logger.error(f"{log_prefix} 读取流时发生意外错误: {e}", exc_info=True) | |
| finally: | |
| output_queue.put((stream_name, None)) | |
| if hasattr(stream, 'close') and not stream.closed: | |
| try: | |
| stream.close() | |
| except Exception: | |
| pass | |
| logger.debug(f"{log_prefix} 线程退出。") | |
| # --- 设置本启动器脚本的日志系统 (setup_launcher_logging) (from dev - clears log on start) --- | |
| def setup_launcher_logging(log_level=logging.INFO): | |
| os.makedirs(LOG_DIR, exist_ok=True) | |
| file_log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - [%(name)s:%(funcName)s:%(lineno)d] - %(message)s') | |
| console_log_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') | |
| if logger.hasHandlers(): | |
| logger.handlers.clear() | |
| logger.setLevel(log_level) | |
| logger.propagate = False | |
| if os.path.exists(LAUNCHER_LOG_FILE_PATH): | |
| try: | |
| os.remove(LAUNCHER_LOG_FILE_PATH) | |
| except OSError: | |
| pass | |
| file_handler = logging.handlers.RotatingFileHandler( | |
| LAUNCHER_LOG_FILE_PATH, maxBytes=2*1024*1024, backupCount=3, encoding='utf-8', mode='w' | |
| ) | |
| file_handler.setFormatter(file_log_formatter) | |
| logger.addHandler(file_handler) | |
| stream_handler = logging.StreamHandler(sys.stderr) | |
| stream_handler.setFormatter(console_log_formatter) | |
| logger.addHandler(stream_handler) | |
| logger.info("=" * 30 + " Camoufox启动器日志系统已初始化 " + "=" * 30) | |
| logger.info(f"日志级别设置为: {logging.getLevelName(logger.getEffectiveLevel())}") | |
| logger.info(f"日志文件路径: {LAUNCHER_LOG_FILE_PATH}") | |
| # --- 确保认证文件目录存在 (ensure_auth_dirs_exist) --- | |
| def ensure_auth_dirs_exist(): | |
| logger.info("正在检查并确保认证文件目录存在...") | |
| try: | |
| os.makedirs(ACTIVE_AUTH_DIR, exist_ok=True) | |
| logger.info(f" ✓ 活动认证目录就绪: {ACTIVE_AUTH_DIR}") | |
| os.makedirs(SAVED_AUTH_DIR, exist_ok=True) | |
| logger.info(f" ✓ 已保存认证目录就绪: {SAVED_AUTH_DIR}") | |
| except Exception as e: | |
| logger.error(f" ❌ 创建认证目录失败: {e}", exc_info=True) | |
| sys.exit(1) | |
| # --- 清理函数 (在脚本退出时执行) (from dev - more detailed logging and checks) --- | |
| def cleanup(): | |
| global camoufox_proc | |
| logger.info("--- 开始执行清理程序 (launch_camoufox.py) ---") | |
| if camoufox_proc and camoufox_proc.poll() is None: | |
| pid = camoufox_proc.pid | |
| logger.info(f"正在终止 Camoufox 内部子进程 (PID: {pid})...") | |
| try: | |
| if sys.platform != "win32" and hasattr(os, 'getpgid') and hasattr(os, 'killpg'): | |
| try: | |
| pgid = os.getpgid(pid) | |
| logger.info(f" 向 Camoufox 进程组 (PGID: {pgid}) 发送 SIGTERM 信号...") | |
| os.killpg(pgid, signal.SIGTERM) | |
| except ProcessLookupError: | |
| logger.info(f" Camoufox 进程组 (PID: {pid}) 未找到,尝试直接终止进程...") | |
| camoufox_proc.terminate() | |
| else: | |
| logger.info(f" 向 Camoufox (PID: {pid}) 发送 SIGTERM 信号...") | |
| camoufox_proc.terminate() | |
| camoufox_proc.wait(timeout=5) | |
| logger.info(f" ✓ Camoufox (PID: {pid}) 已通过 SIGTERM 成功终止。") | |
| except subprocess.TimeoutExpired: | |
| logger.warning(f" ⚠️ Camoufox (PID: {pid}) SIGTERM 超时。正在发送 SIGKILL 强制终止...") | |
| if sys.platform != "win32" and hasattr(os, 'getpgid') and hasattr(os, 'killpg'): | |
| try: | |
| pgid = os.getpgid(pid) | |
| logger.info(f" 向 Camoufox 进程组 (PGID: {pgid}) 发送 SIGKILL 信号...") | |
| os.killpg(pgid, signal.SIGKILL) | |
| except ProcessLookupError: | |
| logger.info(f" Camoufox 进程组 (PID: {pid}) 在 SIGKILL 时未找到,尝试直接强制终止...") | |
| camoufox_proc.kill() | |
| else: | |
| camoufox_proc.kill() | |
| try: | |
| camoufox_proc.wait(timeout=2) | |
| logger.info(f" ✓ Camoufox (PID: {pid}) 已通过 SIGKILL 成功终止。") | |
| except Exception as e_kill: | |
| logger.error(f" ❌ 等待 Camoufox (PID: {pid}) SIGKILL 完成时出错: {e_kill}") | |
| except Exception as e_term: | |
| logger.error(f" ❌ 终止 Camoufox (PID: {pid}) 时发生错误: {e_term}", exc_info=True) | |
| finally: | |
| if hasattr(camoufox_proc, 'stdout') and camoufox_proc.stdout and not camoufox_proc.stdout.closed: | |
| camoufox_proc.stdout.close() | |
| if hasattr(camoufox_proc, 'stderr') and camoufox_proc.stderr and not camoufox_proc.stderr.closed: | |
| camoufox_proc.stderr.close() | |
| camoufox_proc = None | |
| elif camoufox_proc: | |
| logger.info(f"Camoufox 内部子进程 (PID: {camoufox_proc.pid if hasattr(camoufox_proc, 'pid') else 'N/A'}) 先前已自行结束,退出码: {camoufox_proc.poll()}。") | |
| camoufox_proc = None | |
| else: | |
| logger.info("Camoufox 内部子进程未运行或已清理。") | |
| logger.info("--- 清理程序执行完毕 (launch_camoufox.py) ---") | |
| atexit.register(cleanup) | |
| def signal_handler(sig, frame): | |
| logger.info(f"接收到信号 {signal.Signals(sig).name} ({sig})。正在启动退出程序...") | |
| sys.exit(0) | |
| signal.signal(signal.SIGINT, signal_handler) | |
| signal.signal(signal.SIGTERM, signal_handler) | |
| # --- 检查依赖项 (check_dependencies) (from dev - more comprehensive) --- | |
| def check_dependencies(): | |
| logger.info("--- 步骤 1: 检查依赖项 ---") | |
| required_modules = {} | |
| if launch_server is not None and DefaultAddons is not None: | |
| required_modules["camoufox"] = "camoufox (for server and addons)" | |
| elif launch_server is not None: | |
| required_modules["camoufox_server"] = "camoufox.server" | |
| logger.warning(" ⚠️ 'camoufox.server' 已导入,但 'camoufox.DefaultAddons' 未导入。排除插件功能可能受限。") | |
| missing_py_modules = [] | |
| dependencies_ok = True | |
| if required_modules: | |
| logger.info("正在检查 Python 模块:") | |
| for module_name, install_package_name in required_modules.items(): | |
| try: | |
| __import__(module_name) | |
| logger.info(f" ✓ 模块 '{module_name}' 已找到。") | |
| except ImportError: | |
| logger.error(f" ❌ 模块 '{module_name}' (包: '{install_package_name}') 未找到。") | |
| missing_py_modules.append(install_package_name) | |
| dependencies_ok = False | |
| else: | |
| # 检查是否是内部启动模式,如果是,则 camoufox 必须可导入 | |
| is_any_internal_arg = any(arg.startswith('--internal-') for arg in sys.argv) | |
| if is_any_internal_arg and (launch_server is None or DefaultAddons is None): | |
| logger.error(f" ❌ 内部启动模式 (--internal-*) 需要 'camoufox' 包,但未能导入。") | |
| dependencies_ok = False | |
| elif not is_any_internal_arg: | |
| logger.info("未请求内部启动模式,且未导入 camoufox.server,跳过对 'camoufox' Python 包的检查。") | |
| try: | |
| from server import app as server_app_check | |
| if server_app_check: | |
| logger.info(f" ✓ 成功从 'server.py' 导入 'app' 对象。") | |
| except ImportError as e_import_server: | |
| logger.error(f" ❌ 无法从 'server.py' 导入 'app' 对象: {e_import_server}") | |
| logger.error(f" 请确保 'server.py' 文件存在且没有导入错误。") | |
| dependencies_ok = False | |
| if not dependencies_ok: | |
| logger.error("-------------------------------------------------") | |
| logger.error("❌ 依赖项检查失败!") | |
| if missing_py_modules: | |
| logger.error(f" 缺少的 Python 库: {', '.join(missing_py_modules)}") | |
| logger.error(f" 请尝试使用 pip 安装: pip install {' '.join(missing_py_modules)}") | |
| logger.error("-------------------------------------------------") | |
| sys.exit(1) | |
| else: | |
| logger.info("✅ 所有启动器依赖项检查通过。") | |
| # --- 端口检查和清理函数 (from dev - more robust) --- | |
| def is_port_in_use(port: int, host: str = "0.0.0.0") -> bool: | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| try: | |
| s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| s.bind((host, port)) | |
| return False | |
| except OSError: | |
| return True | |
| except Exception as e: | |
| logger.warning(f"检查端口 {port} (主机 {host}) 时发生未知错误: {e}") | |
| return True | |
| def find_pids_on_port(port: int) -> list[int]: | |
| pids = [] | |
| system_platform = platform.system() | |
| command = "" | |
| try: | |
| if system_platform == "Linux" or system_platform == "Darwin": | |
| command = f"lsof -ti :{port} -sTCP:LISTEN" | |
| process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, close_fds=True) | |
| stdout, stderr = process.communicate(timeout=5) | |
| if process.returncode == 0 and stdout: | |
| pids = [int(pid) for pid in stdout.strip().split('\n') if pid.isdigit()] | |
| elif process.returncode != 0 and ("command not found" in stderr.lower() or "未找到命令" in stderr): | |
| logger.error(f"命令 'lsof' 未找到。请确保已安装。") | |
| elif process.returncode not in [0, 1]: # lsof 在未找到时返回1 | |
| logger.warning(f"执行 lsof 命令失败 (返回码 {process.returncode}): {stderr.strip()}") | |
| elif system_platform == "Windows": | |
| command = f'netstat -ano -p TCP | findstr "LISTENING" | findstr ":{port} "' | |
| process = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) | |
| stdout, stderr = process.communicate(timeout=10) | |
| if process.returncode == 0 and stdout: | |
| for line in stdout.strip().split('\n'): | |
| parts = line.split() | |
| if len(parts) >= 4 and parts[0].upper() == 'TCP' and f":{port}" in parts[1]: | |
| if parts[-1].isdigit(): pids.append(int(parts[-1])) | |
| pids = list(set(pids)) # 去重 | |
| elif process.returncode not in [0, 1]: # findstr 在未找到时返回1 | |
| logger.warning(f"执行 netstat/findstr 命令失败 (返回码 {process.returncode}): {stderr.strip()}") | |
| else: | |
| logger.warning(f"不支持的操作系统 '{system_platform}' 用于查找占用端口的进程。") | |
| except FileNotFoundError: | |
| cmd_name = command.split()[0] if command else "相关工具" | |
| logger.error(f"命令 '{cmd_name}' 未找到。") | |
| except subprocess.TimeoutExpired: | |
| logger.error(f"执行命令 '{command}' 超时。") | |
| except Exception as e: | |
| logger.error(f"查找占用端口 {port} 的进程时出错: {e}", exc_info=True) | |
| return pids | |
| def kill_process_interactive(pid: int) -> bool: | |
| system_platform = platform.system() | |
| success = False | |
| logger.info(f" 尝试终止进程 PID: {pid}...") | |
| try: | |
| if system_platform == "Linux" or system_platform == "Darwin": | |
| result_term = subprocess.run(f"kill {pid}", shell=True, capture_output=True, text=True, timeout=3, check=False) | |
| if result_term.returncode == 0: | |
| logger.info(f" ✓ PID {pid} 已发送 SIGTERM 信号。") | |
| success = True | |
| else: | |
| logger.warning(f" PID {pid} SIGTERM 失败: {result_term.stderr.strip() or result_term.stdout.strip()}. 尝试 SIGKILL...") | |
| result_kill = subprocess.run(f"kill -9 {pid}", shell=True, capture_output=True, text=True, timeout=3, check=False) | |
| if result_kill.returncode == 0: | |
| logger.info(f" ✓ PID {pid} 已发送 SIGKILL 信号。") | |
| success = True | |
| else: | |
| logger.error(f" ✗ PID {pid} SIGKILL 失败: {result_kill.stderr.strip() or result_kill.stdout.strip()}.") | |
| elif system_platform == "Windows": | |
| command_desc = f"taskkill /PID {pid} /T /F" | |
| result = subprocess.run(command_desc, shell=True, capture_output=True, text=True, timeout=5, check=False) | |
| output = result.stdout.strip() | |
| error_output = result.stderr.strip() | |
| if result.returncode == 0 and ("SUCCESS" in output.upper() or "成功" in output): | |
| logger.info(f" ✓ PID {pid} 已通过 taskkill /F 终止。") | |
| success = True | |
| elif "could not find process" in error_output.lower() or "找不到" in error_output: # 进程可能已自行退出 | |
| logger.info(f" PID {pid} 执行 taskkill 时未找到 (可能已退出)。") | |
| success = True # 视为成功,因为目标是端口可用 | |
| else: | |
| logger.error(f" ✗ PID {pid} taskkill /F 失败: {(error_output + ' ' + output).strip()}.") | |
| else: | |
| logger.warning(f" 不支持的操作系统 '{system_platform}' 用于终止进程。") | |
| except Exception as e: | |
| logger.error(f" 终止 PID {pid} 时发生意外错误: {e}", exc_info=True) | |
| return success | |
| # --- 带超时的用户输入函数 (from dev - more robust Windows implementation) --- | |
| def input_with_timeout(prompt_message: str, timeout_seconds: int = 30) -> str: | |
| print(prompt_message, end='', flush=True) | |
| if sys.platform == "win32": | |
| user_input_container = [None] | |
| def get_input_in_thread(): | |
| try: | |
| user_input_container[0] = sys.stdin.readline().strip() | |
| except Exception: | |
| user_input_container[0] = "" # 出错时返回空字符串 | |
| input_thread = threading.Thread(target=get_input_in_thread, daemon=True) | |
| input_thread.start() | |
| input_thread.join(timeout=timeout_seconds) | |
| if input_thread.is_alive(): | |
| print("\n输入超时。将使用默认值。", flush=True) | |
| return "" | |
| return user_input_container[0] if user_input_container[0] is not None else "" | |
| else: # Linux/macOS | |
| readable_fds, _, _ = select.select([sys.stdin], [], [], timeout_seconds) | |
| if readable_fds: | |
| return sys.stdin.readline().strip() | |
| else: | |
| print("\n输入超时。将使用默认值。", flush=True) | |
| return "" | |
| def get_proxy_from_gsettings(): | |
| """ | |
| Retrieves the proxy settings from GSettings on Linux systems. | |
| Returns a proxy string like "http://host:port" or None. | |
| """ | |
| def _run_gsettings_command(command_parts: list[str]) -> str | None: | |
| """Helper function to run gsettings command and return cleaned string output.""" | |
| try: | |
| process_result = subprocess.run( | |
| command_parts, | |
| capture_output=True, | |
| text=True, | |
| check=False, # Do not raise CalledProcessError for non-zero exit codes | |
| timeout=1 # Timeout for the subprocess call | |
| ) | |
| if process_result.returncode == 0: | |
| value = process_result.stdout.strip() | |
| if value.startswith("'") and value.endswith("'"): # Remove surrounding single quotes | |
| value = value[1:-1] | |
| # If after stripping quotes, value is empty, or it's a gsettings "empty" representation | |
| if not value or value == "''" or value == "@as []" or value == "[]": | |
| return None | |
| return value | |
| else: | |
| return None | |
| except subprocess.TimeoutExpired: | |
| return None | |
| except Exception: # Broad exception as per pseudocode | |
| return None | |
| proxy_mode = _run_gsettings_command(["gsettings", "get", "org.gnome.system.proxy", "mode"]) | |
| if proxy_mode == "manual": | |
| # Try HTTP proxy first | |
| http_host = _run_gsettings_command(["gsettings", "get", "org.gnome.system.proxy.http", "host"]) | |
| http_port_str = _run_gsettings_command(["gsettings", "get", "org.gnome.system.proxy.http", "port"]) | |
| if http_host and http_port_str: | |
| try: | |
| http_port = int(http_port_str) | |
| if http_port > 0: | |
| return f"http://{http_host}:{http_port}" | |
| except ValueError: | |
| pass # Continue to HTTPS | |
| # Try HTTPS proxy if HTTP not found or invalid | |
| https_host = _run_gsettings_command(["gsettings", "get", "org.gnome.system.proxy.https", "host"]) | |
| https_port_str = _run_gsettings_command(["gsettings", "get", "org.gnome.system.proxy.https", "port"]) | |
| if https_host and https_port_str: | |
| try: | |
| https_port = int(https_port_str) | |
| if https_port > 0: | |
| # Note: Even for HTTPS proxy settings, the scheme for Playwright/requests is usually http:// | |
| return f"http://{https_host}:{https_port}" | |
| except ValueError: | |
| pass | |
| return None | |
| def determine_proxy_configuration(internal_camoufox_proxy_arg=None): | |
| """ | |
| 统一的代理配置确定函数 | |
| 按优先级顺序:命令行参数 > 环境变量 > 系统设置 | |
| Args: | |
| internal_camoufox_proxy_arg: --internal-camoufox-proxy 命令行参数值 | |
| Returns: | |
| dict: 包含代理配置信息的字典 | |
| { | |
| 'camoufox_proxy': str or None, # Camoufox浏览器使用的代理 | |
| 'stream_proxy': str or None, # 流式代理服务使用的上游代理 | |
| 'source': str # 代理来源说明 | |
| } | |
| """ | |
| result = { | |
| 'camoufox_proxy': None, | |
| 'stream_proxy': None, | |
| 'source': '无代理' | |
| } | |
| # 1. 优先使用命令行参数 | |
| if internal_camoufox_proxy_arg is not None: | |
| if internal_camoufox_proxy_arg.strip(): # 非空字符串 | |
| result['camoufox_proxy'] = internal_camoufox_proxy_arg.strip() | |
| result['stream_proxy'] = internal_camoufox_proxy_arg.strip() | |
| result['source'] = f"命令行参数 --internal-camoufox-proxy: {internal_camoufox_proxy_arg.strip()}" | |
| else: # 空字符串,明确禁用代理 | |
| result['source'] = "命令行参数 --internal-camoufox-proxy='' (明确禁用代理)" | |
| return result | |
| # 2. 尝试环境变量 UNIFIED_PROXY_CONFIG (优先级高于 HTTP_PROXY/HTTPS_PROXY) | |
| unified_proxy = os.environ.get("UNIFIED_PROXY_CONFIG") | |
| if unified_proxy: | |
| result['camoufox_proxy'] = unified_proxy | |
| result['stream_proxy'] = unified_proxy | |
| result['source'] = f"环境变量 UNIFIED_PROXY_CONFIG: {unified_proxy}" | |
| return result | |
| # 3. 尝试环境变量 HTTP_PROXY | |
| http_proxy = os.environ.get("HTTP_PROXY") | |
| if http_proxy: | |
| result['camoufox_proxy'] = http_proxy | |
| result['stream_proxy'] = http_proxy | |
| result['source'] = f"环境变量 HTTP_PROXY: {http_proxy}" | |
| return result | |
| # 4. 尝试环境变量 HTTPS_PROXY | |
| https_proxy = os.environ.get("HTTPS_PROXY") | |
| if https_proxy: | |
| result['camoufox_proxy'] = https_proxy | |
| result['stream_proxy'] = https_proxy | |
| result['source'] = f"环境变量 HTTPS_PROXY: {https_proxy}" | |
| return result | |
| # 5. 尝试系统代理设置 (仅限 Linux) | |
| if sys.platform.startswith('linux'): | |
| gsettings_proxy = get_proxy_from_gsettings() | |
| if gsettings_proxy: | |
| result['camoufox_proxy'] = gsettings_proxy | |
| result['stream_proxy'] = gsettings_proxy | |
| result['source'] = f"gsettings 系统代理: {gsettings_proxy}" | |
| return result | |
| return result | |
| # --- 主执行逻辑 --- | |
| if __name__ == "__main__": | |
| # 检查是否是内部启动调用,如果是,则不配置 launcher 的日志 | |
| is_internal_call = any(arg.startswith('--internal-') for arg in sys.argv) | |
| if not is_internal_call: | |
| setup_launcher_logging(log_level=logging.INFO) | |
| parser = argparse.ArgumentParser( | |
| description="Camoufox 浏览器模拟与 FastAPI 代理服务器的启动器。", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter | |
| ) | |
| # 内部参数 (from dev) | |
| parser.add_argument('--internal-launch-mode', type=str, choices=['debug', 'headless', 'virtual_headless'], help=argparse.SUPPRESS) | |
| parser.add_argument('--internal-auth-file', type=str, default=None, help=argparse.SUPPRESS) | |
| parser.add_argument('--internal-camoufox-port', type=int, default=DEFAULT_CAMOUFOX_PORT, help=argparse.SUPPRESS) | |
| parser.add_argument('--internal-camoufox-proxy', type=str, default=None, help=argparse.SUPPRESS) | |
| parser.add_argument('--internal-camoufox-os', type=str, default="random", help=argparse.SUPPRESS) | |
| # 用户可见参数 (merged from dev and helper) | |
| parser.add_argument("--server-port", type=int, default=DEFAULT_SERVER_PORT, help=f"FastAPI 服务器监听的端口号 (默认: {DEFAULT_SERVER_PORT})") | |
| parser.add_argument( | |
| "--stream-port", | |
| type=int, | |
| default=DEFAULT_STREAM_PORT, # 从 .env 文件读取默认值 | |
| help=( | |
| f"流式代理服务器使用端口" | |
| f"提供来禁用此功能 --stream-port=0 . 默认: {DEFAULT_STREAM_PORT}" | |
| ) | |
| ) | |
| parser.add_argument( | |
| "--helper", | |
| type=str, | |
| default=DEFAULT_HELPER_ENDPOINT, # 使用默认值 | |
| help=( | |
| f"Helper 服务器的 getStreamResponse 端点地址 (例如: http://127.0.0.1:3121/getStreamResponse). " | |
| f"提供空字符串 (例如: --helper='') 来禁用此功能. 默认: {DEFAULT_HELPER_ENDPOINT}" | |
| ) | |
| ) | |
| parser.add_argument( | |
| "--camoufox-debug-port", # from dev | |
| type=int, | |
| default=DEFAULT_CAMOUFOX_PORT, | |
| help=f"内部 Camoufox 实例监听的调试端口号 (默认: {DEFAULT_CAMOUFOX_PORT})" | |
| ) | |
| mode_selection_group = parser.add_mutually_exclusive_group() # from dev (more options) | |
| mode_selection_group.add_argument("--debug", action="store_true", help="启动调试模式 (浏览器界面可见,允许交互式认证)") | |
| mode_selection_group.add_argument("--headless", action="store_true", help="启动无头模式 (浏览器无界面,需要预先保存的认证文件)") | |
| mode_selection_group.add_argument("--virtual-display", action="store_true", help="启动无头模式并使用虚拟显示 (Xvfb, 仅限 Linux)") # from dev | |
| # --camoufox-os 参数已移除,将由脚本内部自动检测系统并设置 | |
| parser.add_argument( # from dev | |
| "--active-auth-json", type=str, default=None, | |
| help="[无头模式/调试模式可选] 指定要使用的活动认证JSON文件的路径 (在 auth_profiles/active/ 或 auth_profiles/saved/ 中,或绝对路径)。" | |
| "如果未提供,无头模式将使用 active/ 目录中最新的JSON文件,调试模式将提示选择或不使用。" | |
| ) | |
| parser.add_argument( # from dev | |
| "--auto-save-auth", action='store_true', | |
| help="[调试模式] 在登录成功后,如果之前未加载认证文件,则自动提示并保存新的认证状态。" | |
| ) | |
| parser.add_argument( # from dev | |
| "--auth-save-timeout", type=int, default=DEFAULT_AUTH_SAVE_TIMEOUT, | |
| help=f"[调试模式] 自动保存认证或输入认证文件名的等待超时时间 (秒)。默认: {DEFAULT_AUTH_SAVE_TIMEOUT}" | |
| ) | |
| # 日志相关参数 (from dev) | |
| parser.add_argument( | |
| "--server-log-level", type=str, default=DEFAULT_SERVER_LOG_LEVEL, choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], | |
| help=f"server.py 的日志级别。默认: {DEFAULT_SERVER_LOG_LEVEL}" | |
| ) | |
| parser.add_argument( | |
| "--server-redirect-print", action='store_true', | |
| help="将 server.py 中的 print 输出重定向到其日志系统。默认不重定向以便调试模式下的 input() 提示可见。" | |
| ) | |
| parser.add_argument("--debug-logs", action='store_true', help="启用 server.py 内部的 DEBUG 级别详细日志 (环境变量 DEBUG_LOGS_ENABLED)。") | |
| parser.add_argument("--trace-logs", action='store_true', help="启用 server.py 内部的 TRACE 级别更详细日志 (环境变量 TRACE_LOGS_ENABLED)。") | |
| args = parser.parse_args() | |
| # --- 自动检测当前系统并设置 Camoufox OS 模拟 --- | |
| # 这个变量将用于后续的 Camoufox 内部启动和 HOST_OS_FOR_SHORTCUT 设置 | |
| current_system_for_camoufox = platform.system() | |
| if current_system_for_camoufox == "Linux": | |
| simulated_os_for_camoufox = "linux" | |
| elif current_system_for_camoufox == "Windows": | |
| simulated_os_for_camoufox = "windows" | |
| elif current_system_for_camoufox == "Darwin": # macOS | |
| simulated_os_for_camoufox = "macos" | |
| else: | |
| simulated_os_for_camoufox = "linux" # 未知系统的默认回退值 | |
| logger.warning(f"无法识别当前系统 '{current_system_for_camoufox}'。Camoufox OS 模拟将默认设置为: {simulated_os_for_camoufox}") | |
| logger.info(f"根据当前系统 '{current_system_for_camoufox}',Camoufox OS 模拟已自动设置为: {simulated_os_for_camoufox}") | |
| # --- 处理内部 Camoufox 启动逻辑 (如果脚本被自身作为子进程调用) (from dev) --- | |
| if args.internal_launch_mode: | |
| if not launch_server or not DefaultAddons: | |
| print("❌ 致命错误 (--internal-launch-mode): camoufox.server.launch_server 或 camoufox.DefaultAddons 不可用。脚本无法继续。", file=sys.stderr) | |
| sys.exit(1) | |
| internal_mode_arg = args.internal_launch_mode | |
| auth_file = args.internal_auth_file | |
| camoufox_port_internal = args.internal_camoufox_port | |
| # 使用统一的代理配置确定逻辑 | |
| proxy_config = determine_proxy_configuration(args.internal_camoufox_proxy) | |
| actual_proxy_to_use = proxy_config['camoufox_proxy'] | |
| print(f"--- [内部Camoufox启动] 代理配置: {proxy_config['source']} ---", flush=True) | |
| camoufox_proxy_internal = actual_proxy_to_use # 更新此变量以供后续使用 | |
| camoufox_os_internal = args.internal_camoufox_os | |
| print(f"--- [内部Camoufox启动] 模式: {internal_mode_arg}, 认证文件: {os.path.basename(auth_file) if auth_file else '无'}, " | |
| f"Camoufox端口: {camoufox_port_internal}, 代理: {camoufox_proxy_internal or '无'}, 模拟OS: {camoufox_os_internal} ---", flush=True) | |
| print(f"--- [内部Camoufox启动] 正在调用 camoufox.server.launch_server ... ---", flush=True) | |
| try: | |
| launch_args_for_internal_camoufox = { | |
| "port": camoufox_port_internal, | |
| "addons": [], | |
| # "proxy": camoufox_proxy_internal, # 已移除 | |
| "exclude_addons": [DefaultAddons.UBO], # Assuming DefaultAddons.UBO exists | |
| } | |
| # 正确添加代理的方式 | |
| if camoufox_proxy_internal: # 如果代理字符串存在且不为空 | |
| launch_args_for_internal_camoufox["proxy"] = {"server": camoufox_proxy_internal} | |
| # 如果 camoufox_proxy_internal 是 None 或空字符串,"proxy" 键就不会被添加。 | |
| if auth_file: | |
| launch_args_for_internal_camoufox["storage_state"] = auth_file | |
| if "," in camoufox_os_internal: | |
| camoufox_os_list_internal = [s.strip().lower() for s in camoufox_os_internal.split(',')] | |
| valid_os_values = ["windows", "macos", "linux"] | |
| if not all(val in valid_os_values for val in camoufox_os_list_internal): | |
| print(f"❌ 内部Camoufox启动错误: camoufox_os_internal 列表中包含无效值: {camoufox_os_list_internal}", file=sys.stderr) | |
| sys.exit(1) | |
| launch_args_for_internal_camoufox['os'] = camoufox_os_list_internal | |
| elif camoufox_os_internal.lower() in ["windows", "macos", "linux"]: | |
| launch_args_for_internal_camoufox['os'] = camoufox_os_internal.lower() | |
| elif camoufox_os_internal.lower() != "random": | |
| print(f"❌ 内部Camoufox启动错误: camoufox_os_internal 值无效: '{camoufox_os_internal}'", file=sys.stderr) | |
| sys.exit(1) | |
| print(f" 传递给 launch_server 的参数: {launch_args_for_internal_camoufox}", flush=True) | |
| if internal_mode_arg == 'headless': | |
| launch_server(headless=True, **launch_args_for_internal_camoufox) | |
| elif internal_mode_arg == 'virtual_headless': | |
| launch_server(headless="virtual", **launch_args_for_internal_camoufox) | |
| elif internal_mode_arg == 'debug': | |
| launch_server(headless=False, **launch_args_for_internal_camoufox) | |
| print(f"--- [内部Camoufox启动] camoufox.server.launch_server ({internal_mode_arg}模式) 调用已完成/阻塞。脚本将等待其结束。 ---", flush=True) | |
| except Exception as e_internal_launch_final: | |
| print(f"❌ 错误 (--internal-launch-mode): 执行 camoufox.server.launch_server 时发生异常: {e_internal_launch_final}", file=sys.stderr, flush=True) | |
| traceback.print_exc(file=sys.stderr) | |
| sys.exit(1) | |
| sys.exit(0) | |
| # --- 主启动器逻辑 --- | |
| logger.info("🚀 Camoufox 启动器开始运行 🚀") | |
| logger.info("=================================================") | |
| ensure_auth_dirs_exist() | |
| check_dependencies() | |
| logger.info("=================================================") | |
| deprecated_auth_state_path = os.path.join(os.path.dirname(__file__), "auth_state.json") | |
| if os.path.exists(deprecated_auth_state_path): | |
| logger.warning(f"检测到已弃用的认证文件: {deprecated_auth_state_path}。此文件不再被直接使用。") | |
| logger.warning("请使用调试模式生成新的认证文件,并按需管理 'auth_profiles' 目录中的文件。") | |
| final_launch_mode = None # from dev | |
| if args.debug: | |
| final_launch_mode = 'debug' | |
| elif args.headless: | |
| final_launch_mode = 'headless' | |
| elif args.virtual_display: # from dev | |
| final_launch_mode = 'virtual_headless' | |
| if platform.system() != "Linux": | |
| logger.warning("⚠️ --virtual-display 模式主要为 Linux 设计。在非 Linux 系统上,其行为可能与标准无头模式相同或导致 Camoufox 内部错误。") | |
| else: | |
| # 读取 .env 文件中的 LAUNCH_MODE 配置作为默认值 | |
| env_launch_mode = os.environ.get('LAUNCH_MODE', '').lower() | |
| default_mode_from_env = None | |
| default_interactive_choice = '1' # 默认选择无头模式 | |
| # 将 .env 中的 LAUNCH_MODE 映射到交互式选择 | |
| if env_launch_mode == 'headless': | |
| default_mode_from_env = 'headless' | |
| default_interactive_choice = '1' | |
| elif env_launch_mode == 'debug' or env_launch_mode == 'normal': | |
| default_mode_from_env = 'debug' | |
| default_interactive_choice = '2' | |
| elif env_launch_mode == 'virtual_display' or env_launch_mode == 'virtual_headless': | |
| default_mode_from_env = 'virtual_headless' | |
| default_interactive_choice = '3' if platform.system() == "Linux" else '1' | |
| logger.info("--- 请选择启动模式 (未通过命令行参数指定) ---") | |
| if env_launch_mode and default_mode_from_env: | |
| logger.info(f" 从 .env 文件读取到默认启动模式: {env_launch_mode} -> {default_mode_from_env}") | |
| prompt_options_text = "[1] 无头模式, [2] 调试模式" | |
| valid_choices = {'1': 'headless', '2': 'debug'} | |
| if platform.system() == "Linux": # from dev | |
| prompt_options_text += ", [3] 无头模式 (虚拟显示 Xvfb)" | |
| valid_choices['3'] = 'virtual_headless' | |
| # 构建提示信息,显示当前默认选择 | |
| default_mode_name = valid_choices.get(default_interactive_choice, 'headless') | |
| user_mode_choice = input_with_timeout( | |
| f" 请输入启动模式 ({prompt_options_text}; 默认: {default_interactive_choice} {default_mode_name}模式,{15}秒超时): ", 15 | |
| ) or default_interactive_choice | |
| if user_mode_choice in valid_choices: | |
| final_launch_mode = valid_choices[user_mode_choice] | |
| else: | |
| final_launch_mode = default_mode_from_env or 'headless' # 使用 .env 默认值或回退到无头模式 | |
| logger.info(f"无效输入 '{user_mode_choice}' 或超时,使用默认启动模式: {final_launch_mode}模式") | |
| logger.info(f"最终选择的启动模式: {final_launch_mode.replace('_', ' ')}模式") | |
| logger.info("-------------------------------------------------") | |
| if final_launch_mode == 'virtual_headless' and platform.system() == "Linux": # from dev | |
| logger.info("--- 检查 Xvfb (虚拟显示) 依赖 ---") | |
| if not shutil.which("Xvfb"): | |
| logger.error(" ❌ Xvfb 未找到。虚拟显示模式需要 Xvfb。请安装 (例如: sudo apt-get install xvfb) 后重试。") | |
| sys.exit(1) | |
| logger.info(" ✓ Xvfb 已找到。") | |
| server_target_port = args.server_port | |
| logger.info(f"--- 步骤 2: 检查 FastAPI 服务器目标端口 ({server_target_port}) 是否被占用 ---") | |
| port_is_available = False | |
| uvicorn_bind_host = "0.0.0.0" # from dev (was 127.0.0.1 in helper) | |
| if is_port_in_use(server_target_port, host=uvicorn_bind_host): | |
| logger.warning(f" ❌ 端口 {server_target_port} (主机 {uvicorn_bind_host}) 当前被占用。") | |
| pids_on_port = find_pids_on_port(server_target_port) | |
| if pids_on_port: | |
| logger.warning(f" 识别到以下进程 PID 可能占用了端口 {server_target_port}: {pids_on_port}") | |
| if final_launch_mode == 'debug': | |
| sys.stderr.flush() | |
| # Using input_with_timeout for consistency, though timeout might not be strictly needed here | |
| choice = input_with_timeout(f" 是否尝试终止这些进程? (y/n, 输入 n 将继续并可能导致启动失败, 15s超时): ", 15).strip().lower() | |
| if choice == 'y': | |
| logger.info(" 用户选择尝试终止进程...") | |
| all_killed = all(kill_process_interactive(pid) for pid in pids_on_port) | |
| time.sleep(2) | |
| if not is_port_in_use(server_target_port, host=uvicorn_bind_host): | |
| logger.info(f" ✅ 端口 {server_target_port} (主机 {uvicorn_bind_host}) 现在可用。") | |
| port_is_available = True | |
| else: | |
| logger.error(f" ❌ 尝试终止后,端口 {server_target_port} (主机 {uvicorn_bind_host}) 仍然被占用。") | |
| else: | |
| logger.info(" 用户选择不自动终止或超时。将继续尝试启动服务器。") | |
| else: | |
| logger.error(f" 无头模式下,不会尝试自动终止占用端口的进程。服务器启动可能会失败。") | |
| else: | |
| logger.warning(f" 未能自动识别占用端口 {server_target_port} 的进程。服务器启动可能会失败。") | |
| if not port_is_available: | |
| logger.warning(f"--- 端口 {server_target_port} 仍可能被占用。继续启动服务器,它将自行处理端口绑定。 ---") | |
| else: | |
| logger.info(f" ✅ 端口 {server_target_port} (主机 {uvicorn_bind_host}) 当前可用。") | |
| port_is_available = True | |
| logger.info("--- 步骤 3: 准备并启动 Camoufox 内部进程 ---") | |
| captured_ws_endpoint = None | |
| effective_active_auth_json_path = None # from dev | |
| if args.active_auth_json: | |
| logger.info(f" 尝试使用 --active-auth-json 参数提供的路径: '{args.active_auth_json}'") | |
| candidate_path = os.path.expanduser(args.active_auth_json) | |
| # 尝试解析路径: | |
| # 1. 作为绝对路径 | |
| if os.path.isabs(candidate_path) and os.path.exists(candidate_path) and os.path.isfile(candidate_path): | |
| effective_active_auth_json_path = candidate_path | |
| else: | |
| # 2. 作为相对于当前工作目录的路径 | |
| path_rel_to_cwd = os.path.abspath(candidate_path) | |
| if os.path.exists(path_rel_to_cwd) and os.path.isfile(path_rel_to_cwd): | |
| effective_active_auth_json_path = path_rel_to_cwd | |
| else: | |
| # 3. 作为相对于脚本目录的路径 | |
| path_rel_to_script = os.path.join(os.path.dirname(__file__), candidate_path) | |
| if os.path.exists(path_rel_to_script) and os.path.isfile(path_rel_to_script): | |
| effective_active_auth_json_path = path_rel_to_script | |
| # 4. 如果它只是一个文件名,则在 ACTIVE_AUTH_DIR 然后 SAVED_AUTH_DIR 中检查 | |
| elif not os.path.sep in candidate_path: # 这是一个简单的文件名 | |
| path_in_active = os.path.join(ACTIVE_AUTH_DIR, candidate_path) | |
| if os.path.exists(path_in_active) and os.path.isfile(path_in_active): | |
| effective_active_auth_json_path = path_in_active | |
| else: | |
| path_in_saved = os.path.join(SAVED_AUTH_DIR, candidate_path) | |
| if os.path.exists(path_in_saved) and os.path.isfile(path_in_saved): | |
| effective_active_auth_json_path = path_in_saved | |
| if effective_active_auth_json_path: | |
| logger.info(f" 将使用通过 --active-auth-json 解析的认证文件: {effective_active_auth_json_path}") | |
| else: | |
| logger.error(f"❌ 指定的认证文件 (--active-auth-json='{args.active_auth_json}') 未找到或不是一个文件。") | |
| sys.exit(1) | |
| else: | |
| # --active-auth-json 未提供。 | |
| if final_launch_mode == 'debug': | |
| # 对于调试模式,一律扫描全目录并提示用户选择,不自动使用任何文件 | |
| logger.info(f" 调试模式: 扫描全目录并提示用户从可用认证文件中选择...") | |
| else: | |
| # 对于无头模式,检查 active/ 目录中的默认认证文件 | |
| logger.info(f" --active-auth-json 未提供。检查 '{ACTIVE_AUTH_DIR}' 中的默认认证文件...") | |
| try: | |
| if os.path.exists(ACTIVE_AUTH_DIR): | |
| active_json_files = sorted([ | |
| f for f in os.listdir(ACTIVE_AUTH_DIR) | |
| if f.lower().endswith('.json') and os.path.isfile(os.path.join(ACTIVE_AUTH_DIR, f)) | |
| ]) | |
| if active_json_files: | |
| effective_active_auth_json_path = os.path.join(ACTIVE_AUTH_DIR, active_json_files[0]) | |
| logger.info(f" 将使用 '{ACTIVE_AUTH_DIR}' 中按名称排序的第一个JSON文件: {os.path.basename(effective_active_auth_json_path)}") | |
| else: | |
| logger.info(f" 目录 '{ACTIVE_AUTH_DIR}' 为空或不包含JSON文件。") | |
| else: | |
| logger.info(f" 目录 '{ACTIVE_AUTH_DIR}' 不存在。") | |
| except Exception as e_scan_active: | |
| logger.warning(f" 扫描 '{ACTIVE_AUTH_DIR}' 时发生错误: {e_scan_active}", exc_info=True) | |
| # 处理 debug 模式的用户选择逻辑 | |
| if final_launch_mode == 'debug': | |
| # 对于调试模式,一律扫描全目录并提示用户选择 | |
| available_profiles = [] | |
| # 首先扫描 ACTIVE_AUTH_DIR,然后是 SAVED_AUTH_DIR | |
| for profile_dir_path_str, dir_label in [(ACTIVE_AUTH_DIR, "active"), (SAVED_AUTH_DIR, "saved")]: | |
| if os.path.exists(profile_dir_path_str): | |
| try: | |
| # 在每个目录中对文件名进行排序 | |
| filenames = sorted([ | |
| f for f in os.listdir(profile_dir_path_str) | |
| if f.lower().endswith(".json") and os.path.isfile(os.path.join(profile_dir_path_str, f)) | |
| ]) | |
| for filename in filenames: | |
| full_path = os.path.join(profile_dir_path_str, filename) | |
| available_profiles.append({"name": f"{dir_label}/{filename}", "path": full_path}) | |
| except OSError as e: | |
| logger.warning(f" ⚠️ 警告: 无法读取目录 '{profile_dir_path_str}': {e}") | |
| if available_profiles: | |
| # 对可用配置文件列表进行排序,以确保一致的显示顺序 | |
| available_profiles.sort(key=lambda x: x['name']) | |
| print('-'*60 + "\n 找到以下可用的认证文件:", flush=True) | |
| for i, profile in enumerate(available_profiles): print(f" {i+1}: {profile['name']}", flush=True) | |
| print(" N: 不加载任何文件 (使用浏览器当前状态)\n" + '-'*60, flush=True) | |
| choice = input_with_timeout(f" 请选择要加载的认证文件编号 (输入 N 或直接回车则不加载, {args.auth_save_timeout}s超时): ", args.auth_save_timeout) | |
| if choice.strip().lower() not in ['n', '']: | |
| try: | |
| choice_index = int(choice.strip()) - 1 | |
| if 0 <= choice_index < len(available_profiles): | |
| selected_profile = available_profiles[choice_index] | |
| effective_active_auth_json_path = selected_profile["path"] | |
| logger.info(f" 已选择加载认证文件: {selected_profile['name']}") | |
| print(f" 已选择加载: {selected_profile['name']}", flush=True) | |
| else: | |
| logger.info(" 无效的选择编号或超时。将不加载认证文件。") | |
| print(" 无效的选择编号或超时。将不加载认证文件。", flush=True) | |
| except ValueError: | |
| logger.info(" 无效的输入。将不加载认证文件。") | |
| print(" 无效的输入。将不加载认证文件。", flush=True) | |
| else: | |
| logger.info(" 好的,不加载认证文件或超时。") | |
| print(" 好的,不加载认证文件或超时。", flush=True) | |
| print('-'*60, flush=True) | |
| else: | |
| logger.info(" 未找到认证文件。将使用浏览器当前状态。") | |
| print(" 未找到认证文件。将使用浏览器当前状态。", flush=True) | |
| elif not effective_active_auth_json_path: | |
| # 对于无头模式,如果 --active-auth-json 未提供且 active/ 为空,则报错 | |
| logger.error(f" ❌ {final_launch_mode} 模式错误: --active-auth-json 未提供,且活动认证目录 '{ACTIVE_AUTH_DIR}' 中未找到任何 '.json' 认证文件。请先在调试模式下保存一个或通过参数指定。") | |
| sys.exit(1) | |
| # 构建 Camoufox 内部启动命令 (from dev) | |
| camoufox_internal_cmd_args = [ | |
| PYTHON_EXECUTABLE, '-u', __file__, | |
| '--internal-launch-mode', final_launch_mode | |
| ] | |
| if effective_active_auth_json_path: | |
| camoufox_internal_cmd_args.extend(['--internal-auth-file', effective_active_auth_json_path]) | |
| camoufox_internal_cmd_args.extend(['--internal-camoufox-os', simulated_os_for_camoufox]) | |
| camoufox_internal_cmd_args.extend(['--internal-camoufox-port', str(args.camoufox_debug_port)]) | |
| # 修复:传递代理参数到内部Camoufox进程 | |
| if args.internal_camoufox_proxy is not None: | |
| camoufox_internal_cmd_args.extend(['--internal-camoufox-proxy', args.internal_camoufox_proxy]) | |
| camoufox_popen_kwargs = {'stdout': subprocess.PIPE, 'stderr': subprocess.PIPE, 'env': os.environ.copy()} | |
| camoufox_popen_kwargs['env']['PYTHONIOENCODING'] = 'utf-8' | |
| if sys.platform != "win32" and final_launch_mode != 'debug': | |
| camoufox_popen_kwargs['start_new_session'] = True | |
| elif sys.platform == "win32" and (final_launch_mode == 'headless' or final_launch_mode == 'virtual_headless'): | |
| camoufox_popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW | |
| try: | |
| logger.info(f" 将执行 Camoufox 内部启动命令: {' '.join(camoufox_internal_cmd_args)}") | |
| camoufox_proc = subprocess.Popen(camoufox_internal_cmd_args, **camoufox_popen_kwargs) | |
| logger.info(f" Camoufox 内部进程已启动 (PID: {camoufox_proc.pid})。正在等待 WebSocket 端点输出 (最长 {ENDPOINT_CAPTURE_TIMEOUT} 秒)...") | |
| camoufox_output_q = queue.Queue() | |
| camoufox_stdout_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stdout, "stdout", camoufox_output_q, camoufox_proc.pid), daemon=True) | |
| camoufox_stderr_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stderr, "stderr", camoufox_output_q, camoufox_proc.pid), daemon=True) | |
| camoufox_stdout_reader.start() | |
| camoufox_stderr_reader.start() | |
| ws_capture_start_time = time.time() | |
| camoufox_ended_streams_count = 0 | |
| while time.time() - ws_capture_start_time < ENDPOINT_CAPTURE_TIMEOUT: | |
| if camoufox_proc.poll() is not None: | |
| logger.error(f" Camoufox 内部进程 (PID: {camoufox_proc.pid}) 在等待 WebSocket 端点期间已意外退出,退出码: {camoufox_proc.poll()}。") | |
| break | |
| try: | |
| stream_name, line_from_camoufox = camoufox_output_q.get(timeout=0.2) | |
| if line_from_camoufox is None: | |
| camoufox_ended_streams_count += 1 | |
| logger.debug(f" [InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}] 输出流已关闭 (EOF)。") | |
| if camoufox_ended_streams_count >= 2: | |
| logger.info(f" Camoufox 内部进程 (PID: {camoufox_proc.pid}) 的所有输出流均已关闭。") | |
| break | |
| continue | |
| log_line_content = f"[InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}]: {line_from_camoufox.rstrip()}" | |
| if stream_name == "stderr" or "ERROR" in line_from_camoufox.upper() or "❌" in line_from_camoufox: | |
| logger.warning(log_line_content) | |
| else: | |
| logger.info(log_line_content) | |
| ws_match = ws_regex.search(line_from_camoufox) | |
| if ws_match: | |
| captured_ws_endpoint = ws_match.group(1) | |
| logger.info(f" ✅ 成功从 Camoufox 内部进程捕获到 WebSocket 端点: {captured_ws_endpoint[:40]}...") | |
| break | |
| except queue.Empty: | |
| continue | |
| if camoufox_stdout_reader.is_alive(): camoufox_stdout_reader.join(timeout=1.0) | |
| if camoufox_stderr_reader.is_alive(): camoufox_stderr_reader.join(timeout=1.0) | |
| if not captured_ws_endpoint and (camoufox_proc and camoufox_proc.poll() is None): | |
| logger.error(f" ❌ 未能在 {ENDPOINT_CAPTURE_TIMEOUT} 秒内从 Camoufox 内部进程 (PID: {camoufox_proc.pid}) 捕获到 WebSocket 端点。") | |
| logger.error(" Camoufox 内部进程仍在运行,但未输出预期的 WebSocket 端点。请检查其日志或行为。") | |
| cleanup() | |
| sys.exit(1) | |
| elif not captured_ws_endpoint and (camoufox_proc and camoufox_proc.poll() is not None): | |
| logger.error(f" ❌ Camoufox 内部进程已退出,且未能捕获到 WebSocket 端点。") | |
| sys.exit(1) | |
| elif not captured_ws_endpoint: | |
| logger.error(f" ❌ 未能捕获到 WebSocket 端点。") | |
| sys.exit(1) | |
| except Exception as e_launch_camoufox_internal: | |
| logger.critical(f" ❌ 在内部启动 Camoufox 或捕获其 WebSocket 端点时发生致命错误: {e_launch_camoufox_internal}", exc_info=True) | |
| cleanup() | |
| sys.exit(1) | |
| # --- Helper mode logic (New implementation) --- | |
| if args.helper: # 如果 args.helper 不是空字符串 (即 helper 功能已通过默认值或用户指定启用) | |
| logger.info(f" Helper 模式已启用,端点: {args.helper}") | |
| os.environ['HELPER_ENDPOINT'] = args.helper # 设置端点环境变量 | |
| if effective_active_auth_json_path: | |
| logger.info(f" 尝试从认证文件 '{os.path.basename(effective_active_auth_json_path)}' 提取 SAPISID...") | |
| sapisid = "" | |
| try: | |
| with open(effective_active_auth_json_path, 'r', encoding='utf-8') as file: | |
| auth_file_data = json.load(file) | |
| if "cookies" in auth_file_data and isinstance(auth_file_data["cookies"], list): | |
| for cookie in auth_file_data["cookies"]: | |
| if isinstance(cookie, dict) and cookie.get("name") == "SAPISID" and cookie.get("domain") == ".google.com": | |
| sapisid = cookie.get("value", "") | |
| break | |
| except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError) as e: | |
| logger.warning(f" ⚠️ 无法从认证文件 '{os.path.basename(effective_active_auth_json_path)}' 加载或解析SAPISID: {e}") | |
| except Exception as e_sapisid_extraction: | |
| logger.warning(f" ⚠️ 提取SAPISID时发生未知错误: {e_sapisid_extraction}") | |
| if sapisid: | |
| logger.info(f" ✅ 成功加载 SAPISID。将设置 HELPER_SAPISID 环境变量。") | |
| os.environ['HELPER_SAPISID'] = sapisid | |
| else: | |
| logger.warning(f" ⚠️ 未能从认证文件 '{os.path.basename(effective_active_auth_json_path)}' 中找到有效的 SAPISID。HELPER_SAPISID 将不会被设置。") | |
| if 'HELPER_SAPISID' in os.environ: # 清理,以防万一 | |
| del os.environ['HELPER_SAPISID'] | |
| else: # args.helper 有值 (Helper 模式启用), 但没有认证文件 | |
| logger.warning(f" ⚠️ Helper 模式已启用,但没有有效的认证文件来提取 SAPISID。HELPER_SAPISID 将不会被设置。") | |
| if 'HELPER_SAPISID' in os.environ: # 清理 | |
| del os.environ['HELPER_SAPISID'] | |
| else: # args.helper 是空字符串 (用户通过 --helper='' 禁用了 helper) | |
| logger.info(" Helper 模式已通过 --helper='' 禁用。") | |
| # 清理相关的环境变量 | |
| if 'HELPER_ENDPOINT' in os.environ: | |
| del os.environ['HELPER_ENDPOINT'] | |
| if 'HELPER_SAPISID' in os.environ: | |
| del os.environ['HELPER_SAPISID'] | |
| # --- 步骤 4: 设置环境变量并准备启动 FastAPI/Uvicorn 服务器 (from dev) --- | |
| logger.info("--- 步骤 4: 设置环境变量并准备启动 FastAPI/Uvicorn 服务器 ---") | |
| if captured_ws_endpoint: | |
| os.environ['CAMOUFOX_WS_ENDPOINT'] = captured_ws_endpoint | |
| else: | |
| logger.error(" 严重逻辑错误: WebSocket 端点未捕获,但程序仍在继续。") | |
| sys.exit(1) | |
| os.environ['LAUNCH_MODE'] = final_launch_mode | |
| os.environ['SERVER_LOG_LEVEL'] = args.server_log_level.upper() | |
| os.environ['SERVER_REDIRECT_PRINT'] = str(args.server_redirect_print).lower() | |
| os.environ['DEBUG_LOGS_ENABLED'] = str(args.debug_logs).lower() | |
| os.environ['TRACE_LOGS_ENABLED'] = str(args.trace_logs).lower() | |
| if effective_active_auth_json_path: | |
| os.environ['ACTIVE_AUTH_JSON_PATH'] = effective_active_auth_json_path | |
| os.environ['AUTO_SAVE_AUTH'] = str(args.auto_save_auth).lower() | |
| os.environ['AUTH_SAVE_TIMEOUT'] = str(args.auth_save_timeout) | |
| os.environ['SERVER_PORT_INFO'] = str(args.server_port) | |
| os.environ['STREAM_PORT'] = str(args.stream_port) | |
| # 设置统一的代理配置环境变量 | |
| proxy_config = determine_proxy_configuration(args.internal_camoufox_proxy) | |
| if proxy_config['stream_proxy']: | |
| os.environ['UNIFIED_PROXY_CONFIG'] = proxy_config['stream_proxy'] | |
| logger.info(f" 设置统一代理配置: {proxy_config['source']}") | |
| elif 'UNIFIED_PROXY_CONFIG' in os.environ: | |
| del os.environ['UNIFIED_PROXY_CONFIG'] | |
| host_os_for_shortcut_env = None | |
| camoufox_os_param_lower = simulated_os_for_camoufox.lower() | |
| if camoufox_os_param_lower == "macos": host_os_for_shortcut_env = "Darwin" | |
| elif camoufox_os_param_lower == "windows": host_os_for_shortcut_env = "Windows" | |
| elif camoufox_os_param_lower == "linux": host_os_for_shortcut_env = "Linux" | |
| if host_os_for_shortcut_env: | |
| os.environ['HOST_OS_FOR_SHORTCUT'] = host_os_for_shortcut_env | |
| elif 'HOST_OS_FOR_SHORTCUT' in os.environ: | |
| del os.environ['HOST_OS_FOR_SHORTCUT'] | |
| logger.info(f" 为 server.app 设置的环境变量:") | |
| env_keys_to_log = [ | |
| 'CAMOUFOX_WS_ENDPOINT', 'LAUNCH_MODE', 'SERVER_LOG_LEVEL', | |
| 'SERVER_REDIRECT_PRINT', 'DEBUG_LOGS_ENABLED', 'TRACE_LOGS_ENABLED', | |
| 'ACTIVE_AUTH_JSON_PATH', 'AUTO_SAVE_AUTH', 'AUTH_SAVE_TIMEOUT', | |
| 'SERVER_PORT_INFO', 'HOST_OS_FOR_SHORTCUT', | |
| 'HELPER_ENDPOINT', 'HELPER_SAPISID', 'STREAM_PORT', | |
| 'UNIFIED_PROXY_CONFIG' # 新增统一代理配置 | |
| ] | |
| for key in env_keys_to_log: | |
| if key in os.environ: | |
| val_to_log = os.environ[key] | |
| if key == 'CAMOUFOX_WS_ENDPOINT' and len(val_to_log) > 40: val_to_log = val_to_log[:40] + "..." | |
| if key == 'ACTIVE_AUTH_JSON_PATH': val_to_log = os.path.basename(val_to_log) | |
| logger.info(f" {key}={val_to_log}") | |
| else: | |
| logger.info(f" {key}= (未设置)") | |
| # --- 步骤 5: 启动 FastAPI/Uvicorn 服务器 (from dev) --- | |
| logger.info(f"--- 步骤 5: 启动集成的 FastAPI 服务器 (监听端口: {args.server_port}) ---") | |
| try: | |
| uvicorn.run( | |
| app, | |
| host="0.0.0.0", # Bind to all interfaces | |
| port=args.server_port, | |
| log_config=None # server.py will handle its own logging based on env vars | |
| ) | |
| logger.info("Uvicorn 服务器已停止。") | |
| except SystemExit as e_sysexit: | |
| logger.info(f"Uvicorn 或其子系统通过 sys.exit({e_sysexit.code}) 退出。") | |
| except Exception as e_uvicorn: | |
| logger.critical(f"❌ 运行 Uvicorn 时发生致命错误: {e_uvicorn}", exc_info=True) | |
| sys.exit(1) # Ensure launcher exits if Uvicorn fails critically | |
| logger.info("🚀 Camoufox 启动器主逻辑执行完毕 🚀") |