diff --git a/plugins/sehuatang_push/config.toml b/plugins/sehuatang_push/config.toml index 1880bff..d57bcb0 100644 --- a/plugins/sehuatang_push/config.toml +++ b/plugins/sehuatang_push/config.toml @@ -10,3 +10,16 @@ enabled = true [push] # 默认 @ 的用户名(可在后台任务 payload 中覆盖) at_user = "Jyunere" + +[browser] +# 是否优先复用已经长期在线的 Chrome 浏览器。 +# 复用模式要求该浏览器启动时带上 --remote-debugging-port=9222 这类参数。 +reuse_existing_browser = true + +# 常驻浏览器暴露出来的远程调试地址。 +debugger_host = "127.0.0.1" +debugger_port = 9222 + +# 当常驻浏览器不可用时,是否允许插件回退到“自己启动一个临时浏览器”的旧模式。 +# 如果你明确不希望插件再自己管理浏览器,可以改成 false。 +allow_launch_fallback = true diff --git a/plugins/sehuatang_push/main.py b/plugins/sehuatang_push/main.py index 5062d90..0eec07b 100644 --- a/plugins/sehuatang_push/main.py +++ b/plugins/sehuatang_push/main.py @@ -46,6 +46,23 @@ class SehuatangPushPlugin(MessagePluginInterface): super().__init__() self.feature = self.register_feature() + def _build_browser_config(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """整理浏览器配置,优先读取插件配置,再允许任务 payload 做局部覆盖。""" + # 这里把浏览器连接参数集中整理出来, + # 目的是让调度入口不需要关心太多细节,同时兼容后续从后台任务动态覆盖端口。 + browser_cfg = dict(self._config.get("browser", {}) or {}) + + if "reuse_existing_browser" in payload: + browser_cfg["reuse_existing_browser"] = payload.get("reuse_existing_browser") + if "debugger_host" in payload: + browser_cfg["debugger_host"] = payload.get("debugger_host") + if "debugger_port" in payload: + browser_cfg["debugger_port"] = payload.get("debugger_port") + if "allow_launch_fallback" in payload: + browser_cfg["allow_launch_fallback"] = payload.get("allow_launch_fallback") + + return browser_cfg + def initialize(self, context: Dict[str, Any]) -> bool: return True @@ -94,11 +111,15 @@ class SehuatangPushPlugin(MessagePluginInterface): payload = context.get("payload") or {} at_user = str(payload.get("at_user", "Jyunere") or "Jyunere").strip() + browser_config = self._build_browser_config(payload) + strict_reuse_existing_browser = bool(browser_config.get("reuse_existing_browser")) and not bool( + browser_config.get("allow_launch_fallback", True) + ) # 兼容历史逻辑:优先使用 undetected 方案,失败后回退普通抓取。 try: - ok, path = await asyncio.to_thread(pdf_file_path_undetected) - if not ok: + ok, path = await asyncio.to_thread(pdf_file_path_undetected, browser_config) + if not ok and not strict_reuse_existing_browser: ok, path = await asyncio.to_thread(pdf_file_path) if not ok: return {"success": False, "summary": "PDF 生成失败", "detail": {}} diff --git a/plugins/sehuatang_push/shehuatang_undetected.py b/plugins/sehuatang_push/shehuatang_undetected.py index bcabccf..29d438a 100644 --- a/plugins/sehuatang_push/shehuatang_undetected.py +++ b/plugins/sehuatang_push/shehuatang_undetected.py @@ -5,6 +5,7 @@ import subprocess import requests from io import BytesIO import undetected_chromedriver as uc +from selenium import webdriver # 注意:不要禁用析构函数,否则会导致Chrome进程泄漏 # if os.name == 'nt': @@ -30,6 +31,57 @@ from PyPDF2 import PdfReader, PdfWriter from loguru import logger +def _build_chrome_options(debugger_address=None): + """构建 Chrome 启动参数;当提供调试地址时,表示附着到外部常驻浏览器。""" + # 这里统一使用 Selenium 的 ChromeOptions, + # 这样既能给 undetected_chromedriver 复用,也能给 Selenium 直连现有浏览器复用。 + options = webdriver.ChromeOptions() + + if debugger_address: + # 当需要复用已经启动的浏览器时,只需要告诉 ChromeDriver 去连接哪个调试地址。 + # 这种模式下不会新拉起浏览器进程,因此也不应该再塞 headless 等启动参数。 + options.add_experimental_option("debuggerAddress", debugger_address) + return options + + # 下面这组参数用于“自己启动浏览器”的场景。 + # Linux 服务器上继续使用 headless,避免任务依赖桌面环境。 + if os.name != 'nt': + options.headless = True + options.add_argument('--headless=new') + else: + options.headless = False + + options.add_argument('--no-sandbox') + options.add_argument('--disable-gpu') + options.add_argument('--disable-dev-shm-usage') + options.add_argument('--disable-extensions') + options.add_argument('--disable-background-networking') + options.add_argument('--disable-crash-reporter') + options.add_argument('--disable-in-process-stack-traces') + options.add_argument('--disable-logging') + options.add_argument('--disable-dev-shm-usage') + return options + + +def _normalize_browser_config(browser_config=None): + """整理浏览器配置,保证后续逻辑总能拿到结构稳定的字典。""" + browser_config = browser_config or {} + return { + "reuse_existing_browser": bool(browser_config.get("reuse_existing_browser", False)), + "debugger_host": str(browser_config.get("debugger_host", "127.0.0.1") or "127.0.0.1").strip(), + "debugger_port": int(browser_config.get("debugger_port", 9222) or 9222), + "allow_launch_fallback": bool(browser_config.get("allow_launch_fallback", True)), + } + + +def _probe_existing_browser(debugger_host, debugger_port): + """探测常驻浏览器调试端口是否可用,并返回浏览器端的元信息。""" + version_url = f"http://{debugger_host}:{debugger_port}/json/version" + response = requests.get(version_url, timeout=5) + response.raise_for_status() + return response.json() + + def _detect_local_chrome_major_version(): """检测本机 Chrome/Chromium 主版本号,尽量让 ChromeDriver 跟浏览器版本保持一致。""" # 这里按不同平台准备一组常见的 Chrome/Chromium 可执行文件位置。 @@ -111,6 +163,42 @@ def _create_chrome_driver(options): raise +def _attach_to_existing_browser(browser_config): + """附着到已经启动的 Chrome 调试会话,避免重复创建和管理浏览器进程。""" + debugger_host = browser_config["debugger_host"] + debugger_port = browser_config["debugger_port"] + debugger_address = f"{debugger_host}:{debugger_port}" + + # 先探测调试端口,能提前把“端口没开”与“连接成功”的原因写清楚,排查会更轻松。 + browser_meta = _probe_existing_browser(debugger_host, debugger_port) + browser_version = browser_meta.get("Browser", "未知版本") + logger.info(f"准备复用常驻浏览器: {debugger_address},浏览器信息: {browser_version}") + + options = _build_chrome_options(debugger_address=debugger_address) + driver = webdriver.Chrome(options=options) + return driver + + +def _create_browser_session(browser_config=None): + """根据配置决定是复用常驻浏览器,还是回退到自管理浏览器。""" + normalized_config = _normalize_browser_config(browser_config) + + if normalized_config["reuse_existing_browser"]: + try: + driver = _attach_to_existing_browser(normalized_config) + # attached_to_existing_browser=True 表示后续清理时不能去关闭用户自己的常驻浏览器。 + return driver, True + except Exception as exc: + if not normalized_config["allow_launch_fallback"]: + logger.error(f"复用常驻浏览器失败,且已禁止启动备用浏览器: {exc}") + raise + logger.warning(f"复用常驻浏览器失败,准备回退到自管理浏览器: {exc}") + + options = _build_chrome_options() + driver = _create_chrome_driver(options) + return driver, False + + def download_image(url, session): """使用同步的 session 下载图片,确保 Cookie 一致""" try: @@ -138,34 +226,13 @@ def add_pdf_encryption(pdf_file, password="4000"): logger.error(f"PDF加密失败: {e}") -def fetch_and_create_pdf(url): +def fetch_and_create_pdf(url, browser_config=None): driver = None - service = None + attached_to_existing_browser = False try: - options = uc.ChromeOptions() - # 规避检测的关键配置 - # 在Linux服务器上使用headless模式 - if os.name != 'nt': - options.headless = True - options.add_argument('--headless=new') # 使用新版headless模式 - else: - options.headless = False - - options.add_argument('--no-sandbox') - options.add_argument('--disable-gpu') - options.add_argument('--disable-dev-shm-usage') - options.add_argument('--disable-extensions') - options.add_argument('--disable-background-networking') - # 确保进程能被正确清理 - options.add_argument('--disable-crash-reporter') - options.add_argument('--disable-in-process-stack-traces') - options.add_argument('--disable-logging') - options.add_argument('--disable-dev-shm-usage') - - # 创建 driver 实例。 - # 这里不再把版本硬编码成 144,而是优先跟随本机 Chrome 版本; - # 如果首次启动时仍然遇到版本不匹配,再从报错里解析真实版本自动重试。 - driver = _create_chrome_driver(options) + # 优先复用外部常驻浏览器,避免插件自己创建和管理浏览器进程; + # 如果未启用复用,或者复用失败但允许回退,则继续使用原来的自管理浏览器方案。 + driver, attached_to_existing_browser = _create_browser_session(browser_config) logger.info(f"正在访问: {url}") driver.get(url) @@ -264,24 +331,30 @@ def fetch_and_create_pdf(url): logger.exception(f"抓取异常: {e}") return "" finally: - # --- 确保Chrome进程被完全关闭 --- if driver: - try: - logger.debug("正在安全关闭浏览器...") - # 先关闭所有标签页和窗口 + if attached_to_existing_browser: try: - driver.close() + # 这里是“借用”用户已经在运行的浏览器,只释放当前 WebDriver 会话即可。 + # 明确不执行 close(),避免把用户正在用的标签页关掉。 + logger.debug("当前使用的是外部常驻浏览器,仅释放 WebDriver 会话,不关闭浏览器本体") + driver.quit() except Exception as e: - logger.warning(f"关闭浏览器窗口时出错: {e}") + logger.error(f"释放外部浏览器会话时出错: {e}") + else: + try: + logger.debug("正在安全关闭自管理浏览器...") + try: + driver.close() + except Exception as e: + logger.warning(f"关闭浏览器窗口时出错: {e}") - # 强制退出所有Chrome进程 - driver.quit() - logger.debug("浏览器已完全关闭") - except Exception as e: - logger.error(f"关闭浏览器时出错: {e}") + driver.quit() + logger.debug("浏览器已完全关闭") + except Exception as e: + logger.error(f"关闭浏览器时出错: {e}") - # 额外保险:强制清理残留的Chrome进程(仅Linux) - if os.name != 'nt': + # 只有当本次浏览器由当前任务自己拉起时,才需要额外清理潜在残留进程。 + if os.name != 'nt' and not attached_to_existing_browser: try: import psutil current_user = os.getlogin() @@ -302,10 +375,11 @@ def fetch_and_create_pdf(url): logger.warning(f"强制清理Chrome进程时出错: {e}") -def pdf_file_path_undetected(): +def pdf_file_path_undetected(browser_config=None): try: url = 'https://www.sehuatang.net/forum.php?mod=forumdisplay&fid=103&filter=typeid&typeid=481' - pdf_path = fetch_and_create_pdf(url) + # 将插件配置透传给抓取函数,便于优先复用外部常驻浏览器会话。 + pdf_path = fetch_and_create_pdf(url, browser_config=browser_config) if pdf_path: logger.info(f"返回的PDF文件路径:{pdf_path}") return True, pdf_path