优化滑块和密码登录

This commit is contained in:
zhinianboke 2025-11-25 13:31:43 +08:00
parent c69840e9c8
commit 6bf2ac43e4
16 changed files with 1888 additions and 387 deletions

View File

@ -93,6 +93,9 @@ RUN apt-get update && \
libxfixes3 \
xdg-utils \
chromium \
xvfb \
x11vnc \
fluxbox \
# OpenCV运行时依赖
libgl1 \
libglib2.0-0 \

394
Start.py
View File

@ -1,9 +1,8 @@
"""项目启动入口:
1. 自动初始化必要的目录和数据库
2. 创建 CookieManager按配置文件 / 环境变量初始化账号任务
3. 在后台线程启动 FastAPI (reply_server) 提供管理与自动回复接口
4. 主协程保持运行
1. 创建 CookieManager按配置文件 / 环境变量初始化账号任务
2. 在后台线程启动 FastAPI (reply_server) 提供管理与自动回复接口
3. 主协程保持运行
"""
import os
@ -11,51 +10,65 @@ import sys
import shutil
from pathlib import Path
# ==================== 初始化目录和配置 ====================
def _init_directories():
"""初始化必要的目录结构"""
print("=" * 50)
print("闲鱼自动回复系统 - 启动中...")
print("=" * 50)
# 创建必要的目录
directories = ['data', 'logs', 'backups']
for dir_name in directories:
dir_path = Path(dir_name)
if not dir_path.exists():
dir_path.mkdir(parents=True, exist_ok=True)
print(f"✓ 创建 {dir_name}/ 目录")
else:
print(f"{dir_name}/ 目录已存在")
return True
# 设置标准输出编码为UTF-8Windows兼容
def _setup_console_encoding():
"""设置控制台编码为UTF-8避免Windows GBK编码问题"""
if sys.platform == 'win32':
try:
# 方法1: 设置环境变量
os.environ['PYTHONIOENCODING'] = 'utf-8'
# 方法2: 尝试设置控制台代码页为UTF-8
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleOutputCP(65001) # UTF-8代码页
except Exception:
pass
# 方法3: 重新包装stdout和stderr
try:
if hasattr(sys.stdout, 'buffer'):
import io
# 只在编码不是UTF-8时重新包装
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ('utf-8', 'utf8'):
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer,
encoding='utf-8',
errors='replace',
line_buffering=True
)
if sys.stderr.encoding and sys.stderr.encoding.lower() not in ('utf-8', 'utf8'):
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer,
encoding='utf-8',
errors='replace',
line_buffering=True
)
except Exception:
pass
except Exception:
pass
def _init_database():
"""初始化数据库(如果不存在)"""
db_path = Path("data/xianyu_data.db")
if not db_path.exists():
print("✓ 首次启动,将自动创建数据库...")
# 数据库会在 db_manager 导入时自动创建
return True
else:
print(f"✓ 数据库已存在: {db_path}")
return False
# 在程序启动时设置编码
_setup_console_encoding()
# 在导入任何模块之前先初始化
_init_directories()
_init_database()
# 定义ASCII安全字符备用方案
_OK = '[OK]'
_WARN = '[WARN]'
_ERROR = '[ERROR]'
_INFO = '[INFO]'
# ==================== 数据库文件迁移(兼容旧版本) ====================
# ==================== 在导入任何模块之前先迁移数据库 ====================
def _migrate_database_files_early():
"""在启动前检查并迁移数据库文件到data目录使用print因为logger还未初始化"""
print("\n检查旧版本数据库文件...")
print("检查数据库文件位置...")
# 确保data目录存在
data_dir = Path("data")
if not data_dir.exists():
data_dir.mkdir(parents=True, exist_ok=True)
print(" 创建 data 目录")
print(f"{_OK} 创建 data 目录")
# 定义需要迁移的文件
files_to_migrate = [
@ -75,23 +88,23 @@ def _migrate_database_files_early():
# 新位置不存在,移动文件
try:
shutil.move(str(old_file), str(new_file))
print(f" 迁移{description}: {old_path} -> {new_path}")
print(f"{_OK} 迁移{description}: {old_path} -> {new_path}")
migrated_files.append(description)
except Exception as e:
print(f" 无法迁移{description}: {e}")
print(f"{_WARN} 无法迁移{description}: {e}")
print(f" 尝试复制文件...")
try:
shutil.copy2(str(old_file), str(new_file))
print(f" 已复制{description}到新位置")
print(f"{_OK} 已复制{description}到新位置")
print(f" 请在确认数据正常后手动删除: {old_path}")
migrated_files.append(f"{description}(已复制)")
except Exception as e2:
print(f" 复制{description}失败: {e2}")
print(f"{_ERROR} 复制{description}失败: {e2}")
else:
# 新位置已存在,检查旧文件大小
try:
if old_file.stat().st_size > 0:
print(f" 发现旧{description}文件: {old_path}")
print(f"{_WARN} 发现旧{description}文件: {old_path}")
print(f" 新数据库位于: {new_path}")
print(f" 建议备份后删除旧文件")
except:
@ -107,19 +120,19 @@ def _migrate_database_files_early():
if not new_backup_path.exists():
try:
shutil.move(str(backup_file), str(new_backup_path))
print(f" 迁移备份文件: {backup_file.name}")
print(f"{_OK} 迁移备份文件: {backup_file.name}")
backup_migrated += 1
except Exception as e:
print(f" 无法迁移备份文件 {backup_file.name}: {e}")
print(f"{_WARN} 无法迁移备份文件 {backup_file.name}: {e}")
if backup_migrated > 0:
migrated_files.append(f"{backup_migrated}个备份文件")
# 输出迁移总结
if migrated_files:
print(f" 数据库迁移完成,已迁移: {', '.join(migrated_files)}")
print(f"{_OK} 数据库迁移完成,已迁移: {', '.join(migrated_files)}")
else:
print(" 数据库文件检查完成")
print(f"{_OK} 数据库文件检查完成")
return True
@ -127,9 +140,286 @@ def _migrate_database_files_early():
try:
_migrate_database_files_early()
except Exception as e:
print(f" 数据库迁移检查失败: {e}")
print(f"{_WARN} 数据库迁移检查失败: {e}")
# 继续启动,因为可能是首次运行
# ==================== 检查并安装Playwright浏览器 ====================
def _check_and_install_playwright():
"""检查Playwright浏览器是否存在如果不存在则自动安装"""
print("检查Playwright浏览器...")
# 检查是否安装了playwright模块
try:
import playwright
except ImportError:
print(f"{_WARN} Playwright模块未安装跳过浏览器检查")
return False
# 检查Playwright浏览器是否存在
playwright_installed = False
possible_paths = []
# 如果是打包后的exe优先检查exe同目录
if getattr(sys, 'frozen', False):
exe_dir = Path(sys.executable).parent
playwright_dir = exe_dir / 'playwright'
possible_paths.insert(0, playwright_dir) # 插入到最前面,优先检查
# 检查exe同目录的浏览器是否完整
if playwright_dir.exists():
chromium_dirs = list(playwright_dir.glob('chromium-*'))
if chromium_dirs:
chromium_dir = chromium_dirs[0]
chrome_exe = chromium_dir / 'chrome-win' / 'chrome.exe'
if chrome_exe.exists() and chrome_exe.stat().st_size > 0:
print(f"{_OK} 找到已提取的Playwright浏览器: {chrome_exe}")
print(f"{_INFO} 浏览器版本: {chromium_dir.name}")
# 清除可能存在的旧环境变量,使用实际存在的浏览器
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
if old_path != str(playwright_dir):
print(f"{_INFO} 清除旧的环境变量: {old_path}")
del os.environ['PLAYWRIGHT_BROWSERS_PATH']
# 确保环境变量已设置
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
print(f"{_INFO} 已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
playwright_installed = True
return True
# Windows上的常见位置
if sys.platform == 'win32':
# 用户缓存目录
user_cache = Path.home() / '.cache' / 'ms-playwright'
possible_paths.append(user_cache)
# LocalAppData目录
local_appdata = os.getenv('LOCALAPPDATA')
if local_appdata:
possible_paths.append(Path(local_appdata) / 'ms-playwright')
# AppData目录
appdata = os.getenv('APPDATA')
if appdata:
possible_paths.append(Path(appdata) / 'ms-playwright')
# 检查是否存在chromium浏览器
for path in possible_paths:
if path.exists():
# 查找chromium目录
chromium_dirs = list(path.glob('chromium-*'))
if chromium_dirs:
for chromium_dir in chromium_dirs:
chrome_win = chromium_dir / 'chrome-win'
chrome_exe = chrome_win / 'chrome.exe'
if chrome_exe.exists():
print(f"{_OK} 找到Playwright浏览器: {chrome_exe}")
# 设置环境变量
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(path)
playwright_installed = True
break
if playwright_installed:
break
# 如果没找到尝试使用playwright命令检查
if not playwright_installed:
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
browser.close()
print(f"{_OK} Playwright浏览器已安装通过API检测")
playwright_installed = True
except Exception:
pass
except Exception:
pass
# 如果没找到先尝试从临时目录提取如果是打包的exe
if not playwright_installed and getattr(sys, 'frozen', False):
try:
exe_dir = Path(sys.executable).parent
playwright_dir = exe_dir / 'playwright'
if hasattr(sys, '_MEIPASS'):
temp_dir = Path(sys._MEIPASS)
temp_playwright = temp_dir / 'playwright'
if temp_playwright.exists():
# 查找所有 chromium 相关目录(包括 chromium-* 和 chromium_headless_shell-*
temp_chromium_dirs = list(temp_playwright.glob('chromium*'))
if temp_chromium_dirs:
print(f"{_INFO} 检测到打包的浏览器文件,正在提取...")
playwright_dir.mkdir(parents=True, exist_ok=True)
extracted_count = 0
for temp_chromium_dir in temp_chromium_dirs:
temp_chrome_win = temp_chromium_dir / 'chrome-win'
# 检查完整版或 headless_shell 版
temp_chrome_exe = temp_chrome_win / 'chrome.exe'
temp_headless_exe = temp_chrome_win / 'headless_shell.exe'
# 验证文件是否存在
is_valid = False
if temp_chromium_dir.name.startswith('chromium_headless_shell'):
is_valid = temp_headless_exe.exists() and temp_headless_exe.stat().st_size > 0
else:
is_valid = temp_chrome_exe.exists() and temp_chrome_exe.stat().st_size > 0
if is_valid:
target_chromium_dir = playwright_dir / temp_chromium_dir.name
if not target_chromium_dir.exists():
try:
shutil.copytree(temp_chromium_dir, target_chromium_dir, dirs_exist_ok=True)
# 验证提取的文件
if temp_chromium_dir.name.startswith('chromium_headless_shell'):
target_exe = target_chromium_dir / 'chrome-win' / 'headless_shell.exe'
else:
target_exe = target_chromium_dir / 'chrome-win' / 'chrome.exe'
if target_exe.exists() and target_exe.stat().st_size > 0:
print(f"{_OK} 浏览器文件提取成功: {target_exe}")
print(f"{_INFO} 浏览器版本: {temp_chromium_dir.name}")
extracted_count += 1
except Exception as e:
print(f"{_WARN} 提取 {temp_chromium_dir.name} 失败: {e}")
if extracted_count > 0:
# 清除可能存在的旧环境变量
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
print(f"{_INFO} 清除旧的环境变量: {old_path}")
del os.environ['PLAYWRIGHT_BROWSERS_PATH']
# 设置新的环境变量
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
print(f"{_INFO} 已提取 {extracted_count} 个浏览器版本")
print(f"{_INFO} 已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
playwright_installed = True
return True
except Exception as e:
print(f"{_WARN} 提取浏览器文件时出错: {e}")
# 如果没找到,尝试安装
if not playwright_installed:
print(f"{_WARN} 未找到Playwright浏览器正在自动安装...")
print(" 这可能需要几分钟时间,请耐心等待...")
try:
# 方法1: 尝试使用playwright的Python API安装推荐适用于打包后的exe
try:
# 直接调用playwright的安装函数
from playwright._impl._driver import install_driver, install_browsers
print(" 正在安装Playwright驱动...")
install_driver()
print(" 正在安装Chromium浏览器...")
install_browsers(['chromium'])
print(f"{_OK} Playwright浏览器安装成功通过API")
playwright_installed = True
except ImportError:
# 如果API不可用使用命令行方式
print(" 使用命令行方式安装...")
import subprocess
# 尝试使用playwright的安装命令
# 对于打包后的exeplaywright模块应该已经包含
creation_flags = 0
if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
creation_flags = subprocess.CREATE_NO_WINDOW
result = subprocess.run(
[sys.executable, '-m', 'playwright', 'install', 'chromium'],
capture_output=True,
text=True,
timeout=600, # 10分钟超时
creationflags=creation_flags
)
if result.returncode == 0:
print(f"{_OK} Playwright浏览器安装成功")
playwright_installed = True
else:
print(f"{_WARN} Playwright浏览器安装失败")
if result.stdout:
print(f" 输出: {result.stdout[-500:]}") # 只显示最后500字符
if result.stderr:
print(f" 错误: {result.stderr[-500:]}")
print(" 您可以稍后手动运行: playwright install chromium")
return False
except Exception as api_error:
# API安装失败尝试命令行方式
print(f" API安装失败尝试命令行方式: {api_error}")
import subprocess
creation_flags = 0
if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW'):
creation_flags = subprocess.CREATE_NO_WINDOW
result = subprocess.run(
[sys.executable, '-m', 'playwright', 'install', 'chromium'],
capture_output=True,
text=True,
timeout=600,
creationflags=creation_flags
)
if result.returncode == 0:
print(f"{_OK} Playwright浏览器安装成功通过命令行")
playwright_installed = True
else:
print(f"{_WARN} Playwright浏览器安装失败")
if result.stdout:
print(f" 输出: {result.stdout[-500:]}")
if result.stderr:
print(f" 错误: {result.stderr[-500:]}")
print(" 您可以稍后手动运行: playwright install chromium")
return False
except ImportError:
# 如果playwright模块不可用尝试使用subprocess
import subprocess
result = subprocess.run(
[sys.executable, '-m', 'playwright', 'install', 'chromium'],
capture_output=True,
text=True,
timeout=600,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == 'win32' and hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
if result.returncode == 0:
print(f"{_OK} Playwright浏览器安装成功")
playwright_installed = True
else:
print(f"{_WARN} Playwright浏览器安装失败")
if result.stdout:
print(f" 输出: {result.stdout}")
if result.stderr:
print(f" 错误: {result.stderr}")
print(" 您可以稍后手动运行: playwright install chromium")
return False
except subprocess.TimeoutExpired:
print(f"{_WARN} Playwright浏览器安装超时超过10分钟")
print(" 您可以稍后手动运行: playwright install chromium")
return False
except Exception as e:
print(f"{_WARN} Playwright浏览器安装失败: {e}")
import traceback
print(f" 详细错误: {traceback.format_exc()}")
print(" 您可以稍后手动运行: playwright install chromium")
return False
return playwright_installed
# 检查并安装Playwright浏览器
try:
_check_and_install_playwright()
except Exception as e:
print(f"{_WARN} Playwright浏览器检查失败: {e}")
print(" 程序将继续启动但Playwright功能可能不可用")
# 继续启动,不影响主程序运行
# ==================== 现在可以安全地导入其他模块 ====================
import asyncio
import threading
@ -179,17 +469,13 @@ def _start_api_server():
# 在后台线程中创建独立事件循环并直接运行 server.serve()
import uvicorn
try:
# 直接导入 app 对象,避免字符串引用在打包后无法工作
from reply_server import app
config = uvicorn.Config(app, host=host, port=port, log_level="info")
config = uvicorn.Config("reply_server:app", host=host, port=port, log_level="info")
server = uvicorn.Server(config)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(server.serve())
except Exception as e:
logger.error(f"uvicorn服务器启动失败: {e}")
import traceback
logger.error(f"详细错误: {traceback.format_exc()}")
try:
# 确保线程内事件循环被正确关闭
loop = asyncio.get_event_loop()

View File

@ -282,21 +282,41 @@ class XianyuLive:
try:
tasks_to_cancel = []
# 收集所有需要取消的任务
# 收集所有需要取消的任务(只收集未完成的任务)
if self.heartbeat_task:
tasks_to_cancel.append(("心跳任务", self.heartbeat_task))
if not self.heartbeat_task.done():
tasks_to_cancel.append(("心跳任务", self.heartbeat_task))
else:
logger.debug(f"{self.cookie_id}】心跳任务已完成,跳过")
if self.token_refresh_task:
tasks_to_cancel.append(("Token刷新任务", self.token_refresh_task))
if not self.token_refresh_task.done():
tasks_to_cancel.append(("Token刷新任务", self.token_refresh_task))
else:
logger.debug(f"{self.cookie_id}】Token刷新任务已完成跳过")
if self.cleanup_task:
tasks_to_cancel.append(("清理任务", self.cleanup_task))
if not self.cleanup_task.done():
tasks_to_cancel.append(("清理任务", self.cleanup_task))
else:
logger.debug(f"{self.cookie_id}】清理任务已完成,跳过")
if self.cookie_refresh_task:
tasks_to_cancel.append(("Cookie刷新任务", self.cookie_refresh_task))
if not self.cookie_refresh_task.done():
tasks_to_cancel.append(("Cookie刷新任务", self.cookie_refresh_task))
else:
logger.debug(f"{self.cookie_id}】Cookie刷新任务已完成跳过")
if not tasks_to_cancel:
logger.info(f"{self.cookie_id}】没有后台任务需要取消")
logger.info(f"{self.cookie_id}】没有后台任务需要取消(所有任务已完成或不存在)")
# 立即重置任务引用
self.heartbeat_task = None
self.token_refresh_task = None
self.cleanup_task = None
self.cookie_refresh_task = None
return
logger.info(f"{self.cookie_id}】开始取消 {len(tasks_to_cancel)} 个后台任务...")
logger.info(f"{self.cookie_id}】开始取消 {len(tasks_to_cancel)}未完成的后台任务...")
# 取消所有任务
for task_name, task in tasks_to_cancel:
@ -545,6 +565,66 @@ class XianyuLive:
except Exception as e:
logger.warning(f"{self.cookie_id}】清理Playwright缓存时出错: {self._safe_str(e)}")
async def _cleanup_old_logs(self, retention_days: int = 7):
"""清理过期的日志文件
Args:
retention_days: 保留的天数默认7天
Returns:
清理的文件数量
"""
try:
import glob
from datetime import datetime, timedelta
logs_dir = "logs"
if not os.path.exists(logs_dir):
logger.warning(f"{self.cookie_id}】日志目录不存在: {logs_dir}")
return 0
# 计算过期时间点
cutoff_time = datetime.now() - timedelta(days=retention_days)
# 查找所有日志文件(包括.log和.log.zip
log_patterns = [
os.path.join(logs_dir, "xianyu_*.log"),
os.path.join(logs_dir, "xianyu_*.log.zip"),
os.path.join(logs_dir, "app_*.log"),
os.path.join(logs_dir, "app_*.log.zip"),
]
total_cleaned = 0
total_size_mb = 0
for pattern in log_patterns:
log_files = glob.glob(pattern)
for log_file in log_files:
try:
# 获取文件修改时间
file_mtime = datetime.fromtimestamp(os.path.getmtime(log_file))
# 如果文件早于保留期限,则删除
if file_mtime < cutoff_time:
file_size = os.path.getsize(log_file)
os.remove(log_file)
total_size_mb += file_size / (1024 * 1024)
total_cleaned += 1
logger.debug(f"{self.cookie_id}】删除过期日志文件: {log_file} (修改时间: {file_mtime})")
except Exception as e:
logger.warning(f"{self.cookie_id}】删除日志文件失败 {log_file}: {self._safe_str(e)}")
if total_cleaned > 0:
logger.info(f"{self.cookie_id}】日志清理完成: 删除了 {total_cleaned} 个日志文件,释放 {total_size_mb:.2f} MB (保留 {retention_days} 天内的日志)")
else:
logger.debug(f"{self.cookie_id}】日志清理: 没有需要清理的过期日志文件 (保留 {retention_days} 天)")
return total_cleaned
except Exception as e:
logger.error(f"{self.cookie_id}】清理日志文件时出错: {self._safe_str(e)}")
return 0
def __init__(self, cookies_str=None, cookie_id: str = "default", user_id: int = None):
"""初始化闲鱼直播类"""
logger.info(f"{cookie_id}】开始初始化XianyuLive...")
@ -649,13 +729,14 @@ class XianyuLive:
# 消息防抖管理器:用于处理用户连续发送消息的情况
# {chat_id: {'task': asyncio.Task, 'last_message': dict, 'timer': float}}
self.message_debounce_tasks = {} # 存储每个chat_id的防抖任务
self.message_debounce_delay = 3 # 防抖延迟时间用户停止发送消息3秒后才回复
self.message_debounce_delay = 1 # 防抖延迟时间用户停止发送消息1秒后才回复
self.message_debounce_lock = asyncio.Lock() # 防抖任务管理的锁
# 消息去重机制:防止同一条消息被处理多次
self.processed_message_ids = set() # 存储已处理的消息ID
self.processed_message_ids = {} # 存储已处理的消息ID和时间戳 {message_id: timestamp}
self.processed_message_ids_lock = asyncio.Lock() # 消息ID去重的锁
self.processed_message_ids_max_size = 10000 # 最大保存10000个消息ID防止内存泄漏
self.message_expire_time = 3600 # 消息过期时间默认1小时后可以重复回复
# 初始化订单状态处理器
self._init_order_status_handler()
@ -1509,136 +1590,17 @@ class XianyuLive:
if isinstance(res_json, dict):
res_json_str = json.dumps(res_json, ensure_ascii=False, separators=(',', ':'))
if '令牌过期' in res_json_str or 'Session过期' in res_json_str:
logger.warning(f"{self.cookie_id}】检测到令牌/Session过期准备刷新Cookie并重启实例...")
# 检查是否在密码登录冷却期内,避免重复登录
current_time = time.time()
last_password_login = XianyuLive._last_password_login_time.get(self.cookie_id, 0)
time_since_last_login = current_time - last_password_login
# 调用统一的密码登录刷新方法
refresh_success = await self._try_password_login_refresh("令牌/Session过期")
if last_password_login > 0 and time_since_last_login < XianyuLive._password_login_cooldown:
remaining_time = XianyuLive._password_login_cooldown - time_since_last_login
logger.warning(f"{self.cookie_id}】距离上次密码登录仅 {time_since_last_login:.1f} 秒,仍在冷却期内(还需等待 {remaining_time:.1f} 秒),跳过密码登录")
logger.warning(f"{self.cookie_id}】提示如果新Cookie仍然无效请检查账号状态或手动更新Cookie")
if not refresh_success:
# 标记已发送通知,避免重复通知
notification_sent = True
# 返回None让调用者知道刷新失败
return None
# 记录到日志文件
log_captcha_event(self.cookie_id, "令牌/Session过期触发Cookie刷新和实例重启", None,
f"检测到令牌/Session过期准备刷新Cookie并重启实例")
try:
# 从数据库获取账号登录信息
from db_manager import db_manager
account_info = db_manager.get_cookie_details(self.cookie_id)
if not account_info:
logger.error(f"{self.cookie_id}】无法获取账号信息")
raise Exception("无法获取账号信息")
# 【重要】先检查数据库中的cookie是否已经更新
# 如果用户已经手动更新了cookie就不需要触发密码登录刷新
db_cookie_value = account_info.get('cookie_value', '')
if db_cookie_value and db_cookie_value != self.cookies_str:
logger.info(f"{self.cookie_id}】检测到数据库中的cookie已更新重新加载cookie并重试token刷新")
self.cookies_str = db_cookie_value
self.cookies = trans_cookies(self.cookies_str)
logger.info(f"{self.cookie_id}】Cookie已从数据库重新加载跳过密码登录刷新")
# 重新尝试刷新token递归调用但不会无限递归因为已经重新加载了cookie
return await self.refresh_token(captcha_retry_count)
username = account_info.get('username', '')
password = account_info.get('password', '')
show_browser = account_info.get('show_browser', False)
# 检查是否配置了用户名和密码
if not username or not password:
logger.warning(f"{self.cookie_id}】未配置用户名或密码,跳过密码登录刷新")
raise Exception("未配置用户名或密码")
# 使用集成的 Playwright 登录方法(无需猴子补丁)
from utils.xianyu_slider_stealth import XianyuSliderStealth
browser_mode = "有头" if show_browser else "无头"
logger.info(f"{self.cookie_id}】开始使用{browser_mode}浏览器进行密码登录刷新Cookie...")
logger.info(f"{self.cookie_id}】使用账号: {username}")
# 创建一个通知回调包装函数,支持接收截图路径和验证链接
async def notification_callback_wrapper(message: str, screenshot_path: str = None, verification_url: str = None):
"""通知回调包装函数,支持接收截图路径和验证链接"""
await self.send_token_refresh_notification(
error_message=message,
notification_type="token_refresh",
chat_id=None,
attachment_path=screenshot_path,
verification_url=verification_url
)
# 在单独的线程中运行同步的登录方法
import asyncio
slider = XianyuSliderStealth(user_id=self.cookie_id, enable_learning=False, headless=not show_browser)
result = await asyncio.to_thread(
slider.login_with_password_playwright,
account=username,
password=password,
show_browser=show_browser,
notification_callback=notification_callback_wrapper
)
if result:
logger.info(f"{self.cookie_id}】密码登录成功获取到Cookie")
logger.info(f"{self.cookie_id}】Cookie内容: {result}")
# 打印密码登录获取的Cookie字段详情
logger.info(f"{self.cookie_id}】========== 密码登录Cookie字段详情 ==========")
logger.info(f"{self.cookie_id}】Cookie字段数: {len(result)}")
logger.info(f"{self.cookie_id}】Cookie字段列表:")
for i, (key, value) in enumerate(result.items(), 1):
if len(str(value)) > 50:
logger.info(f"{self.cookie_id}{i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
else:
logger.info(f"{self.cookie_id}{i:2d}. {key}: {value}")
# 检查关键字段
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
logger.info(f"{self.cookie_id}】关键字段检查:")
for key in important_keys:
if key in result:
val = result[key]
logger.info(f"{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
else:
logger.info(f"{self.cookie_id}】 ❌ {key}: 缺失")
logger.info(f"{self.cookie_id}】==========================================")
# 将cookie字典转换为字符串格式
new_cookies_str = '; '.join([f"{k}={v}" for k, v in result.items()])
logger.info(f"{self.cookie_id}】Cookie字符串格式: {new_cookies_str[:200]}..." if len(new_cookies_str) > 200 else f"{self.cookie_id}】Cookie字符串格式: {new_cookies_str}")
# 更新Cookie并重启任务
logger.info(f"{self.cookie_id}】尝试通过访问指定页面获取真实cookie...")
update_success = await self._refresh_cookies_via_browser_page(new_cookies_str)
if update_success:
logger.info(f"{self.cookie_id}】Cookie更新并重启任务成功")
# 记录密码登录时间,防止重复登录
XianyuLive._last_password_login_time[self.cookie_id] = time.time()
logger.warning(f"{self.cookie_id}】已记录密码登录时间,冷却期 {XianyuLive._password_login_cooldown}")
# 发送账号密码登录成功通知
await self.send_token_refresh_notification(
f"账号密码登录成功Cookie已更新任务已重启",
"password_login_success"
)
else:
logger.warning(f"{self.cookie_id}】Cookie更新或重启任务失败")
else:
logger.warning(f"{self.cookie_id}】密码登录失败未获取到Cookie")
except Exception as refresh_e:
logger.error(f"{self.cookie_id}】Cookie刷新或实例重启失败: {self._safe_str(refresh_e)}")
else:
# 刷新成功后重新尝试获取token
return await self.refresh_token(captcha_retry_count)
# 刷新失败时继续执行原有的失败处理逻辑
@ -2035,8 +1997,10 @@ class XianyuLive:
# 通过CookieManager重启任务
logger.info(f"{self.cookie_id}】通过CookieManager重启任务...")
await self._restart_instance()
logger.info(f"{self.cookie_id}】cookies更新和任务重启完成")
# ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
# 立即返回,不执行后续代码
logger.info(f"{self.cookie_id}】cookies更新成功重启请求已触发")
return True
except Exception as update_e:
@ -2096,10 +2060,335 @@ class XianyuLive:
# 发送Cookie更新失败通知
await self.send_token_refresh_notification(f"Cookie更新失败: {str(e)}", "cookie_update_failed")
async def _restart_instance(self):
"""重启XianyuLive实例"""
async def _try_password_login_refresh(self, trigger_reason: str = "令牌/Session过期"):
"""尝试通过密码登录刷新Cookie并重启实例
Args:
trigger_reason: 触发原因用于日志记录
Returns:
bool: 是否成功刷新Cookie
"""
logger.warning(f"{self.cookie_id}】检测到{trigger_reason}准备刷新Cookie并重启实例...")
# 检查是否在密码登录冷却期内,避免重复登录
current_time = time.time()
last_password_login = XianyuLive._last_password_login_time.get(self.cookie_id, 0)
time_since_last_login = current_time - last_password_login
if last_password_login > 0 and time_since_last_login < XianyuLive._password_login_cooldown:
remaining_time = XianyuLive._password_login_cooldown - time_since_last_login
logger.warning(f"{self.cookie_id}】距离上次密码登录仅 {time_since_last_login:.1f} 秒,仍在冷却期内(还需等待 {remaining_time:.1f} 秒),跳过密码登录")
logger.warning(f"{self.cookie_id}】提示如果新Cookie仍然无效请检查账号状态或手动更新Cookie")
return False
# 记录到日志文件
log_captcha_event(self.cookie_id, f"{trigger_reason}触发Cookie刷新和实例重启", None,
f"检测到{trigger_reason}准备刷新Cookie并重启实例")
try:
logger.info(f"{self.cookie_id}】Token刷新成功准备重启实例...")
# 从数据库获取账号登录信息
from db_manager import db_manager
account_info = db_manager.get_cookie_details(self.cookie_id)
if not account_info:
logger.error(f"{self.cookie_id}】无法获取账号信息")
return False
# 【重要】先检查数据库中的cookie是否已经更新
# 如果用户已经手动更新了cookie就不需要触发密码登录刷新
db_cookie_value = account_info.get('cookie_value', '')
if db_cookie_value and db_cookie_value != self.cookies_str:
logger.info(f"{self.cookie_id}】检测到数据库中的cookie已更新重新加载cookie")
self.cookies_str = db_cookie_value
self.cookies = trans_cookies(self.cookies_str)
logger.info(f"{self.cookie_id}】Cookie已从数据库重新加载跳过密码登录刷新")
return True
username = account_info.get('username', '')
password = account_info.get('password', '')
show_browser = account_info.get('show_browser', False)
# 检查是否配置了用户名和密码
if not username or not password:
logger.warning(f"{self.cookie_id}】未配置用户名或密码,跳过密码登录刷新")
await self.send_token_refresh_notification(
f"检测到{trigger_reason}但未配置用户名或密码无法自动刷新Cookie",
"no_credentials"
)
return False
# 使用集成的 Playwright 登录方法(无需猴子补丁)
from utils.xianyu_slider_stealth import XianyuSliderStealth
browser_mode = "有头" if show_browser else "无头"
logger.info(f"{self.cookie_id}】开始使用{browser_mode}浏览器进行密码登录刷新Cookie...")
logger.info(f"{self.cookie_id}】使用账号: {username}")
# 创建一个通知回调包装函数,支持接收截图路径和验证链接
async def notification_callback_wrapper(message: str, screenshot_path: str = None, verification_url: str = None):
"""通知回调包装函数,支持接收截图路径和验证链接"""
await self.send_token_refresh_notification(
error_message=message,
notification_type="token_refresh",
chat_id=None,
attachment_path=screenshot_path,
verification_url=verification_url
)
# 在单独的线程中运行同步的登录方法
import asyncio
slider = XianyuSliderStealth(user_id=self.cookie_id, enable_learning=False, headless=not show_browser)
result = await asyncio.to_thread(
slider.login_with_password_playwright,
account=username,
password=password,
show_browser=show_browser,
notification_callback=notification_callback_wrapper
)
if result:
logger.info(f"{self.cookie_id}】密码登录成功获取到Cookie")
logger.info(f"{self.cookie_id}】Cookie内容: {result}")
# 打印密码登录获取的Cookie字段详情
logger.info(f"{self.cookie_id}】========== 密码登录Cookie字段详情 ==========")
logger.info(f"{self.cookie_id}】Cookie字段数: {len(result)}")
logger.info(f"{self.cookie_id}】Cookie字段列表:")
for i, (key, value) in enumerate(result.items(), 1):
if len(str(value)) > 50:
logger.info(f"{self.cookie_id}{i:2d}. {key}: {str(value)[:30]}...{str(value)[-20:]} (长度: {len(str(value))})")
else:
logger.info(f"{self.cookie_id}{i:2d}. {key}: {value}")
# 检查关键字段
important_keys = ['unb', '_m_h5_tk', '_m_h5_tk_enc', 'cookie2', 't', 'sgcookie', 'cna']
logger.info(f"{self.cookie_id}】关键字段检查:")
for key in important_keys:
if key in result:
val = result[key]
logger.info(f"{self.cookie_id}】 ✅ {key}: {'存在' if val else '为空'} (长度: {len(str(val)) if val else 0})")
else:
logger.info(f"{self.cookie_id}】 ❌ {key}: 缺失")
logger.info(f"{self.cookie_id}】==========================================")
# 将cookie字典转换为字符串格式
new_cookies_str = '; '.join([f"{k}={v}" for k, v in result.items()])
logger.info(f"{self.cookie_id}】Cookie字符串格式: {new_cookies_str[:200]}..." if len(new_cookies_str) > 200 else f"{self.cookie_id}】Cookie字符串格式: {new_cookies_str}")
# 记录密码登录时间,防止重复登录
XianyuLive._last_password_login_time[self.cookie_id] = time.time()
logger.warning(f"{self.cookie_id}】已记录密码登录时间,冷却期 {XianyuLive._password_login_cooldown}")
# 更新cookies并重启任务
update_success = await self._update_cookies_and_restart(new_cookies_str)
if update_success:
logger.info(f"{self.cookie_id}】Cookie更新并重启任务成功")
# 发送账号密码登录成功通知
await self.send_token_refresh_notification(
f"账号密码登录成功Cookie已更新任务已重启",
"password_login_success"
)
return True
else:
logger.error(f"{self.cookie_id}】Cookie更新失败")
return False
else:
logger.warning(f"{self.cookie_id}】密码登录失败未获取到Cookie")
return False
except Exception as refresh_e:
logger.error(f"{self.cookie_id}】Cookie刷新或实例重启失败: {self._safe_str(refresh_e)}")
import traceback
logger.error(f"{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
return False
async def _verify_cookie_validity(self) -> dict:
"""验证Cookie的有效性通过实际调用API测试
Returns:
dict: {
'valid': bool, # 总体是否有效
'confirm_api': bool, # 确认发货API是否有效
'image_api': bool, # 图片上传API是否有效
'details': str # 详细信息
}
"""
logger.info(f"{self.cookie_id}】开始验证Cookie有效性使用真实API调用...")
result = {
'valid': True,
'confirm_api': None,
'image_api': None,
'details': []
}
# 1. 测试确认发货API - 使用测试订单ID实际调用
# try:
# logger.info(f"【{self.cookie_id}】测试确认发货API使用测试数据实际调用...")
# # 确保session存在
# if not self.session:
# import aiohttp
# connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
# timeout = aiohttp.ClientTimeout(total=30)
# self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
# # 创建临时的确认发货实例
# from secure_confirm_decrypted import SecureConfirm
# confirm_tester = SecureConfirm(
# session=self.session,
# cookies_str=self.cookies_str,
# cookie_id=self.cookie_id,
# main_instance=self
# )
# # 使用一个测试订单ID不存在的订单ID
# # 如果Cookie有效应该返回"订单不存在"类的错误
# # 如果Cookie无效会返回"Session过期"错误
# test_order_id = "999999999999999999" # 不存在的测试订单ID
# # 实际调用API (retry_count=3阻止重试快速失败)
# response = await confirm_tester.auto_confirm(test_order_id, retry_count=3)
# # 分析响应
# if response and isinstance(response, dict):
# error_msg = str(response.get('error', ''))
# success = response.get('success', False)
# # 检查是否是Session过期错误
# if 'Session过期' in error_msg or 'SESSION_EXPIRED' in error_msg:
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
# result['confirm_api'] = False
# result['valid'] = False
# result['details'].append("确认发货API: Session过期")
# elif '令牌过期' in error_msg:
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: 令牌过期")
# result['confirm_api'] = False
# result['valid'] = False
# result['details'].append("确认发货API: 令牌过期")
# elif success:
# # 竟然成功了不太可能因为是测试订单ID
# logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: API调用成功")
# result['confirm_api'] = True
# result['details'].append("确认发货API: 通过验证")
# elif error_msg and len(error_msg) > 0:
# # 有其他错误信息如订单不存在、重试次数过多等说明Cookie是有效的
# logger.info(f"【{self.cookie_id}】✅ 确认发货API验证通过: Cookie有效返回业务错误: {error_msg[:50]}")
# result['confirm_api'] = True
# result['details'].append(f"确认发货API: 通过验证")
# else:
# # 没有明确信息,保守认为可能有问题
# logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 响应不明确")
# result['confirm_api'] = False
# result['valid'] = False
# result['details'].append("确认发货API: 响应不明确")
# else:
# # 没有响应,可能有问题
# logger.warning(f"【{self.cookie_id}】⚠️ 确认发货API验证警告: 无响应")
# result['confirm_api'] = False
# result['valid'] = False
# result['details'].append("确认发货API: 无响应")
# except Exception as e:
# error_str = self._safe_str(e)
# # 检查异常信息中是否包含Session过期
# if 'Session过期' in error_str or 'SESSION_EXPIRED' in error_str:
# logger.warning(f"【{self.cookie_id}】❌ 确认发货API验证失败: Session过期")
# result['confirm_api'] = False
# result['valid'] = False
# result['details'].append("确认发货API: Session过期")
# else:
# logger.error(f"【{self.cookie_id}】确认发货API验证异常: {error_str}")
# # 网络异常等问题不一定是Cookie问题暂时标记为通过
# result['confirm_api'] = True
# result['details'].append(f"确认发货API: 调用异常(可能非Cookie问题)")
# 2. 测试图片上传API - 创建测试图片并实际上传
try:
logger.info(f"{self.cookie_id}】测试图片上传API使用测试图片实际上传...")
# 创建一个最小的测试图片1x1像素的PNG
import tempfile
import os
from PIL import Image
# 创建临时目录
temp_dir = tempfile.gettempdir()
test_image_path = os.path.join(temp_dir, f'cookie_test_{self.cookie_id}.png')
try:
# 创建1x1像素的白色图片
img = Image.new('RGB', (1, 1), color='white')
img.save(test_image_path, 'PNG')
logger.info(f"{self.cookie_id}】已创建测试图片: {test_image_path}")
# 创建图片上传实例
from utils.image_uploader import ImageUploader
uploader = ImageUploader(cookies_str=self.cookies_str)
# 创建session
await uploader.create_session()
try:
# 实际上传测试图片
upload_result = await uploader.upload_image(test_image_path)
finally:
# 确保关闭session
await uploader.close_session()
# 分析上传结果
if upload_result:
# 上传成功Cookie有效
logger.info(f"{self.cookie_id}】✅ 图片上传API验证通过: 上传成功 ({upload_result[:50]}...)")
result['image_api'] = True
result['details'].append("图片上传API: 通过验证")
else:
# 上传失败,需要进一步判断原因
# 如果是Cookie失效通常会返回HTML登录页面
logger.warning(f"{self.cookie_id}】❌ 图片上传API验证失败: 上传失败可能是Cookie失效")
result['image_api'] = False
result['valid'] = False
result['details'].append("图片上传API: 上传失败可能Cookie已失效")
finally:
# 清理测试图片
if os.path.exists(test_image_path):
try:
os.remove(test_image_path)
logger.debug(f"{self.cookie_id}】已删除测试图片")
except:
pass
except Exception as e:
error_str = self._safe_str(e)
logger.error(f"{self.cookie_id}】图片上传API验证异常: {error_str}")
# 图片上传异常,标记为失败
result['image_api'] = False
result['valid'] = False
result['details'].append(f"图片上传API: 验证异常 - {error_str[:50]}")
# 汇总结果
if result['valid']:
logger.info(f"{self.cookie_id}】✅ Cookie验证通过: 所有关键API均可用")
else:
logger.warning(f"{self.cookie_id}】❌ Cookie验证失败:")
for detail in result['details']:
logger.warning(f"{self.cookie_id}】 - {detail}")
result['details'] = '; '.join(result['details'])
return result
async def _restart_instance(self):
"""重启XianyuLive实例
注意此方法会触发当前任务被取消
调用此方法后当前任务会立即被 CookieManager 取消
因此不要在此方法后执行任何重要操作
"""
try:
logger.info(f"{self.cookie_id}】准备重启实例...")
# 导入CookieManager
from cookie_manager import manager as cookie_manager
@ -2107,29 +2396,48 @@ class XianyuLive:
if cookie_manager:
# 通过CookieManager重启实例
logger.info(f"{self.cookie_id}】通过CookieManager重启实例...")
# 使用异步方式调用update_cookie避免阻塞
def restart_task():
# ⚠️ 重要:不要等待重启完成!
# cookie_manager.update_cookie() 会立即取消当前任务
# 如果我们等待它完成,会导致 CancelledError 中断等待
# 正确的做法是:触发重启后立即返回,让任务自然退出
import threading
def trigger_restart():
"""在后台线程中触发重启,不阻塞当前任务"""
try:
# 给当前任务一点时间完成清理(避免竞态条件)
import time
time.sleep(0.5)
# save_to_db=False 因为 update_config_cookies 已经保存过了
cookie_manager.update_cookie(self.cookie_id, self.cookies_str, save_to_db=False)
logger.info(f"{self.cookie_id}】实例重启请求已发送")
logger.info(f"{self.cookie_id}】实例重启请求已")
except Exception as e:
logger.error(f"{self.cookie_id}】重启实例失败: {e}")
logger.error(f"{self.cookie_id}】触发实例重启失败: {e}")
import traceback
logger.error(f"{self.cookie_id}】重启失败详情:\n{traceback.format_exc()}")
# 在后台执行重启任务
import threading
restart_thread = threading.Thread(target=restart_task, daemon=True)
# 在后台线程中触发重启
restart_thread = threading.Thread(target=trigger_restart, daemon=True)
restart_thread.start()
logger.info(f"{self.cookie_id}】实例重启已在后台执行")
logger.info(f"{self.cookie_id}】实例重启已触发,当前任务即将退出...")
logger.warning(f"{self.cookie_id}】注意重启请求已发送CookieManager将在0.5秒后取消当前任务并启动新实例")
else:
logger.warning(f"{self.cookie_id}】CookieManager不可用无法重启实例")
except Exception as e:
logger.error(f"{self.cookie_id}】重启实例失败: {self._safe_str(e)}")
import traceback
logger.error(f"{self.cookie_id}】重启失败堆栈:\n{traceback.format_exc()}")
# 发送重启失败通知
await self.send_token_refresh_notification(f"实例重启失败: {str(e)}", "instance_restart_failed")
try:
await self.send_token_refresh_notification(f"实例重启失败: {str(e)}", "instance_restart_failed")
except Exception as notify_e:
logger.error(f"{self.cookie_id}】发送重启失败通知时出错: {self._safe_str(notify_e)}")
async def save_item_info_to_db(self, item_id: str, item_detail: str = None, item_title: str = None):
"""保存商品信息到数据库
@ -2976,7 +3284,8 @@ class XianyuLive:
image_url = cdn_url
else:
logger.error(f"图片上传失败: {local_image_path}")
return f"抱歉,图片发送失败,请稍后重试。"
logger.error(f"❌ Cookie可能已失效请检查配置并更新Cookie")
return f"抱歉图片发送失败Cookie可能已失效请检查日志"
else:
logger.error(f"本地图片文件不存在: {local_image_path}")
return f"抱歉,图片文件不存在。"
@ -3995,6 +4304,9 @@ class XianyuLive:
case 'bark':
await self._send_bark_notification(config_data, notification_message)
logger.info(f"已发送自动发货通知到Bark")
case 'feishu' | 'lark':
await self._send_feishu_notification(config_data, notification_message)
logger.info(f"已发送自动发货通知到飞书")
case _:
logger.warning(f"不支持的通知渠道类型: {channel_type}")
@ -4706,11 +5018,24 @@ class XianyuLive:
logger.info("Token即将过期准备刷新...")
new_token = await self.refresh_token()
if new_token:
logger.info(f"{self.cookie_id}】Token刷新成功准备重启实例...")
# 注意refresh_token方法中已经调用了_restart_instance()
# 这里只需要关闭当前连接让main循环重新开始
self.connection_restart_flag = True
await self._restart_instance()
logger.info(f"{self.cookie_id}】Token刷新成功将关闭WebSocket以使用新Token重连")
# Token刷新成功后需要关闭WebSocket连接让它用新Token重新连接
# 原因WebSocket连接建立时使用的是旧Token新Token需要重新建立连接才能生效
# 注意只关闭WebSocket不重启整个实例后台任务继续运行
# 关闭当前WebSocket连接
if self.ws and not self.ws.closed:
try:
logger.info(f"{self.cookie_id}】关闭当前WebSocket连接以使用新Token重连...")
await self.ws.close()
logger.info(f"{self.cookie_id}】WebSocket连接已关闭将自动重连")
except Exception as close_e:
logger.warning(f"{self.cookie_id}】关闭WebSocket时出错: {self._safe_str(close_e)}")
# 退出Token刷新循环让main循环重新建立连接
# 后台任务(心跳、清理等)继续运行
logger.info(f"{self.cookie_id}】Token刷新完成WebSocket将使用新Token重新连接")
break
else:
# 根据上一次刷新状态决定日志级别(冷却/已重启为正常情况)
@ -5006,6 +5331,15 @@ class XianyuLive:
except Exception as pw_clean_e:
logger.warning(f"{self.cookie_id}】清理Playwright缓存时出错: {pw_clean_e}")
# 清理过期的日志文件每5分钟检查一次保留7天
try:
cleaned_logs = await self._cleanup_old_logs(retention_days=7)
await asyncio.sleep(0) # 让出控制权,允许检查取消信号
except asyncio.CancelledError:
raise
except Exception as log_clean_e:
logger.warning(f"{self.cookie_id}】清理日志文件时出错: {log_clean_e}")
# 清理数据库历史数据每天一次保留90天数据
# 为避免所有实例同时执行,只让第一个实例执行
try:
@ -5144,6 +5478,35 @@ class XianyuLive:
if success:
self.last_cookie_refresh_time = current_time
logger.info(f"{self.cookie_id}】Cookie刷新任务完成心跳已恢复")
# 刷新成功后验证Cookie有效性
logger.info(f"{self.cookie_id}】开始验证刷新后的Cookie有效性...")
try:
validation_result = await self._verify_cookie_validity()
if not validation_result['valid']:
logger.warning(f"{self.cookie_id}】❌ Cookie验证失败: {validation_result['details']}")
logger.warning(f"{self.cookie_id}】检测到Cookie可能无法用于关键API尝试通过密码登录重新获取...")
# 触发密码登录刷新
password_refresh_success = await self._try_password_login_refresh("Cookie验证失败(关键API不可用)")
if password_refresh_success:
logger.info(f"{self.cookie_id}】✅ 密码登录刷新成功Cookie已更新")
else:
logger.warning(f"{self.cookie_id}】⚠️ 密码登录刷新失败Cookie可能仍然无效")
# 发送通知
await self.send_token_refresh_notification(
f"Cookie验证失败且密码登录刷新也失败\n验证详情: {validation_result['details']}",
"cookie_validation_failed"
)
else:
logger.info(f"{self.cookie_id}】✅ Cookie验证通过: {validation_result['details']}")
except Exception as verify_e:
logger.error(f"{self.cookie_id}】Cookie验证过程异常: {self._safe_str(verify_e)}")
import traceback
logger.error(f"{self.cookie_id}】详细堆栈:\n{traceback.format_exc()}")
else:
logger.warning(f"{self.cookie_id}】Cookie刷新任务失败")
# 即使失败也要更新时间,避免频繁重试
@ -6150,13 +6513,16 @@ class XianyuLive:
# 兜底:直接在此处触发实例重启,避免外层协程在返回后被取消导致未重启
try:
# 标记“刷新流程内已触发重启”,供外层去重
# 标记"刷新流程内已触发重启",供外层去重
self.restarted_in_browser_refresh = True
logger.info(f"{self.cookie_id}】Cookie刷新成功准备重启实例...(via _refresh_cookies_via_browser)")
await self._restart_instance()
logger.info(f"{self.cookie_id}】实例重启完成(via _refresh_cookies_via_browser)")
# ⚠️ _restart_instance() 已触发重启,当前任务即将被取消
# 不要等待或执行耗时操作
logger.info(f"{self.cookie_id}】重启请求已触发(via _refresh_cookies_via_browser)")
# 标记重启标志无需主动关闭WS重启由管理器处理
self.connection_restart_flag = True
except Exception as e:
@ -6587,19 +6953,45 @@ class XianyuLive:
message_id = f"{chat_id}_{send_message}_{int(time.time() * 1000)}"
async with self.processed_message_ids_lock:
current_time = time.time()
# 检查消息是否已处理且未过期
if message_id in self.processed_message_ids:
logger.warning(f"{self.cookie_id}】消息ID {message_id[:50]}... 已处理过,跳过重复处理")
return
# 标记消息ID为已处理
self.processed_message_ids.add(message_id)
# 如果集合过大,清理最旧的一半(简单策略)
last_process_time = self.processed_message_ids[message_id]
time_elapsed = current_time - last_process_time
# 如果消息处理时间未超过1小时跳过
if time_elapsed < self.message_expire_time:
remaining_time = int(self.message_expire_time - time_elapsed)
logger.warning(f"{self.cookie_id}】消息ID {message_id[:50]}... 已处理过,距离可重复回复还需 {remaining_time}")
return
else:
# 超过1小时可以重新处理
logger.info(f"{self.cookie_id}】消息ID {message_id[:50]}... 已超过 {int(time_elapsed/60)} 分钟,允许重新回复")
# 标记消息ID为已处理更新或添加时间戳
self.processed_message_ids[message_id] = current_time
# 定期清理过期的消息ID
if len(self.processed_message_ids) > self.processed_message_ids_max_size:
# 转换为列表,删除前一半
ids_list = list(self.processed_message_ids)
remove_count = len(ids_list) // 2
for i in range(remove_count):
self.processed_message_ids.discard(ids_list[i])
logger.info(f"{self.cookie_id}】消息ID去重集合过大已清理 {remove_count} 个旧记录")
# 清理超过1小时的旧记录
expired_ids = [
msg_id for msg_id, timestamp in self.processed_message_ids.items()
if current_time - timestamp > self.message_expire_time
]
for msg_id in expired_ids:
del self.processed_message_ids[msg_id]
logger.info(f"{self.cookie_id}】已清理 {len(expired_ids)} 个过期消息ID")
# 如果清理后仍然过大,删除最旧的一半
if len(self.processed_message_ids) > self.processed_message_ids_max_size:
sorted_ids = sorted(self.processed_message_ids.items(), key=lambda x: x[1])
remove_count = len(sorted_ids) // 2
for msg_id, _ in sorted_ids[:remove_count]:
del self.processed_message_ids[msg_id]
logger.info(f"{self.cookie_id}】消息ID去重字典过大已清理 {remove_count} 个最旧记录")
async with self.message_debounce_lock:
# 如果该chat_id已有防抖任务取消它
@ -7047,8 +7439,14 @@ class XianyuLive:
logger.info(f"[{msg_time}] 【收到】用户: {send_user_name} (ID: {send_user_id}), 商品({item_id}): {send_message}")
# 🔔 立即发送消息通知(独立于自动回复功能)
# 检查是否为群组消息,如果是群组消息则跳过通知
try:
await self.send_notification(send_user_name, send_user_id, send_message, item_id, chat_id)
session_type = message_10.get("sessionType", "1") # 默认为个人消息类型
if session_type == "30":
logger.info(f"📱 检测到群组消息sessionType=30跳过消息通知")
else:
# 只对个人消息发送通知
await self.send_notification(send_user_name, send_user_id, send_message, item_id, chat_id)
except Exception as notify_error:
logger.error(f"📱 发送消息通知失败: {self._safe_str(notify_error)}")
@ -7128,6 +7526,9 @@ class XianyuLive:
elif send_message == '[你已发货]':
logger.info(f'[{msg_time}] 【{self.cookie_id}】发货确认消息不处理')
return
elif send_message == '已发货':
logger.info(f'[{msg_time}] 【{self.cookie_id}】发货确认消息不处理')
return
# 【重要】检查是否为自动发货触发消息 - 即使在人工接入暂停期间也要处理
elif self._is_auto_delivery_trigger(send_message):
logger.info(f'[{msg_time}] 【{self.cookie_id}】检测到自动发货触发消息,即使在暂停期间也继续处理: {send_message}')
@ -7376,10 +7777,51 @@ class XianyuLive:
# 检查是否超过最大失败次数
if self.connection_failures >= self.max_connection_failures:
self._set_connection_state(ConnectionState.FAILED, f"连续失败{self.max_connection_failures}")
logger.warning(f"{self.cookie_id}】连续失败{self.max_connection_failures}尝试通过密码登录刷新Cookie...")
try:
# 调用统一的密码登录刷新方法
refresh_success = await self._try_password_login_refresh(f"连续失败{self.max_connection_failures}")
if refresh_success:
logger.info(f"{self.cookie_id}】✅ 密码登录刷新成功,将重置失败计数并继续重连")
# 重置失败计数因为已经刷新了Cookie
self.connection_failures = 0
# 更新连接状态
self._set_connection_state(ConnectionState.RECONNECTING, "Cookie已刷新准备重连")
# 短暂等待后继续重连循环
await asyncio.sleep(2)
continue
else:
logger.warning(f"{self.cookie_id}】❌ 密码登录刷新失败,将重启实例...")
except Exception as refresh_e:
logger.error(f"{self.cookie_id}】密码登录刷新过程异常: {self._safe_str(refresh_e)}")
logger.warning(f"{self.cookie_id}】将重启实例...")
# 如果密码登录刷新失败或异常,则重启实例
logger.error(f"{self.cookie_id}】准备重启实例...")
self.connection_failures = 0 # 重置失败计数
await self._restart_instance() # 重启实例
return # 重启后退出当前连接循环
# 先清理后台任务,避免与重启过程冲突
logger.info(f"{self.cookie_id}】重启前先清理后台任务...")
try:
await asyncio.wait_for(
self._cancel_background_tasks(),
timeout=8.0 # 给足够时间让任务响应
)
logger.info(f"{self.cookie_id}】后台任务已清理完成")
except asyncio.TimeoutError:
logger.warning(f"{self.cookie_id}】后台任务清理超时,强制继续重启")
except Exception as cleanup_e:
logger.error(f"{self.cookie_id}】后台任务清理失败: {self._safe_str(cleanup_e)}")
# 触发重启(不等待完成)
await self._restart_instance()
# ⚠️ 重要_restart_instance() 已触发重启0.5秒后当前任务会被取消
# 不要在这里等待或执行其他操作,让任务自然退出
logger.info(f"{self.cookie_id}】重启请求已触发,主程序即将退出,新实例将自动启动")
return # 退出当前连接循环,等待被取消
# 计算重试延迟
retry_delay = self._calculate_retry_delay(error_msg)
@ -7486,17 +7928,34 @@ class XianyuLive:
logger.info(f"{self.cookie_id}】程序退出清空当前token")
self.current_token = None
# 使用统一的任务清理方法,添加超时保护
try:
await asyncio.wait_for(
self._cancel_background_tasks(),
timeout=10.0
)
except asyncio.TimeoutError:
logger.error(f"{self.cookie_id}】程序退出时任务取消超时,强制继续")
except Exception as e:
logger.error(f"{self.cookie_id}】程序退出时任务取消失败: {self._safe_str(e)}")
finally:
# 检查是否还有未取消的后台任务,如果有才执行清理
has_pending_tasks = any([
self.heartbeat_task and not self.heartbeat_task.done(),
self.token_refresh_task and not self.token_refresh_task.done(),
self.cleanup_task and not self.cleanup_task.done(),
self.cookie_refresh_task and not self.cookie_refresh_task.done()
])
if has_pending_tasks:
logger.info(f"{self.cookie_id}】检测到未完成的后台任务,执行清理...")
# 使用统一的任务清理方法,添加超时保护
try:
await asyncio.wait_for(
self._cancel_background_tasks(),
timeout=10.0
)
except asyncio.TimeoutError:
logger.error(f"{self.cookie_id}】程序退出时任务取消超时,强制继续")
except Exception as e:
logger.error(f"{self.cookie_id}】程序退出时任务取消失败: {self._safe_str(e)}")
finally:
# 确保任务引用被重置
self.heartbeat_task = None
self.token_refresh_task = None
self.cleanup_task = None
self.cookie_refresh_task = None
else:
logger.info(f"{self.cookie_id}】所有后台任务已清理完成,跳过重复清理")
# 确保任务引用被重置
self.heartbeat_task = None
self.token_refresh_task = None
@ -7787,7 +8246,8 @@ class XianyuLive:
logger.warning(f"{self.cookie_id}】获取图片尺寸失败,使用默认尺寸: {e}")
else:
logger.error(f"{self.cookie_id}】图片上传失败: {local_image_path}")
raise Exception(f"图片上传失败: {local_image_path}")
logger.error(f"{self.cookie_id}】❌ Cookie可能已失效请检查配置并更新Cookie")
raise Exception(f"图片上传失败Cookie可能已失效: {local_image_path}")
else:
logger.error(f"{self.cookie_id}】本地图片文件不存在: {local_image_path}")
raise Exception(f"本地图片文件不存在: {local_image_path}")
@ -7898,6 +8358,7 @@ class XianyuLive:
return True
else:
logger.error(f"{self.cookie_id}】图片上传失败: {image_path}")
logger.error(f"{self.cookie_id}】❌ Cookie可能已失效请检查配置并更新Cookie")
return False
except Exception as e:

View File

@ -309,9 +309,9 @@ class AIReplyEngine:
# 使用锁确保同一chat_id的消息串行处理
with chat_lock:
# 获取最近时间窗口内的所有用户消息
# 如果 skip_wait=True外部防抖查询窗口为8秒3秒防抖 + 5秒缓冲
# 如果 skip_wait=True外部防抖查询窗口为6秒1秒防抖 + 5秒缓冲
# 如果 skip_wait=False内部等待查询窗口为25秒10秒等待 + 10秒消息间隔 + 5秒缓冲
query_seconds = 8 if skip_wait else 25
query_seconds = 6 if skip_wait else 25
recent_messages = self._get_recent_user_messages(chat_id, cookie_id, seconds=query_seconds)
logger.info(f"{cookie_id}】最近{query_seconds}秒内的消息: {[msg['content'][:20] for msg in recent_messages]}")

View File

@ -91,7 +91,7 @@ config = Config()
# 导出常用配置项
COOKIES_STR = config.get('COOKIES.value', '')
COOKIES_LAST_UPDATE = config.get('COOKIES.last_update_time', '')
WEBSOCKET_URL = config.get('WEBSOCKET_URL', '://wss-goofish.dingtalk.com/')
WEBSOCKET_URL = config.get('WEBSOCKET_URL', 'wss://wss-goofish.dingtalk.com/')
HEARTBEAT_INTERVAL = config.get('HEARTBEAT_INTERVAL', 15)
HEARTBEAT_TIMEOUT = config.get('HEARTBEAT_TIMEOUT', 30)
TOKEN_REFRESH_INTERVAL = config.get('TOKEN_REFRESH_INTERVAL', 72000)

View File

@ -484,8 +484,44 @@ async def admin_page():
index_path = os.path.join(static_dir, 'index.html')
if not os.path.exists(index_path):
return HTMLResponse('<h3>No front-end found</h3>')
with open(index_path, 'r', encoding='utf-8') as f:
return HTMLResponse(f.read())
# 获取静态文件的修改时间作为版本号,解决浏览器缓存问题
def get_file_version(file_path, default='1.0.0'):
"""获取文件的版本号(基于修改时间)"""
if os.path.exists(file_path):
try:
mtime = os.path.getmtime(file_path)
return str(int(mtime))
except Exception as e:
logger.warning(f"获取文件 {file_path} 修改时间失败: {e}")
return default
app_js_path = os.path.join(static_dir, 'js', 'app.js')
app_css_path = os.path.join(static_dir, 'css', 'app.css')
js_version = get_file_version(app_js_path, '2.2.0')
css_version = get_file_version(app_css_path, '1.0.0')
try:
with open(index_path, 'r', encoding='utf-8') as f:
html_content = f.read()
# 替换 app.js 的版本号参数
js_pattern = r'/static/js/app\.js\?v=[^"\'\s>]+'
js_new_url = f'/static/js/app.js?v={js_version}'
if re.search(js_pattern, html_content):
html_content = re.sub(js_pattern, js_new_url, html_content)
logger.debug(f"已替换 app.js 版本号: {js_version}")
# 为 app.css 添加或更新版本号参数
css_pattern = r'/static/css/app\.css(\?v=[^"\'\s>]+)?'
css_new_url = f'/static/css/app.css?v={css_version}'
html_content = re.sub(css_pattern, css_new_url, html_content)
return HTMLResponse(html_content)
except Exception as e:
logger.error(f"读取或处理 index.html 失败: {e}")
return HTMLResponse('<h3>Error loading page</h3>')
@ -1316,16 +1352,90 @@ async def _execute_password_login(session_id: str, account_id: str, account: str
# 更新会话信息
password_login_sessions[session_id]['slider_instance'] = slider_instance
# 定义通知回调函数,用于检测到人脸认证时返回验证链接(同步函数)
def notification_callback(message: str, screenshot_path: str = None, verification_url: str = None):
"""人脸认证通知回调(同步)"""
# 定义通知回调函数,用于检测到人脸认证时返回验证链接或截图(同步函数)
def notification_callback(message: str, screenshot_path: str = None, verification_url: str = None, screenshot_path_new: str = None):
"""人脸认证通知回调(同步)
Args:
message: 通知消息
screenshot_path: 旧版截图路径兼容参数
verification_url: 验证链接
screenshot_path_new: 新版截图路径新参数优先使用
"""
try:
# 优先使用验证链接
if verification_url:
# 更新会话状态,保存验证链接
# 优先使用新的截图路径参数
actual_screenshot_path = screenshot_path_new if screenshot_path_new else screenshot_path
# 优先使用截图路径,如果没有截图则使用验证链接
if actual_screenshot_path and os.path.exists(actual_screenshot_path):
# 更新会话状态,保存截图路径
password_login_sessions[session_id]['status'] = 'verification_required'
password_login_sessions[session_id]['screenshot_path'] = actual_screenshot_path
password_login_sessions[session_id]['verification_url'] = None
password_login_sessions[session_id]['qr_code_url'] = None
log_with_user('info', f"人脸认证截图已保存: {session_id}, 路径: {actual_screenshot_path}", current_user)
# 发送通知到用户配置的渠道
def send_face_verification_notification():
"""在后台线程中发送人脸验证通知"""
try:
from XianyuAutoAsync import XianyuLive
log_with_user('info', f"开始尝试发送人脸验证通知: {account_id}", current_user)
# 尝试获取XianyuLive实例如果账号已经存在
live_instance = XianyuLive.get_instance(account_id)
if live_instance:
log_with_user('info', f"找到账号实例,准备发送通知: {account_id}", current_user)
# 创建新的事件循环来运行异步通知
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
new_loop.run_until_complete(
live_instance.send_token_refresh_notification(
error_message=message,
notification_type="face_verification",
verification_url=None,
attachment_path=actual_screenshot_path
)
)
log_with_user('info', f"✅ 已发送人脸验证通知: {account_id}", current_user)
except Exception as notify_err:
log_with_user('error', f"发送人脸验证通知失败: {str(notify_err)}", current_user)
import traceback
log_with_user('error', f"通知错误详情: {traceback.format_exc()}", current_user)
finally:
new_loop.close()
else:
# 如果账号实例不存在,记录警告并尝试从数据库获取通知配置
log_with_user('warning', f"账号实例不存在: {account_id},尝试从数据库获取通知配置", current_user)
try:
# 尝试从数据库获取通知配置
notifications = db_manager.get_account_notifications(account_id)
if notifications:
log_with_user('info', f"找到 {len(notifications)} 个通知配置,但需要账号实例才能发送", current_user)
log_with_user('warning', f"账号实例不存在,无法发送通知: {account_id}。请确保账号已登录并运行中。", current_user)
else:
log_with_user('warning', f"账号 {account_id} 未配置通知渠道", current_user)
except Exception as db_err:
log_with_user('error', f"获取通知配置失败: {str(db_err)}", current_user)
except Exception as notify_err:
log_with_user('error', f"发送人脸验证通知时出错: {str(notify_err)}", current_user)
import traceback
log_with_user('error', f"通知错误详情: {traceback.format_exc()}", current_user)
# 在后台线程中发送通知,避免阻塞登录流程
import threading
notification_thread = threading.Thread(target=send_face_verification_notification)
notification_thread.daemon = True
notification_thread.start()
log_with_user('info', f"已启动人脸验证通知发送线程: {account_id}", current_user)
elif verification_url:
# 如果没有截图,使用验证链接(兼容旧版本)
password_login_sessions[session_id]['status'] = 'verification_required'
password_login_sessions[session_id]['verification_url'] = verification_url
password_login_sessions[session_id]['qr_code_url'] = None # 不再使用截图
password_login_sessions[session_id]['screenshot_path'] = None
password_login_sessions[session_id]['qr_code_url'] = None
log_with_user('info', f"人脸认证验证链接已保存: {session_id}, URL: {verification_url}", current_user)
# 发送通知到用户配置的渠道
@ -1382,9 +1492,6 @@ async def _execute_password_login(session_id: str, account_id: str, account: str
notification_thread.daemon = True
notification_thread.start()
log_with_user('info', f"已启动人脸验证通知发送线程: {account_id}", current_user)
elif screenshot_path and os.path.exists(screenshot_path):
# 兼容旧版本:如果有截图路径,仍然处理(但不再使用)
log_with_user('info', f"收到截图路径(已弃用): {session_id}", current_user)
except Exception as e:
log_with_user('error', f"处理人脸认证通知失败: {str(e)}", current_user)
@ -1601,6 +1708,7 @@ async def password_login(
'show_browser': show_browser,
'status': 'processing',
'verification_url': None,
'screenshot_path': None,
'qr_code_url': None,
'slider_instance': None,
'task': None,
@ -1658,14 +1766,29 @@ async def check_password_login_status(
if status == 'verification_required':
# 需要人脸认证
screenshot_path = session.get('screenshot_path')
verification_url = session.get('verification_url')
return {
'status': 'verification_required',
'verification_url': session.get('verification_url'),
'verification_url': verification_url,
'screenshot_path': screenshot_path,
'qr_code_url': session.get('qr_code_url'), # 保留兼容性
'message': '需要人脸验证,请点击验证链接'
'message': '需要人脸验证,请查看验证截图' if screenshot_path else '需要人脸验证,请点击验证链接'
}
elif status == 'success':
# 登录成功
# 删除截图(如果存在)
screenshot_path = session.get('screenshot_path')
if screenshot_path:
try:
from utils.image_utils import image_manager
if image_manager.delete_image(screenshot_path):
log_with_user('info', f"验证成功后已删除截图: {screenshot_path}", current_user)
else:
log_with_user('warning', f"删除截图失败: {screenshot_path}", current_user)
except Exception as e:
log_with_user('error', f"删除截图时出错: {str(e)}", current_user)
result = {
'status': 'success',
'message': f'账号 {session["account_id"]} 登录成功',
@ -1678,6 +1801,18 @@ async def check_password_login_status(
return result
elif status == 'failed':
# 登录失败
# 删除截图(如果存在)
screenshot_path = session.get('screenshot_path')
if screenshot_path:
try:
from utils.image_utils import image_manager
if image_manager.delete_image(screenshot_path):
log_with_user('info', f"验证失败后已删除截图: {screenshot_path}", current_user)
else:
log_with_user('warning', f"删除截图失败: {screenshot_path}", current_user)
except Exception as e:
log_with_user('error', f"删除截图时出错: {str(e)}", current_user)
error_msg = session.get('error', '登录失败')
log_with_user('info', f"返回登录失败状态: {session_id}, 错误消息: {error_msg}", current_user) # 添加日志
result = {
@ -1700,6 +1835,132 @@ async def check_password_login_status(
return {'status': 'error', 'message': str(e)}
# ========================= 人脸验证截图相关接口 =========================
@app.get("/face-verification/screenshot/{account_id}")
async def get_account_face_verification_screenshot(
account_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""获取指定账号的人脸验证截图"""
try:
import glob
from datetime import datetime
# 检查账号是否属于当前用户
user_id = current_user['user_id']
username = current_user['username']
# 如果是管理员,允许访问所有账号
is_admin = username == 'admin'
if not is_admin:
cookie_info = db_manager.get_cookie_details(account_id)
if not cookie_info:
log_with_user('warning', f"账号 {account_id} 不存在", current_user)
return {
'success': False,
'message': '账号不存在'
}
cookie_user_id = cookie_info.get('user_id')
if cookie_user_id != user_id:
log_with_user('warning', f"用户 {user_id} 尝试访问账号 {account_id}(归属用户: {cookie_user_id}", current_user)
return {
'success': False,
'message': '无权访问该账号'
}
# 获取该账号的验证截图
screenshots_dir = os.path.join(static_dir, 'uploads', 'images')
pattern = os.path.join(screenshots_dir, f'face_verify_{account_id}_*.jpg')
screenshot_files = glob.glob(pattern)
log_with_user('debug', f"查找截图: {pattern}, 找到 {len(screenshot_files)} 个文件", current_user)
if not screenshot_files:
log_with_user('warning', f"账号 {account_id} 没有找到验证截图", current_user)
return {
'success': False,
'message': '未找到验证截图'
}
# 获取最新的截图
latest_file = max(screenshot_files, key=os.path.getmtime)
filename = os.path.basename(latest_file)
stat = os.stat(latest_file)
screenshot_info = {
'filename': filename,
'account_id': account_id,
'path': f'/static/uploads/images/{filename}',
'size': stat.st_size,
'created_time': stat.st_ctime,
'created_time_str': datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S')
}
log_with_user('info', f"获取账号 {account_id} 的验证截图", current_user)
return {
'success': True,
'screenshot': screenshot_info
}
except Exception as e:
log_with_user('error', f"获取验证截图失败: {str(e)}", current_user)
return {
'success': False,
'message': str(e)
}
@app.delete("/face-verification/screenshot/{account_id}")
async def delete_account_face_verification_screenshot(
account_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""删除指定账号的人脸验证截图"""
try:
import glob
# 检查账号是否属于当前用户
user_id = current_user['user_id']
cookie_info = db_manager.get_cookie_details(account_id)
if not cookie_info or cookie_info.get('user_id') != user_id:
return {
'success': False,
'message': '无权访问该账号'
}
# 删除该账号的所有验证截图
screenshots_dir = os.path.join(static_dir, 'uploads', 'images')
pattern = os.path.join(screenshots_dir, f'face_verify_{account_id}_*.jpg')
screenshot_files = glob.glob(pattern)
deleted_count = 0
for file_path in screenshot_files:
try:
if os.path.exists(file_path):
os.remove(file_path)
deleted_count += 1
log_with_user('info', f"删除账号 {account_id} 的验证截图: {os.path.basename(file_path)}", current_user)
except Exception as e:
log_with_user('error', f"删除截图失败 {file_path}: {str(e)}", current_user)
return {
'success': True,
'message': f'已删除 {deleted_count} 个验证截图',
'deleted_count': deleted_count
}
except Exception as e:
log_with_user('error', f"删除验证截图失败: {str(e)}", current_user)
return {
'success': False,
'message': str(e)
}
# ========================= 扫码登录相关接口 =========================
@app.post("/qr-login/generate")

View File

@ -78,7 +78,7 @@ class SecureConfirm:
try:
from db_manager import db_manager
# 更新数据库中的cookies
db_manager.update_cookie_value(self.cookie_id, self.cookies_str)
db_manager.update_cookie_account_info(self.cookie_id, cookie_value=self.cookies_str)
logger.debug(f"{self.cookie_id}】已更新数据库中的Cookie")
except Exception as e:
logger.error(f"{self.cookie_id}】更新数据库Cookie失败: {self._safe_str(e)}")

View File

@ -3705,5 +3705,14 @@
</div>
</div>
<!-- 底部赞助商信息 -->
<footer class="text-center py-3 mt-5" style="background-color: #f8f9fa; border-top: 1px solid #dee2e6;">
<div class="container">
<p class="mb-0 text-muted">
<small>赞助商:划算云服务器 <a href="https://www.hsykj.com" target="_blank" class="text-decoration-none">www.hsykj.com</a></small>
</p>
</div>
</footer>
</body>
</html>

View File

@ -1307,6 +1307,9 @@ async function loadCookies() {
</td>
<td class="align-middle">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" onclick="showFaceVerification('${cookie.id}')" title="人脸验证">
<i class="bi bi-shield-check"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="editCookieInline('${cookie.id}', '${cookie.value}')" title="修改Cookie" ${!isEnabled ? 'disabled' : ''}>
<i class="bi bi-pencil"></i>
</button>
@ -1319,6 +1322,7 @@ async function loadCookies() {
<button class="btn btn-sm btn-outline-info" onclick="copyCookie('${cookie.id}', '${cookie.value}')" title="复制Cookie">
<i class="bi bi-clipboard"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="delCookie('${cookie.id}')" title="删除账号">
<i class="bi bi-trash"></i>
</button>
@ -7182,8 +7186,8 @@ async function checkPasswordLoginStatus() {
// 处理中,继续等待
break;
case 'verification_required':
// 需要人脸认证,显示验证链接
showPasswordLoginQRCode(data.verification_url || data.qr_code_url);
// 需要人脸认证,显示验证截图或链接
showPasswordLoginQRCode(data.screenshot_path || data.verification_url || data.qr_code_url, data.screenshot_path);
// 继续监控(人脸认证后需要继续等待登录完成)
break;
case 'success':
@ -7226,8 +7230,8 @@ async function checkPasswordLoginStatus() {
}
}
// 显示账号密码登录验证链接(人脸认证)
function showPasswordLoginQRCode(verificationUrl) {
// 显示账号密码登录验证(人脸认证)
function showPasswordLoginQRCode(verificationUrl, screenshotPath) {
// 使用现有的二维码登录模态框
let modal = document.getElementById('passwordLoginQRModal');
if (!modal) {
@ -7255,49 +7259,53 @@ function showPasswordLoginQRCode(verificationUrl) {
qrContainer.style.display = 'none';
}
// 隐藏二维码图片(不再使用)
const qrImg = document.getElementById('passwordLoginQRImg');
if (qrImg) {
qrImg.style.display = 'none';
}
// 优先显示截图,如果没有截图则显示链接
const screenshotImg = document.getElementById('passwordLoginScreenshotImg');
const linkButton = document.getElementById('passwordLoginVerificationLink');
const statusText = document.getElementById('passwordLoginQRStatusText');
// 显示验证链接按钮
let linkButton = document.getElementById('passwordLoginVerificationLink');
if (!linkButton) {
// 如果按钮不存在,创建一个
const linkContainer = document.createElement('div');
linkContainer.id = 'passwordLoginLinkContainer';
linkContainer.className = 'mt-4';
linkContainer.innerHTML = `
<a id="passwordLoginVerificationLink" href="#" target="_blank" class="btn btn-warning btn-lg">
<i class="bi bi-shield-check me-2"></i>
跳转闲鱼人脸验证
</a>
`;
const modalBody = modal.querySelector('.modal-body');
if (modalBody) {
modalBody.appendChild(linkContainer);
if (screenshotPath) {
// 显示截图
if (screenshotImg) {
screenshotImg.src = `/${screenshotPath}?t=${new Date().getTime()}`;
screenshotImg.style.display = 'block';
}
linkButton = document.getElementById('passwordLoginVerificationLink');
}
// 更新按钮链接和显示状态
if (linkButton) {
if (verificationUrl) {
linkButton.href = verificationUrl;
linkButton.style.display = 'inline-block';
} else {
// 隐藏链接按钮
if (linkButton) {
linkButton.style.display = 'none';
}
}
// 更新状态文本
const statusText = document.getElementById('passwordLoginQRStatusText');
if (statusText) {
if (verificationUrl) {
// 更新状态文本
if (statusText) {
statusText.textContent = '需要闲鱼人脸验证请使用手机闲鱼APP扫描下方二维码完成验证';
}
} else if (verificationUrl) {
// 隐藏截图
if (screenshotImg) {
screenshotImg.style.display = 'none';
}
// 显示链接按钮
if (linkButton) {
linkButton.href = verificationUrl;
linkButton.style.display = 'inline-block';
}
// 更新状态文本
if (statusText) {
statusText.textContent = '需要闲鱼验证,请点击下方按钮跳转到验证页面';
} else {
statusText.textContent = '需要闲鱼验证,请等待验证链接...';
}
} else {
// 都没有,显示等待
if (screenshotImg) {
screenshotImg.style.display = 'none';
}
if (linkButton) {
linkButton.style.display = 'none';
}
if (statusText) {
statusText.textContent = '需要闲鱼验证,请等待验证信息...';
}
}
}
@ -7315,9 +7323,28 @@ function createPasswordLoginQRModal() {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<p id="passwordLoginQRStatusText" class="text-muted mb-3">
需要闲鱼人脸验证请等待验证信息...
</p>
<!-- 截图显示区域 -->
<div id="passwordLoginScreenshotContainer" class="mb-3 d-flex justify-content-center">
<img id="passwordLoginScreenshotImg" src="" alt="人脸验证二维码"
class="img-fluid" style="display: none; max-width: 400px; height: auto; border: 2px solid #ddd; border-radius: 8px;">
</div>
<!-- 验证链接按钮回退方案 -->
<div id="passwordLoginLinkContainer" class="mt-4">
<a id="passwordLoginVerificationLink" href="#" target="_blank"
class="btn btn-warning btn-lg" style="display: none;">
<i class="bi bi-shield-check me-2"></i>
跳转闲鱼人脸验证
</a>
</div>
<div class="alert alert-info mt-3">
<i class="bi bi-info-circle me-2"></i>
<small>请点击下方按钮跳转到验证页面完成闲鱼人脸验证</small>
<small>验证完成后系统将自动检测并继续登录流程</small>
</div>
</div>
</div>
@ -11709,4 +11736,90 @@ async function checkCaptchaCompletion(modal, sessionId) {
});
}
// ========================= 人脸验证相关功能 =========================
// 显示人脸验证截图
async function showFaceVerification(accountId) {
try {
toggleLoading(true);
// 获取该账号的验证截图
const response = await fetch(`${apiBase}/face-verification/screenshot/${accountId}`, {
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (!response.ok) {
throw new Error('获取验证截图失败');
}
const data = await response.json();
toggleLoading(false);
if (!data.success) {
showToast(data.message || '未找到验证截图', 'warning');
return;
}
// 使用与密码登录相同的弹窗显示验证截图
showAccountFaceVerificationModal(accountId, data.screenshot);
} catch (error) {
toggleLoading(false);
console.error('获取人脸验证截图失败:', error);
showToast('获取验证截图失败: ' + error.message, 'danger');
}
}
// 显示账号列表的人脸验证弹窗(使用与密码登录相同的样式)
function showAccountFaceVerificationModal(accountId, screenshot) {
// 复用密码登录的弹窗
let modal = document.getElementById('passwordLoginQRModal');
if (!modal) {
createPasswordLoginQRModal();
modal = document.getElementById('passwordLoginQRModal');
}
// 更新模态框标题
const modalTitle = document.getElementById('passwordLoginQRModalLabel');
if (modalTitle) {
modalTitle.innerHTML = `<i class="bi bi-shield-exclamation text-warning me-2"></i>人脸验证 - 账号 ${accountId}`;
}
// 显示截图
const screenshotImg = document.getElementById('passwordLoginScreenshotImg');
const linkButton = document.getElementById('passwordLoginVerificationLink');
const statusText = document.getElementById('passwordLoginQRStatusText');
if (screenshotImg) {
screenshotImg.src = `${screenshot.path}?t=${new Date().getTime()}`;
screenshotImg.style.display = 'block';
}
// 隐藏链接按钮
if (linkButton) {
linkButton.style.display = 'none';
}
// 更新状态文本
if (statusText) {
statusText.innerHTML = `需要闲鱼人脸验证请使用手机闲鱼APP扫描下方二维码完成验证<br><small class="text-muted">创建时间: ${screenshot.created_time_str}</small>`;
}
// 获取或创建模态框实例
let modalInstance = bootstrap.Modal.getInstance(modal);
if (!modalInstance) {
modalInstance = new bootstrap.Modal(modal);
}
// 显示弹窗
modalInstance.show();
// 注意:截图删除由后端在验证完成或失败时自动处理,前端不需要手动删除
}
// 注:人脸验证弹窗已复用密码登录的 passwordLoginQRModal不再需要单独的弹窗

View File

@ -19,8 +19,10 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.login-container {
@ -712,5 +714,10 @@
}
});
</script>
<!-- 底部赞助商信息 -->
<div class="text-center mt-4" style="color: rgba(255,255,255,0.8);">
<small>赞助商:划算云服务器 <a href="https://www.hsykj.com" target="_blank" class="text-white text-decoration-none" style="font-weight: 500;">www.hsykj.com</a></small>
</div>
</body>
</html>

View File

@ -11,9 +11,10 @@
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px;
padding: 20px 10px;
}
.register-container {
background: white;
@ -565,5 +566,10 @@
updateSendCodeButton();
});
</script>
<!-- 底部赞助商信息 -->
<div class="text-center mt-4" style="color: rgba(255,255,255,0.8);">
<small>赞助商:划算云服务器 <a href="https://www.hsykj.com" target="_blank" class="text-white text-decoration-none" style="font-weight: 500;">www.hsykj.com</a></small>
</div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

@ -167,6 +167,20 @@ class ImageUploader:
def _parse_upload_response(self, response_text: str) -> Optional[str]:
"""解析上传响应获取图片URL"""
try:
# 检查是否返回了登录页面Cookie失效的标志
if '<!DOCTYPE html>' in response_text or '<html>' in response_text:
if '闲鱼' in response_text and ('login' in response_text.lower() or 'mini-login' in response_text):
logger.error("❌ 图片上传失败Cookie已失效返回了登录页面请重新登录获取有效的Cookie")
logger.error("💡 解决方法:")
logger.error(" 1. 打开浏览器访问 https://www.goofish.com/")
logger.error(" 2. 登录您的闲鱼账号")
logger.error(" 3. 按F12打开开发者工具在控制台输入: document.cookie")
logger.error(" 4. 复制完整的Cookie字符串更新配置文件中的Cookie")
return None
else:
logger.error(f"收到HTML响应而非JSON可能是Cookie失效: {response_text[:500]}")
return None
# 尝试解析JSON响应
response_data = json.loads(response_text)
@ -202,7 +216,7 @@ class ImageUploader:
except json.JSONDecodeError:
# 如果不是JSON格式尝试其他解析方式
logger.error(f"响应不是有效的JSON格式: {response_text}")
logger.error(f"响应不是有效的JSON格式可能是Cookie失效: {response_text[:200]}...")
return None
except Exception as e:
logger.error(f"解析上传响应异常: {e}")

View File

@ -1413,19 +1413,60 @@ class XianyuSliderStealth:
# 极速模式:不进行页面行为模拟,直接开始滑动
pass
def find_slider_elements(self):
"""查找滑块元素支持在主页面和所有frame中查找"""
def find_slider_elements(self, fast_mode=False):
"""查找滑块元素支持在主页面和所有frame中查找
Args:
fast_mode: 快速模式不使用wait_for_selector减少等待时间当已确认滑块存在时使用
"""
try:
# 快速等待页面稳定
time.sleep(0.1)
# 快速等待页面稳定(快速模式下跳过)
if not fast_mode:
time.sleep(0.1)
# ===== 【优化】优先在 frames 中快速查找最常见的滑块组合 =====
# 根据实际日志,滑块按钮和轨道通常在同一个 frame 中
# 按钮: #nc_1_n1z, 轨道: #nc_1_n1t
logger.debug(f"{self.pure_user_id}】优先在frames中快速查找常见滑块组合...")
try:
frames = self.page.frames
for idx, frame in enumerate(frames):
try:
# 优先查找最常见的按钮选择器
button_element = frame.query_selector("#nc_1_n1z")
if button_element and button_element.is_visible():
# 在同一个 frame 中查找轨道
track_element = frame.query_selector("#nc_1_n1t")
if track_element and track_element.is_visible():
# 找到容器(可以用按钮或其他选择器)
container_element = frame.query_selector("#baxia-dialog-content")
if not container_element:
container_element = frame.query_selector(".nc-container")
if not container_element:
# 如果找不到容器,用按钮作为容器标识
container_element = button_element
logger.info(f"{self.pure_user_id}】✅ 在Frame {idx} 快速找到完整滑块组合!")
logger.info(f"{self.pure_user_id}】 - 按钮: #nc_1_n1z")
logger.info(f"{self.pure_user_id}】 - 轨道: #nc_1_n1t")
# 保存frame引用
self._detected_slider_frame = frame
return container_element, button_element, track_element
except Exception as e:
logger.debug(f"{self.pure_user_id}】Frame {idx} 快速查找失败: {e}")
continue
except Exception as e:
logger.debug(f"{self.pure_user_id}】frames 快速查找出错: {e}")
# ===== 如果快速查找失败,使用原来的完整查找逻辑 =====
logger.debug(f"{self.pure_user_id}】快速查找未成功,使用完整查找逻辑...")
# 定义滑块容器选择器(支持多种类型)
# 注意:将 #nc_1_n1z 放在前面,因为检测时通常先找到这个按钮元素
container_selectors = [
# nc 系列滑块(优先查找按钮,因为检测时通常先找到这个)
"#nc_1_n1z", # 滑块按钮也可以作为容器标识
".nc-container",
"#baxia-dialog-content",
".nc-container",
".nc_wrapper",
".nc_scale",
"[class*='nc-container']",
@ -1559,30 +1600,35 @@ class XianyuSliderStealth:
# 如果容器是在frame中找到的按钮也应该在同一个frame中查找
for selector in button_selectors:
try:
if search_frame == self.page:
element = self.page.wait_for_selector(selector, timeout=3000)
element = None
if fast_mode:
# 快速模式:直接使用 query_selector不等待
element = search_frame.query_selector(selector)
else:
# 在frame中先尝试wait_for_selector如果支持
element = None
try:
# 尝试使用wait_for_selectorPlaywright的frame支持
element = search_frame.wait_for_selector(selector, timeout=3000)
except:
# 如果不支持wait_for_selector使用query_selector并等待
time.sleep(0.5) # 等待元素加载
element = search_frame.query_selector(selector)
if element:
# 检查元素是否可见,但不要因为不可见就放弃
# 正常模式:使用 wait_for_selector
if search_frame == self.page:
element = self.page.wait_for_selector(selector, timeout=3000)
else:
# 在frame中先尝试wait_for_selector如果支持
try:
is_visible = element.is_visible()
if not is_visible:
logger.debug(f"{self.pure_user_id}】找到元素但不可见: {selector},继续尝试其他选择器")
element = None
except Exception as vis_e:
# 如果无法检查可见性,仍然使用该元素
logger.debug(f"{self.pure_user_id}】无法检查元素可见性: {vis_e},继续使用该元素")
pass
# 尝试使用wait_for_selectorPlaywright的frame支持
element = search_frame.wait_for_selector(selector, timeout=3000)
except:
# 如果不支持wait_for_selector使用query_selector并等待
time.sleep(0.5) # 等待元素加载
element = search_frame.query_selector(selector)
if element:
# 检查元素是否可见,但不要因为不可见就放弃
try:
is_visible = element.is_visible()
if not is_visible:
logger.debug(f"{self.pure_user_id}】找到元素但不可见: {selector},继续尝试其他选择器")
element = None
except Exception as vis_e:
# 如果无法检查可见性,仍然使用该元素
logger.debug(f"{self.pure_user_id}】无法检查元素可见性: {vis_e},继续使用该元素")
pass
if element:
frame_info = "主页面" if search_frame == self.page else f"Frame"
@ -1609,13 +1655,17 @@ class XianyuSliderStealth:
for selector in button_selectors:
try:
# 先尝试wait_for_selector
element = None
try:
element = frame.wait_for_selector(selector, timeout=2000)
except:
time.sleep(0.3) # 等待元素加载
if fast_mode:
# 快速模式:直接使用 query_selector
element = frame.query_selector(selector)
else:
# 正常模式先尝试wait_for_selector
try:
element = frame.wait_for_selector(selector, timeout=2000)
except:
time.sleep(0.3) # 等待元素加载
element = frame.query_selector(selector)
if element:
try:
@ -1646,7 +1696,14 @@ class XianyuSliderStealth:
logger.warning(f"{self.pure_user_id}】在所有frame中未找到按钮尝试在主页面查找...")
for selector in button_selectors:
try:
element = self.page.wait_for_selector(selector, timeout=2000)
element = None
if fast_mode:
# 快速模式:直接使用 query_selector
element = self.page.query_selector(selector)
else:
# 正常模式:使用 wait_for_selector
element = self.page.wait_for_selector(selector, timeout=2000)
if element:
try:
if element.is_visible():
@ -1721,17 +1778,24 @@ class XianyuSliderStealth:
for selector in track_selectors:
try:
if track_search_frame == self.page:
element = self.page.wait_for_selector(selector, timeout=3000)
else:
# 在frame中使用query_selector
element = None
if fast_mode:
# 快速模式:直接使用 query_selector
element = track_search_frame.query_selector(selector)
if element:
try:
if not element.is_visible():
element = None
except:
pass
else:
# 正常模式:使用 wait_for_selector
if track_search_frame == self.page:
element = self.page.wait_for_selector(selector, timeout=3000)
else:
# 在frame中使用query_selector
element = track_search_frame.query_selector(selector)
if element:
try:
if not element.is_visible():
element = None
except:
pass
if element:
frame_info = "主页面" if track_search_frame == self.page else f"Frame"
@ -2179,8 +2243,13 @@ class XianyuSliderStealth:
logger.error(f"{self.pure_user_id}】分析失败原因时出错: {e}")
return {}
def solve_slider(self, max_retries: int = 2):
"""处理滑块验证(极速模式)"""
def solve_slider(self, max_retries: int = 3, fast_mode: bool = False):
"""处理滑块验证(极速模式)
Args:
max_retries: 最大重试次数默认3次因为同一个页面连续失败3次后就不会成功了
fast_mode: 快速查找模式当已确认滑块存在时使用减少等待时间
"""
failure_records = []
current_strategy = 'ultra_fast' # 极速策略
@ -2202,8 +2271,8 @@ class XianyuSliderStealth:
else:
logger.info(f"{self.pure_user_id}】未找到frame引用将重新检测滑块位置")
# 1. 查找滑块元素
slider_container, slider_button, slider_track = self.find_slider_elements()
# 1. 查找滑块元素(使用快速模式)
slider_container, slider_button, slider_track = self.find_slider_elements(fast_mode=fast_mode)
if not all([slider_container, slider_button, slider_track]):
logger.error(f"{self.pure_user_id}】滑块元素查找失败")
continue
@ -2502,12 +2571,25 @@ class XianyuSliderStealth:
# 检测到滑块验证,立即处理
logger.warning(f"{self.pure_user_id}】检测到滑块验证,开始自动处理...")
slider_success = self.solve_slider(max_retries=5)
slider_success = self.solve_slider(max_retries=3)
if slider_success:
logger.success(f"{self.pure_user_id}】✅ 滑块验证成功!")
time.sleep(3) # 等待滑块验证后的状态更新
else:
logger.error(f"{self.pure_user_id}】❌ 滑块验证失败")
# 3次失败后刷新页面重试
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理3次都失败刷新页面后重试...")
try:
self.page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
time.sleep(2)
slider_success = self.solve_slider(max_retries=3)
if not slider_success:
logger.error(f"{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
else:
logger.success(f"{self.pure_user_id}】✅ 刷新后滑块验证成功!")
time.sleep(3)
except Exception as e:
logger.error(f"{self.pure_user_id}】❌ 页面刷新失败: {e}")
# 清理临时变量
if hasattr(self, '_detected_slider_frame'):
@ -2537,16 +2619,75 @@ class XianyuSliderStealth:
face_verify_url = self._get_face_verification_url(frame)
if face_verify_url:
logger.info(f"{self.pure_user_id}】✅ 获取到人脸验证链接: {face_verify_url}")
# 创建一个特殊的frame对象包含验证链接
# 截图并保存
screenshot_path = None
try:
# 等待页面加载完成
time.sleep(2)
# 先删除该账号的旧截图
import glob
screenshots_dir = "static/uploads/images"
os.makedirs(screenshots_dir, exist_ok=True)
old_screenshots = glob.glob(os.path.join(screenshots_dir, f"face_verify_{self.pure_user_id}_*.jpg"))
for old_file in old_screenshots:
try:
os.remove(old_file)
logger.info(f"{self.pure_user_id}】删除旧的验证截图: {old_file}")
except Exception as e:
logger.warning(f"{self.pure_user_id}】删除旧截图失败: {e}")
# 尝试截取iframe元素的截图
screenshot_bytes = None
try:
# 获取iframe元素并截图
iframe_element = page.query_selector('iframe#alibaba-login-box')
if iframe_element:
screenshot_bytes = iframe_element.screenshot()
logger.info(f"{self.pure_user_id}】已截取iframe元素")
else:
# 如果找不到iframe截取整个页面
screenshot_bytes = page.screenshot(full_page=False)
logger.info(f"{self.pure_user_id}】已截取整个页面")
except Exception as e:
logger.warning(f"{self.pure_user_id}】截取iframe失败尝试截取整个页面: {e}")
screenshot_bytes = page.screenshot(full_page=False)
if screenshot_bytes:
# 生成带时间戳的文件名并直接保存
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"face_verify_{self.pure_user_id}_{timestamp}.jpg"
file_path = os.path.join(screenshots_dir, filename)
try:
with open(file_path, 'wb') as f:
f.write(screenshot_bytes)
# 返回相对路径
screenshot_path = file_path.replace('\\', '/')
logger.info(f"{self.pure_user_id}】✅ 人脸验证截图已保存: {screenshot_path}")
except Exception as e:
logger.error(f"{self.pure_user_id}】保存截图失败: {e}")
screenshot_path = None
else:
logger.warning(f"{self.pure_user_id}】⚠️ 截图失败,无法获取截图数据")
except Exception as e:
logger.error(f"{self.pure_user_id}】截图时出错: {e}")
import traceback
logger.debug(traceback.format_exc())
# 创建一个特殊的frame对象包含截图路径
class VerificationFrame:
def __init__(self, original_frame, verify_url):
def __init__(self, original_frame, verify_url, screenshot_path=None):
self._original_frame = original_frame
self.verify_url = verify_url
self.screenshot_path = screenshot_path
def __getattr__(self, name):
return getattr(self._original_frame, name)
return True, VerificationFrame(frame, face_verify_url)
return True, VerificationFrame(frame, face_verify_url, screenshot_path)
return True, frame
except Exception as e:
@ -2840,6 +2981,33 @@ class XianyuSliderStealth:
'--lang=zh-CN', # 设置浏览器语言为中文
]
# 在启动Playwright之前重新检查和设置浏览器路径
# 确保使用正确的浏览器版本(避免版本不匹配问题)
import sys
from pathlib import Path
if getattr(sys, 'frozen', False):
# 如果是打包后的exe检查exe同目录下的浏览器
exe_dir = Path(sys.executable).parent
playwright_dir = exe_dir / 'playwright'
if playwright_dir.exists():
chromium_dirs = list(playwright_dir.glob('chromium-*'))
# 找到第一个完整的浏览器目录
for chromium_dir in chromium_dirs:
chrome_exe = chromium_dir / 'chrome-win' / 'chrome.exe'
if chrome_exe.exists() and chrome_exe.stat().st_size > 0:
# 清除旧的环境变量,使用实际存在的浏览器
if 'PLAYWRIGHT_BROWSERS_PATH' in os.environ:
old_path = os.environ['PLAYWRIGHT_BROWSERS_PATH']
if old_path != str(playwright_dir):
logger.info(f"{self.pure_user_id}】清除旧的环境变量: {old_path}")
del os.environ['PLAYWRIGHT_BROWSERS_PATH']
# 设置正确的环境变量
os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(playwright_dir)
logger.info(f"{self.pure_user_id}】已设置PLAYWRIGHT_BROWSERS_PATH: {playwright_dir}")
logger.info(f"{self.pure_user_id}】使用浏览器版本: {chromium_dir.name}")
break
# 启动浏览器
playwright = sync_playwright().start()
context = playwright.chromium.launch_persistent_context(
@ -3029,13 +3197,27 @@ class XianyuSliderStealth:
self._detected_slider_frame = detected_slider_frame
logger.warning(f"{self.pure_user_id}】检测到滑块验证,开始处理...")
slider_success = self.solve_slider(max_retries=5)
time.sleep(3)
slider_success = self.solve_slider(max_retries=3)
if not slider_success:
logger.error(f"{self.pure_user_id}】❌ 滑块验证失败")
return None
logger.success(f"{self.pure_user_id}】✅ 滑块验证成功!")
# 3次失败后刷新页面重试
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理3次都失败刷新页面后重试...")
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
time.sleep(2)
slider_success = self.solve_slider(max_retries=3)
if not slider_success:
logger.error(f"{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
return None
else:
logger.success(f"{self.pure_user_id}】✅ 刷新后滑块验证成功!")
except Exception as e:
logger.error(f"{self.pure_user_id}】❌ 页面刷新失败: {e}")
return None
else:
logger.success(f"{self.pure_user_id}】✅ 滑块验证成功!")
# 等待页面加载和状态更新第一次等待3秒
logger.info(f"{self.pure_user_id}】等待3秒让页面加载完成...")
@ -3249,13 +3431,26 @@ class XianyuSliderStealth:
logger.warning(f"{self.pure_user_id}】检测到滑块验证,开始处理...")
# 【复用】直接调用 solve_slider() 方法处理滑块
slider_success = self.solve_slider(max_retries=5)
slider_success = self.solve_slider(max_retries=3)
if slider_success:
logger.success(f"{self.pure_user_id}】✅ 滑块验证成功!")
else:
logger.error(f"{self.pure_user_id}】❌ 滑块验证失败")
return None
# 3次失败后刷新页面重试
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理3次都失败刷新页面后重试...")
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
time.sleep(2)
slider_success = self.solve_slider(max_retries=3)
if not slider_success:
logger.error(f"{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
return None
else:
logger.success(f"{self.pure_user_id}】✅ 刷新后滑块验证成功!")
except Exception as e:
logger.error(f"{self.pure_user_id}】❌ 页面刷新失败: {e}")
return None
else:
logger.info(f"{self.pure_user_id}】未检测到滑块验证")
@ -3279,13 +3474,27 @@ class XianyuSliderStealth:
if has_slider_after_wait:
logger.warning(f"{self.pure_user_id}】检测到滑块验证,开始处理...")
slider_success = self.solve_slider(max_retries=5)
slider_success = self.solve_slider(max_retries=3)
if slider_success:
logger.success(f"{self.pure_user_id}】✅ 滑块验证成功!")
time.sleep(3) # 等待滑块验证后的状态更新
else:
logger.error(f"{self.pure_user_id}】❌ 滑块验证失败")
return None
# 3次失败后刷新页面重试
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理3次都失败刷新页面后重试...")
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
time.sleep(2)
slider_success = self.solve_slider(max_retries=3)
if not slider_success:
logger.error(f"{self.pure_user_id}】❌ 刷新后滑块验证仍然失败")
return None
else:
logger.success(f"{self.pure_user_id}】✅ 刷新后滑块验证成功!")
time.sleep(3)
except Exception as e:
logger.error(f"{self.pure_user_id}】❌ 页面刷新失败: {e}")
return None
# 检查登录状态
logger.info(f"{self.pure_user_id}】等待1秒后检查登录状态...")
@ -3331,8 +3540,9 @@ class XianyuSliderStealth:
logger.warning(f"{self.pure_user_id}】⚠️ 检测到二维码/人脸验证")
logger.info(f"{self.pure_user_id}】请在浏览器中完成二维码/人脸验证")
# 获取验证链接URL
# 获取验证链接URL和截图路径
frame_url = None
screenshot_path = None
if qr_frame:
try:
# 检查是否有验证链接从VerificationFrame对象
@ -3341,20 +3551,30 @@ class XianyuSliderStealth:
logger.info(f"{self.pure_user_id}】使用获取到的人脸验证链接: {frame_url}")
else:
frame_url = qr_frame.url if hasattr(qr_frame, 'url') else None
# 检查是否有截图路径从VerificationFrame对象
if hasattr(qr_frame, 'screenshot_path') and qr_frame.screenshot_path:
screenshot_path = qr_frame.screenshot_path
logger.info(f"{self.pure_user_id}】使用获取到的人脸验证截图: {screenshot_path}")
except Exception as e:
logger.warning(f"{self.pure_user_id}】获取frame URL失败: {e}")
logger.warning(f"{self.pure_user_id}】获取frame信息失败: {e}")
import traceback
logger.debug(traceback.format_exc())
# 显示验证链接(如果有)
if frame_url:
# 显示验证信息
if screenshot_path:
logger.warning(f"{self.pure_user_id}" + "=" * 60)
logger.warning(f"{self.pure_user_id}】二维码/人脸验证截图:")
logger.warning(f"{self.pure_user_id}{screenshot_path}")
logger.warning(f"{self.pure_user_id}" + "=" * 60)
elif frame_url:
logger.warning(f"{self.pure_user_id}" + "=" * 60)
logger.warning(f"{self.pure_user_id}】二维码/人脸验证链接:")
logger.warning(f"{self.pure_user_id}{frame_url}")
logger.warning(f"{self.pure_user_id}" + "=" * 60)
else:
logger.warning(f"{self.pure_user_id}" + "=" * 60)
logger.warning(f"{self.pure_user_id}】二维码/人脸验证已检测到,但无法获取验证链接")
logger.warning(f"{self.pure_user_id}】二维码/人脸验证已检测到,但无法获取验证信息")
logger.warning(f"{self.pure_user_id}】请在浏览器中查看验证页面")
logger.warning(f"{self.pure_user_id}" + "=" * 60)
@ -3363,17 +3583,27 @@ class XianyuSliderStealth:
# 【重要】发送通知给客户
if notification_callback:
try:
if frame_url:
if screenshot_path or frame_url:
# 构造清晰的通知消息
notification_msg = (
f"⚠️ 账号密码登录需要人脸验证\n\n"
f"账号: {self.pure_user_id}\n"
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
f"请点击验证链接完成验证:\n{frame_url}\n\n"
f"在验证期间,闲鱼自动回复暂时无法使用。"
)
if screenshot_path:
notification_msg = (
f"⚠️ 账号密码登录需要人脸验证\n\n"
f"账号: {self.pure_user_id}\n"
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
f"请登录自动化网站,访问账号管理模块,进行对应账号的人脸验证"
f"在验证期间,闲鱼自动回复暂时无法使用。"
)
else:
notification_msg = (
f"⚠️ 账号密码登录需要人脸验证\n\n"
f"账号: {self.pure_user_id}\n"
f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
f"请点击验证链接完成验证:\n{frame_url}\n\n"
f"在验证期间,闲鱼自动回复暂时无法使用。"
)
logger.info(f"{self.pure_user_id}】准备发送人脸验证通知URL: {frame_url}")
logger.info(f"{self.pure_user_id}】准备发送人脸验证通知,截图路径: {screenshot_path}, URL: {frame_url}")
# 如果回调是异步函数,使用 asyncio.run 在新的事件循环中运行
import asyncio
@ -3384,8 +3614,9 @@ class XianyuSliderStealth:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# 传递通知消息和URL给回调
loop.run_until_complete(notification_callback(notification_msg, None, frame_url))
# 传递通知消息、截图路径和URL给回调
# 参数顺序message, screenshot_path, verification_url
loop.run_until_complete(notification_callback(notification_msg, screenshot_path, frame_url))
logger.info(f"{self.pure_user_id}】✅ 异步通知回调已执行")
except Exception as async_err:
logger.error(f"{self.pure_user_id}】异步通知回调执行失败: {async_err}")
@ -3400,11 +3631,11 @@ class XianyuSliderStealth:
logger.info(f"{self.pure_user_id}】异步通知线程已启动")
# 不等待线程完成,让通知在后台发送
else:
# 同步回调直接调用(传递通知消息和URL
notification_callback(notification_msg, None, frame_url)
# 同步回调直接调用(传递通知消息、截图路径和URL
notification_callback(notification_msg, None, frame_url, screenshot_path)
logger.info(f"{self.pure_user_id}】✅ 同步通知回调已执行")
else:
logger.warning(f"{self.pure_user_id}】无法获取验证URL,跳过通知发送")
logger.warning(f"{self.pure_user_id}】无法获取验证信息,跳过通知发送")
except Exception as notify_err:
logger.error(f"{self.pure_user_id}】发送人脸验证通知失败: {notify_err}")
@ -3417,13 +3648,99 @@ class XianyuSliderStealth:
# 持续等待用户完成二维码/人脸验证
logger.info(f"{self.pure_user_id}】等待二维码/人脸验证完成...")
check_interval = 10 # 每10秒检查一次
max_wait_time = 600 # 最多等待10分钟
max_wait_time = 450 # 最多等待7.5分钟
waited_time = 0
while waited_time < max_wait_time:
time.sleep(check_interval)
waited_time += check_interval
# 先检测是否有滑块,如果有就处理
try:
logger.debug(f"{self.pure_user_id}】检测是否存在滑块...")
slider_detected = False
# 快速检测滑块元素(不等待,仅检测)
slider_selectors = [
"#nc_1_n1z",
".nc-container",
"#baxia-dialog-content",
".nc_wrapper",
"#nocaptcha"
]
# 先在主页面检测
for selector in slider_selectors:
try:
element = page.query_selector(selector)
if element and element.is_visible():
slider_detected = True
logger.info(f"{self.pure_user_id}】🔍 检测到滑块元素: {selector}")
break
except:
pass
# 如果主页面没找到检查所有frame
if not slider_detected:
try:
frames = page.frames
for frame in frames:
for selector in slider_selectors:
try:
element = frame.query_selector(selector)
if element and element.is_visible():
slider_detected = True
logger.info(f"{self.pure_user_id}】🔍 在frame中检测到滑块元素: {selector}")
break
except:
pass
if slider_detected:
break
except:
pass
# 如果检测到滑块,尝试处理
if slider_detected:
logger.info(f"{self.pure_user_id}】⚡ 检测到滑块,开始自动处理...")
time.sleep(3)
try:
# 调用滑块处理方法(使用快速模式,因为已确认滑块存在)
# 最多尝试3次因为同一个页面连续失败3次后就不会成功了
if self.solve_slider(max_retries=3, fast_mode=True):
logger.success(f"{self.pure_user_id}】✅ 滑块处理成功!")
# 滑块处理成功后,刷新页面
try:
logger.info(f"{self.pure_user_id}】🔄 滑块处理成功,刷新页面...")
page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
# 刷新后短暂等待,让页面稳定
time.sleep(2)
except Exception as reload_err:
logger.warning(f"{self.pure_user_id}】⚠️ 页面刷新失败: {reload_err}")
else:
# 3次都失败了刷新页面后再尝试一次
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理3次都失败刷新页面后重试...")
try:
logger.info(f"{self.pure_user_id}】🔄 刷新页面以重置滑块状态...")
page.reload(wait_until="domcontentloaded", timeout=30000)
logger.info(f"{self.pure_user_id}】✅ 页面刷新完成")
time.sleep(2)
# 刷新后再次尝试处理滑块(给一次机会)
logger.info(f"{self.pure_user_id}】🔄 页面刷新后,再次尝试处理滑块...")
if self.solve_slider(max_retries=3, fast_mode=True):
logger.success(f"{self.pure_user_id}】✅ 刷新后滑块处理成功!")
else:
logger.error(f"{self.pure_user_id}】❌ 刷新后滑块处理仍然失败,继续等待...")
except Exception as reload_err:
logger.warning(f"{self.pure_user_id}】⚠️ 页面刷新失败: {reload_err}")
except Exception as slider_err:
logger.warning(f"{self.pure_user_id}】⚠️ 滑块处理出错: {slider_err}")
logger.debug(traceback.format_exc())
except Exception as e:
logger.debug(f"{self.pure_user_id}】滑块检测时出错: {e}")
# 检查登录状态(通过页面元素)
try:
if self._check_login_success_by_element(page):
@ -3435,6 +3752,25 @@ class XianyuSliderStealth:
except Exception as e:
logger.debug(f"{self.pure_user_id}】检查登录状态时出错: {e}")
# 删除截图(无论成功或失败)
if screenshot_path:
try:
import glob
# 删除该账号的所有验证截图
screenshots_dir = "static/uploads/images"
all_screenshots = glob.glob(os.path.join(screenshots_dir, f"face_verify_{self.pure_user_id}_*.jpg"))
for screenshot_file in all_screenshots:
try:
if os.path.exists(screenshot_file):
os.remove(screenshot_file)
logger.info(f"{self.pure_user_id}】✅ 已删除验证截图: {screenshot_file}")
else:
logger.warning(f"{self.pure_user_id}】⚠️ 截图文件不存在: {screenshot_file}")
except Exception as e:
logger.warning(f"{self.pure_user_id}】⚠️ 删除截图失败: {e}")
except Exception as e:
logger.error(f"{self.pure_user_id}】删除截图时出错: {e}")
if login_success:
logger.info(f"{self.pure_user_id}】二维码/人脸验证已完成")
else:

View File

@ -0,0 +1,5 @@
[{000214A0-0000-0000-C000-000000000046}]
Prop3=19,11
[InternetShortcut]
IDList=
URL=https://www.hsykj.com/