mirror of
https://github.com/GuDong2003/xianyu-auto-reply-fix.git
synced 2026-08-28 17:40:45 +08:00
fix: 锁定 playwright 1.59 并修复滑块 headless / 硬编码外联 / 路径穿越
This commit is contained in:
+65
-13
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
# ==================== 加密和安全 ====================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user