diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index 3ddae1d..81564b7 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -1904,6 +1904,9 @@ class XianyuLive: if not cookies_str: raise ValueError("未提供cookies,请在global_config.yml中配置COOKIES_STR或通过参数传入") + # 清理从浏览器/记事本粘贴时常见的 BOM 与首尾空白,避免 trans_cookies 解析失败 + cookies_str = str(cookies_str).replace("\ufeff", "").strip() + logger.info(f"【{cookie_id}】解析cookies...") self.cookies = trans_cookies(cookies_str) logger.info(f"【{cookie_id}】cookies解析完成,包含字段: {list(self.cookies.keys())}") @@ -3233,9 +3236,15 @@ class XianyuLive: import aiohttp try: - # 好评接口地址 - comment_api_url = "http://119.29.64.68:8081/comment" - + # 好评接口地址:从系统设置读取;未配置则拒绝调用,避免向未知第三方泄露 Cookie + comment_api_url = (db_manager.get_system_setting('auto_comment_api_url') or '').strip() + if not comment_api_url: + logger.warning(f"【{self.cookie_id}】未配置 auto_comment_api_url,跳过自动好评接口调用") + return { + "success": False, + "message": "未配置自动好评 API 地址,请在系统设置中填写后再启用此功能" + } + # 获取当前账号的cookie cookie_str = self.cookies_str @@ -6735,13 +6744,17 @@ class XianyuLive: from utils.xianyu_slider_stealth import XianyuSliderStealth logger.info(f"【{self.cookie_id}】XianyuSliderStealth导入成功,使用滑块验证") + # 读取账号配置以决定浏览器模式(默认无头) + account_info = db_manager.get_cookie_details(self.cookie_id) or {} + show_browser = bool(account_info.get('show_browser', False)) # 创建独立的滑块验证实例(每个用户独立实例,避免并发冲突) slider_stealth = XianyuSliderStealth( - # user_id=f"{self.cookie_id}_{int(time.time() * 1000)}", # 使用唯一ID避免冲突 user_id=f"{self.cookie_id}", # 使用唯一ID避免冲突 enable_learning=True, # 启用学习功能 - headless=True # 使用有头模式(可视化浏览器) + headless=not show_browser, ) + # 给当前滑块实例打上 token_refresh 场景标,让滑块层在硬拒绝时尽早交还给外层走账密恢复 + slider_stealth.risk_trigger_scene = 'token_refresh' # 直接使用异步方法执行滑块验证(避免 ThreadPoolExecutor 导致的 Playwright 初始化问题) success, cookies = await slider_stealth.async_run(verification_url) @@ -12848,9 +12861,11 @@ class XianyuLive: '--use-mock-keychain' ]) - # 使用无头浏览器 + # 读取账号配置以决定浏览器模式(默认无头) + account_info = db_manager.get_cookie_details(self.cookie_id) or {} + show_browser = bool(account_info.get('show_browser', False)) browser = await playwright.chromium.launch( - headless=True, + headless=not show_browser, args=browser_args ) @@ -13136,9 +13151,11 @@ class XianyuLive: '--use-mock-keychain' ]) - # Cookie刷新模式使用无头浏览器 + # Cookie刷新模式:读取账号配置以决定浏览器模式(默认无头) + account_info = db_manager.get_cookie_details(self.cookie_id) or {} + show_browser = bool(account_info.get('show_browser', False)) browser = await playwright.chromium.launch( - headless=True, + headless=not show_browser, args=browser_args ) @@ -14022,6 +14039,36 @@ class XianyuLive: if self.active_message_tasks % 100 == 0 and self.active_message_tasks > 0: logger.info(f"【{self.cookie_id}】当前活跃消息处理任务数: {self.active_message_tasks}") + def _unwrap_message_for_dedupe(self, message_data: dict) -> Optional[dict]: + """把同步包还原成内部消息结构,让 messageId / createTime 提取走统一路径。 + + - 如果 message_data 已是内部结构(包含 key '1'),原样返回 + - 如果是 syncPushPackage 同步包,先 base64 + json 解第一条 data 段返回 + - 其它情况返回 None,让调用方走兜底标识 + """ + if not isinstance(message_data, dict): + return None + if "1" in message_data: + return message_data + + try: + if not self.is_sync_package(message_data): + return None + sync_entries = ( + ((message_data.get("body") or {}).get("syncPushPackage") or {}).get("data") or [] + ) + if not sync_entries: + return None + payload = sync_entries[0].get("data") + if not payload: + return None + decoded = base64.b64decode(payload).decode("utf-8") + inner = json.loads(decoded) + return inner if isinstance(inner, dict) else None + except Exception as exc: + logger.debug(f"【{self.cookie_id}】解析同步包消息用于去重时失败: {self._safe_str(exc)}") + return None + def _extract_message_id(self, message_data: dict) -> str: """ 从消息数据中提取消息ID,用于去重 @@ -14033,9 +14080,12 @@ class XianyuLive: 消息ID字符串,如果无法提取则返回None """ try: + # 同步包消息要先还原到内部结构,否则下面的 message['1']['10']['bizTag'] 路径取不到 + normalized_message = self._unwrap_message_for_dedupe(message_data) + # 尝试从 message['1']['10']['bizTag'] 中提取 messageId - if isinstance(message_data, dict) and "1" in message_data: - message_1 = message_data.get("1") + if isinstance(normalized_message, dict) and "1" in normalized_message: + message_1 = normalized_message.get("1") if isinstance(message_1, dict) and "10" in message_1: message_10 = message_1.get("10") if isinstance(message_10, dict) and "bizTag" in message_10: @@ -14087,10 +14137,12 @@ class XianyuLive: # 如果没有 messageId,使用备用标识(chat_id + send_message + 时间戳) if not message_id: try: + # 同步包消息要先还原到内部结构再取 createTime + normalized_message = self._unwrap_message_for_dedupe(message_data) or {} # 尝试从消息数据中提取时间戳 create_time = 0 - if isinstance(message_data, dict) and "1" in message_data: - message_1 = message_data.get("1") + if isinstance(normalized_message, dict) and "1" in normalized_message: + message_1 = normalized_message.get("1") if isinstance(message_1, dict): create_time = message_1.get("5", 0) # 使用组合键作为备用标识 diff --git a/auto_updater.py b/auto_updater.py index fa2fd88..155c175 100644 --- a/auto_updater.py +++ b/auto_updater.py @@ -218,6 +218,29 @@ class AutoUpdater: ext = Path(file_path).suffix.lower() return ext in self.RESTART_REQUIRED_EXTENSIONS + def _safe_join_under_app_dir(self, manifest_path: str) -> Optional[Path]: + """把更新清单里的相对路径安全解析到 app_dir 内部;越界 / 空 / 绝对路径都返回 None。 + + 防御面:绝对路径、空字符串、`..` 上跳、symlink 越界、Windows 反斜杠混淆。 + 所有调用方在 None 时必须放弃该项操作并记录失败原因。 + """ + if not manifest_path: + logger.warning("更新清单包含空路径,已拒绝") + return None + + try: + cleaned = Path(str(manifest_path).replace('\\', '/').strip()) + if cleaned.is_absolute() or not cleaned.parts: + logger.warning(f"更新清单包含绝对/空路径,已拒绝: {manifest_path}") + return None + target = (self.app_dir / cleaned).resolve() + target.relative_to(self.app_dir.resolve()) + except (ValueError, OSError) as exc: + logger.warning(f"更新清单路径越界或无法解析: {manifest_path} ({exc})") + return None + + return target + def refresh_current_version(self) -> str: """从本地版本文件刷新当前版本号,避免长生命周期进程读到旧版本""" version = self.current_version or "1.0.0" @@ -446,7 +469,9 @@ class AutoUpdater: logger.debug(f"跳过排除的删除路径: {deleted_file.path}") continue - local_path = self.app_dir / deleted_file.path + local_path = self._safe_join_under_app_dir(deleted_file.path) + if local_path is None: + continue if local_path.exists() and local_path.is_file(): files_to_delete.append(deleted_file) logger.debug(f"需要删除旧文件: {deleted_file.path}") @@ -579,9 +604,15 @@ class AutoUpdater: downloaded_size += len(content) self._update_progress(downloaded_bytes=downloaded_size) - # 备份并安装 - local_path = self.app_dir / file_update.path - + # 备份并安装:先做路径越界校验 + local_path = self._safe_join_under_app_dir(file_update.path) + if local_path is None: + self._update_progress( + status=UpdateStatus.FAILED, + error=f"非法更新路径,已拒绝写入: {file_update.path}" + ) + return False, updated_files, needs_restart + # 备份旧文件 if not self._backup_file(local_path): logger.warning(f"备份失败,继续更新: {file_update.path}") @@ -657,7 +688,13 @@ class AutoUpdater: message=f"正在删除旧文件: {deleted_file.path}" ) - local_path = self.app_dir / deleted_file.path + local_path = self._safe_join_under_app_dir(deleted_file.path) + if local_path is None: + self._update_progress( + status=UpdateStatus.FAILED, + error=f"非法删除路径,已拒绝执行: {deleted_file.path}" + ) + return False, deleted_paths, needs_restart if not local_path.exists(): continue diff --git a/db_manager.py b/db_manager.py index 537f273..3812e2c 100644 --- a/db_manager.py +++ b/db_manager.py @@ -935,6 +935,9 @@ Cookie数量: {cookie_count} ('smtp_from', '', '发件人显示名(留空则使用邮箱地址)'), ('smtp_use_tls', 'true', '是否启用TLS'), ('smtp_use_ssl', 'false', '是否启用SSL'), + ('verification_email_api_url', '', '验证码邮件 API 地址(留空则仅使用 SMTP,不再向旧硬编码地址外发)'), + ('qq_notification_api_url', '', 'QQ 私信通知 API 地址(留空则禁用 QQ 私信通知)'), + ('auto_comment_api_url', '', '自动好评辅助 API 地址(留空则禁用此功能,避免 Cookie 外发)'), ('qq_reply_secret_key', 'xianyu_qq_reply_2024', 'QQ回复消息API秘钥') ''') @@ -4353,8 +4356,11 @@ Cookie数量: {cookie_count} try: import aiohttp - # 使用GET请求发送邮件 - api_url = "https://dy.zhinianboke.com/api/emailSend" + # 邮件 API 地址:从系统设置读取,未配置则拒绝调用以避免向未知第三方泄露 + api_url = (self.get_system_setting('verification_email_api_url') or '').strip() + if not api_url: + logger.warning(f"未配置 verification_email_api_url,无法通过 API 渠道发送验证码邮件: {email}") + return False params = { 'subject': subject, 'receiveUser': email, diff --git a/requirements.txt b/requirements.txt index 0c4c450..80bb5e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,7 +41,7 @@ Pillow>=10.0.0 qrcode[pil]>=7.4.2 # ==================== 浏览器自动化 ==================== -playwright>=1.40.0 +playwright==1.59.0 DrissionPage>=4.0.0 # ==================== 加密和安全 ==================== diff --git a/utils/notification_dispatcher.py b/utils/notification_dispatcher.py index ff38410..1cb7fe7 100644 --- a/utils/notification_dispatcher.py +++ b/utils/notification_dispatcher.py @@ -244,7 +244,12 @@ async def _send_qq_notification(config_data: Dict[str, Any], message: str, *, ac logger.warning(f"【{account_id}】QQ通知配置为空") return False - api_url = 'http://36.111.68.231:3000/sendPrivateMsg' + # QQ 私信 API 地址:从系统设置读取,未配置则禁用此渠道,避免向未知第三方泄露 QQ 号 + from db_manager import db_manager + api_url = (db_manager.get_system_setting('qq_notification_api_url') or '').strip() + if not api_url: + logger.warning(f"【{account_id}】未配置 qq_notification_api_url,已跳过 QQ 私信通知") + return False params = {'qq': qq_number, 'msg': message} async with aiohttp.ClientSession() as session: diff --git a/utils/xianyu_slider_stealth.py b/utils/xianyu_slider_stealth.py index 4a1c98b..370e849 100644 --- a/utils/xianyu_slider_stealth.py +++ b/utils/xianyu_slider_stealth.py @@ -1007,6 +1007,34 @@ class XianyuSliderStealth: return f"滑块验证失败:{feedback_message}" return default_message + def _should_abort_token_refresh_slider_flow_after_failure(self) -> Tuple[bool, str]: + """识别 token_refresh 场景下处罚壳子的 hard reject,让外层尽快走账密恢复。 + + 三个条件必须同时满足才判为 hard reject: + 1. 当前 risk_trigger_scene == 'token_refresh'(XianyuLive 创建滑块时打的标) + 2. 上一轮 verification_feedback 文案里含 '验证失败,点击框体重试' + 3. 反馈里附带 fail_code,或正文里能看到 'error:xxxx' 形式的错误码 + 命中后返回 (True, 原因);否则 (False, "")。 + """ + if getattr(self, "risk_trigger_scene", None) != "token_refresh": + return False, "" + + feedback = self.last_verification_feedback or {} + fail_code = str(feedback.get("fail_code") or "").strip().lower() + message_pieces = ( + str(feedback.get("message") or "").strip(), + str(feedback.get("dom_error_text") or "").strip(), + ) + joined_message = " ".join(piece for piece in message_pieces if piece) + + has_retry_prompt = "验证失败,点击框体重试" in joined_message + has_error_code = bool(fail_code) or "error:" in joined_message.lower() + if not (has_retry_prompt and has_error_code): + return False, "" + + code_label = fail_code or "unknown" + return True, f"token_refresh 场景命中处罚壳 hard reject({code_label}),提前结束滑块重试" + def _capture_verification_screenshot(self, page, frame=None, iframe_selector: Optional[str] = None) -> Optional[str]: """截取验证页面截图,多种方式逐级回退""" try: @@ -2656,9 +2684,15 @@ class XianyuSliderStealth: logger.error(f"【{self.pure_user_id}】删除截图时出错: {e}") def _wait_for_context_login(self, context, fallback_page, max_wait_time: int = 450, check_interval: int = 10, - allow_active_slider_retry: bool = True) -> Tuple[bool, Any]: + allow_active_slider_retry: bool = True, + verification_type: str = 'unknown', + verification_url: Optional[str] = None, + notification_callback: Optional[Callable] = None, + notification_scene: str = '账号密码登录') -> Tuple[bool, Any]: waited_time = 0 monitor_page = fallback_page + last_verification_type = verification_type or 'unknown' + last_verification_url = verification_url or None while waited_time < max_wait_time: monitor_page = self._select_monitor_page(context, monitor_page) @@ -2669,6 +2703,34 @@ class XianyuSliderStealth: if login_success: return True, success_page or monitor_page + # 等待期间持续探测验证页:类型或 URL 变化时刷新通知,避免前端展示过期截图 + try: + has_verification, refreshed_frame = self._detect_qr_code_verification(monitor_page) + if has_verification and refreshed_frame is not None: + refreshed_type = getattr(refreshed_frame, 'verification_type', None) or 'unknown' + refreshed_url = ( + getattr(refreshed_frame, 'verify_url', None) + or getattr(refreshed_frame, 'url', None) + ) + if (refreshed_type != last_verification_type + or (refreshed_url or None) != last_verification_url): + refreshed_screenshot = getattr(refreshed_frame, 'screenshot_path', None) + logger.info( + f"【{self.pure_user_id}】等待期间验证页发生变化: " + f"{last_verification_type}->{refreshed_type}, url={refreshed_url or 'N/A'}; 推送新通知" + ) + self._notify_verification_required( + refreshed_type, + refreshed_url, + refreshed_screenshot, + notification_callback, + notification_scene, + ) + last_verification_type = refreshed_type + last_verification_url = refreshed_url or None + except Exception as detect_err: + logger.debug(f"【{self.pure_user_id}】等待期间重新探测验证页失败: {detect_err}") + time.sleep(check_interval) waited_time += check_interval logger.info(f"【{self.pure_user_id}】等待验证中... (已等待{waited_time}秒/{max_wait_time}秒)") @@ -2833,6 +2895,10 @@ class XianyuSliderStealth: max_wait_time=450, check_interval=10, allow_active_slider_retry=False, + verification_type=verification_type, + verification_url=frame_url, + notification_callback=notification_callback, + notification_scene=notification_scene, ) finally: self._cleanup_verification_screenshots() @@ -5469,7 +5535,13 @@ class XianyuSliderStealth: failure_info = self._analyze_failure(attempt, slide_distance, self.current_trajectory_data) failure_records.append(failure_info) self._save_failure_record(self.current_trajectory_data, failure_info) - + + # token_refresh 场景下若已是处罚壳硬拒绝,立即停止后续重试,把控制权交还外层 + abort_now, abort_reason = self._should_abort_token_refresh_slider_flow_after_failure() + if abort_now: + logger.warning(f"【{self.pure_user_id}】{abort_reason}") + break + # 如果不是最后一次尝试,继续 if attempt < max_retries: continue