fix: 锁定 playwright 1.59 并修复滑块 headless / 硬编码外联 / 路径穿越

This commit is contained in:
GuDong
2026-05-06 20:12:41 +08:00
parent d7c2992467
commit bac4463896
6 changed files with 196 additions and 24 deletions
+65 -13
View File
@@ -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)
# 使用组合键作为备用标识
+42 -5
View File
@@ -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
+8 -2
View File
@@ -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,
+1 -1
View File
@@ -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
# ==================== 加密和安全 ====================
+6 -1
View File
@@ -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:
+74 -2
View File
@@ -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