diff --git a/.dockerignore b/.dockerignore index 0ce6c78..c6af667 100644 --- a/.dockerignore +++ b/.dockerignore @@ -191,3 +191,173 @@ local/ .local/ debug.log *.debug + +# ==================== 2025年新增规则 ==================== + +# Nuitka编译临时文件 +nuitka-crash-report*.xml +nuitka-onefile-warning.txt +utils/*.build/ +utils/*.dist/ +*.build/ +*.dist/ +*.onefile-build/ + +# 浏览器数据和缓存 +DrissionPage/ +.drission/ +selenium/ +webdriver/ +.playwright/.browsers/ +playwright-state/ + +# 统计和监控数据 +user_stats.txt +user_stats.db +stats.log +stats_*.log +metrics/ +analytics/ + +# 运行时生成的数据(这些在容器运行时挂载) +xianyu_data.db +xianyu_data.db.* +xianyu_data_backup_*.db +*.db-journal +*.db-wal +*.db-shm +realtime.log + +# 用户上传的文件(运行时挂载) +static/uploads/* +!static/uploads/.gitkeep + +# 轨迹历史数据 +trajectory_history/*.json +trajectory_history/*.trajectory + +# Python编译缓存(补充) +__pycache__/ +*.pyc +*.pyo +*$py.class + +# 临时脚本和测试文件 +temp_*.py +tmp_*.py +quick_*.py +fix_*.py +test_*.py +check_*.py +debug_*.py +*_test.py +*_demo.py +*_example.py + +# 数据导出文件 +export_*.xlsx +export_*.csv +export_*.json +dump_*.sql +backup_*.db + +# 开发环境文件 +.env +.env.* +.env.local +.env.*.local +*.local.yml +*.dev.yml +*.test.yml +config.local.* + +# Git相关(补充) +.git/ +.github/ +.gitignore +.gitattributes +.git-credentials + +# 编辑器和IDE配置 +.vscode/ +.idea/ +*.sublime-* +*.code-workspace + +# 文档和说明(Dockerfile中不需要) +*.md +!README.md +docs/ +documentation/ + +# 压缩包和大文件 +*.zip +*.tar.gz +*.rar +*.7z +*.iso +*.dmg + +# 媒体文件 +*.mp4 +*.avi +*.mov +*.mp3 +*.wav + +# AI模型文件 +*.model +*.weights +*.h5 +*.pb +*.onnx + +# 性能分析和调试 +*.perf +*.trace +*.profile +*.prof +memory_*.dump +cpu_*.profile + +# 容器相关 +docker-compose.override.yml +.docker/ +docker-data/ +container_logs/ + +# 密钥和证书(安全相关) +*.key +*.pem +*.crt +*.cert +*.p12 +*.pfx +secrets/ +credentials/ +keys/ +ssl/*.key +ssl/*.pem + +# 备份文件 +*.bak +*.backup +*.old +*~ + +# 操作系统特定文件 +.DS_Store +Thumbs.db +desktop.ini + +# Python虚拟环境 +venv/ +env/ +ENV/ +.venv/ + +# 项目特定排除文件(已清理的测试文件和调试工具) +# check_silent_mode.py - 已删除 +# order_status_handler.py - 保留,是核心订单处理模块 +php/ +# simple_stats_server.py - 保留,是可选的统计服务器 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 407711b..ff4e968 100644 --- a/.gitignore +++ b/.gitignore @@ -372,4 +372,253 @@ check_disk_usage.py .env .env.* !.env.example -.env.docker \ No newline at end of file +.env.docker + +# === 允许跟踪二进制扩展模块(用于分发)=== +!utils/xianyu_slider_stealth*.pyd +!utils/xianyu_slider_stealth*.so + +# ==================== 新增项目特定规则 ==================== + +# 用户数据和隐私文件 +user_data/ +personal_configs/ +*.personal.yml +*.private.yml + +# 运行时生成的文件 +*.runtime +*.session +session_* +runtime_* + +# 第三方服务配置 +*.service.yml +*.webhook.yml +external_configs/ + +# 性能分析和调试文件 +*.perf +*.trace +memory_*.dump +cpu_*.profile + +# 机器学习模型文件(如果有AI功能扩展) +models/ +*.model +*.weights +*.checkpoint + +# 容器相关文件 +.docker/ +docker-data/ +container_logs/ + +# 监控和统计文件 +metrics/ +analytics/ +*.metrics +*.analytics + +# 自动生成的文档 +auto_docs/ +generated_docs/ + +# 临时API文件 +api_temp/ +temp_api/ + +# 插件和扩展 +plugins/ +extensions/ +addons/ + +# 测试覆盖率报告 +coverage_html/ +.coverage.* +coverage.xml + +# IDE和编辑器特定文件 +.vscode/settings.json +.vscode/launch.json +.idea/workspace.xml +.idea/tasks.xml + +# 系统特定文件 +.DS_Store +Thumbs.db +desktop.ini +*.lnk + +# 网络和代理配置 +proxy_configs/ +*.proxy +network_configs/ + +# ==================== 项目清理后新增规则 ==================== + +# 临时文档和说明文件 +*功能说明.md +*修改说明.md +*分析报告.md +*使用说明.md + +# 轨迹历史文件 +trajectory_history/ +*.trajectory + +# 实时日志文件 +realtime.log +*.realtime + +# Nuitka编译报告 +nuitka-crash-report.xml +*.crash-report.xml + +# 项目压缩包 +*.zip +*.tar.gz +*.rar +*.7z + +# 临时数据库文件 +*.db-journal +*.db-wal +*.db-shm + +# ==================== 2025年新增规则 ==================== + +# 编译相关临时文件 +*.build/ +*.dist/ +*.onefile-build/ +nuitka-crash-report*.xml +nuitka-onefile-warning.txt + +# Nuitka 编译产物(保留在utils目录下的二进制模块) +utils/*.build/ +utils/*.dist/ +!utils/xianyu_slider_stealth*.pyd +!utils/xianyu_slider_stealth*.so + +# Python类型提示文件(Nuitka生成的) +*.pyi +!utils/xianyu_slider_stealth.pyi + +# DrissionPage浏览器缓存 +DrissionPage/ +.drission/ + +# 其他浏览器数据 +selenium/ +webdriver/ +*.crdownload + +# 项目运行时生成的统计文件 +user_stats.txt +user_stats.db +stats.log +stats_*.log + +# 临时Python脚本(不要提交临时脚本) +temp_*.py +tmp_*.py +quick_*.py +fix_*.py +test_*.py +check_*.py +debug_*.py + +# 数据导出文件(避免提交用户数据) +export_*.xlsx +export_*.csv +export_*.json +dump_*.sql +backup_*.db + +# 日志文件(更全面) +*.log +*.log.* +logs/ +*.realtime +realtime.log + +# 配置文件备份 +*.yml.bak +*.yaml.bak +*.json.bak +*.conf.bak + +# 系统监控和性能分析 +*.perf +*.trace +*.profile +memory_*.dump +cpu_*.profile +performance_*.txt + +# 容器相关(补充) +.docker/ +docker-data/ +container_logs/ +docker-compose.override.yml + +# 数据目录(补充确保不提交用户数据) +data/ +backups/ +trajectory_history/ +*.trajectory + +# 临时下载和缓存 +downloads/ +temp_downloads/ +.cache/ +cache/ +__cache__/ + +# 开发环境配置(保护开发者隐私) +.env +.env.* +!.env.example +*.local.yml +*.dev.yml +*.test.yml +*.personal.yml +*.private.yml +config.local.* +config.dev.* + +# 加密密钥文件(确保安全) +*.key +*.pem +*.crt +*.cert +*.p12 +*.pfx +secrets/ +credentials/ +keys/ + +# AI模型和大文件 +*.model +*.weights +*.h5 +*.pb +*.onnx +*.tflite +models/ +checkpoints/ + +# 媒体文件(避免提交大文件) +*.mp4 +*.avi +*.mov +*.wmv +*.flv +*.mp3 +*.wav +*.ogg + +# 项目特定排除(明确列出不需要跟踪的文件) +# check_silent_mode.py - 已删除 +# order_status_handler.py - 是核心模块,需要跟踪 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 822bf45..079e0c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV TZ=Asia/Shanghai ENV DOCKER_ENV=true ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +# Nuitka编译优化 +ENV CC=gcc +ENV CXX=g++ +ENV NUITKA_CACHE_DIR=/tmp/nuitka-cache # 安装系统依赖(包括Playwright浏览器依赖) RUN apt-get update && \ @@ -30,6 +34,12 @@ RUN apt-get update && \ tzdata \ curl \ ca-certificates \ + # 编译工具(Nuitka需要) + build-essential \ + gcc \ + g++ \ + ccache \ + patchelf \ # 图像处理依赖 libjpeg-dev \ libpng-dev \ @@ -63,6 +73,10 @@ RUN apt-get update && \ libx11-xcb1 \ libxfixes3 \ xdg-utils \ + chromium \ + # OpenCV运行时依赖 + libgl1 \ + libglib2.0-0 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && rm -rf /tmp/* \ @@ -83,6 +97,34 @@ RUN pip install --no-cache-dir --upgrade pip && \ # 复制项目文件 COPY . . +# 条件执行:如果 xianyu_slider_stealth.py 存在,则编译为二进制模块 +RUN if [ -f "utils/xianyu_slider_stealth.py" ]; then \ + echo "===================================="; \ + echo "检测到 xianyu_slider_stealth.py"; \ + echo "开始编译为二进制模块..."; \ + echo "===================================="; \ + pip install --no-cache-dir nuitka ordered-set zstandard && \ + python build_binary_module.py; \ + BUILD_RESULT=$?; \ + if [ $BUILD_RESULT -eq 0 ]; then \ + echo "===================================="; \ + echo "✓ 二进制模块编译成功"; \ + echo "===================================="; \ + ls -lh utils/xianyu_slider_stealth.* 2>/dev/null || true; \ + else \ + echo "===================================="; \ + echo "✗ 二进制模块编译失败 (错误码: $BUILD_RESULT)"; \ + echo "将继续使用 Python 源代码版本"; \ + echo "===================================="; \ + fi; \ + rm -rf /tmp/nuitka-cache utils/xianyu_slider_stealth.build utils/xianyu_slider_stealth.dist; \ + else \ + echo "===================================="; \ + echo "未检测到 xianyu_slider_stealth.py"; \ + echo "跳过二进制编译"; \ + echo "===================================="; \ + fi + # 安装Playwright浏览器(必须在复制项目文件之后) RUN playwright install chromium && \ playwright install-deps chromium @@ -91,6 +133,9 @@ RUN playwright install chromium && \ RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \ chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images +# 配置系统限制,防止core文件生成 +RUN echo "ulimit -c 0" >> /etc/profile + # 注意: 为了简化权限问题,使用root用户运行 # 在生产环境中,建议配置适当的用户映射 @@ -101,10 +146,9 @@ EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/health || exit 1 -# 复制启动脚本并设置权限 +# 复制启动脚本 COPY entrypoint.sh /app/entrypoint.sh -RUN chmod +x /app/entrypoint.sh && \ - dos2unix /app/entrypoint.sh 2>/dev/null || true +RUN chmod +x /app/entrypoint.sh -# 启动命令(使用ENTRYPOINT确保脚本被执行) -ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"] \ No newline at end of file +# 启动命令 +CMD ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/Dockerfile-cn b/Dockerfile-cn index 5a543ad..25dad86 100644 --- a/Dockerfile-cn +++ b/Dockerfile-cn @@ -8,6 +8,8 @@ LABEL description="闲鱼自动回复系统 - 企业级多用户版本,支持 LABEL repository="https://github.com/zhinianboke/xianyu-auto-reply" LABEL license="仅供学习使用,禁止商业用途" LABEL author="zhinianboke" +LABEL build-date="" +LABEL vcs-ref="" # 设置工作目录 WORKDIR /app @@ -64,6 +66,10 @@ RUN apt-get update && \ libx11-xcb1 \ libxfixes3 \ xdg-utils \ + chromium \ + # OpenCV运行时依赖 + libgl1 \ + libglib2.0-0 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && rm -rf /tmp/* \ @@ -92,6 +98,9 @@ RUN playwright install chromium && \ RUN mkdir -p /app/logs /app/data /app/backups /app/static/uploads/images && \ chmod 777 /app/logs /app/data /app/backups /app/static/uploads /app/static/uploads/images +# 配置系统限制,防止core文件生成 +RUN echo "ulimit -c 0" >> /etc/profile + # 注意: 为了简化权限问题,使用root用户运行 # 在生产环境中,建议配置适当的用户映射 diff --git a/README.md b/README.md index b7b0414..fc88d56 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ https://pan.baidu.com/s/1I6muOGJQYd6y3oxQSmtvrQ?pwd=gcpd - **数据完全隔离** - 每个用户的数据独立存储,互不干扰 - **权限管理** - 严格的用户权限控制和JWT认证 - **安全保护** - 防暴力破解、会话管理、安全日志 +- **授权期限管理** - 核心滑块验证模块包含授权期限验证,确保合规使用 ### 📱 多账号管理 - **无限账号支持** - 每个用户可管理多个闲鱼账号 @@ -115,10 +116,16 @@ xianyu-auto-reply/ │ ├── db_manager.py # SQLite数据库管理,支持多用户数据隔离 │ ├── cookie_manager.py # 多账号Cookie管理和任务调度 │ ├── ai_reply_engine.py # AI智能回复引擎,支持多种AI模型 +│ ├── order_status_handler.py # 订单状态处理和更新模块 │ ├── file_log_collector.py # 实时日志收集和管理系统 │ ├── config.py # 全局配置文件管理器 +│ ├── usage_statistics.py # 用户统计和数据分析模块 +│ ├── simple_stats_server.py # 简单统计服务器(可选) +│ ├── build_binary_module.py # 二进制模块编译脚本(Nuitka编译工具) │ ├── secure_confirm_ultra.py # 自动确认发货模块(多层加密保护) -│ └── secure_freeshipping_ultra.py # 自动免拼发货模块(多层加密保护) +│ ├── secure_confirm_decrypted.py # 自动确认发货模块(解密版本) +│ ├── secure_freeshipping_ultra.py # 自动免拼发货模块(多层加密保护) +│ └── secure_freeshipping_decrypted.py # 自动免拼发货模块(解密版本) ├── 🛠️ 工具模块 │ └── utils/ │ ├── xianyu_utils.py # 闲鱼API工具函数(加密、签名、解析) @@ -184,6 +191,33 @@ xianyu-auto-reply/ +## 🆕 最新更新 + +### 2025年1月更新 + +**🔥 性能与安全增强** +- ✅ 新增 Nuitka 二进制编译支持,核心模块可编译为 .pyd/.so 提升性能和安全性 +- ✅ 滑块验证模块增加授权期限验证机制,确保合规使用 +- ✅ Docker 构建优化,自动编译二进制模块,提升容器启动效率 +- ✅ 完善的错误处理和重试机制,提升系统稳定性 +- ✅ 修复滑块验证模块内存泄漏问题,浏览器资源正确释放 + +**🛠️ 配置文件优化** +- ✅ 完善 `.gitignore`,新增编译产物、浏览器缓存等规则 +- ✅ 完善 `.dockerignore`,优化Docker构建速度和镜像体积 +- ✅ 增强 `entrypoint.sh`,添加环境验证和详细启动日志 +- ✅ 清理测试文件和临时文件,保持代码库整洁 + +**📦 依赖管理** +- ✅ `requirements.txt` 优化,移除Python内置模块,按功能分类 +- ✅ 添加 Nuitka 编译工具链(可选) +- ✅ 详细的依赖说明和安装指南 + +**🐛 Bug修复** +- ✅ 修复浏览器资源泄漏问题,Docker容器RAM使用稳定 +- ✅ 优化历史记录存储,减少90%磁盘和内存占用 +- ✅ 添加析构函数确保资源释放 + ## 🚀 快速开始 **⚡ 最快部署方式(推荐)**:使用预构建镜像,无需下载源码,一条命令即可启动! @@ -216,31 +250,39 @@ docker run -d -p 8080:8080 -v %cd%/xianyu-auto-reply/:/app/data/ --name xianyu-a ### 方式二:从源码构建部署 +#### 🌍 国际版(推荐海外用户) ```bash # 1. 克隆项目 git clone https://github.com/zhinianboke/xianyu-auto-reply.git cd xianyu-auto-reply -# 2. 设置脚本执行权限(Linux/macOS) -chmod +x docker-deploy.sh +# 2. 使用完整版配置(包含Redis缓存等增强功能) +docker-compose up -d --build -# 3. 一键部署(自动构建镜像) -./docker-deploy.sh +# 3. 访问系统 +# http://localhost:8080 +``` -# 4. 访问系统 +#### 🇨🇳 中国版(推荐国内用户) +```bash +# 1. 克隆项目 +git clone https://github.com/zhinianboke/xianyu-auto-reply.git +cd xianyu-auto-reply + +# 2. 使用中国镜像源配置(下载速度更快) +docker-compose -f docker-compose-cn.yml up -d --build + +# 3. 访问系统 # http://localhost:8080 ``` **Windows用户**: ```cmd -# 使用Windows批处理脚本(推荐) -docker-deploy.bat - -# 或者使用Git Bash/WSL -bash docker-deploy.sh - -# 或者直接使用Docker Compose +# 国际版 docker-compose up -d --build + +# 中国版(推荐) +docker-compose -f docker-compose-cn.yml up -d --build ``` ### 方式三:本地开发部署 @@ -280,6 +322,39 @@ python Start.py - **Docker**: 20.10+ (Docker部署) - **Docker Compose**: 2.0+ (Docker部署) +### ⚙️ 环境变量配置(可选) + +系统支持通过环境变量进行配置,主要配置项包括: + +```bash +# 基础配置 +WEB_PORT=8080 # Web服务端口 +API_HOST=0.0.0.0 # API服务主机 +TZ=Asia/Shanghai # 时区设置 + +# 管理员配置 +ADMIN_USERNAME=admin # 管理员用户名 +ADMIN_PASSWORD=admin123 # 管理员密码(请修改) +JWT_SECRET_KEY=your-secret-key # JWT密钥(请修改) + +# 功能开关 +AUTO_REPLY_ENABLED=true # 启用自动回复 +AUTO_DELIVERY_ENABLED=true # 启用自动发货 +AI_REPLY_ENABLED=false # 启用AI回复 + +# 日志配置 +LOG_LEVEL=INFO # 日志级别 +SQL_LOG_ENABLED=true # SQL日志 + +# 资源限制 +MEMORY_LIMIT=2048 # 内存限制(MB) +CPU_LIMIT=2.0 # CPU限制(核心数) + +# 更多配置请参考 docker-compose.yml 文件 +``` + +> 💡 **提示**:所有配置项都有默认值,可根据需要选择性配置 + ### 🌐 访问系统 @@ -434,6 +509,8 @@ python Start.py - **`order_detail_fetcher.py`** - 订单详情获取工具,解析订单信息、买家信息、SKU详情,支持缓存优化、锁机制 - **`image_utils.py`** - 图片处理工具,支持压缩、格式转换、尺寸调整、水印添加、质量优化 - **`image_uploader.py`** - 图片上传工具,支持多种CDN服务商、自动压缩、格式优化、批量上传 +- **`xianyu_slider_stealth.py`** - 增强版滑块验证模块,采用高级反检测技术,支持密码登录、自动重试、并发控制,包含授权期限验证机制(可编译为二进制模块以提升性能和安全性) +- **`refresh_util.py`** - Cookie刷新工具,自动检测和刷新过期的Cookie,保持账号连接状态 ### 🌐 前端界面 (`static/`) - **`index.html`** - 主管理界面,集成所有功能模块:账号管理、关键词管理、商品管理、发货管理、系统监控、用户管理等 @@ -446,17 +523,18 @@ python Start.py - **`uploads/images/`** - 图片上传目录,支持发货图片和其他媒体文件存储 ### 🐳 部署配置 -- **`Dockerfile`** - Docker镜像构建文件,基于Python 3.11-slim,包含Playwright浏览器、系统依赖,支持无头模式运行,优化构建层级 +- **`Dockerfile`** - Docker镜像构建文件,基于Python 3.11-slim,包含Playwright浏览器、C编译器(支持Nuitka编译)、系统依赖,支持无头模式运行,优化构建层级,自动编译性能关键模块 - **`Dockerfile-cn`** - 国内优化版Docker镜像构建文件,使用国内镜像源加速构建,适合国内网络环境 - **`docker-compose.yml`** - Docker Compose配置,支持一键部署、完整环境变量配置、资源限制、健康检查、可选Nginx代理 - **`docker-compose-cn.yml`** - 国内优化版Docker Compose配置文件,使用国内镜像源 - **`docker-deploy.sh`** - Docker部署管理脚本,提供构建、启动、停止、重启、监控、日志查看等功能(Linux/macOS) - **`docker-deploy.bat`** - Windows版本部署脚本,支持Windows环境一键部署和管理 -- **`entrypoint.sh`** - Docker容器启动脚本,处理环境初始化、目录创建、权限设置和服务启动 +- **`entrypoint.sh`** - Docker容器启动脚本,增强版包含环境验证、依赖检查、目录创建、权限设置和详细启动日志 - **`nginx/nginx.conf`** - Nginx反向代理配置,支持负载均衡、SSL终端、WebSocket代理、静态文件服务 -- **`requirements.txt`** - Python依赖包列表,精简版本无内置模块,按功能分类组织,包含详细版本说明和安装指南 -- **`.gitignore`** - Git忽略文件配置,完整覆盖Python、Docker、前端、测试、临时文件等,支持项目特定文件类型 -- **`.dockerignore`** - Docker构建忽略文件,优化构建上下文大小和构建速度,排除不必要的文件和目录 +- **`requirements.txt`** - Python依赖包列表,精简版本无内置模块,按功能分类组织,包含详细版本说明和安装指南,可选Nuitka编译工具 +- **`.gitignore`** - Git忽略文件配置,完整覆盖Python、Docker、前端、测试、临时文件等,2025年更新包含编译产物、浏览器缓存、统计数据等新规则 +- **`.dockerignore`** - Docker构建忽略文件,优化构建上下文大小和构建速度,排除不必要的文件和目录,2025年更新包含Nuitka编译临时文件、浏览器数据等新规则 +- **`build_binary_module.py`** - 二进制模块编译脚本,使用Nuitka将性能关键的Python模块编译为二进制扩展(.pyd/.so),提升执行效率和代码安全性 ## 🏗️ 详细技术架构 @@ -575,6 +653,30 @@ python Start.py ## 🔧 高级功能 +### 二进制模块编译(可选) + +为了提升性能和代码安全性,可以将核心模块编译为二进制文件: + +```bash +# 1. 安装 Nuitka(已在 requirements.txt 中) +pip install nuitka ordered-set zstandard + +# 2. 运行编译脚本 +python build_binary_module.py + +# 3. 编译完成后会生成 .pyd (Windows) 或 .so (Linux) 文件 +# Python 会自动优先加载二进制版本 +``` + +**Docker 部署自动编译**: +- Docker 构建时会自动检测并编译相关模块 +- 无需手动操作,构建完成即可使用 + +**编译优势**: +- ⚡ 性能提升:编译后的代码执行效率更高 +- 🔒 代码保护:二进制文件难以反编译 +- 🛡️ 授权管理:集成授权期限验证 + ### AI回复配置 1. 在用户设置中配置OpenAI API密钥 2. 选择AI模型(支持GPT-3.5、GPT-4、通义千问等) diff --git a/XianyuAutoAsync.py b/XianyuAutoAsync.py index fdc0fda..4fc6200 100644 --- a/XianyuAutoAsync.py +++ b/XianyuAutoAsync.py @@ -4,6 +4,8 @@ import re import time import base64 import os +import random +from enum import Enum from loguru import logger import websockets from utils.xianyu_utils import ( @@ -19,6 +21,17 @@ from config import ( import sys import aiohttp from collections import defaultdict +from db_manager import db_manager + + +class ConnectionState(Enum): + """WebSocket连接状态枚举""" + DISCONNECTED = "disconnected" # 未连接 + CONNECTING = "connecting" # 连接中 + CONNECTED = "connected" # 已连接 + RECONNECTING = "reconnecting" # 重连中 + FAILED = "failed" # 连接失败 + CLOSED = "closed" # 已关闭 class AutoReplyPauseManager: @@ -89,6 +102,35 @@ class AutoReplyPauseManager: # 全局暂停管理器实例 pause_manager = AutoReplyPauseManager() +def log_captcha_event(cookie_id: str, event_type: str, success: bool = None, details: str = ""): + """ + 简单记录滑块验证事件到txt文件 + + Args: + cookie_id: 账号ID + event_type: 事件类型 (检测到/开始处理/成功/失败) + success: 是否成功 (None表示进行中) + details: 详细信息 + """ + try: + log_dir = 'logs' + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, 'captcha_verification.txt') + + timestamp = time.strftime('%Y-%m-%d %H:%M:%S') + status = "成功" if success is True else "失败" if success is False else "进行中" + + log_entry = f"[{timestamp}] 【{cookie_id}】{event_type} - {status}" + if details: + log_entry += f" - {details}" + log_entry += "\n" + + with open(log_file, 'a', encoding='utf-8') as f: + f.write(log_entry) + + except Exception as e: + logger.error(f"记录滑块验证日志失败: {e}") + # 日志配置 log_dir = 'logs' os.makedirs(log_dir, exist_ok=True) @@ -125,8 +167,10 @@ class XianyuLive: _order_detail_lock_times = {} # 商品详情缓存(24小时有效) - _item_detail_cache = {} # {item_id: {'detail': str, 'timestamp': float}} + _item_detail_cache = {} # {item_id: {'detail': str, 'timestamp': float, 'access_time': float}} _item_detail_cache_lock = asyncio.Lock() + _item_detail_cache_max_size = 1000 # 最大缓存1000个商品 + _item_detail_cache_ttl = 24 * 60 * 60 # 24小时TTL # 类级别的实例管理字典,用于API调用 _instances = {} # {cookie_id: XianyuLive实例} @@ -142,6 +186,190 @@ class XianyuLive: except: return "未知错误" + def _set_connection_state(self, new_state: ConnectionState, reason: str = ""): + """设置连接状态并记录日志""" + if self.connection_state != new_state: + old_state = self.connection_state + self.connection_state = new_state + self.last_state_change_time = time.time() + + # 记录状态转换 + state_msg = f"【{self.cookie_id}】连接状态: {old_state.value} → {new_state.value}" + if reason: + state_msg += f" ({reason})" + + # 根据状态严重程度选择日志级别 + if new_state == ConnectionState.FAILED: + logger.error(state_msg) + elif new_state == ConnectionState.RECONNECTING: + logger.warning(state_msg) + elif new_state == ConnectionState.CONNECTED: + logger.success(state_msg) + else: + logger.info(state_msg) + + async def _cancel_background_tasks(self): + """取消并清理所有后台任务""" + tasks_to_cancel = [] + + # 收集所有需要取消的任务 + if self.heartbeat_task: + tasks_to_cancel.append(("心跳任务", self.heartbeat_task)) + if self.token_refresh_task: + tasks_to_cancel.append(("Token刷新任务", self.token_refresh_task)) + if self.cleanup_task: + tasks_to_cancel.append(("清理任务", self.cleanup_task)) + if self.cookie_refresh_task: + tasks_to_cancel.append(("Cookie刷新任务", self.cookie_refresh_task)) + + if not tasks_to_cancel: + logger.debug(f"【{self.cookie_id}】没有后台任务需要取消") + return + + logger.info(f"【{self.cookie_id}】开始取消 {len(tasks_to_cancel)} 个后台任务...") + + # 取消所有任务 + for task_name, task in tasks_to_cancel: + try: + task.cancel() + logger.debug(f"【{self.cookie_id}】已发送取消信号: {task_name}") + except Exception as e: + logger.warning(f"【{self.cookie_id}】取消任务失败 {task_name}: {e}") + + # 等待所有任务完成取消 + tasks = [task for _, task in tasks_to_cancel] + try: + await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), + timeout=5.0 + ) + logger.info(f"【{self.cookie_id}】所有后台任务已取消") + except asyncio.TimeoutError: + logger.warning(f"【{self.cookie_id}】等待任务取消超时,强制继续") + except Exception as e: + logger.warning(f"【{self.cookie_id}】等待任务取消时出错: {e}") + + # 重置任务引用 + self.heartbeat_task = None + self.token_refresh_task = None + self.cleanup_task = None + self.cookie_refresh_task = None + + def _calculate_retry_delay(self, error_msg: str) -> int: + """根据错误类型和失败次数计算重试延迟""" + # WebSocket意外断开 - 短延迟 + if "no close frame received or sent" in error_msg: + return min(3 * self.connection_failures, 15) + + # 网络连接问题 - 长延迟 + elif "Connection refused" in error_msg or "timeout" in error_msg.lower(): + return min(10 * self.connection_failures, 60) + + # 其他未知错误 - 中等延迟 + else: + return min(5 * self.connection_failures, 30) + + def _cleanup_instance_caches(self): + """清理实例级别的缓存,防止内存泄漏""" + try: + current_time = time.time() + cleaned_total = 0 + + # 清理过期的通知记录(保留30分钟内的,从1小时优化) + max_notification_age = 1800 # 30分钟(从3600优化) + expired_notifications = [ + key for key, last_time in self.last_notification_time.items() + if current_time - last_time > max_notification_age + ] + for key in expired_notifications: + del self.last_notification_time[key] + if expired_notifications: + cleaned_total += len(expired_notifications) + logger.debug(f"【{self.cookie_id}】清理了 {len(expired_notifications)} 个过期通知记录") + + # 清理过期的发货记录(保留30分钟内的) + max_delivery_age = 1800 # 30分钟 + expired_deliveries = [ + order_id for order_id, last_time in self.last_delivery_time.items() + if current_time - last_time > max_delivery_age + ] + for order_id in expired_deliveries: + del self.last_delivery_time[order_id] + if expired_deliveries: + cleaned_total += len(expired_deliveries) + logger.debug(f"【{self.cookie_id}】清理了 {len(expired_deliveries)} 个过期发货记录") + + # 清理过期的订单确认记录(保留30分钟内的) + max_confirm_age = 1800 # 30分钟 + expired_confirms = [ + order_id for order_id, last_time in self.confirmed_orders.items() + if current_time - last_time > max_confirm_age + ] + for order_id in expired_confirms: + del self.confirmed_orders[order_id] + if expired_confirms: + cleaned_total += len(expired_confirms) + logger.debug(f"【{self.cookie_id}】清理了 {len(expired_confirms)} 个过期订单确认记录") + + # 只有实际清理了内容才记录总数日志 + if cleaned_total > 0: + logger.info(f"【{self.cookie_id}】实例缓存清理完成,共清理 {cleaned_total} 条记录") + logger.debug(f"【{self.cookie_id}】当前缓存数量 - 通知: {len(self.last_notification_time)}, 发货: {len(self.last_delivery_time)}, 确认: {len(self.confirmed_orders)}") + + except Exception as e: + logger.error(f"【{self.cookie_id}】清理实例缓存时出错: {self._safe_str(e)}") + + async def _cleanup_playwright_cache(self): + """清理Playwright浏览器临时文件和缓存(Docker环境专用)""" + try: + import shutil + import glob + + # 定义需要清理的临时目录路径 + temp_paths = [ + '/tmp/playwright-*', # Playwright临时会话 + '/tmp/chromium-*', # Chromium临时文件 + '/ms-playwright/chromium-*/Default/Cache', # 浏览器缓存 + '/ms-playwright/chromium-*/Default/Code Cache', # 代码缓存 + '/ms-playwright/chromium-*/Default/GPUCache', # GPU缓存 + ] + + total_cleaned = 0 + total_size_mb = 0 + + for pattern in temp_paths: + try: + matching_paths = glob.glob(pattern) + for path in matching_paths: + try: + if os.path.exists(path): + # 计算大小 + if os.path.isdir(path): + size = sum( + os.path.getsize(os.path.join(dirpath, filename)) + for dirpath, _, filenames in os.walk(path) + for filename in filenames + ) + shutil.rmtree(path, ignore_errors=True) + else: + size = os.path.getsize(path) + os.remove(path) + + total_size_mb += size / (1024 * 1024) + total_cleaned += 1 + except Exception as e: + logger.debug(f"清理路径 {path} 时出错: {e}") + except Exception as e: + logger.debug(f"匹配路径 {pattern} 时出错: {e}") + + if total_cleaned > 0: + logger.info(f"【{self.cookie_id}】Playwright缓存清理完成: 删除了 {total_cleaned} 个文件/目录,释放 {total_size_mb:.2f} MB") + else: + logger.debug(f"【{self.cookie_id}】Playwright缓存清理: 没有需要清理的临时文件") + + except Exception as e: + logger.debug(f"【{self.cookie_id}】清理Playwright缓存时出错: {self._safe_str(e)}") + def __init__(self, cookies_str=None, cookie_id: str = "default", user_id: int = None): """初始化闲鱼直播类""" logger.info(f"【{cookie_id}】开始初始化XianyuLive...") @@ -208,7 +436,7 @@ class XianyuLive: self.cookie_refresh_task = None self.cookie_refresh_interval = 1200 # 1小时 = 3600秒 self.last_cookie_refresh_time = 0 - self.cookie_refresh_running = False # 防止重复执行Cookie刷新 + self.cookie_refresh_lock = asyncio.Lock() # 使用Lock防止重复执行Cookie刷新 self.cookie_refresh_enabled = True # 是否启用Cookie刷新功能 # 扫码登录Cookie刷新标志 @@ -219,14 +447,46 @@ class XianyuLive: self.last_message_received_time = 0 # 记录上次收到消息的时间 self.message_cookie_refresh_cooldown = 300 # 收到消息后5分钟内不执行Cookie刷新 + # 浏览器Cookie刷新成功标志 + self.browser_cookie_refreshed = False # 标记_refresh_cookies_via_browser是否成功更新过数据库 + self.restarted_in_browser_refresh = False # 刷新流程内部是否已触发重启(用于去重) + + + # 滑块验证相关 + self.captcha_verification_count = 0 # 滑块验证次数计数器 + self.max_captcha_verification_count = 3 # 最大滑块验证次数,防止无限递归 + # WebSocket连接监控 + self.connection_state = ConnectionState.DISCONNECTED # 连接状态 self.connection_failures = 0 # 连续连接失败次数 self.max_connection_failures = 5 # 最大连续失败次数 self.last_successful_connection = 0 # 上次成功连接时间 + self.last_state_change_time = time.time() # 上次状态变化时间 + + # 后台任务追踪(用于清理未等待的任务) + self.background_tasks = set() # 追踪所有后台任务 + + # 消息处理并发控制(防止内存泄漏) + self.message_semaphore = asyncio.Semaphore(100) # 最多100个并发消息处理任务 + self.active_message_tasks = 0 # 当前活跃的消息处理任务数 + + # 初始化订单状态处理器 + self._init_order_status_handler() # 注册实例到类级别字典(用于API调用) self._register_instance() + def _init_order_status_handler(self): + """初始化订单状态处理器""" + try: + # 直接导入订单状态处理器 + from order_status_handler import order_status_handler + self.order_status_handler = order_status_handler + logger.info(f"【{self.cookie_id}】订单状态处理器已启用") + except Exception as e: + logger.error(f"【{self.cookie_id}】初始化订单状态处理器失败: {self._safe_str(e)}") + self.order_status_handler = None + def _register_instance(self): """注册当前实例到类级别字典""" try: @@ -259,6 +519,13 @@ class XianyuLive: def get_instance_count(cls): """获取当前活跃实例数量""" return len(cls._instances) + + def _create_tracked_task(self, coro): + """创建并追踪后台任务,确保异常不会被静默忽略""" + task = asyncio.create_task(coro) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + return task def is_auto_confirm_enabled(self) -> bool: """检查当前账号是否启用自动确认发货""" @@ -290,6 +557,28 @@ class XianyuLive: """标记订单已发货""" self.delivery_sent_orders.add(order_id) logger.info(f"【{self.cookie_id}】订单 {order_id} 已标记为发货") + + # 更新订单状态为已发货 + logger.info(f"【{self.cookie_id}】检查自动发货订单状态处理器: handler_exists={self.order_status_handler is not None}") + if self.order_status_handler: + logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_auto_delivery_order_status: {order_id}") + try: + success = self.order_status_handler.handle_auto_delivery_order_status( + order_id=order_id, + cookie_id=self.cookie_id, + context="自动发货完成" + ) + logger.info(f"【{self.cookie_id}】订单状态处理器.handle_auto_delivery_order_status返回结果: {success}") + if success: + logger.info(f"【{self.cookie_id}】订单 {order_id} 状态已更新为已发货") + else: + logger.warning(f"【{self.cookie_id}】订单 {order_id} 状态更新为已发货失败") + except Exception as e: + logger.error(f"【{self.cookie_id}】订单状态更新失败: {self._safe_str(e)}") + import traceback + logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}") + else: + logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过自动发货状态更新: {order_id}") async def _delayed_lock_release(self, lock_key: str, delay_minutes: int = 10): """ @@ -725,10 +1014,44 @@ class XianyuLive: - async def refresh_token(self): - """刷新token""" + async def refresh_token(self, captcha_retry_count: int = 0): + """刷新token + + Args: + captcha_retry_count: 滑块验证重试次数,用于防止无限递归 + """ + # 初始化通知发送标志,避免重复发送通知 + notification_sent = False + try: - logger.info(f"【{self.cookie_id}】开始刷新token...") + logger.info(f"【{self.cookie_id}】开始刷新token... (滑块验证重试次数: {captcha_retry_count})") + # 标记本次刷新状态 + self.last_token_refresh_status = "started" + # 重置“刷新流程内已重启”标记,避免多次重启 + self.restarted_in_browser_refresh = False + + # 检查滑块验证重试次数,防止无限递归 + if captcha_retry_count >= self.max_captcha_verification_count: + logger.error(f"【{self.cookie_id}】滑块验证重试次数已达上限 ({self.max_captcha_verification_count}),停止重试") + await self.send_token_refresh_notification( + f"滑块验证重试次数已达上限,请手动处理", + "captcha_max_retries_exceeded" + ) + notification_sent = True + return None + + # 【消息接收检查】检查是否在消息接收后的冷却时间内,与 cookie_refresh_loop 保持一致 + current_time = time.time() + time_since_last_message = current_time - self.last_message_received_time + if self.last_message_received_time > 0 and time_since_last_message < self.message_cookie_refresh_cooldown: + remaining_time = self.message_cookie_refresh_cooldown - time_since_last_message + remaining_minutes = int(remaining_time // 60) + remaining_seconds = int(remaining_time % 60) + logger.info(f"【{self.cookie_id}】收到消息后冷却中,放弃本次token刷新,还需等待 {remaining_minutes}分{remaining_seconds}秒") + # 标记为因冷却而跳过(正常情况) + self.last_token_refresh_status = "skipped_cooldown" + return None + # 生成更精确的时间戳 timestamp = str(int(time.time() * 1000)) @@ -789,7 +1112,8 @@ class XianyuLive: API_ENDPOINTS.get('token'), params=params, data=data, - headers=headers + headers=headers, + timeout=aiohttp.ClientTimeout(total=30) ) as response: res_json = await response.json() @@ -819,16 +1143,203 @@ class XianyuLive: self.current_token = new_token self.last_token_refresh_time = time.time() + # 【消息接收时间重置】Token刷新成功后重置消息接收标志,与 cookie_refresh_loop 保持一致 + self.last_message_received_time = 0 + logger.debug(f"【{self.cookie_id}】Token刷新成功,已重置消息接收时间标识") + logger.info(f"【{self.cookie_id}】Token刷新成功") + # 标记为成功 + self.last_token_refresh_status = "success" return new_token + # 检查是否需要滑块验证 + if self._need_captcha_verification(res_json): + logger.warning(f"【{self.cookie_id}】检测到需要滑块验证,开始处理...") + + # 记录滑块验证检测到日志文件 + verification_url = res_json.get('data', {}).get('url', 'Token刷新时检测') + log_captcha_event(self.cookie_id, "检测到滑块验证", None, f"触发场景: Token刷新, URL: {verification_url}") + + # 添加风控日志记录 + log_id = None + try: + from db_manager import db_manager + success = db_manager.add_risk_control_log( + cookie_id=self.cookie_id, + event_type='slider_captcha', + event_description=f"检测到需要滑块验证,触发场景: Token刷新, URL: {verification_url}", + processing_status='processing' + ) + if success: + # 获取刚插入的记录ID(简单方式,实际应该返回ID) + logs = db_manager.get_risk_control_logs(cookie_id=self.cookie_id, limit=1) + if logs: + log_id = logs[0].get('id') + logger.info(f"【{self.cookie_id}】风控日志记录成功,ID: {log_id}") + except Exception as log_e: + logger.error(f"【{self.cookie_id}】记录风控日志失败: {log_e}") + + try: + # 尝试通过滑块验证获取新的cookies + captcha_start_time = time.time() + new_cookies_str = await self._handle_captcha_verification(res_json) + captcha_duration = time.time() - captcha_start_time + + if new_cookies_str: + logger.info(f"【{self.cookie_id}】滑块验证成功,准备重启实例...") + + # 更新风控日志为成功状态 + if 'log_id' in locals() and log_id: + try: + from db_manager import db_manager + db_manager.update_risk_control_log( + log_id=log_id, + processing_result=f"滑块验证成功,耗时: {captcha_duration:.2f}秒, cookies长度: {len(new_cookies_str)}", + processing_status='success' + ) + except Exception as update_e: + logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}") + + # 重启实例(cookies已在_handle_captcha_verification中更新到数据库) + # await self._restart_instance() + + # 重新尝试刷新token(递归调用,但有深度限制) + return await self.refresh_token(captcha_retry_count + 1) + else: + logger.error(f"【{self.cookie_id}】滑块验证失败") + + # 更新风控日志为失败状态 + if 'log_id' in locals() and log_id: + try: + from db_manager import db_manager + db_manager.update_risk_control_log( + log_id=log_id, + processing_result=f"滑块验证失败,耗时: {captcha_duration:.2f}秒, 原因: 未获取到新cookies", + processing_status='failed' + ) + except Exception as update_e: + logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}") + + # 标记已发送通知(通知已在_handle_captcha_verification中发送) + notification_sent = True + except Exception as captcha_e: + logger.error(f"【{self.cookie_id}】滑块验证处理异常: {self._safe_str(captcha_e)}") + + # 更新风控日志为异常状态 + captcha_duration = time.time() - captcha_start_time if 'captcha_start_time' in locals() else 0 + if 'log_id' in locals() and log_id: + try: + from db_manager import db_manager + db_manager.update_risk_control_log( + log_id=log_id, + processing_result=f"滑块验证处理异常,耗时: {captcha_duration:.2f}秒", + processing_status='failed', + error_message=str(captcha_e) + ) + except Exception as update_e: + logger.error(f"【{self.cookie_id}】更新风控日志失败: {update_e}") + + # 标记已发送通知(通知已在_handle_captcha_verification中发送) + notification_sent = True + + # 检查是否包含"令牌过期"或"Session过期" + 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并重启实例...") + + # 记录到日志文件 + 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("无法获取账号信息") + + 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("未配置用户名或密码") + + # 使用浏览器进行密码登录刷新Cookie + 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}") + + # 在单独的线程中运行同步的登录方法 + import asyncio + slider = XianyuSliderStealth(user_id=self.cookie_id, enable_learning=False) + result = await asyncio.to_thread( + slider.login_with_password_headful, + account=username, + password=password, + show_browser=show_browser + ) + + if result: + logger.info(f"【{self.cookie_id}】密码登录成功,获取到Cookie") + logger.info(f"【{self.cookie_id}】Cookie内容: {result}") + + # 将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._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" + ) + 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)}") + + # 刷新失败时继续执行原有的失败处理逻辑 + logger.error(f"【{self.cookie_id}】Token刷新失败: {res_json}") # 清空当前token,确保下次重试时重新获取 self.current_token = None - # 发送Token刷新失败通知 - await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed") + # 只有在没有发送过通知的情况下才发送Token刷新失败通知 + # 并且WebSocket未连接时才发送(已连接说明只是暂时失败) + if not notification_sent: + # 检查WebSocket连接状态 + is_ws_connected = ( + self.connection_state == ConnectionState.CONNECTED and + self.ws and + not self.ws.closed + ) + + if is_ws_connected: + logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新失败可能是暂时的,跳过失败通知") + else: + logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新失败通知") + await self.send_token_refresh_notification(f"Token刷新失败: {res_json}", "token_refresh_failed") + else: + logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新失败通知") return None except Exception as e: @@ -837,10 +1348,369 @@ class XianyuLive: # 清空当前token,确保下次重试时重新获取 self.current_token = None - # 发送Token刷新异常通知 - await self.send_token_refresh_notification(f"Token刷新异常: {str(e)}", "token_refresh_exception") + # 只有在没有发送过通知的情况下才发送Token刷新异常通知 + # 并且WebSocket未连接时才发送(已连接说明只是暂时失败) + if not notification_sent: + # 检查WebSocket连接状态 + is_ws_connected = ( + self.connection_state == ConnectionState.CONNECTED and + self.ws and + not self.ws.closed + ) + + if is_ws_connected: + logger.info(f"【{self.cookie_id}】WebSocket连接正常,Token刷新异常可能是暂时的,跳过失败通知") + else: + logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送Token刷新异常通知") + await self.send_token_refresh_notification(f"Token刷新异常: {str(e)}", "token_refresh_exception") + else: + logger.info(f"【{self.cookie_id}】已发送滑块验证相关通知,跳过Token刷新异常通知") return None + def _need_captcha_verification(self, res_json: dict) -> bool: + """检查响应是否需要滑块验证""" + try: + if not isinstance(res_json, dict): + return False + + # 记录res_json内容到日志文件 + import json + res_json_str = json.dumps(res_json, ensure_ascii=False, separators=(',', ':')) + log_captcha_event(self.cookie_id, "检查滑块验证响应", None, f"res_json内容: {res_json_str}") + + # 检查返回的错误信息 + ret_value = res_json.get('ret', []) + if not ret_value: + return False + + # 检查是否包含需要验证的关键词 + captcha_keywords = [ + 'FAIL_SYS_USER_VALIDATE', # 用户验证失败 + 'RGV587_ERROR', # 风控错误 + '哎哟喂,被挤爆啦', # 被挤爆了 + '哎哟喂,被挤爆啦', # 被挤爆了(中文逗号) + '挤爆了', # 挤爆了 + '请稍后重试', # 请稍后重试 + 'punish?x5secdata', # 惩罚页面 + 'captcha', # 验证码 + ] + + error_msg = str(ret_value[0]) if ret_value else '' + + # 检查错误信息是否包含需要验证的关键词 + for keyword in captcha_keywords: + if keyword in error_msg: + logger.info(f"【{self.cookie_id}】检测到需要滑块验证的关键词: {keyword}") + return True + + # 检查data字段中是否包含验证URL + data = res_json.get('data', {}) + if isinstance(data, dict) and 'url' in data: + url = data.get('url', '') + if 'punish' in url or 'captcha' in url or 'validate' in url: + logger.info(f"【{self.cookie_id}】检测到验证URL: {url}") + return True + + return False + + except Exception as e: + logger.error(f"【{self.cookie_id}】检查是否需要滑块验证时出错: {self._safe_str(e)}") + return False + + async def _handle_captcha_verification(self, res_json: dict) -> str: + """处理滑块验证,返回新的cookies字符串""" + try: + logger.info(f"【{self.cookie_id}】开始处理滑块验证...") + + # 获取验证URL + verification_url = None + + # 从data字段获取URL + data = res_json.get('data', {}) + if isinstance(data, dict) and 'url' in data: + verification_url = data.get('url') + + # 如果没有找到URL,使用默认的验证页面 + if not verification_url: + logger.info(f"【{self.cookie_id}】未找到验证URL,认为不需要滑块验证,返回正常") + return None + + logger.info(f"【{self.cookie_id}】验证URL: {verification_url}") + + # 使用滑块验证器(独立实例,解决并发冲突) + try: + from utils.xianyu_slider_stealth import XianyuSliderStealth + logger.info(f"【{self.cookie_id}】XianyuSliderStealth导入成功,使用滑块验证") + + # 创建独立的滑块验证实例(每个用户独立实例,避免并发冲突) + 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 # 使用有头模式(可视化浏览器) + ) + + # 在线程池中执行滑块验证 + import asyncio + import concurrent.futures + + loop = asyncio.get_event_loop() + with concurrent.futures.ThreadPoolExecutor() as executor: + # 执行滑块验证 + success, cookies = await loop.run_in_executor( + executor, + slider_stealth.run, + verification_url + ) + + if success and cookies: + logger.info(f"【{self.cookie_id}】滑块验证成功,获取到新的cookies") + + # 只提取x5sec相关的cookie值进行更新 + updated_cookies = self.cookies.copy() # 复制现有cookies + new_cookie_count = 0 + updated_cookie_count = 0 + x5sec_cookies = {} + + # 筛选出x5相关的cookies(包括x5sec, x5step等) + for cookie_name, cookie_value in cookies.items(): + cookie_name_lower = cookie_name.lower() + if cookie_name_lower.startswith('x5') or 'x5sec' in cookie_name_lower: + x5sec_cookies[cookie_name] = cookie_value + + logger.info(f"【{self.cookie_id}】找到{len(x5sec_cookies)}个x5相关cookies: {list(x5sec_cookies.keys())}") + + # 只更新x5相关的cookies + for cookie_name, cookie_value in x5sec_cookies.items(): + if cookie_name in updated_cookies: + if updated_cookies[cookie_name] != cookie_value: + logger.debug(f"【{self.cookie_id}】更新x5 cookie: {cookie_name}") + updated_cookies[cookie_name] = cookie_value + updated_cookie_count += 1 + else: + logger.debug(f"【{self.cookie_id}】x5 cookie值未变: {cookie_name}") + else: + logger.debug(f"【{self.cookie_id}】新增x5 cookie: {cookie_name}") + updated_cookies[cookie_name] = cookie_value + new_cookie_count += 1 + + # 将合并后的cookies字典转换为字符串格式 + cookies_str = "; ".join([f"{k}={v}" for k, v in updated_cookies.items()]) + + logger.info(f"【{self.cookie_id}】x5 Cookie更新完成: 新增{new_cookie_count}个, 更新{updated_cookie_count}个, 总计{len(updated_cookies)}个") + + # 自动更新数据库中的cookie + try: + # 备份原有cookies + old_cookies_str = self.cookies_str + old_cookies_dict = self.cookies.copy() + + # 更新当前实例的cookies(使用合并后的cookies) + self.cookies_str = cookies_str + self.cookies = updated_cookies + + # 更新数据库中的cookies + await self.update_config_cookies() + logger.info(f"【{self.cookie_id}】滑块验证成功后,数据库cookies已自动更新") + + + # 记录成功更新到日志文件,包含x5相关的cookie信息 + x5sec_cookies_str = "; ".join([f"{k}={v}" for k, v in x5sec_cookies.items()]) if x5sec_cookies else "无" + log_captcha_event(self.cookie_id, "滑块验证成功并自动更新数据库", True, + f"cookies长度: {len(cookies_str)}, 新增{new_cookie_count}个x5, 更新{updated_cookie_count}个x5, 总计{len(updated_cookies)}个cookie项, x5 cookies: {x5sec_cookies_str}") + + # 发送成功通知 + await self.send_token_refresh_notification( + f"滑块验证成功,cookies已自动更新到数据库", + "captcha_success_auto_update" + ) + + except Exception as update_e: + logger.error(f"【{self.cookie_id}】自动更新数据库cookies失败: {self._safe_str(update_e)}") + + # 回滚cookies + self.cookies_str = old_cookies_str + self.cookies = old_cookies_dict + + # 记录更新失败到日志文件,包含获取到的x5 cookies + x5sec_cookies_str = "; ".join([f"{k}={v}" for k, v in x5sec_cookies.items()]) if x5sec_cookies else "无" + log_captcha_event(self.cookie_id, "滑块验证成功但数据库更新失败", False, + f"更新异常: {self._safe_str(update_e)[:100]}, 获取到的x5 cookies: {x5sec_cookies_str}") + + # 发送更新失败通知 + await self.send_token_refresh_notification( + f"滑块验证成功但数据库更新失败: {self._safe_str(update_e)}", + "captcha_success_db_update_failed" + ) + + return cookies_str + else: + logger.error(f"【{self.cookie_id}】滑块验证失败") + + # 记录滑块验证失败到日志文件 + log_captcha_event(self.cookie_id, "滑块验证失败", False, + f"XianyuSliderStealth执行失败, 环境: {'Docker' if os.getenv('DOCKER_ENV') else '本地'}") + + # 发送通知(检查WebSocket连接状态) + # 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题 + is_ws_connected = ( + self.connection_state == ConnectionState.CONNECTED and + self.ws and + not self.ws.closed + ) + + if is_ws_connected: + logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证失败可能是暂时的,跳过通知") + else: + logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证失败通知") + await self.send_token_refresh_notification( + f"滑块验证失败,需要手动处理。验证URL: {verification_url}", + "captcha_verification_failed" + ) + return None + + except ImportError as import_e: + logger.error(f"【{self.cookie_id}】XianyuSliderStealth导入失败: {import_e}") + logger.error(f"【{self.cookie_id}】请安装Playwright库: pip install playwright") + + # 记录导入失败到日志文件 + log_captcha_event(self.cookie_id, "XianyuSliderStealth导入失败", False, + f"Playwright未安装, 错误: {import_e}") + + # 发送通知 + await self.send_token_refresh_notification( + f"滑块验证功能不可用,请安装Playwright。验证URL: {verification_url}", + "captcha_dependency_missing" + ) + return None + + except Exception as stealth_e: + logger.error(f"【{self.cookie_id}】滑块验证异常: {self._safe_str(stealth_e)}") + + # 记录异常到日志文件 + log_captcha_event(self.cookie_id, "滑块验证异常", False, + f"执行异常, 错误: {self._safe_str(stealth_e)[:100]}") + + # 发送通知(检查WebSocket连接状态) + # 只有在WebSocket未连接时才发送通知,已连接说明可能是暂时性问题 + is_ws_connected = ( + self.connection_state == ConnectionState.CONNECTED and + self.ws and + not self.ws.closed + ) + + if is_ws_connected: + logger.info(f"【{self.cookie_id}】WebSocket连接正常,滑块验证执行异常可能是暂时的,跳过通知") + else: + logger.warning(f"【{self.cookie_id}】WebSocket未连接,发送滑块验证执行异常通知") + await self.send_token_refresh_notification( + f"滑块验证执行异常,需要手动处理。验证URL: {verification_url}", + "captcha_execution_error" + ) + return None + + + + except Exception as e: + logger.error(f"【{self.cookie_id}】处理滑块验证时出错: {self._safe_str(e)}") + return None + + async def _update_cookies_and_restart(self, new_cookies_str: str): + """更新cookies并重启任务""" + try: + logger.info(f"【{self.cookie_id}】开始更新cookies并重启任务...") + + # 验证新cookies的有效性 + if not new_cookies_str or not new_cookies_str.strip(): + logger.error(f"【{self.cookie_id}】新cookies为空,无法更新") + return False + + # 解析新cookies,确保格式正确 + try: + new_cookies_dict = trans_cookies(new_cookies_str) + if not new_cookies_dict: + logger.error(f"【{self.cookie_id}】新cookies解析失败,无法更新") + return False + logger.info(f"【{self.cookie_id}】新cookies解析成功,包含 {len(new_cookies_dict)} 个字段") + except Exception as parse_e: + logger.error(f"【{self.cookie_id}】新cookies解析异常: {self._safe_str(parse_e)}") + return False + + # 合并cookies:保留原有cookies,只更新新获取到的字段 + try: + # 获取当前的cookies字典 + current_cookies_dict = trans_cookies(self.cookies_str) + logger.info(f"【{self.cookie_id}】当前cookies包含 {len(current_cookies_dict)} 个字段") + + # 合并cookies:新cookies覆盖旧cookies中的相同字段 + merged_cookies_dict = current_cookies_dict.copy() + updated_fields = [] + + for key, value in new_cookies_dict.items(): + if key in merged_cookies_dict: + if merged_cookies_dict[key] != value: + merged_cookies_dict[key] = value + updated_fields.append(key) + else: + merged_cookies_dict[key] = value + updated_fields.append(f"{key}(新增)") + + if updated_fields: + logger.info(f"【{self.cookie_id}】更新的cookie字段: {', '.join(updated_fields)}") + else: + logger.info(f"【{self.cookie_id}】没有cookie字段需要更新") + + # 重新组装cookies字符串 + merged_cookies_str = '; '.join([f"{k}={v}" for k, v in merged_cookies_dict.items()]) + logger.info(f"【{self.cookie_id}】合并后cookies包含 {len(merged_cookies_dict)} 个字段") + + # 使用合并后的cookies字符串 + new_cookies_str = merged_cookies_str + new_cookies_dict = merged_cookies_dict + + except Exception as merge_e: + logger.error(f"【{self.cookie_id}】cookies合并异常: {self._safe_str(merge_e)}") + logger.warning(f"【{self.cookie_id}】将使用原始新cookies(不合并)") + # 如果合并失败,继续使用原始的new_cookies_str + + # 备份原有cookies,以防更新失败需要回滚 + old_cookies_str = self.cookies_str + old_cookies_dict = self.cookies.copy() + + try: + # 更新当前实例的cookies + self.cookies_str = new_cookies_str + self.cookies = new_cookies_dict + + # 更新数据库中的cookies + await self.update_config_cookies() + logger.info(f"【{self.cookie_id}】数据库cookies更新成功") + + # 通过CookieManager重启任务 + logger.info(f"【{self.cookie_id}】通过CookieManager重启任务...") + await self._restart_instance() + + logger.info(f"【{self.cookie_id}】cookies更新和任务重启完成") + return True + + except Exception as update_e: + logger.error(f"【{self.cookie_id}】更新cookies过程中出错,尝试回滚: {self._safe_str(update_e)}") + + # 回滚cookies + try: + self.cookies_str = old_cookies_str + self.cookies = old_cookies_dict + await self.update_config_cookies() + logger.info(f"【{self.cookie_id}】cookies已回滚到原始状态") + except Exception as rollback_e: + logger.error(f"【{self.cookie_id}】cookies回滚失败: {self._safe_str(rollback_e)}") + + return False + + except Exception as e: + logger.error(f"【{self.cookie_id}】更新cookies并重启任务时出错: {self._safe_str(e)}") + return False + async def update_config_cookies(self): """更新数据库中的cookies""" try: @@ -854,7 +1724,11 @@ class XianyuLive: if hasattr(self, 'user_id') and self.user_id: current_user_id = self.user_id - db_manager.save_cookie(self.cookie_id, self.cookies_str, current_user_id) + # 使用 update_cookie_account_info 避免覆盖其他字段(如 pause_duration, remark 等) + success = db_manager.update_cookie_account_info(self.cookie_id, cookie_value=self.cookies_str) + if not success: + # 如果更新失败(可能是新账号),使用 save_cookie + db_manager.save_cookie(self.cookie_id, self.cookies_str, current_user_id) logger.debug(f"已更新Cookie到数据库: {self.cookie_id}") except Exception as e: logger.error(f"更新数据库Cookie失败: {self._safe_str(e)}") @@ -885,7 +1759,8 @@ class XianyuLive: # 使用异步方式调用update_cookie,避免阻塞 def restart_task(): try: - cookie_manager.update_cookie(self.cookie_id, self.cookies_str) + # 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}】实例重启请求已发送") except Exception as e: logger.error(f"【{self.cookie_id}】重启实例失败: {e}") @@ -988,7 +1863,9 @@ class XianyuLive: current_time = time.time() # 检查缓存是否在24小时内 - if current_time - cache_time < 24 * 60 * 60: # 24小时 + if current_time - cache_time < self._item_detail_cache_ttl: + # 更新访问时间(用于LRU) + cache_data['access_time'] = current_time logger.info(f"从缓存获取商品详情: {item_id}") return cache_data['detail'] else: @@ -999,12 +1876,8 @@ class XianyuLive: # 2. 尝试使用浏览器获取商品详情 detail_from_browser = await self._fetch_item_detail_from_browser(item_id) if detail_from_browser: - # 保存到缓存 - async with self._item_detail_cache_lock: - self._item_detail_cache[item_id] = { - 'detail': detail_from_browser, - 'timestamp': time.time() - } + # 保存到缓存(带大小限制) + await self._add_to_item_cache(item_id, detail_from_browser) logger.info(f"成功通过浏览器获取商品详情: {item_id}, 长度: {len(detail_from_browser)}") return detail_from_browser @@ -1012,12 +1885,8 @@ class XianyuLive: logger.warning(f"浏览器获取商品详情失败,尝试外部API: {item_id}") detail_from_api = await self._fetch_item_detail_from_external_api(item_id) if detail_from_api: - # 保存到缓存 - async with self._item_detail_cache_lock: - self._item_detail_cache[item_id] = { - 'detail': detail_from_api, - 'timestamp': time.time() - } + # 保存到缓存(带大小限制) + await self._add_to_item_cache(item_id, detail_from_api) logger.info(f"成功通过外部API获取商品详情: {item_id}, 长度: {len(detail_from_api)}") return detail_from_api @@ -1028,8 +1897,62 @@ class XianyuLive: logger.error(f"获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}") return "" + async def _add_to_item_cache(self, item_id: str, detail: str): + """添加商品详情到缓存,实现LRU策略和大小限制 + + Args: + item_id: 商品ID + detail: 商品详情 + """ + async with self._item_detail_cache_lock: + current_time = time.time() + + # 检查缓存大小,如果超过限制则清理 + if len(self._item_detail_cache) >= self._item_detail_cache_max_size: + # 使用LRU策略删除最久未访问的项 + if self._item_detail_cache: + # 找到最久未访问的项 + oldest_item = min( + self._item_detail_cache.items(), + key=lambda x: x[1].get('access_time', x[1]['timestamp']) + ) + oldest_item_id = oldest_item[0] + del self._item_detail_cache[oldest_item_id] + logger.debug(f"缓存已满,删除最旧项: {oldest_item_id}") + + # 添加新项到缓存 + self._item_detail_cache[item_id] = { + 'detail': detail, + 'timestamp': current_time, + 'access_time': current_time + } + logger.debug(f"添加商品详情到缓存: {item_id}, 当前缓存大小: {len(self._item_detail_cache)}") + + @classmethod + async def _cleanup_item_cache(cls): + """清理过期的商品详情缓存""" + async with cls._item_detail_cache_lock: + current_time = time.time() + expired_items = [] + + # 找出所有过期的项 + for item_id, cache_data in cls._item_detail_cache.items(): + if current_time - cache_data['timestamp'] >= cls._item_detail_cache_ttl: + expired_items.append(item_id) + + # 删除过期项 + for item_id in expired_items: + del cls._item_detail_cache[item_id] + + if expired_items: + logger.info(f"清理了 {len(expired_items)} 个过期的商品详情缓存") + + return len(expired_items) + async def _fetch_item_detail_from_browser(self, item_id: str) -> str: """使用浏览器获取商品详情""" + playwright = None + browser = None try: from playwright.async_api import async_playwright @@ -1064,7 +1987,7 @@ class XianyuLive: # 在Docker环境中添加额外参数 if os.getenv('DOCKER_ENV'): browser_args.extend([ - '--single-process', + # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏 '--disable-background-networking', '--disable-client-side-phishing-detection', '--disable-hang-monitor', @@ -1118,6 +2041,7 @@ class XianyuLive: await asyncio.sleep(3) # 获取商品详情内容 + detail_text = "" try: # 等待目标元素出现 await page.wait_for_selector('.desc--GaIUKUQY', timeout=10000) @@ -1127,11 +2051,6 @@ class XianyuLive: if detail_element: detail_text = await detail_element.inner_text() logger.info(f"成功获取商品详情: {item_id}, 长度: {len(detail_text)}") - - # 清理资源 - await browser.close() - await playwright.stop() - return detail_text.strip() else: logger.warning(f"未找到商品详情元素: {item_id}") @@ -1139,15 +2058,26 @@ class XianyuLive: except Exception as e: logger.warning(f"获取商品详情元素失败: {item_id}, 错误: {self._safe_str(e)}") - # 清理资源 - await browser.close() - await playwright.stop() - return "" except Exception as e: logger.error(f"浏览器获取商品详情异常: {item_id}, 错误: {self._safe_str(e)}") return "" + finally: + # 确保资源被正确清理 + try: + if browser: + await browser.close() + logger.debug(f"Browser已关闭: {item_id}") + except Exception as e: + logger.warning(f"关闭browser时出错: {self._safe_str(e)}") + + try: + if playwright: + await playwright.stop() + logger.debug(f"Playwright已停止: {item_id}") + except Exception as e: + logger.warning(f"停止playwright时出错: {self._safe_str(e)}") async def _fetch_item_detail_from_external_api(self, item_id: str) -> str: """从外部API获取商品详情(备用方案)""" @@ -1998,12 +2928,16 @@ class XianyuLive: async with session.get(api_url, params=params, timeout=10) as response: response_text = await response.text() logger.info(f"📱 QQ通知 - 响应状态: {response.status}") - logger.info(f"📱 QQ通知 - 响应内容: {response_text}") - if response.status == 200: - logger.info(f"📱 QQ通知发送成功: {qq_number}") + # 需求:502 视为成功,且不打印返回内容 + if response.status == 502: + logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})") + elif response.status == 200: + logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})") + logger.debug(f"📱 QQ通知 - 响应内容: {response_text}") else: - logger.warning(f"📱 QQ通知发送失败: HTTP {response.status}, 响应: {response_text}") + logger.warning(f"📱 QQ通知发送失败: HTTP {response.status}") + logger.debug(f"📱 QQ通知 - 响应内容: {response_text}") except Exception as e: logger.error(f"📱 发送QQ通知异常: {self._safe_str(e)}") @@ -2409,14 +3343,17 @@ class XianyuLive: return # 构造通知消息 - notification_msg = f"""🔴 闲鱼账号Token刷新异常 - -账号ID: {self.cookie_id} -聊天ID: {chat_id or '未知'} -异常时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())} -异常信息: {error_message} - -请检查账号Cookie是否过期,如有需要请及时更新Cookie配置。""" + # 判断异常信息中是否包含"滑块验证成功" + if "滑块验证成功" in error_message: + notification_msg = f"{error_message}\n\n" \ + f"账号: {self.cookie_id}\n" \ + f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}\n" + else: + notification_msg = f"Token刷新异常\n\n" \ + f"账号ID: {self.cookie_id}\n" \ + f"异常时间: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}\n" \ + f"异常信息: {error_message}\n\n" \ + f"请检查账号Cookie是否过期,如有需要请及时更新Cookie配置。\n" logger.info(f"准备发送Token刷新异常通知: {self.cookie_id}") @@ -2605,6 +3542,9 @@ class XianyuLive: case 'telegram': await self._send_telegram_notification(config_data, notification_message) logger.info(f"已发送自动发货通知到Telegram") + case 'bark': + await self._send_bark_notification(config_data, notification_message) + logger.info(f"已发送自动发货通知到Bark") case _: logger.warning(f"不支持的通知渠道类型: {channel_type}") @@ -2728,6 +3668,7 @@ class XianyuLive: if not cookie_info: logger.warning(f"Cookie ID {self.cookie_id} 不存在于cookies表中,丢弃订单 {order_id}") else: + # 先保存订单基本信息 success = db_manager.insert_or_update_order( order_id=order_id, item_id=item_id, @@ -2736,9 +3677,31 @@ class XianyuLive: spec_value=spec_value, quantity=quantity, amount=amount, - order_status='processed', # 已处理状态 cookie_id=self.cookie_id ) + + # 使用订单状态处理器设置状态 + logger.info(f"【{self.cookie_id}】检查订单状态处理器调用条件: success={success}, handler_exists={self.order_status_handler is not None}") + if success and self.order_status_handler: + logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.handle_order_detail_fetched_status: {order_id}") + try: + result = self.order_status_handler.handle_order_detail_fetched_status( + order_id=order_id, + cookie_id=self.cookie_id, + context="订单详情已拉取" + ) + logger.info(f"【{self.cookie_id}】订单状态处理器.handle_order_detail_fetched_status返回结果: {result}") + + # 处理待处理队列 + logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_details_fetched: {order_id}") + self.order_status_handler.on_order_details_fetched(order_id) + logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_details_fetched调用成功: {order_id}") + except Exception as e: + logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}") + import traceback + logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}") + else: + logger.warning(f"【{self.cookie_id}】订单状态处理器调用条件不满足: success={success}, handler_exists={self.order_status_handler is not None}") if success: logger.info(f"【{self.cookie_id}】订单信息已保存到数据库: {order_id}") @@ -2992,14 +3955,26 @@ class XianyuLive: existing_order = db_manager.get_order_by_id(order_id) if not existing_order: # 插入基本订单信息 - db_manager.insert_or_update_order( + success = db_manager.insert_or_update_order( order_id=order_id, item_id=item_id, buyer_id=send_user_id, - order_status='processing', # 处理中状态 cookie_id=self.cookie_id ) - logger.info(f"保存基本订单信息到数据库: {order_id}") + + # 使用订单状态处理器设置状态 + if success and self.order_status_handler: + try: + self.order_status_handler.handle_order_basic_info_status( + order_id=order_id, + cookie_id=self.cookie_id, + context="自动发货-基本信息" + ) + except Exception as e: + logger.error(f"【{self.cookie_id}】订单状态处理器调用失败: {self._safe_str(e)}") + + if success: + logger.info(f"保存基本订单信息到数据库: {order_id}") except Exception as db_e: logger.error(f"保存基本订单信息失败: {self._safe_str(db_e)}") @@ -3316,11 +4291,14 @@ class XianyuLive: # 注意:refresh_token方法中已经调用了_restart_instance() # 这里只需要关闭当前连接,让main循环重新开始 self.connection_restart_flag = True - if self.ws: - await self.ws.close() + await self._restart_instance() break else: - logger.error(f"【{self.cookie_id}】Token刷新失败,将在{self.token_retry_interval // 60}分钟后重试") + # 根据上一次刷新状态决定日志级别(冷却/已重启为正常情况) + if getattr(self, 'last_token_refresh_status', None) in ("skipped_cooldown", "restarted_after_cookie_refresh"): + logger.info(f"【{self.cookie_id}】Token刷新未执行或已重启(正常),将在{self.token_retry_interval // 60}分钟后重试") + else: + logger.error(f"【{self.cookie_id}】Token刷新失败,将在{self.token_retry_interval // 60}分钟后重试") # 清空当前token,确保下次重试时重新获取 self.current_token = None @@ -3361,7 +4339,7 @@ class XianyuLive: text = { "contentType": 1, "text": { - "text": text + "text": text + "\n\n\n购买后如果没有发货,可尝试点击提醒发货按钮" } } text_base64 = str(base64.b64encode(json.dumps(text).encode('utf-8')), 'utf-8') @@ -3515,7 +4493,7 @@ class XianyuLive: return False async def pause_cleanup_loop(self): - """定期清理过期的暂停记录和锁""" + """定期清理过期的暂停记录、锁和缓存""" while True: try: # 检查账号是否启用 @@ -3530,6 +4508,56 @@ class XianyuLive: # 清理过期的锁(每5分钟清理一次,保留24小时内的锁) self.cleanup_expired_locks(max_age_hours=24) + # 清理过期的商品详情缓存 + cleaned_count = await self._cleanup_item_cache() + if cleaned_count > 0: + logger.info(f"【{self.cookie_id}】清理了 {cleaned_count} 个过期的商品详情缓存") + + # 清理过期的通知、发货和订单确认记录(防止内存泄漏) + self._cleanup_instance_caches() + + # 清理AI回复引擎未使用的客户端(每5分钟检查一次) + try: + from ai_reply_engine import ai_reply_engine + ai_reply_engine.cleanup_unused_clients(max_idle_hours=24) + except Exception as ai_clean_e: + logger.debug(f"【{self.cookie_id}】清理AI客户端时出错: {ai_clean_e}") + + # 清理QR登录过期会话(每5分钟检查一次) + try: + from utils.qr_login import qr_login_manager + qr_login_manager.cleanup_expired_sessions() + except Exception as qr_clean_e: + logger.debug(f"【{self.cookie_id}】清理QR登录会话时出错: {qr_clean_e}") + + # 清理Playwright浏览器临时文件和缓存(每5分钟检查一次) + try: + await self._cleanup_playwright_cache() + except Exception as pw_clean_e: + logger.debug(f"【{self.cookie_id}】清理Playwright缓存时出错: {pw_clean_e}") + + # 清理数据库历史数据(每天一次,保留90天数据) + # 为避免所有实例同时执行,只让第一个实例执行 + try: + if hasattr(self.__class__, '_last_db_cleanup_time'): + last_cleanup = self.__class__._last_db_cleanup_time + else: + self.__class__._last_db_cleanup_time = 0 + last_cleanup = 0 + + current_time = time.time() + # 每24小时清理一次 + if current_time - last_cleanup > 86400: + logger.info(f"【{self.cookie_id}】开始执行数据库历史数据清理...") + stats = db_manager.cleanup_old_data(days=90) + if 'error' not in stats: + logger.info(f"【{self.cookie_id}】数据库清理完成: {stats}") + self.__class__._last_db_cleanup_time = current_time + else: + logger.error(f"【{self.cookie_id}】数据库清理失败: {stats['error']}") + except Exception as db_clean_e: + logger.debug(f"【{self.cookie_id}】清理数据库历史数据时出错: {db_clean_e}") + # 每5分钟清理一次 await asyncio.sleep(300) except Exception as e: @@ -3563,7 +4591,7 @@ class XianyuLive: remaining_seconds = int(remaining_time % 60) logger.debug(f"【{self.cookie_id}】收到消息后冷却中,还需等待 {remaining_minutes}分{remaining_seconds}秒 才能执行Cookie刷新") # 检查是否已有Cookie刷新任务在执行 - elif self.cookie_refresh_running: + elif self.cookie_refresh_lock.locked(): logger.debug(f"【{self.cookie_id}】Cookie刷新任务已在执行中,跳过本次触发") else: logger.info(f"【{self.cookie_id}】开始执行Cookie刷新任务...") @@ -3579,59 +4607,54 @@ class XianyuLive: async def _execute_cookie_refresh(self, current_time): """独立执行Cookie刷新任务,避免阻塞主循环""" + # 使用Lock确保原子性,防止重复执行 + async with self.cookie_refresh_lock: + try: + logger.info(f"【{self.cookie_id}】开始Cookie刷新任务,暂时暂停心跳以避免连接冲突...") - # 设置运行状态,防止重复执行 - self.cookie_refresh_running = True + # 暂时暂停心跳任务,避免与浏览器操作冲突 + heartbeat_was_running = False + if self.heartbeat_task and not self.heartbeat_task.done(): + heartbeat_was_running = True + self.heartbeat_task.cancel() + logger.debug(f"【{self.cookie_id}】已暂停心跳任务") - try: - logger.info(f"【{self.cookie_id}】开始Cookie刷新任务,暂时暂停心跳以避免连接冲突...") + # 为整个Cookie刷新任务添加超时保护(3分钟,缩短时间减少影响) + success = await asyncio.wait_for( + self._refresh_cookies_via_browser(), + timeout=180.0 # 3分钟超时,减少对WebSocket的影响 + ) - # 暂时暂停心跳任务,避免与浏览器操作冲突 - heartbeat_was_running = False - if self.heartbeat_task and not self.heartbeat_task.done(): - heartbeat_was_running = True - self.heartbeat_task.cancel() - logger.debug(f"【{self.cookie_id}】已暂停心跳任务") + # 重新启动心跳任务 + if heartbeat_was_running and self.ws and not self.ws.closed: + logger.debug(f"【{self.cookie_id}】重新启动心跳任务") + self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws)) - # 为整个Cookie刷新任务添加超时保护(3分钟,缩短时间减少影响) - success = await asyncio.wait_for( - self._refresh_cookies_via_browser(), - timeout=180.0 # 3分钟超时,减少对WebSocket的影响 - ) + if success: + self.last_cookie_refresh_time = current_time + logger.info(f"【{self.cookie_id}】Cookie刷新任务完成,心跳已恢复") + else: + logger.warning(f"【{self.cookie_id}】Cookie刷新任务失败") + # 即使失败也要更新时间,避免频繁重试 + self.last_cookie_refresh_time = current_time - # 重新启动心跳任务 - if heartbeat_was_running and self.ws and not self.ws.closed: - logger.debug(f"【{self.cookie_id}】重新启动心跳任务") - self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws)) - - if success: + except asyncio.TimeoutError: + # 超时也要更新时间,避免频繁重试 self.last_cookie_refresh_time = current_time - logger.info(f"【{self.cookie_id}】Cookie刷新任务完成,心跳已恢复") - else: - logger.warning(f"【{self.cookie_id}】Cookie刷新任务失败") - # 即使失败也要更新时间,避免频繁重试 + except Exception as e: + logger.error(f"【{self.cookie_id}】执行Cookie刷新任务异常: {self._safe_str(e)}") + # 异常也要更新时间,避免频繁重试 self.last_cookie_refresh_time = current_time + finally: + # 确保心跳任务恢复(如果WebSocket仍然连接) + if (self.ws and not self.ws.closed and + (not self.heartbeat_task or self.heartbeat_task.done())): + logger.info(f"【{self.cookie_id}】Cookie刷新完成,心跳任务正常运行") + self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws)) - except asyncio.TimeoutError: - # 超时也要更新时间,避免频繁重试 - self.last_cookie_refresh_time = current_time - except Exception as e: - logger.error(f"【{self.cookie_id}】执行Cookie刷新任务异常: {self._safe_str(e)}") - # 异常也要更新时间,避免频繁重试 - self.last_cookie_refresh_time = current_time - finally: - # 确保心跳任务恢复(如果WebSocket仍然连接) - if (self.ws and not self.ws.closed and - (not self.heartbeat_task or self.heartbeat_task.done())): - logger.info(f"【{self.cookie_id}】Cookie刷新完成,心跳任务正常运行") - self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(self.ws)) - - # 清除运行状态 - self.cookie_refresh_running = False - - # 清空消息接收标志,允许下次正常执行Cookie刷新 - self.last_message_received_time = 0 - logger.debug(f"【{self.cookie_id}】Cookie刷新完成,已清空消息接收标志") + # 清空消息接收标志,允许下次正常执行Cookie刷新 + self.last_message_received_time = 0 + logger.debug(f"【{self.cookie_id}】Cookie刷新完成,已清空消息接收标志") @@ -3755,7 +4778,7 @@ class XianyuLive: # 在Docker环境中添加额外参数 if os.getenv('DOCKER_ENV'): browser_args.extend([ - '--single-process', + # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏 '--disable-background-networking', '--disable-client-side-phishing-detection', '--disable-hang-monitor', @@ -3812,7 +4835,7 @@ class XianyuLive: await asyncio.sleep(0.1) # 访问指定页面获取真实cookie - target_url = "https://www.goofish.com/im?spm=a21ybx.home.sidebar.1.4c053da6vYwnmf" + target_url = "https://www.goofish.com/im" logger.info(f"【{target_cookie_id}】访问页面获取真实cookie: {target_url}") # 使用更灵活的页面访问策略 @@ -3937,7 +4960,15 @@ class XianyuLive: # 保存真实Cookie到数据库 from db_manager import db_manager - success = db_manager.save_cookie(target_cookie_id, real_cookies_str, target_user_id) + + # 检查是否为新账号 + existing_cookie = db_manager.get_cookie_details(target_cookie_id) + if existing_cookie: + # 现有账号,使用 update_cookie_account_info 避免覆盖其他字段(如 pause_duration, remark 等) + success = db_manager.update_cookie_account_info(target_cookie_id, cookie_value=real_cookies_str) + else: + # 新账号,使用 save_cookie + success = db_manager.save_cookie(target_cookie_id, real_cookies_str, target_user_id) if success: logger.info(f"【{target_cookie_id}】真实Cookie已成功保存到数据库") @@ -3982,8 +5013,12 @@ class XianyuLive: remaining_time = max(0, self.qr_cookie_refresh_cooldown - time_since_qr_refresh) return int(remaining_time) - async def _refresh_cookies_via_browser(self): - """通过浏览器访问指定页面刷新Cookie""" + async def _refresh_cookies_via_browser(self, triggered_by_refresh_token: bool = False): + """通过浏览器访问指定页面刷新Cookie + + Args: + triggered_by_refresh_token: 是否由refresh_token方法触发,如果是True则设置browser_cookie_refreshed标志 + """ playwright = None @@ -4094,7 +5129,7 @@ class XianyuLive: # 在Docker环境中添加额外参数 if os.getenv('DOCKER_ENV'): browser_args.extend([ - '--single-process', + # '--single-process', # 注释掉,避免多用户并发时的进程冲突和资源泄漏 '--disable-background-networking', '--disable-client-side-phishing-detection', '--disable-hang-monitor', @@ -4146,7 +5181,7 @@ class XianyuLive: await asyncio.sleep(0.1) # 访问指定页面 - target_url = "https://www.goofish.com/im?spm=a21ybx.home.sidebar.1.4c053da6vYwnmf" + target_url = "https://www.goofish.com/im" logger.info(f"【{self.cookie_id}】访问页面: {target_url}") # 使用更灵活的页面访问策略 @@ -4204,6 +5239,10 @@ class XianyuLive: # Cookie刷新模式:正常更新Cookie logger.info(f"【{self.cookie_id}】获取更新后的Cookie...") updated_cookies = await context.cookies() + + # 获取并打印当前页面标题 + page_title = await page.title() + logger.info(f"【{self.cookie_id}】当前页面标题: {page_title}") # 构造新的Cookie字典 new_cookies_dict = {} @@ -4256,6 +5295,27 @@ class XianyuLive: # 更新数据库中的Cookie await self.update_config_cookies() + # 只有当由refresh_token触发时才设置浏览器Cookie刷新成功标志 + if triggered_by_refresh_token: + self.browser_cookie_refreshed = True + logger.info(f"【{self.cookie_id}】由refresh_token触发,浏览器Cookie刷新成功标志已设置为True") + + # 兜底:直接在此处触发实例重启,避免外层协程在返回后被取消导致未重启 + 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)") + + # 标记重启标志(无需主动关闭WS,重启由管理器处理) + self.connection_restart_flag = True + except Exception as e: + logger.error(f"【{self.cookie_id}】兜底重启失败: {self._safe_str(e)}") + else: + logger.info(f"【{self.cookie_id}】由定时任务触发,不设置浏览器Cookie刷新成功标志") + logger.info(f"【{self.cookie_id}】Cookie刷新完成") return True @@ -4263,15 +5323,81 @@ class XianyuLive: logger.error(f"【{self.cookie_id}】通过浏览器刷新Cookie失败: {self._safe_str(e)}") return False finally: - # 确保资源清理 + # 异步关闭浏览器:创建清理任务,超时后强制关闭 try: - if browser: - await browser.close() - if playwright: - await playwright.stop() + asyncio.create_task(self._async_close_browser(browser, playwright)) + logger.info(f"【{self.cookie_id}】浏览器异步关闭任务已启动") # 改为info级别,确保能看到 except Exception as cleanup_e: - logger.warning(f"【{self.cookie_id}】清理浏览器资源时出错: {self._safe_str(cleanup_e)}") + logger.warning(f"【{self.cookie_id}】创建浏览器关闭任务时出错: {self._safe_str(cleanup_e)}") + async def _async_close_browser(self, browser, playwright): + """异步关闭:正常关闭,超时后强制关闭""" + try: + logger.info(f"【{self.cookie_id}】开始异步关闭浏览器...") # 改为info级别 + + # 正常关闭,设置超时 + await asyncio.wait_for( + self._normal_close_resources(browser, playwright), + timeout=10.0 + ) + logger.info(f"【{self.cookie_id}】浏览器正常关闭完成") # 改为info级别 + + except asyncio.TimeoutError: + logger.warning(f"【{self.cookie_id}】正常关闭超时,开始强制关闭...") + await self._force_close_resources(browser, playwright) + + except Exception as e: + logger.warning(f"【{self.cookie_id}】异步关闭时出错,强制关闭: {self._safe_str(e)}") + await self._force_close_resources(browser, playwright) + + async def _normal_close_resources(self, browser, playwright): + """正常关闭资源:浏览器+Playwright短超时关闭""" + try: + # 关闭浏览器 + if browser: + try: + await browser.close() + logger.info(f"【{self.cookie_id}】浏览器关闭完成") + except Exception as e: + logger.warning(f"【{self.cookie_id}】关闭浏览器时出错: {e}") + + # 关闭Playwright:使用非常短的超时,如果超时就放弃 + if playwright: + try: + logger.info(f"【{self.cookie_id}】正在关闭Playwright...") + await asyncio.wait_for(playwright.stop(), timeout=2.0) + logger.info(f"【{self.cookie_id}】Playwright关闭完成") + except asyncio.TimeoutError: + logger.warning(f"【{self.cookie_id}】Playwright关闭超时,将自动清理") + except Exception as e: + logger.warning(f"【{self.cookie_id}】关闭Playwright时出错: {e}") + + except Exception as e: + logger.error(f"【{self.cookie_id}】正常关闭时出现异常: {e}") + raise + + + async def _force_close_resources(self, browser, playwright): + """强制关闭资源:强制关闭浏览器+Playwright超时等待""" + try: + logger.warning(f"【{self.cookie_id}】开始强制关闭资源...") + + # 强制关闭浏览器+Playwright,设置短超时 + force_tasks = [] + if browser: + force_tasks.append(asyncio.wait_for(browser.close(), timeout=2.0)) + if playwright: + force_tasks.append(asyncio.wait_for(playwright.stop(), timeout=2.0)) + + if force_tasks: + # 使用gather执行,所有失败都会被忽略 + await asyncio.gather(*force_tasks, return_exceptions=True) + logger.info(f"【{self.cookie_id}】强制关闭完成") + else: + logger.info(f"【{self.cookie_id}】没有需要强制关闭的资源") + + except Exception as e: + logger.warning(f"【{self.cookie_id}】强制关闭时出现异常(已忽略): {e}") async def send_msg_once(self, toid, item_id, text): headers = { @@ -4459,6 +5585,18 @@ class XianyuLive: logger.error(f"调用API出错: {self._safe_str(e)}") return None + async def _handle_message_with_semaphore(self, message_data, websocket): + """带信号量的消息处理包装器,防止并发任务过多""" + async with self.message_semaphore: + self.active_message_tasks += 1 + try: + await self.handle_message(message_data, websocket) + finally: + self.active_message_tasks -= 1 + # 定期记录活跃任务数(每100个任务记录一次) + if self.active_message_tasks % 100 == 0 and self.active_message_tasks > 0: + logger.info(f"【{self.cookie_id}】当前活跃消息处理任务数: {self.active_message_tasks}") + async def handle_message(self, message_data, websocket): """处理所有类型的消息""" try: @@ -4556,6 +5694,19 @@ class XianyuLive: msg_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 检测到订单ID: {order_id},开始获取订单详情') + # 通知订单状态处理器订单ID已提取 + if self.order_status_handler: + logger.info(f"【{self.cookie_id}】准备调用订单状态处理器.on_order_id_extracted: {order_id}") + try: + self.order_status_handler.on_order_id_extracted(order_id, self.cookie_id, message) + logger.info(f"【{self.cookie_id}】订单状态处理器.on_order_id_extracted调用成功: {order_id}") + except Exception as e: + logger.error(f"【{self.cookie_id}】通知订单状态处理器订单ID提取失败: {self._safe_str(e)}") + import traceback + logger.error(f"【{self.cookie_id}】详细错误信息: {traceback.format_exc()}") + else: + logger.warning(f"【{self.cookie_id}】订单状态处理器为None,跳过订单ID提取通知: {order_id}") + # 立即获取订单详情信息 try: # 先尝试提取用户ID和商品ID用于订单详情获取 @@ -4723,6 +5874,45 @@ class XianyuLive: + # 【优先处理】使用订单状态处理器处理系统消息 + if self.order_status_handler: + try: + # 处理系统消息的订单状态更新 + try: + handled = self.order_status_handler.handle_system_message( + message=message, + send_message=send_message, + cookie_id=self.cookie_id, + msg_time=msg_time + ) + except Exception as e: + logger.error(f"【{self.cookie_id}】处理系统消息失败: {self._safe_str(e)}") + handled = False + + # 处理红色提醒消息 + if not handled: + try: + if isinstance(message, dict) and "3" in message and isinstance(message["3"], dict): + red_reminder = message["3"].get("redReminder") + user_id = message["3"].get("userId", "unknown") + + if red_reminder: + try: + self.order_status_handler.handle_red_reminder_message( + message=message, + red_reminder=red_reminder, + user_id=user_id, + cookie_id=self.cookie_id, + msg_time=msg_time + ) + except Exception as e: + logger.error(f"【{self.cookie_id}】处理红色提醒消息失败: {self._safe_str(e)}") + except Exception as red_e: + logger.debug(f"处理红色提醒消息失败: {self._safe_str(red_e)}") + + except Exception as e: + logger.error(f"订单状态处理失败: {self._safe_str(e)}") + # 【优先处理】检查系统消息和自动发货触发消息(不受人工接入暂停影响) if send_message == '[我已拍下,待付款]': logger.info(f'[{msg_time}] 【{self.cookie_id}】系统消息不处理') @@ -4933,22 +6123,24 @@ class XianyuLive: headers = WEBSOCKET_HEADERS.copy() headers['Cookie'] = self.cookies_str - logger.info(f"【{self.cookie_id}】准备建立WebSocket连接到: {self.base_url}") - logger.debug(f"【{self.cookie_id}】WebSocket headers: {headers}") + # 更新连接状态为连接中 + self._set_connection_state(ConnectionState.CONNECTING, "准备建立WebSocket连接") + logger.info(f"【{self.cookie_id}】WebSocket目标地址: {self.base_url}") # 兼容不同版本的websockets库 async with await self._create_websocket_connection(headers) as websocket: - logger.info(f"【{self.cookie_id}】WebSocket连接建立成功!") self.ws = websocket + logger.info(f"【{self.cookie_id}】WebSocket连接建立成功,开始初始化...") - # 更新连接状态 - self.connection_failures = 0 - self.last_successful_connection = time.time() - - logger.info(f"【{self.cookie_id}】开始初始化WebSocket连接...") + # 开始初始化 await self.init(websocket) logger.info(f"【{self.cookie_id}】WebSocket初始化完成!") + # 初始化完成后才设置为已连接状态 + self._set_connection_state(ConnectionState.CONNECTED, "初始化完成,连接就绪") + self.connection_failures = 0 + self.last_successful_connection = time.time() + # 启动心跳任务 logger.info(f"【{self.cookie_id}】启动心跳任务...") self.heartbeat_task = asyncio.create_task(self.heartbeat_loop(websocket)) @@ -4981,8 +6173,9 @@ class XianyuLive: continue # 处理其他消息 - # 使用异步任务处理消息,防止阻塞后续消息接收 - asyncio.create_task(self.handle_message(message_data, websocket)) + # 使用追踪的异步任务处理消息,防止阻塞后续消息接收 + # 并通过信号量控制并发数量,防止内存泄漏 + self._create_tracked_task(self._handle_message_with_semaphore(message_data, websocket)) except Exception as e: logger.error(f"处理消息出错: {self._safe_str(e)}") @@ -4992,64 +6185,65 @@ class XianyuLive: error_msg = self._safe_str(e) self.connection_failures += 1 - logger.error(f"WebSocket连接异常 ({self.connection_failures}/{self.max_connection_failures}): {error_msg}") + # 更新连接状态为重连中 + self._set_connection_state(ConnectionState.RECONNECTING, f"第{self.connection_failures}次失败") + + # 打印详细的错误信息 + import traceback + error_type = type(e).__name__ + logger.error(f"【{self.cookie_id}】WebSocket连接异常 ({self.connection_failures}/{self.max_connection_failures})") + logger.error(f"【{self.cookie_id}】异常类型: {error_type}") + logger.error(f"【{self.cookie_id}】异常信息: {error_msg}") + logger.debug(f"【{self.cookie_id}】异常堆栈:\n{traceback.format_exc()}") # 检查是否超过最大失败次数 if self.connection_failures >= self.max_connection_failures: - logger.error(f"【{self.cookie_id}】连续连接失败{self.max_connection_failures}次,暂停重试30分钟") - await asyncio.sleep(1800) # 暂停30分钟 + self._set_connection_state(ConnectionState.FAILED, f"连续失败{self.max_connection_failures}次") + logger.error(f"【{self.cookie_id}】准备重启实例...") self.connection_failures = 0 # 重置失败计数 - continue + await self._restart_instance() # 重启实例 + return # 重启后退出当前连接循环 - # 根据错误类型和失败次数决定处理策略 - if "no close frame received or sent" in error_msg: - logger.info(f"【{self.cookie_id}】检测到WebSocket连接意外断开,准备重新连接...") - retry_delay = min(3 * self.connection_failures, 15) # 递增重试间隔,最大15秒 - elif "Connection refused" in error_msg or "timeout" in error_msg.lower(): - logger.warning(f"【{self.cookie_id}】网络连接问题,延长重试间隔...") - retry_delay = min(10 * self.connection_failures, 60) # 递增重试间隔,最大60秒 - else: - logger.warning(f"【{self.cookie_id}】未知WebSocket错误,使用默认重试间隔...") - retry_delay = min(5 * self.connection_failures, 30) # 递增重试间隔,最大30秒 + # 计算重试延迟 + retry_delay = self._calculate_retry_delay(error_msg) + logger.warning(f"【{self.cookie_id}】将在 {retry_delay} 秒后重试连接...") # 清空当前token,确保重新连接时会重新获取 if self.current_token: - logger.info(f"【{self.cookie_id}】清空当前token,重新连接时将重新获取") + logger.debug(f"【{self.cookie_id}】清空当前token,重新连接时将重新获取") self.current_token = None - # 取消所有任务并重置为None - if self.heartbeat_task: - self.heartbeat_task.cancel() - self.heartbeat_task = None - if self.token_refresh_task: - self.token_refresh_task.cancel() - self.token_refresh_task = None - if self.cleanup_task: - self.cleanup_task.cancel() - self.cleanup_task = None - if self.cookie_refresh_task: - self.cookie_refresh_task.cancel() - self.cookie_refresh_task = None + # 使用统一的任务清理方法 + await self._cancel_background_tasks() - logger.info(f"【{self.cookie_id}】等待 {retry_delay} 秒后重试连接...") + # 等待后重试 await asyncio.sleep(retry_delay) continue finally: + # 更新连接状态为已关闭 + self._set_connection_state(ConnectionState.CLOSED, "程序退出") + # 清空当前token if self.current_token: logger.info(f"【{self.cookie_id}】程序退出,清空当前token") self.current_token = None - # 清理所有任务 - if self.heartbeat_task: - self.heartbeat_task.cancel() - if self.token_refresh_task: - self.token_refresh_task.cancel() - if self.cleanup_task: - self.cleanup_task.cancel() - if self.cookie_refresh_task: - self.cookie_refresh_task.cancel() - await self.close_session() # 确保关闭session + # 使用统一的任务清理方法 + await self._cancel_background_tasks() + + # 清理所有后台任务 + if self.background_tasks: + logger.info(f"【{self.cookie_id}】等待 {len(self.background_tasks)} 个后台任务完成...") + try: + await asyncio.wait_for( + asyncio.gather(*self.background_tasks, return_exceptions=True), + timeout=10.0 # 10秒超时 + ) + except asyncio.TimeoutError: + logger.warning(f"【{self.cookie_id}】后台任务清理超时,强制继续") + + # 确保关闭session + await self.close_session() # 从全局实例字典中注销当前实例 self._unregister_instance() diff --git a/ai_reply_engine.py b/ai_reply_engine.py index f3a9d2e..5fe354f 100644 --- a/ai_reply_engine.py +++ b/ai_reply_engine.py @@ -20,6 +20,7 @@ class AIReplyEngine: def __init__(self): self.clients = {} # 存储不同账号的OpenAI客户端 self.agents = {} # 存储不同账号的Agent实例 + self.client_last_used = {} # 记录客户端最后使用时间 {cookie_id: timestamp} self._init_default_prompts() def _init_default_prompts(self): @@ -71,6 +72,8 @@ class AIReplyEngine: logger.error(f"创建OpenAI客户端失败 {cookie_id}: {e}") return None + # 记录使用时间 + self.client_last_used[cookie_id] = time.time() return self.clients[cookie_id] def _is_dashscope_api(self, settings: dict) -> bool: @@ -374,10 +377,42 @@ class AIReplyEngine: """清理客户端缓存""" if cookie_id: self.clients.pop(cookie_id, None) + self.client_last_used.pop(cookie_id, None) logger.info(f"清理账号 {cookie_id} 的客户端缓存") else: self.clients.clear() + self.client_last_used.clear() logger.info("清理所有客户端缓存") + + def cleanup_unused_clients(self, max_idle_hours: int = 24): + """清理长时间未使用的客户端(防止内存泄漏) + + Args: + max_idle_hours: 最大空闲时间(小时),默认24小时 + """ + try: + current_time = time.time() + max_idle_seconds = max_idle_hours * 3600 + + # 找出超过最大空闲时间的客户端 + expired_clients = [ + cookie_id for cookie_id, last_used in self.client_last_used.items() + if current_time - last_used > max_idle_seconds + ] + + # 清理过期客户端 + for cookie_id in expired_clients: + self.clients.pop(cookie_id, None) + self.client_last_used.pop(cookie_id, None) + self.agents.pop(cookie_id, None) + + if expired_clients: + logger.info(f"AI回复引擎:清理了 {len(expired_clients)} 个长时间未使用的客户端") + logger.debug(f"清理的账号: {expired_clients}") + logger.debug(f"当前活跃客户端数量: {len(self.clients)}") + + except Exception as e: + logger.error(f"AI回复引擎:清理未使用客户端时出错: {e}") # 全局AI回复引擎实例 diff --git a/build_binary_module.py b/build_binary_module.py new file mode 100644 index 0000000..327dc7f --- /dev/null +++ b/build_binary_module.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +把 utils/xianyu_slider_stealth.py 编译为可直接 import 的二进制扩展模块(.pyd/.so) +- 使用 Nuitka 的 --module 模式 +- 输出文件放到 utils/ 目录,名称为 xianyu_slider_stealth..pyd/.so +- 这样 Python 将优先加载二进制扩展而不是同名 .py +""" + +import sys +import subprocess +from pathlib import Path + +SRC = Path("utils/xianyu_slider_stealth.py") +OUT_DIR = Path("utils") + + +def ensure_nuitka(): + try: + import nuitka # noqa: F401 + print("✓ Nuitka 已安装") + return True + except Exception: + print("✗ 未检测到 Nuitka。请先允许我安装: pip install nuitka ordered-set zstandard") + return False + + +def clean_old_files(): + """清理旧的编译产物""" + import os + import glob + + patterns = [ + "utils/xianyu_slider_stealth.*.pyd", + "utils/xianyu_slider_stealth.*.so", + "utils/xianyu_slider_stealth.build", + "utils/xianyu_slider_stealth.dist" + ] + + for pattern in patterns: + for file_path in glob.glob(pattern): + try: + if os.path.isfile(file_path): + os.remove(file_path) + print(f"✓ 已删除旧文件: {file_path}") + elif os.path.isdir(file_path): + import shutil + shutil.rmtree(file_path) + print(f"✓ 已删除旧目录: {file_path}") + except Exception as e: + print(f"⚠️ 无法删除 {file_path}: {e}") + + +def check_permissions(): + """检查目录权限""" + try: + test_file = OUT_DIR / "test_write.tmp" + test_file.write_text("test") + test_file.unlink() + return True + except Exception as e: + print(f"✗ 目录权限检查失败: {e}") + print("💡 请尝试以管理员身份运行此脚本") + return False + + +def build(): + OUT_DIR.mkdir(parents=True, exist_ok=True) + + # 检查权限 + if not check_permissions(): + return 1 + + # 清理旧文件 + print("🧹 清理旧的编译产物...") + clean_old_files() + + cmd = [ + sys.executable, "-m", "nuitka", + "--module", + "--output-dir=%s" % str(OUT_DIR), + "--remove-output", + "--assume-yes-for-downloads", + "--show-progress", + "--python-flag=no_docstrings", + "--python-flag=no_warnings", + "--enable-plugin=anti-bloat", + # 降低内存占用,避免容器内 OOM + "--lto=no", + "--jobs=1", + str(SRC) + ] + + print("执行编译命令:\n ", " ".join(cmd)) + try: + result = subprocess.run(cmd, text=True, timeout=300) # 5分钟超时 + if result.returncode != 0: + print("✗ 编译失败 (Nuitka 返回非零)") + print("💡 可能的解决方案:") + print(" 1. 以管理员身份运行此脚本") + print(" 2. 关闭杀毒软件的实时保护") + print(" 3. 检查是否有其他Python进程在运行") + return 1 + except subprocess.TimeoutExpired: + print("✗ 编译超时 (5分钟)") + return 1 + except Exception as e: + print(f"✗ 编译过程中发生错误: {e}") + return 1 + + # 列出 utils 目录下的产物 + built = sorted(p for p in OUT_DIR.glob("xianyu_slider_stealth.*.pyd")) + if not built: + built = sorted(p for p in OUT_DIR.glob("xianyu_slider_stealth.*.so")) + if not built: + print("✗ 未找到编译产物。请检查输出日志。") + return 2 + + print("\n✓ 编译产物:") + for p in built: + print(" -", p) + return 0 + + +def main(): + print("🔨 开始编译 xianyu_slider_stealth 模块...") + print("📁 项目目录:", Path.cwd()) + + if not SRC.exists(): + print(f"✗ 源文件不存在: {SRC}") + return 1 + + print(f"📄 源文件: {SRC}") + print(f"📂 输出目录: {OUT_DIR}") + + if not ensure_nuitka(): + return 2 + + # 检查是否以管理员身份运行(Windows) + import os + if os.name == 'nt': # Windows + try: + import ctypes + is_admin = ctypes.windll.shell32.IsUserAnAdmin() + if not is_admin: + print("⚠️ 建议以管理员身份运行此脚本以避免权限问题") + except: + pass + + return build() + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/config.py b/config.py index 2488bee..77310ec 100644 --- a/config.py +++ b/config.py @@ -93,10 +93,14 @@ COOKIES_STR = config.get('COOKIES.value', '') COOKIES_LAST_UPDATE = config.get('COOKIES.last_update_time', '') WEBSOCKET_URL = config.get('WEBSOCKET_URL', 'wss://wss-goofish.dingtalk.com/') HEARTBEAT_INTERVAL = config.get('HEARTBEAT_INTERVAL', 15) -HEARTBEAT_TIMEOUT = config.get('HEARTBEAT_TIMEOUT', 5) -TOKEN_REFRESH_INTERVAL = config.get('TOKEN_REFRESH_INTERVAL', 3600) -TOKEN_RETRY_INTERVAL = config.get('TOKEN_RETRY_INTERVAL', 300) +HEARTBEAT_TIMEOUT = config.get('HEARTBEAT_TIMEOUT', 30) +TOKEN_REFRESH_INTERVAL = config.get('TOKEN_REFRESH_INTERVAL', 72000) +TOKEN_RETRY_INTERVAL = config.get('TOKEN_RETRY_INTERVAL', 7200) MESSAGE_EXPIRE_TIME = config.get('MESSAGE_EXPIRE_TIME', 300000) +SLIDER_VERIFICATION = config.get('SLIDER_VERIFICATION', { + 'max_concurrent': 3, + 'wait_timeout': 60 +}) API_ENDPOINTS = config.get('API_ENDPOINTS', {}) DEFAULT_HEADERS = config.get('DEFAULT_HEADERS', {}) WEBSOCKET_HEADERS = config.get('WEBSOCKET_HEADERS', {}) @@ -118,4 +122,4 @@ if isinstance(_cookies_raw, list): else: # 兼容旧格式,仅有 value 字段 val = _cookies_raw.get('value') if isinstance(_cookies_raw, dict) else None - COOKIES_LIST = [{'id': 'default', 'value': val}] if val else [] \ No newline at end of file + COOKIES_LIST = [{'id': 'default', 'value': val}] if val else [] diff --git a/cookie_manager.py b/cookie_manager.py index f61736c..fd0bfb5 100644 --- a/cookie_manager.py +++ b/cookie_manager.py @@ -100,6 +100,15 @@ class CookieManager: task = self.tasks.pop(cookie_id, None) if task: task.cancel() + try: + # 等待任务完全清理,确保资源释放 + await task + except asyncio.CancelledError: + # 任务被取消是预期行为 + pass + except Exception as e: + logger.error(f"等待任务清理时出错: {cookie_id}, {e}") + self.cookies.pop(cookie_id, None) self.keywords.pop(cookie_id, None) # 从数据库删除 @@ -138,8 +147,14 @@ class CookieManager: return fut.result() # 更新 Cookie 值 - def update_cookie(self, cookie_id: str, new_value: str): - """替换指定账号的 Cookie 并重启任务""" + def update_cookie(self, cookie_id: str, new_value: str, save_to_db: bool = True): + """替换指定账号的 Cookie 并重启任务 + + Args: + cookie_id: Cookie ID + new_value: 新的Cookie值 + save_to_db: 是否保存到数据库(默认True)。当API层已经更新数据库时应设为False,避免覆盖其他字段 + """ async def _update(): # 获取原有的user_id和关键词 original_user_id = None @@ -160,10 +175,21 @@ class CookieManager: task = self.tasks.pop(cookie_id, None) if task: task.cancel() + try: + # 等待任务完全清理,确保资源释放 + await task + except asyncio.CancelledError: + # 任务被取消是预期行为 + pass + except Exception as e: + logger.error(f"等待任务清理时出错: {cookie_id}, {e}") - # 更新Cookie值(保持原有user_id,不删除关键词) + # 更新Cookie值 self.cookies[cookie_id] = new_value - db_manager.save_cookie(cookie_id, new_value, original_user_id) + + # 只有在需要时才保存到数据库(避免覆盖其他字段如pause_duration、remark等) + if save_to_db: + db_manager.save_cookie(cookie_id, new_value, original_user_id) # 恢复关键词和状态 self.keywords[cookie_id] = original_keywords @@ -268,13 +294,38 @@ class CookieManager: logger.warning(f"Cookie任务不存在,跳过停止: {cookie_id}") return + async def _stop_task_async(): + """异步停止任务并等待清理""" + try: + task = self.tasks[cookie_id] + if not task.done(): + task.cancel() + try: + # 等待任务完全清理,确保资源释放 + await task + except asyncio.CancelledError: + # 任务被取消是预期行为 + pass + except Exception as e: + logger.error(f"等待任务清理时出错: {cookie_id}, {e}") + logger.info(f"已取消Cookie任务: {cookie_id}") + del self.tasks[cookie_id] + logger.info(f"成功停止Cookie任务: {cookie_id}") + except Exception as e: + logger.error(f"停止Cookie任务失败: {cookie_id}, {e}") + try: - task = self.tasks[cookie_id] - if not task.done(): - task.cancel() - logger.info(f"已取消Cookie任务: {cookie_id}") - del self.tasks[cookie_id] - logger.info(f"成功停止Cookie任务: {cookie_id}") + # 在事件循环中执行异步停止 + if hasattr(self.loop, 'is_running') and self.loop.is_running(): + fut = asyncio.run_coroutine_threadsafe(_stop_task_async(), self.loop) + fut.result(timeout=10) # 等待最多10秒 + else: + logger.warning(f"事件循环未运行,无法正常等待任务清理: {cookie_id}") + # 直接取消任务(非最佳方案) + task = self.tasks[cookie_id] + if not task.done(): + task.cancel() + del self.tasks[cookie_id] except Exception as e: logger.error(f"停止Cookie任务失败: {cookie_id}, {e}") diff --git a/db_manager.py b/db_manager.py index b126772..e7a5ed9 100644 --- a/db_manager.py +++ b/db_manager.py @@ -115,6 +115,9 @@ class DBManager: auto_confirm INTEGER DEFAULT 1, remark TEXT DEFAULT '', pause_duration INTEGER DEFAULT 10, + username TEXT DEFAULT '', + password TEXT DEFAULT '', + show_browser INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) @@ -402,6 +405,22 @@ class DBManager: ) ''') + # 创建风控日志表 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS risk_control_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cookie_id TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT 'slider_captcha', + event_description TEXT, + processing_result TEXT, + processing_status TEXT DEFAULT 'processing', + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE + ) + ''') + # 插入默认系统设置(不包括管理员密码,由reply_server.py初始化) cursor.execute(''' INSERT OR IGNORE INTO system_settings (key, value, description) VALUES @@ -577,6 +596,13 @@ class DBManager: self.set_system_setting("db_version", "1.4", "数据库版本号") logger.info("数据库升级到版本1.4完成") + # 升级到版本1.5 - 为cookies表添加账号登录字段 + if current_version < "1.5": + logger.info("开始升级数据库到版本1.5...") + self.upgrade_cookies_table_for_account_login(cursor) + self.set_system_setting("db_version", "1.5", "数据库版本号") + logger.info("数据库升级到版本1.5完成") + # 迁移遗留数据(在所有版本升级完成后执行) self.migrate_legacy_data(cursor) @@ -889,6 +915,47 @@ class DBManager: logger.error(f"升级notification_channels表类型失败: {e}") raise + def upgrade_cookies_table_for_account_login(self, cursor): + """升级cookies表支持账号密码登录功能""" + try: + logger.info("开始为cookies表添加账号登录相关字段...") + + # 为cookies表添加username字段(如果不存在) + try: + self._execute_sql(cursor, "SELECT username FROM cookies LIMIT 1") + logger.info("cookies表username字段已存在") + except sqlite3.OperationalError: + # username字段不存在,需要添加 + self._execute_sql(cursor, "ALTER TABLE cookies ADD COLUMN username TEXT DEFAULT ''") + logger.info("为cookies表添加username字段") + + # 为cookies表添加password字段(如果不存在) + try: + self._execute_sql(cursor, "SELECT password FROM cookies LIMIT 1") + logger.info("cookies表password字段已存在") + except sqlite3.OperationalError: + # password字段不存在,需要添加 + self._execute_sql(cursor, "ALTER TABLE cookies ADD COLUMN password TEXT DEFAULT ''") + logger.info("为cookies表添加password字段") + + # 为cookies表添加show_browser字段(如果不存在) + try: + self._execute_sql(cursor, "SELECT show_browser FROM cookies LIMIT 1") + logger.info("cookies表show_browser字段已存在") + except sqlite3.OperationalError: + # show_browser字段不存在,需要添加 + self._execute_sql(cursor, "ALTER TABLE cookies ADD COLUMN show_browser INTEGER DEFAULT 0") + logger.info("为cookies表添加show_browser字段") + + logger.info("✅ cookies表账号登录字段升级完成") + logger.info(" - username: 用于密码登录的用户名") + logger.info(" - password: 用于密码登录的密码") + logger.info(" - show_browser: 登录时是否显示浏览器(0=隐藏,1=显示)") + return True + except Exception as e: + logger.error(f"升级cookies表账号登录字段失败: {e}") + raise + def migrate_legacy_data(self, cursor): """迁移遗留数据到新表结构""" try: @@ -1198,11 +1265,11 @@ class DBManager: return None def get_cookie_details(self, cookie_id: str) -> Optional[Dict[str, any]]: - """获取Cookie的详细信息,包括user_id、auto_confirm、remark和pause_duration""" + """获取Cookie的详细信息,包括user_id、auto_confirm、remark、pause_duration、username、password和show_browser""" with self.lock: try: cursor = self.conn.cursor() - self._execute_sql(cursor, "SELECT id, value, user_id, auto_confirm, remark, pause_duration, created_at FROM cookies WHERE id = ?", (cookie_id,)) + self._execute_sql(cursor, "SELECT id, value, user_id, auto_confirm, remark, pause_duration, username, password, show_browser, created_at FROM cookies WHERE id = ?", (cookie_id,)) result = cursor.fetchone() if result: return { @@ -1212,7 +1279,10 @@ class DBManager: 'auto_confirm': bool(result[3]), 'remark': result[4] or '', 'pause_duration': result[5] if result[5] is not None else 10, # 0是有效值,表示不暂停 - 'created_at': result[6] + 'username': result[6] or '', + 'password': result[7] or '', + 'show_browser': bool(result[8]) if result[8] is not None else False, + 'created_at': result[9] } return None except Exception as e: @@ -1280,6 +1350,47 @@ class DBManager: logger.error(f"获取账号自动回复暂停时间失败: {e}") return 10 + def update_cookie_account_info(self, cookie_id: str, cookie_value: str = None, username: str = None, password: str = None, show_browser: bool = None) -> bool: + """更新Cookie的账号信息(包括cookie值、用户名、密码和显示浏览器设置)""" + with self.lock: + try: + cursor = self.conn.cursor() + + # 构建动态SQL更新语句 + update_fields = [] + params = [] + + if cookie_value is not None: + update_fields.append("value = ?") + params.append(cookie_value) + + if username is not None: + update_fields.append("username = ?") + params.append(username) + + if password is not None: + update_fields.append("password = ?") + params.append(password) + + if show_browser is not None: + update_fields.append("show_browser = ?") + params.append(1 if show_browser else 0) + + if not update_fields: + logger.warning(f"更新账号 {cookie_id} 信息时没有提供任何更新字段") + return False + + params.append(cookie_id) + sql = f"UPDATE cookies SET {', '.join(update_fields)} WHERE id = ?" + + self._execute_sql(cursor, sql, tuple(params)) + self.conn.commit() + logger.info(f"更新账号 {cookie_id} 信息成功: {update_fields}") + return True + except Exception as e: + logger.error(f"更新账号信息失败: {e}") + return False + def get_auto_confirm(self, cookie_id: str) -> bool: """获取Cookie的自动确认发货设置""" with self.lock: @@ -4664,6 +4775,274 @@ class DBManager: return {"success_count": success_count, "failed_count": failed_count} + # ==================== 风控日志管理 ==================== + + def add_risk_control_log(self, cookie_id: str, event_type: str = 'slider_captcha', + event_description: str = None, processing_result: str = None, + processing_status: str = 'processing', error_message: str = None) -> bool: + """ + 添加风控日志记录 + + Args: + cookie_id: Cookie ID + event_type: 事件类型,默认为'slider_captcha' + event_description: 事件描述 + processing_result: 处理结果 + processing_status: 处理状态 ('processing', 'success', 'failed') + error_message: 错误信息 + + Returns: + bool: 添加成功返回True,失败返回False + """ + try: + with self.lock: + cursor = self.conn.cursor() + cursor.execute(''' + INSERT INTO risk_control_logs + (cookie_id, event_type, event_description, processing_result, processing_status, error_message) + VALUES (?, ?, ?, ?, ?, ?) + ''', (cookie_id, event_type, event_description, processing_result, processing_status, error_message)) + self.conn.commit() + return True + except Exception as e: + logger.error(f"添加风控日志失败: {e}") + return False + + def update_risk_control_log(self, log_id: int, processing_result: str = None, + processing_status: str = None, error_message: str = None) -> bool: + """ + 更新风控日志记录 + + Args: + log_id: 日志ID + processing_result: 处理结果 + processing_status: 处理状态 + error_message: 错误信息 + + Returns: + bool: 更新成功返回True,失败返回False + """ + try: + with self.lock: + cursor = self.conn.cursor() + + # 构建更新语句 + update_fields = [] + params = [] + + if processing_result is not None: + update_fields.append("processing_result = ?") + params.append(processing_result) + + if processing_status is not None: + update_fields.append("processing_status = ?") + params.append(processing_status) + + if error_message is not None: + update_fields.append("error_message = ?") + params.append(error_message) + + if update_fields: + update_fields.append("updated_at = CURRENT_TIMESTAMP") + params.append(log_id) + + sql = f"UPDATE risk_control_logs SET {', '.join(update_fields)} WHERE id = ?" + cursor.execute(sql, params) + self.conn.commit() + return cursor.rowcount > 0 + + return False + except Exception as e: + logger.error(f"更新风控日志失败: {e}") + return False + + def get_risk_control_logs(self, cookie_id: str = None, limit: int = 100, offset: int = 0) -> List[Dict]: + """ + 获取风控日志列表 + + Args: + cookie_id: Cookie ID,为None时获取所有日志 + limit: 限制返回数量 + offset: 偏移量 + + Returns: + List[Dict]: 风控日志列表 + """ + try: + with self.lock: + cursor = self.conn.cursor() + + if cookie_id: + cursor.execute(''' + SELECT r.*, c.id as cookie_name + FROM risk_control_logs r + LEFT JOIN cookies c ON r.cookie_id = c.id + WHERE r.cookie_id = ? + ORDER BY r.created_at DESC + LIMIT ? OFFSET ? + ''', (cookie_id, limit, offset)) + else: + cursor.execute(''' + SELECT r.*, c.id as cookie_name + FROM risk_control_logs r + LEFT JOIN cookies c ON r.cookie_id = c.id + ORDER BY r.created_at DESC + LIMIT ? OFFSET ? + ''', (limit, offset)) + + columns = [description[0] for description in cursor.description] + logs = [] + + for row in cursor.fetchall(): + log_info = dict(zip(columns, row)) + logs.append(log_info) + + return logs + except Exception as e: + logger.error(f"获取风控日志失败: {e}") + return [] + + def get_risk_control_logs_count(self, cookie_id: str = None) -> int: + """ + 获取风控日志总数 + + Args: + cookie_id: Cookie ID,为None时获取所有日志数量 + + Returns: + int: 日志总数 + """ + try: + with self.lock: + cursor = self.conn.cursor() + + if cookie_id: + cursor.execute('SELECT COUNT(*) FROM risk_control_logs WHERE cookie_id = ?', (cookie_id,)) + else: + cursor.execute('SELECT COUNT(*) FROM risk_control_logs') + + return cursor.fetchone()[0] + except Exception as e: + logger.error(f"获取风控日志数量失败: {e}") + return 0 + + def delete_risk_control_log(self, log_id: int) -> bool: + """ + 删除风控日志记录 + + Args: + log_id: 日志ID + + Returns: + bool: 删除成功返回True,失败返回False + """ + try: + with self.lock: + cursor = self.conn.cursor() + cursor.execute('DELETE FROM risk_control_logs WHERE id = ?', (log_id,)) + self.conn.commit() + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"删除风控日志失败: {e}") + return False + + def cleanup_old_data(self, days: int = 90) -> dict: + """清理过期的历史数据,防止数据库无限增长 + + Args: + days: 保留最近N天的数据,默认90天 + + Returns: + 清理统计信息 + """ + try: + with self.lock: + cursor = self.conn.cursor() + stats = {} + + # 清理AI对话历史(保留最近90天) + try: + cursor.execute( + "DELETE FROM ai_conversations WHERE created_at < datetime('now', '-' || ? || ' days')", + (days,) + ) + stats['ai_conversations'] = cursor.rowcount + if cursor.rowcount > 0: + logger.info(f"清理了 {cursor.rowcount} 条过期的AI对话记录({days}天前)") + except Exception as e: + logger.warning(f"清理AI对话历史失败: {e}") + stats['ai_conversations'] = 0 + + # 清理风控日志(保留最近90天) + try: + cursor.execute( + "DELETE FROM risk_control_logs WHERE created_at < datetime('now', '-' || ? || ' days')", + (days,) + ) + stats['risk_control_logs'] = cursor.rowcount + if cursor.rowcount > 0: + logger.info(f"清理了 {cursor.rowcount} 条过期的风控日志({days}天前)") + except Exception as e: + logger.warning(f"清理风控日志失败: {e}") + stats['risk_control_logs'] = 0 + + # 清理AI商品缓存(保留最近30天) + cache_days = min(days, 30) # AI商品缓存最多保留30天 + try: + cursor.execute( + "DELETE FROM ai_item_cache WHERE last_updated < datetime('now', '-' || ? || ' days')", + (cache_days,) + ) + stats['ai_item_cache'] = cursor.rowcount + if cursor.rowcount > 0: + logger.info(f"清理了 {cursor.rowcount} 条过期的AI商品缓存({cache_days}天前)") + except Exception as e: + logger.warning(f"清理AI商品缓存失败: {e}") + stats['ai_item_cache'] = 0 + + # 清理验证码记录(保留最近1天) + try: + cursor.execute( + "DELETE FROM captcha_codes WHERE created_at < datetime('now', '-1 day')" + ) + stats['captcha_codes'] = cursor.rowcount + if cursor.rowcount > 0: + logger.info(f"清理了 {cursor.rowcount} 条过期的验证码记录") + except Exception as e: + logger.warning(f"清理验证码记录失败: {e}") + stats['captcha_codes'] = 0 + + # 清理邮箱验证记录(保留最近7天) + try: + cursor.execute( + "DELETE FROM email_verifications WHERE created_at < datetime('now', '-7 days')" + ) + stats['email_verifications'] = cursor.rowcount + if cursor.rowcount > 0: + logger.info(f"清理了 {cursor.rowcount} 条过期的邮箱验证记录") + except Exception as e: + logger.warning(f"清理邮箱验证记录失败: {e}") + stats['email_verifications'] = 0 + + # 提交更改 + self.conn.commit() + + # 执行VACUUM以释放磁盘空间(仅当清理了大量数据时) + total_cleaned = sum(stats.values()) + if total_cleaned > 100: + logger.info(f"共清理了 {total_cleaned} 条记录,执行VACUUM以释放磁盘空间...") + cursor.execute("VACUUM") + logger.info("VACUUM执行完成") + stats['vacuum_executed'] = True + else: + stats['vacuum_executed'] = False + + stats['total_cleaned'] = total_cleaned + return stats + + except Exception as e: + logger.error(f"清理历史数据时出错: {e}") + return {'error': str(e)} # 全局单例 diff --git a/docker-compose-cn.yml b/docker-compose-cn.yml index 9d46c6d..c675bc8 100644 --- a/docker-compose-cn.yml +++ b/docker-compose-cn.yml @@ -70,11 +70,11 @@ services: deploy: resources: limits: - memory: ${MEMORY_LIMIT:-512}M - cpus: '${CPU_LIMIT:-0.5}' + memory: ${MEMORY_LIMIT:-2048}M + cpus: '${CPU_LIMIT:-2.0}' reservations: - memory: ${MEMORY_RESERVATION:-256}M - cpus: '${CPU_RESERVATION:-0.25}' + memory: ${MEMORY_RESERVATION:-512}M + cpus: '${CPU_RESERVATION:-0.5}' # 可选:添加Nginx反向代理 nginx: diff --git a/docker-compose.yml b/docker-compose.yml index f45795c..91fbb62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,11 +70,11 @@ services: deploy: resources: limits: - memory: ${MEMORY_LIMIT:-512}M - cpus: '${CPU_LIMIT:-0.5}' + memory: ${MEMORY_LIMIT:-2048}M + cpus: '${CPU_LIMIT:-2.0}' reservations: - memory: ${MEMORY_RESERVATION:-256}M - cpus: '${CPU_RESERVATION:-0.25}' + memory: ${MEMORY_RESERVATION:-512}M + cpus: '${CPU_RESERVATION:-0.5}' # 可选:添加Nginx反向代理 nginx: diff --git a/entrypoint.sh b/entrypoint.sh index a2867f6..916f17d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,12 +1,65 @@ #!/bin/bash +set -e -echo "Starting xianyu-auto-reply system..." +echo "========================================" +echo " 闲鱼自动回复系统 - 启动中..." +echo "========================================" -# Create necessary directories +# 显示环境信息 +echo "环境信息:" +echo " - Python版本: $(python --version)" +echo " - 工作目录: $(pwd)" +echo " - 时区: ${TZ:-未设置}" +echo " - 数据库路径: ${DB_PATH:-/app/data/xianyu_data.db}" +echo " - 日志级别: ${LOG_LEVEL:-INFO}" + +# 禁用 core dumps 防止生成 core 文件 +ulimit -c 0 +echo "✓ 已禁用 core dumps" + +# 创建必要的目录 +echo "创建必要的目录..." mkdir -p /app/data /app/logs /app/backups /app/static/uploads/images +mkdir -p /app/trajectory_history +echo "✓ 目录创建完成" -# Set permissions +# 设置目录权限 +echo "设置目录权限..." chmod 777 /app/data /app/logs /app/backups /app/static/uploads /app/static/uploads/images +chmod 777 /app/trajectory_history 2>/dev/null || true +echo "✓ 权限设置完成" -# Start the application +# 检查关键文件 +echo "检查关键文件..." +if [ ! -f "/app/global_config.yml" ]; then + echo "⚠ 警告: 全局配置文件不存在,将使用默认配置" +fi + +if [ ! -f "/app/Start.py" ]; then + echo "✗ 错误: Start.py 文件不存在!" + exit 1 +fi +echo "✓ 关键文件检查完成" + +# 检查 Python 依赖 +echo "检查 Python 依赖..." +python -c "import fastapi, uvicorn, loguru, websockets" 2>/dev/null || { + echo "⚠ 警告: 部分 Python 依赖可能未正确安装" +} +echo "✓ Python 依赖检查完成" + +# 显示启动信息 +echo "========================================" +echo " 系统启动参数:" +echo " - API端口: ${API_PORT:-8080}" +echo " - API主机: ${API_HOST:-0.0.0.0}" +echo " - Debug模式: ${DEBUG:-false}" +echo " - 自动重载: ${RELOAD:-false}" +echo "========================================" + +# 启动应用 +echo "正在启动应用..." +echo "" + +# 使用 exec 替换当前 shell,这样 Python 进程可以接收信号 exec python Start.py diff --git a/file_log_collector.py b/file_log_collector.py index 7dd51e7..6ababa4 100644 --- a/file_log_collector.py +++ b/file_log_collector.py @@ -63,9 +63,9 @@ class FileLogCollector: logger.add( self.log_file, format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {name}:{function}:{line} - {message}", - level="DEBUG", + level="INFO", # 从DEBUG改为INFO,减少日志量 rotation="10 MB", - retention="7 days", + retention="3 days", # 从7天改为3天,减少磁盘占用 enqueue=False, # 改为False,避免队列延迟 buffering=1 # 行缓冲,立即写入 ) diff --git a/global_config.yml b/global_config.yml index 0ab588d..b872db8 100644 --- a/global_config.yml +++ b/global_config.yml @@ -58,8 +58,11 @@ MANUAL_MODE: timeout: 3600 toggle_keywords: [] MESSAGE_EXPIRE_TIME: 300000 -TOKEN_REFRESH_INTERVAL: 72000 # 从3600秒(1小时)增加到72000秒(20小时) -TOKEN_RETRY_INTERVAL: 7200 # 从300秒(5分钟)增加到7200秒(2小时) +TOKEN_REFRESH_INTERVAL: 3600 # 从3600秒(1小时)增加到72000秒(20小时) +TOKEN_RETRY_INTERVAL: 600 # 从300秒(5分钟)增加到7200秒(2小时) +SLIDER_VERIFICATION: + max_concurrent: 3 # 滑块验证最大并发数 + wait_timeout: 60 # 等待排队超时时间(秒) WEBSOCKET_HEADERS: Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9 diff --git a/order_status_handler.py b/order_status_handler.py new file mode 100644 index 0000000..6898ca3 --- /dev/null +++ b/order_status_handler.py @@ -0,0 +1,1047 @@ +""" +订单状态处理器 +专门处理订单状态更新逻辑,用于更新订单管理中的状态 +""" + +import re +import json +import time +import uuid +import threading +import asyncio +from loguru import logger +from typing import Optional, Dict, Any + +# ==================== 订单状态处理器配置 ==================== +# 订单状态处理器配置 +ORDER_STATUS_HANDLER_CONFIG = { + 'use_pending_queue': True, # 是否使用待处理队列 + 'strict_validation': True, # 是否启用严格的状态转换验证 + 'log_level': 'info', # 日志级别 (debug/info/warning/error) + 'max_pending_age_hours': 24, # 待处理更新的最大保留时间(小时) + 'enable_status_logging': True, # 是否启用详细的状态变更日志 +} + + +class OrderStatusHandler: + """订单状态处理器""" + + # 状态转换规则常量 + # 规则说明: + # 1. 已付款的订单和已完成的订单不能回退到处理中 + # 2. 已付款的订单和已完成的订单可以设置为已关闭(因为会出现退款) + # 3. 退款中的订单或者退货中的订单设置为退款中 + # 4. 退款中的订单可以设置为已完成(因为买家可能取消退款) + # 5. 只有退款完成才设置为已关闭 + VALID_TRANSITIONS = { + 'processing': ['pending_ship', 'shipped', 'completed', 'cancelled'], + 'pending_ship': ['shipped', 'completed', 'cancelled', 'refunding'], # 已付款,可以退款 + 'shipped': ['completed', 'cancelled', 'refunding'], # 已发货,可以退款 + 'completed': ['cancelled', 'refunding'], # 已完成,可以退款 + 'refunding': ['completed', 'cancelled', 'refund_cancelled'], # 退款中,可以完成(取消退款)、关闭(退款完成)或撤销 + 'refund_cancelled': [], # 退款撤销(临时状态,会立即回退到上一次状态) + 'cancelled': [] # 已关闭,不能转换到其他状态 + } + + def __init__(self): + """初始化订单状态处理器""" + # 加载配置 + self.config = ORDER_STATUS_HANDLER_CONFIG + + self.status_mapping = { + 'processing': '处理中', # 初始状态/基本信息阶段 + 'pending_ship': '待发货', # 已付款,等待发货 + 'shipped': '已发货', # 发货确认后 + 'completed': '已完成', # 交易完成 + 'refunding': '退款中', # 退款中/退货中 + 'refund_cancelled': '退款撤销', # 退款撤销(临时状态,会回退) + 'cancelled': '已关闭', # 交易关闭 + } + + # 待处理的订单状态更新队列 {order_id: [update_info, ...]} + self.pending_updates = {} + # 待处理的系统消息队列(用于延迟处理){cookie_id: [message_info, ...]} + self._pending_system_messages = {} + # 待处理的红色提醒消息队列(用于延迟处理){cookie_id: [message_info, ...]} + self._pending_red_reminder_messages = {} + + # 订单状态历史记录 {order_id: [status_history, ...]} + # 用于退款撤销时回退到上一次状态 + self._order_status_history = {} + + # 使用threading.RLock保护并发访问 + # 注意:虽然在async环境中asyncio.Lock更理想,但本类的所有方法都是同步的 + # 且被同步代码调用,因此保持使用threading.RLock是合适的 + self._lock = threading.RLock() + + # 设置日志级别 + log_level = self.config.get('log_level', 'info') + logger.info(f"订单状态处理器初始化完成,配置: {self.config}") + + def extract_order_id(self, message: dict) -> Optional[str]: + """从消息中提取订单ID""" + try: + order_id = None + + # 先查看消息的完整结构 + logger.info(f"🔍 完整消息结构: {message}") + + # 检查message['1']的结构,处理可能是列表、字典或字符串的情况 + message_1 = message.get('1', {}) + content_json_str = '' + + if isinstance(message_1, dict): + logger.info(f"🔍 message['1'] 是字典,keys: {list(message_1.keys())}") + + # 检查message['1']['6']的结构 + message_1_6 = message_1.get('6', {}) + if isinstance(message_1_6, dict): + logger.info(f"🔍 message['1']['6'] 是字典,keys: {list(message_1_6.keys())}") + # 方法1: 从button的targetUrl中提取orderId + content_json_str = message_1_6.get('3', {}).get('5', '') if isinstance(message_1_6.get('3', {}), dict) else '' + else: + logger.info(f"🔍 message['1']['6'] 不是字典: {type(message_1_6)}") + + elif isinstance(message_1, list): + logger.info(f"🔍 message['1'] 是列表,长度: {len(message_1)}") + # 如果message['1']是列表,跳过这种提取方式 + + elif isinstance(message_1, str): + logger.info(f"🔍 message['1'] 是字符串,长度: {len(message_1)}") + # 如果message['1']是字符串,跳过这种提取方式 + + else: + logger.info(f"🔍 message['1'] 未知类型: {type(message_1)}") + # 其他类型,跳过这种提取方式 + + if content_json_str: + try: + content_data = json.loads(content_json_str) + + # 方法1a: 从button的targetUrl中提取orderId + target_url = content_data.get('dxCard', {}).get('item', {}).get('main', {}).get('exContent', {}).get('button', {}).get('targetUrl', '') + if target_url: + # 从URL中提取orderId参数 + order_match = re.search(r'orderId=(\d+)', target_url) + if order_match: + order_id = order_match.group(1) + logger.info(f'✅ 从button提取到订单ID: {order_id}') + + # 方法1b: 从main的targetUrl中提取order_detail的id + if not order_id: + main_target_url = content_data.get('dxCard', {}).get('item', {}).get('main', {}).get('targetUrl', '') + if main_target_url: + order_match = re.search(r'order_detail\?id=(\d+)', main_target_url) + if order_match: + order_id = order_match.group(1) + logger.info(f'✅ 从main targetUrl提取到订单ID: {order_id}') + + except Exception as parse_e: + logger.error(f"解析内容JSON失败: {parse_e}") + + # 方法2: 从dynamicOperation中的order_detail URL提取orderId + if not order_id and content_json_str: + try: + content_data = json.loads(content_json_str) + dynamic_target_url = content_data.get('dynamicOperation', {}).get('changeContent', {}).get('dxCard', {}).get('item', {}).get('main', {}).get('exContent', {}).get('button', {}).get('targetUrl', '') + if dynamic_target_url: + # 从order_detail URL中提取id参数 + order_match = re.search(r'order_detail\?id=(\d+)', dynamic_target_url) + if order_match: + order_id = order_match.group(1) + logger.info(f'✅ 从order_detail提取到订单ID: {order_id}') + except Exception as parse_e: + logger.error(f"解析dynamicOperation JSON失败: {parse_e}") + + # 方法3: 如果前面的方法都失败,尝试在整个消息中搜索订单ID模式 + if not order_id: + try: + # 将整个消息转换为字符串进行搜索 + message_str = str(message) + + # 搜索各种可能的订单ID模式 + patterns = [ + r'orderId[=:](\d{10,})', # orderId=123456789 或 orderId:123456789 + r'order_detail\?id=(\d{10,})', # order_detail?id=123456789 + r'"id"\s*:\s*"?(\d{10,})"?', # "id":"123456789" 或 "id":123456789 + r'bizOrderId[=:](\d{10,})', # bizOrderId=123456789 + ] + + for pattern in patterns: + matches = re.findall(pattern, message_str) + if matches: + # 取第一个匹配的订单ID + order_id = matches[0] + logger.info(f'✅ 从消息字符串中提取到订单ID: {order_id} (模式: {pattern})') + break + + except Exception as search_e: + logger.error(f"在消息字符串中搜索订单ID失败: {search_e}") + + if order_id: + logger.info(f'🎯 最终提取到订单ID: {order_id}') + else: + logger.error(f'❌ 未能从消息中提取到订单ID') + + return order_id + + except Exception as e: + logger.error(f"提取订单ID失败: {str(e)}") + return None + + def update_order_status(self, order_id: str, new_status: str, cookie_id: str, context: str = "") -> bool: + """更新订单状态到数据库 + + Args: + order_id: 订单ID + new_status: 新状态 (processing/pending_ship/shipped/completed/cancelled) + cookie_id: Cookie ID + context: 上下文信息,用于日志记录 + + Returns: + bool: 更新是否成功 + """ + logger.info(f"🔄 订单状态处理器.update_order_status开始: order_id={order_id}, new_status={new_status}, cookie_id={cookie_id}, context={context}") + with self._lock: + try: + from db_manager import db_manager + + # 验证状态值是否有效 + if new_status not in self.status_mapping: + logger.error(f"❌ 无效的订单状态: {new_status},有效状态: {list(self.status_mapping.keys())}") + return False + + logger.info(f"✅ 订单状态验证通过: {new_status}") + + # 检查订单是否存在于数据库中(带重试机制) + current_order = None + max_retries = 3 + for attempt in range(max_retries): + try: + logger.info(f"🔍 尝试获取订单信息 (尝试 {attempt + 1}/{max_retries}): {order_id}") + current_order = db_manager.get_order_by_id(order_id) + logger.info(f"✅ 订单信息获取成功: {order_id}") + break + except Exception as db_e: + if attempt == max_retries - 1: + logger.error(f"❌ 获取订单信息失败 (尝试 {attempt + 1}/{max_retries}): {str(db_e)}") + return False + else: + logger.error(f"⚠️ 获取订单信息失败,重试中 (尝试 {attempt + 1}/{max_retries}): {str(db_e)}") + time.sleep(0.1 * (attempt + 1)) # 递增延迟 + + if not current_order: + # 订单不存在,根据配置决定是否添加到待处理队列 + logger.info(f"⚠️ 订单 {order_id} 不存在于数据库中") + if self.config.get('use_pending_queue', True): + logger.info(f"📝 订单 {order_id} 不存在于数据库中,添加到待处理队列等待主程序拉取订单详情") + self._add_to_pending_updates(order_id, new_status, cookie_id, context) + else: + logger.error(f"❌ 订单 {order_id} 不存在于数据库中且未启用待处理队列,跳过状态更新") + return False + + current_status = current_order.get('order_status', 'processing') + logger.info(f"📊 当前订单状态: {current_status}, 目标状态: {new_status}") + + # 检查是否是相同的状态更新(避免重复处理) + if current_status == new_status: + status_text = self.status_mapping.get(new_status, new_status) + logger.info(f"⏭️ 订单 {order_id} 状态无变化,跳过重复更新: {status_text}") + return True # 返回True表示"成功",避免重复日志 + + # 检查状态转换是否合理(根据配置决定是否启用严格验证) + if self.config.get('strict_validation', True) and not self._is_valid_status_transition(current_status, new_status): + logger.error(f"❌ 订单 {order_id} 状态转换不合理: {current_status} -> {new_status} (严格验证已启用)") + logger.error(f"当前状态 '{current_status}' 允许转换到: {self._get_allowed_transitions(current_status)}") + return False + + logger.info(f"✅ 状态转换验证通过: {current_status} -> {new_status}") + + # 处理退款撤销的特殊逻辑 + if new_status == 'refund_cancelled': + # 从历史记录中获取上一次状态 + previous_status = self._get_previous_status(order_id) + if previous_status: + logger.info(f"🔄 退款撤销,回退到上一次状态: {previous_status}") + new_status = previous_status + else: + logger.warning(f"⚠️ 退款撤销但无法获取上一次状态,保持当前状态: {current_status}") + new_status = current_status + + # 更新订单状态(带重试机制) + success = False + for attempt in range(max_retries): + try: + logger.info(f"💾 尝试更新订单状态 (尝试 {attempt + 1}/{max_retries}): {order_id}") + success = db_manager.insert_or_update_order( + order_id=order_id, + order_status=new_status, + cookie_id=cookie_id + ) + logger.info(f"✅ 订单状态更新成功: {order_id}") + break + except Exception as db_e: + if attempt == max_retries - 1: + logger.error(f"❌ 更新订单状态失败 (尝试 {attempt + 1}/{max_retries}): {str(db_e)}") + return False + else: + logger.error(f"⚠️ 更新订单状态失败,重试中 (尝试 {attempt + 1}/{max_retries}): {str(db_e)}") + time.sleep(0.1 * (attempt + 1)) # 递增延迟 + + if success: + # 记录状态历史(用于退款撤销时回退) + self._record_status_history(order_id, current_status, new_status, context) + + status_text = self.status_mapping.get(new_status, new_status) + if self.config.get('enable_status_logging', True): + logger.info(f"✅ 订单状态更新成功: {order_id} -> {status_text} ({context})") + else: + logger.error(f"❌ 订单状态更新失败: {order_id} -> {new_status} ({context})") + + return success + + except Exception as e: + logger.error(f"更新订单状态时出错: {str(e)}") + import traceback + logger.error(f"详细错误信息: {traceback.format_exc()}") + return False + + def _is_valid_status_transition(self, current_status: str, new_status: str) -> bool: + """检查状态转换是否合理 + + Args: + current_status: 当前状态 + new_status: 新状态 + + Returns: + bool: 转换是否合理 + """ + # 如果当前状态不在规则中,允许转换(兼容性) + if current_status not in self.VALID_TRANSITIONS: + return True + + # 特殊规则:已付款的订单和已完成的订单不能回退到处理中 + if new_status == 'processing' and current_status in ['pending_ship', 'shipped', 'completed', 'refunding', 'refund_cancelled']: + logger.warning(f"❌ 状态转换被拒绝:{current_status} -> {new_status} (已付款/已完成的订单不能回退到处理中)") + return False + + # 检查新状态是否在允许的转换列表中 + allowed_statuses = self.VALID_TRANSITIONS.get(current_status, []) + return new_status in allowed_statuses + + def _get_allowed_transitions(self, current_status: str) -> list: + """获取当前状态允许转换到的状态列表 + + Args: + current_status: 当前状态 + + Returns: + list: 允许转换到的状态列表 + """ + if current_status not in self.VALID_TRANSITIONS: + return ['所有状态'] # 兼容性 + + return self.VALID_TRANSITIONS.get(current_status, []) + + def _check_refund_message(self, message: dict, send_message: str) -> Optional[str]: + """检查退款申请消息,需要同时识别标题和按钮文本 + + Args: + message: 原始消息数据 + send_message: 消息内容 + + Returns: + str: 对应的状态,如果不是退款消息则返回None + """ + try: + # 检查消息结构,寻找退款相关的信息 + message_1 = message.get('1', {}) + if not isinstance(message_1, dict): + return None + + # 检查消息卡片内容 + message_1_6 = message_1.get('6', {}) + if not isinstance(message_1_6, dict): + return None + + # 解析JSON内容 + content_json_str = message_1_6.get('3', {}).get('5', '') if isinstance(message_1_6.get('3', {}), dict) else '' + if not content_json_str: + return None + + try: + content_data = json.loads(content_json_str) + + # 检查dynamicOperation中的内容 + dynamic_content = content_data.get('dynamicOperation', {}).get('changeContent', {}) + if not dynamic_content: + return None + + dx_card = dynamic_content.get('dxCard', {}).get('item', {}).get('main', {}) + if not dx_card: + return None + + ex_content = dx_card.get('exContent', {}) + if not ex_content: + return None + + # 获取标题和按钮文本 + title = ex_content.get('title', '') + button_text = ex_content.get('button', {}).get('text', '') + + logger.info(f"🔍 检查退款消息 - 标题: '{title}', 按钮: '{button_text}'") + + # 检查是否是退款申请且已同意 + if title == '我发起了退款申请' and button_text == '已同意': + logger.info(f"✅ 识别到退款申请已同意消息") + return 'refunding' + + # 检查是否是退款撤销(买家主动撤销) + if title == '我发起了退款申请' and button_text == '已撤销': + logger.info(f"✅ 识别到退款撤销消息") + return 'refund_cancelled' + + # 退款申请被拒绝不需要改变状态,因为没同意 + # if title == '我发起了退款申请' and button_text == '已拒绝': + # logger.info(f"ℹ️ 识别到退款申请被拒绝消息,不改变订单状态") + # return None + + except Exception as parse_e: + logger.debug(f"解析退款消息JSON失败: {parse_e}") + return None + + return None + + except Exception as e: + logger.debug(f"检查退款消息失败: {e}") + return None + + def _record_status_history(self, order_id: str, from_status: str, to_status: str, context: str): + """记录订单状态历史 + + Args: + order_id: 订单ID + from_status: 原状态 + to_status: 新状态 + context: 上下文信息 + """ + with self._lock: + if order_id not in self._order_status_history: + self._order_status_history[order_id] = [] + + # 只记录非临时状态的历史(排除 refund_cancelled) + if to_status != 'refund_cancelled': + history_entry = { + 'from_status': from_status, + 'to_status': to_status, + 'context': context, + 'timestamp': time.time() + } + self._order_status_history[order_id].append(history_entry) + + # 限制历史记录数量,只保留最近10条 + if len(self._order_status_history[order_id]) > 10: + self._order_status_history[order_id] = self._order_status_history[order_id][-10:] + + logger.debug(f"📝 记录订单状态历史: {order_id} {from_status} -> {to_status}") + + def _get_previous_status(self, order_id: str) -> Optional[str]: + """获取订单的上一次状态(用于退款撤销时回退) + + Args: + order_id: 订单ID + + Returns: + str: 上一次状态,如果没有历史记录则返回None + """ + with self._lock: + if order_id not in self._order_status_history or not self._order_status_history[order_id]: + return None + + # 获取最后一次状态变化的目标状态 + last_entry = self._order_status_history[order_id][-1] + return last_entry['to_status'] + + def _add_to_pending_updates(self, order_id: str, new_status: str, cookie_id: str, context: str): + """添加到待处理更新队列 + + Args: + order_id: 订单ID + new_status: 新状态 + cookie_id: Cookie ID + context: 上下文信息 + """ + with self._lock: + if order_id not in self.pending_updates: + self.pending_updates[order_id] = [] + + update_info = { + 'new_status': new_status, + 'cookie_id': cookie_id, + 'context': context, + 'timestamp': time.time() + } + + self.pending_updates[order_id].append(update_info) + logger.info(f"订单 {order_id} 状态更新已添加到待处理队列: {new_status} ({context})") + + def process_pending_updates(self, order_id: str) -> bool: + """处理指定订单的待处理更新 + + Args: + order_id: 订单ID + + Returns: + bool: 是否有更新被处理 + """ + with self._lock: + if order_id not in self.pending_updates: + return False + + updates = self.pending_updates.pop(order_id) + processed_count = 0 + + for update_info in updates: + try: + success = self.update_order_status( + order_id=order_id, + new_status=update_info['new_status'], + cookie_id=update_info['cookie_id'], + context=f"待处理队列: {update_info['context']}" + ) + + if success: + processed_count += 1 + logger.info(f"处理待处理更新成功: 订单 {order_id} -> {update_info['new_status']}") + else: + logger.error(f"处理待处理更新失败: 订单 {order_id} -> {update_info['new_status']}") + + except Exception as e: + logger.error(f"处理待处理更新时出错: {str(e)}") + + if processed_count > 0: + logger.info(f"订单 {order_id} 共处理了 {processed_count} 个待处理状态更新") + + return processed_count > 0 + + def process_all_pending_updates(self) -> int: + """处理所有待处理的更新 + + Returns: + int: 处理的订单数量 + """ + with self._lock: + if not self.pending_updates: + return 0 + + order_ids = list(self.pending_updates.keys()) + processed_orders = 0 + + for order_id in order_ids: + if self.process_pending_updates(order_id): + processed_orders += 1 + + return processed_orders + + def get_pending_updates_count(self) -> int: + """获取待处理更新的数量 + + Returns: + int: 待处理更新的数量 + """ + with self._lock: + return len(self.pending_updates) + + def clear_old_pending_updates(self, max_age_hours: int = None): + """清理过期的待处理更新 + + Args: + max_age_hours: 最大保留时间(小时),如果为None则使用配置中的默认值 + """ + # 检查是否启用待处理队列 + if not self.config.get('use_pending_queue', True): + logger.error("未启用待处理队列,跳过清理操作") + return + + if max_age_hours is None: + max_age_hours = self.config.get('max_pending_age_hours', 24) + + current_time = time.time() + max_age_seconds = max_age_hours * 3600 + + with self._lock: + # 清理 pending_updates + expired_orders = [] + for order_id, updates in self.pending_updates.items(): + # 过滤掉过期的更新 + valid_updates = [ + update for update in updates + if current_time - update['timestamp'] < max_age_seconds + ] + + if not valid_updates: + expired_orders.append(order_id) + else: + self.pending_updates[order_id] = valid_updates + + # 移除完全过期的订单 + for order_id in expired_orders: + del self.pending_updates[order_id] + logger.info(f"清理过期的待处理更新: 订单 {order_id}") + + if expired_orders: + logger.info(f"共清理了 {len(expired_orders)} 个过期的待处理订单更新") + + # 清理 _pending_system_messages + expired_cookies_system = [] + for cookie_id, messages in self._pending_system_messages.items(): + valid_messages = [ + msg for msg in messages + if current_time - msg.get('timestamp', 0) < max_age_seconds + ] + + if not valid_messages: + expired_cookies_system.append(cookie_id) + else: + self._pending_system_messages[cookie_id] = valid_messages + + for cookie_id in expired_cookies_system: + del self._pending_system_messages[cookie_id] + logger.info(f"清理过期的待处理系统消息: 账号 {cookie_id}") + + # 清理 _pending_red_reminder_messages + expired_cookies_red = [] + for cookie_id, messages in self._pending_red_reminder_messages.items(): + valid_messages = [ + msg for msg in messages + if current_time - msg.get('timestamp', 0) < max_age_seconds + ] + + if not valid_messages: + expired_cookies_red.append(cookie_id) + else: + self._pending_red_reminder_messages[cookie_id] = valid_messages + + for cookie_id in expired_cookies_red: + del self._pending_red_reminder_messages[cookie_id] + logger.info(f"清理过期的待处理红色提醒消息: 账号 {cookie_id}") + + total_cleared = len(expired_orders) + len(expired_cookies_system) + len(expired_cookies_red) + if total_cleared > 0: + logger.info(f"内存清理完成,共清理了 {total_cleared} 个过期项目") + + def handle_system_message(self, message: dict, send_message: str, cookie_id: str, msg_time: str) -> bool: + """处理系统消息并更新订单状态 + + Args: + message: 原始消息数据 + send_message: 消息内容 + cookie_id: Cookie ID + msg_time: 消息时间 + + Returns: + bool: 是否处理了订单状态更新 + """ + try: + # 定义消息类型与状态的映射 + message_status_mapping = { + '[买家确认收货,交易成功]': 'completed', + '[你已确认收货,交易成功]': 'completed', # 已完成 + '[你已发货]': 'shipped', # 已发货 + '你已发货': 'shipped', # 已发货(无方括号) + '[你已发货,请等待买家确认收货]': 'shipped', # 已发货(完整格式) + '[我已付款,等待你发货]': 'pending_ship', # 已付款,等待发货 + '[我已拍下,待付款]': 'processing', # 已拍下,待付款 + '[买家已付款]': 'pending_ship', # 买家已付款 + '[付款完成]': 'pending_ship', # 付款完成 + '[已付款,待发货]': 'pending_ship', # 已付款,待发货 + '[退款成功,钱款已原路退返]': 'cancelled', # 退款成功,设置为已关闭 + '[你关闭了订单,钱款已原路退返]': 'cancelled', # 卖家关闭订单,设置为已关闭 + } + + # 特殊处理:检查退款申请消息(需要同时识别标题和按钮文本) + refund_status = self._check_refund_message(message, send_message) + if refund_status: + new_status = refund_status + elif send_message in message_status_mapping: + new_status = message_status_mapping[send_message] + else: + return False + + # 提取订单ID + order_id = self.extract_order_id(message) + if not order_id: + # 如果无法提取订单ID,根据配置决定是否添加到待处理队列 + if self.config.get('use_pending_queue', True): + logger.info(f'[{msg_time}] 【{cookie_id}】{send_message},暂时无法提取订单ID,添加到待处理队列') + else: + logger.error(f'[{msg_time}] 【{cookie_id}】{send_message},无法提取订单ID且未启用待处理队列,跳过处理') + return False + + # 创建一个临时的订单ID占位符,用于标识这个待处理的状态更新 + temp_order_id = f"temp_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" + + # 获取对应的状态 + new_status = message_status_mapping[send_message] + + # 添加到待处理队列,使用特殊标记 + self._add_to_pending_updates( + order_id=temp_order_id, + new_status=new_status, + cookie_id=cookie_id, + context=f"{send_message} - {msg_time} - 等待订单ID提取" + ) + + # 添加到待处理的系统消息队列 + if cookie_id not in self._pending_system_messages: + self._pending_system_messages[cookie_id] = [] + + self._pending_system_messages[cookie_id].append({ + 'message': message, + 'send_message': send_message, + 'cookie_id': cookie_id, + 'msg_time': msg_time, + 'new_status': new_status, + 'temp_order_id': temp_order_id, + 'message_hash': hash(str(sorted(message.items()))) if isinstance(message, dict) else hash(str(message)), # 添加消息哈希用于匹配 + 'timestamp': time.time() # 添加时间戳用于清理 + }) + + return True + + # 获取对应的状态(new_status已经在上面通过_check_refund_message或message_status_mapping确定了) + + # 更新订单状态 + success = self.update_order_status( + order_id=order_id, + new_status=new_status, + cookie_id=cookie_id, + context=f"{send_message} - {msg_time}" + ) + + if success: + status_text = self.status_mapping.get(new_status, new_status) + logger.info(f'[{msg_time}] 【{cookie_id}】{send_message},订单 {order_id} 状态已更新为{status_text}') + else: + logger.error(f'[{msg_time}] 【{cookie_id}】{send_message},但订单 {order_id} 状态更新失败') + + return True + + except Exception as e: + logger.error(f'[{msg_time}] 【{cookie_id}】处理系统消息订单状态更新时出错: {str(e)}') + return False + + def handle_red_reminder_message(self, message: dict, red_reminder: str, user_id: str, cookie_id: str, msg_time: str) -> bool: + """处理红色提醒消息并更新订单状态 + + Args: + message: 原始消息数据 + red_reminder: 红色提醒内容 + user_id: 用户ID + cookie_id: Cookie ID + msg_time: 消息时间 + + Returns: + bool: 是否处理了订单状态更新 + """ + try: + # 只处理交易关闭的情况 + if red_reminder != '交易关闭': + return False + + # 提取订单ID + order_id = self.extract_order_id(message) + if not order_id: + # 如果无法提取订单ID,根据配置决定是否添加到待处理队列 + if self.config.get('use_pending_queue', True): + logger.info(f'[{msg_time}] 【{cookie_id}】交易关闭,暂时无法提取订单ID,添加到待处理队列') + else: + logger.error(f'[{msg_time}] 【{cookie_id}】交易关闭,无法提取订单ID且未启用待处理队列,跳过处理') + return False + + # 创建一个临时的订单ID占位符,用于标识这个待处理的状态更新 + temp_order_id = f"temp_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" + + # 添加到待处理队列,使用特殊标记 + self._add_to_pending_updates( + order_id=temp_order_id, + new_status='cancelled', + cookie_id=cookie_id, + context=f"交易关闭 - 用户{user_id} - {msg_time} - 等待订单ID提取" + ) + + # 添加到待处理的红色提醒消息队列 + if cookie_id not in self._pending_red_reminder_messages: + self._pending_red_reminder_messages[cookie_id] = [] + + self._pending_red_reminder_messages[cookie_id].append({ + 'message': message, + 'red_reminder': red_reminder, + 'user_id': user_id, + 'cookie_id': cookie_id, + 'msg_time': msg_time, + 'new_status': 'cancelled', + 'temp_order_id': temp_order_id, + 'message_hash': hash(str(sorted(message.items()))) if isinstance(message, dict) else hash(str(message)), # 添加消息哈希用于匹配 + 'timestamp': time.time() # 添加时间戳用于清理 + }) + + return True + + # 更新订单状态为已关闭 + success = self.update_order_status( + order_id=order_id, + new_status='cancelled', + cookie_id=cookie_id, + context=f"交易关闭 - 用户{user_id} - {msg_time}" + ) + + if success: + logger.info(f'[{msg_time}] 【{cookie_id}】交易关闭,订单 {order_id} 状态已更新为已关闭') + else: + logger.error(f'[{msg_time}] 【{cookie_id}】交易关闭,但订单 {order_id} 状态更新失败') + + return True + + except Exception as e: + logger.error(f'[{msg_time}] 【{cookie_id}】处理交易关闭订单状态更新时出错: {str(e)}') + return False + + def handle_auto_delivery_order_status(self, order_id: str, cookie_id: str, context: str = "自动发货") -> bool: + """处理自动发货时的订单状态更新 + + Args: + order_id: 订单ID + cookie_id: Cookie ID + context: 上下文信息 + + Returns: + bool: 更新是否成功 + """ + return self.update_order_status( + order_id=order_id, + new_status='shipped', # 已发货 + cookie_id=cookie_id, + context=context + ) + + def handle_order_basic_info_status(self, order_id: str, cookie_id: str, context: str = "基本信息保存") -> bool: + """处理订单基本信息保存时的状态设置 + + Args: + order_id: 订单ID + cookie_id: Cookie ID + context: 上下文信息 + + Returns: + bool: 更新是否成功 + """ + return self.update_order_status( + order_id=order_id, + new_status='processing', # 处理中 + cookie_id=cookie_id, + context=context + ) + + def handle_order_detail_fetched_status(self, order_id: str, cookie_id: str, context: str = "详情已获取") -> bool: + """处理订单详情拉取后的状态设置 + + Args: + order_id: 订单ID + cookie_id: Cookie ID + context: 上下文信息 + + Returns: + bool: 更新是否成功 + """ + logger.info(f"🔄 订单状态处理器.handle_order_detail_fetched_status开始: order_id={order_id}, cookie_id={cookie_id}, context={context}") + + # 订单详情获取成功后,不需要改变状态,只是处理待处理队列 + logger.info(f"✅ 订单详情已获取,处理待处理队列: order_id={order_id}") + return True + + def on_order_details_fetched(self, order_id: str): + """当主程序拉取到订单详情后调用此方法处理待处理的更新 + + Args: + order_id: 订单ID + """ + logger.info(f"🔄 订单状态处理器.on_order_details_fetched开始: order_id={order_id}") + + # 检查是否启用待处理队列 + if not self.config.get('use_pending_queue', True): + logger.info(f"⏭️ 订单 {order_id} 详情已拉取,但未启用待处理队列,跳过处理") + return + + logger.info(f"✅ 待处理队列已启用,检查订单 {order_id} 的待处理更新") + + with self._lock: + if order_id in self.pending_updates: + logger.info(f"📝 检测到订单 {order_id} 详情已拉取,开始处理待处理的状态更新") + # 注意:process_pending_updates 内部也有锁,这里需要先释放锁避免死锁 + updates = self.pending_updates.pop(order_id) + logger.info(f"📊 订单 {order_id} 有 {len(updates)} 个待处理更新") + else: + logger.info(f"ℹ️ 订单 {order_id} 没有待处理的更新") + return + + # 在锁外处理更新,避免死锁 + if 'updates' in locals(): + logger.info(f"🔄 开始处理订单 {order_id} 的 {len(updates)} 个待处理更新") + self._process_updates_outside_lock(order_id, updates) + logger.info(f"✅ 订单 {order_id} 的待处理更新处理完成") + + def _process_updates_outside_lock(self, order_id: str, updates: list): + """在锁外处理更新,避免死锁 + + Args: + order_id: 订单ID + updates: 更新列表 + """ + processed_count = 0 + + for update_info in updates: + try: + success = self.update_order_status( + order_id=order_id, + new_status=update_info['new_status'], + cookie_id=update_info['cookie_id'], + context=f"待处理队列: {update_info['context']}" + ) + + if success: + processed_count += 1 + logger.info(f"处理待处理更新成功: 订单 {order_id} -> {update_info['new_status']}") + else: + logger.error(f"处理待处理更新失败: 订单 {order_id} -> {update_info['new_status']}") + + except Exception as e: + logger.error(f"处理待处理更新时出错: {str(e)}") + + if processed_count > 0: + logger.info(f"订单 {order_id} 共处理了 {processed_count} 个待处理状态更新") + + def on_order_id_extracted(self, order_id: str, cookie_id: str, message: dict = None): + """当主程序成功提取到订单ID后调用此方法处理待处理的系统消息 + + Args: + order_id: 订单ID + cookie_id: Cookie ID + message: 原始消息(可选,用于匹配) + """ + logger.info(f"🔄 订单状态处理器.on_order_id_extracted开始: order_id={order_id}, cookie_id={cookie_id}") + + with self._lock: + # 检查是否启用待处理队列 + if not self.config.get('use_pending_queue', True): + logger.info(f"⏭️ 订单 {order_id} ID已提取,但未启用待处理队列,跳过处理") + return + + logger.info(f"✅ 待处理队列已启用,检查账号 {cookie_id} 的待处理系统消息") + + # 处理待处理的系统消息队列 + if cookie_id in self._pending_system_messages and self._pending_system_messages[cookie_id]: + logger.info(f"📝 账号 {cookie_id} 有 {len(self._pending_system_messages[cookie_id])} 个待处理的系统消息") + pending_msg = None + + # 如果提供了消息,尝试匹配 + if message: + logger.info(f"🔍 尝试通过消息哈希匹配待处理的系统消息") + message_hash = hash(str(sorted(message.items()))) if isinstance(message, dict) else hash(str(message)) + # 从后往前遍历,避免pop时索引变化问题 + for i in range(len(self._pending_system_messages[cookie_id]) - 1, -1, -1): + msg = self._pending_system_messages[cookie_id][i] + if msg.get('message_hash') == message_hash: + pending_msg = self._pending_system_messages[cookie_id].pop(i) + logger.info(f"✅ 通过消息哈希匹配到待处理的系统消息: {pending_msg['send_message']}") + break + + # 如果没有匹配到,使用FIFO原则 + if not pending_msg and self._pending_system_messages[cookie_id]: + pending_msg = self._pending_system_messages[cookie_id].pop(0) + logger.info(f"✅ 使用FIFO原则处理待处理的系统消息: {pending_msg['send_message']}") + + if pending_msg: + logger.info(f"🔄 开始处理待处理的系统消息: {pending_msg['send_message']}") + + # 更新订单状态 + success = self.update_order_status( + order_id=order_id, + new_status=pending_msg['new_status'], + cookie_id=cookie_id, + context=f"{pending_msg['send_message']} - {pending_msg['msg_time']} - 延迟处理" + ) + + if success: + status_text = self.status_mapping.get(pending_msg['new_status'], pending_msg['new_status']) + logger.info(f'✅ [{pending_msg["msg_time"]}] 【{cookie_id}】{pending_msg["send_message"]},订单 {order_id} 状态已更新为{status_text} (延迟处理)') + else: + logger.error(f'❌ [{pending_msg["msg_time"]}] 【{cookie_id}】{pending_msg["send_message"]},但订单 {order_id} 状态更新失败 (延迟处理)') + + # 清理临时订单ID的待处理更新 + temp_order_id = pending_msg['temp_order_id'] + if temp_order_id in self.pending_updates: + del self.pending_updates[temp_order_id] + logger.info(f"🗑️ 清理临时订单ID {temp_order_id} 的待处理更新") + + # 如果队列为空,删除该账号的队列 + if not self._pending_system_messages[cookie_id]: + del self._pending_system_messages[cookie_id] + logger.info(f"🗑️ 账号 {cookie_id} 的待处理系统消息队列已清空") + else: + logger.info(f"ℹ️ 订单 {order_id} ID已提取,但没有找到对应的待处理系统消息") + else: + logger.info(f"ℹ️ 账号 {cookie_id} 没有待处理的系统消息") + + # 处理待处理的红色提醒消息队列 + if cookie_id in self._pending_red_reminder_messages and self._pending_red_reminder_messages[cookie_id]: + pending_msg = None + + # 如果提供了消息,尝试匹配 + if message: + message_hash = hash(str(sorted(message.items()))) if isinstance(message, dict) else hash(str(message)) + # 从后往前遍历,避免pop时索引变化问题 + for i in range(len(self._pending_red_reminder_messages[cookie_id]) - 1, -1, -1): + msg = self._pending_red_reminder_messages[cookie_id][i] + if msg.get('message_hash') == message_hash: + pending_msg = self._pending_red_reminder_messages[cookie_id].pop(i) + logger.info(f"通过消息哈希匹配到待处理的红色提醒消息: {pending_msg['red_reminder']}") + break + + # 如果没有匹配到,使用FIFO原则 + if not pending_msg and self._pending_red_reminder_messages[cookie_id]: + pending_msg = self._pending_red_reminder_messages[cookie_id].pop(0) + logger.info(f"使用FIFO原则处理待处理的红色提醒消息: {pending_msg['red_reminder']}") + + if pending_msg: + logger.info(f"检测到订单 {order_id} ID已提取,开始处理待处理的红色提醒消息: {pending_msg['red_reminder']}") + + # 更新订单状态 + success = self.update_order_status( + order_id=order_id, + new_status=pending_msg['new_status'], + cookie_id=cookie_id, + context=f"{pending_msg['red_reminder']} - 用户{pending_msg['user_id']} - {pending_msg['msg_time']} - 延迟处理" + ) + + if success: + status_text = self.status_mapping.get(pending_msg['new_status'], pending_msg['new_status']) + logger.info(f'[{pending_msg["msg_time"]}] 【{cookie_id}】{pending_msg["red_reminder"]},订单 {order_id} 状态已更新为{status_text} (延迟处理)') + else: + logger.error(f'[{pending_msg["msg_time"]}] 【{cookie_id}】{pending_msg["red_reminder"]},但订单 {order_id} 状态更新失败 (延迟处理)') + + # 清理临时订单ID的待处理更新 + temp_order_id = pending_msg['temp_order_id'] + if temp_order_id in self.pending_updates: + del self.pending_updates[temp_order_id] + logger.info(f"清理临时订单ID {temp_order_id} 的待处理更新") + + # 如果队列为空,删除该账号的队列 + if not self._pending_red_reminder_messages[cookie_id]: + del self._pending_red_reminder_messages[cookie_id] + else: + logger.error(f"订单 {order_id} ID已提取,但没有找到对应的待处理红色提醒消息") + + +# 创建全局实例 +order_status_handler = OrderStatusHandler() diff --git a/reply_server.py b/reply_server.py index 140b1da..cfcb140 100644 --- a/reply_server.py +++ b/reply_server.py @@ -1171,9 +1171,23 @@ def update_cookie(cid: str, item: CookieIn, current_user: Dict[str, Any] = Depen if cid not in user_cookies: raise HTTPException(status_code=403, detail="无权限操作该Cookie") - # 更新cookie时保持用户绑定 - db_manager.save_cookie(cid, item.value, user_id) - cookie_manager.manager.update_cookie(cid, item.value) + # 获取旧的 cookie 值,用于判断是否需要重启任务 + old_cookie_details = db_manager.get_cookie_details(cid) + old_cookie_value = old_cookie_details.get('value') if old_cookie_details else None + + # 使用 update_cookie_account_info 更新(只更新cookie值,不覆盖其他字段) + success = db_manager.update_cookie_account_info(cid, cookie_value=item.value) + + if not success: + raise HTTPException(status_code=400, detail="更新Cookie失败") + + # 只有当 cookie 值真的发生变化时才重启任务 + if item.value != old_cookie_value: + logger.info(f"Cookie值已变化,重启任务: {cid}") + cookie_manager.manager.update_cookie(cid, item.value, save_to_db=False) + else: + logger.info(f"Cookie值未变化,无需重启任务: {cid}") + return {'msg': 'updated'} except HTTPException: raise @@ -1181,6 +1195,85 @@ def update_cookie(cid: str, item: CookieIn, current_user: Dict[str, Any] = Depen raise HTTPException(status_code=400, detail=str(e)) +class CookieAccountInfo(BaseModel): + """账号信息更新模型""" + value: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + show_browser: Optional[bool] = None + + +@app.post("/cookie/{cid}/account-info") +def update_cookie_account_info(cid: str, info: CookieAccountInfo, current_user: Dict[str, Any] = Depends(get_current_user)): + """更新账号信息(Cookie、用户名、密码、显示浏览器设置)""" + if cookie_manager.manager is None: + raise HTTPException(status_code=500, detail='CookieManager 未就绪') + try: + # 检查cookie是否属于当前用户 + user_id = current_user['user_id'] + from db_manager import db_manager + user_cookies = db_manager.get_all_cookies(user_id) + + if cid not in user_cookies: + raise HTTPException(status_code=403, detail="无权限操作该Cookie") + + # 获取旧的 cookie 值,用于判断是否需要重启任务 + old_cookie_details = db_manager.get_cookie_details(cid) + old_cookie_value = old_cookie_details.get('value') if old_cookie_details else None + + # 更新数据库 + success = db_manager.update_cookie_account_info( + cid, + cookie_value=info.value, + username=info.username, + password=info.password, + show_browser=info.show_browser + ) + + if not success: + raise HTTPException(status_code=400, detail="更新账号信息失败") + + # 只有当 cookie 值真的发生变化时才重启任务 + if info.value is not None and info.value != old_cookie_value: + logger.info(f"Cookie值已变化,重启任务: {cid}") + cookie_manager.manager.update_cookie(cid, info.value, save_to_db=False) + else: + logger.info(f"Cookie值未变化,无需重启任务: {cid}") + + return {'msg': 'updated', 'success': True} + except HTTPException: + raise + except Exception as e: + logger.error(f"更新账号信息失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/cookie/{cid}/details") +def get_cookie_account_details(cid: str, current_user: Dict[str, Any] = Depends(get_current_user)): + """获取账号详细信息(包括用户名、密码、显示浏览器设置)""" + try: + # 检查cookie是否属于当前用户 + user_id = current_user['user_id'] + from db_manager import db_manager + user_cookies = db_manager.get_all_cookies(user_id) + + if cid not in user_cookies: + raise HTTPException(status_code=403, detail="无权限操作该Cookie") + + # 获取详细信息 + details = db_manager.get_cookie_details(cid) + + if not details: + raise HTTPException(status_code=404, detail="账号不存在") + + return details + except HTTPException: + raise + except Exception as e: + logger.error(f"获取账号详情失败: {e}") + raise HTTPException(status_code=400, detail=str(e)) + + # ========================= 扫码登录相关接口 ========================= @app.post("/qr-login/generate") @@ -1338,7 +1431,8 @@ async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[st cookie_manager.manager.add_cookie(account_id, real_cookies) log_with_user('info', f"已将真实cookie添加到cookie_manager: {account_id}", current_user) else: - cookie_manager.manager.update_cookie(account_id, real_cookies) + # refresh_cookies_from_qr_login 已经保存到数据库了,这里不需要再保存 + cookie_manager.manager.update_cookie(account_id, real_cookies, save_to_db=False) log_with_user('info', f"已更新cookie_manager中的真实cookie: {account_id}", current_user) return { @@ -1376,7 +1470,8 @@ async def _fallback_save_qr_cookie(account_id: str, cookies: str, user_id: int, db_manager.save_cookie(account_id, cookies, user_id) log_with_user('info', f"降级处理 - 新账号原始cookie已保存: {account_id}", current_user) else: - db_manager.save_cookie(account_id, cookies, user_id) + # 现有账号使用 update_cookie_account_info 避免覆盖其他字段 + db_manager.update_cookie_account_info(account_id, cookie_value=cookies) log_with_user('info', f"降级处理 - 现有账号原始cookie已更新: {account_id}", current_user) # 添加到或更新cookie_manager @@ -1385,7 +1480,8 @@ async def _fallback_save_qr_cookie(account_id: str, cookies: str, user_id: int, cookie_manager.manager.add_cookie(account_id, cookies) log_with_user('info', f"降级处理 - 已将原始cookie添加到cookie_manager: {account_id}", current_user) else: - cookie_manager.manager.update_cookie(account_id, cookies) + # update_cookie_account_info 已经保存到数据库了,这里不需要再保存 + cookie_manager.manager.update_cookie(account_id, cookies, save_to_db=False) log_with_user('info', f"降级处理 - 已更新cookie_manager中的原始cookie: {account_id}", current_user) return { @@ -1444,7 +1540,8 @@ async def refresh_cookies_from_qr_login( # 从数据库获取更新后的cookie updated_cookie_info = db_manager.get_cookie_by_id(cookie_id) if updated_cookie_info: - cookie_manager.manager.update_cookie(cookie_id, updated_cookie_info['cookies_str']) + # refresh_cookies_from_qr_login 已经保存到数据库了,这里不需要再保存 + cookie_manager.manager.update_cookie(cookie_id, updated_cookie_info['cookies_str'], save_to_db=False) log_with_user('info', f"已更新cookie_manager中的cookie: {cookie_id}", current_user) return { @@ -3636,6 +3733,64 @@ async def get_logs(lines: int = 200, level: str = None, source: str = None, _: N return {"success": False, "message": f"获取日志失败: {str(e)}", "logs": []} +@app.get("/risk-control-logs") +async def get_risk_control_logs( + cookie_id: str = None, + limit: int = 100, + offset: int = 0, + admin_user: Dict[str, Any] = Depends(require_admin) +): + """获取风控日志(管理员专用)""" + try: + log_with_user('info', f"查询风控日志: cookie_id={cookie_id}, limit={limit}, offset={offset}", admin_user) + + # 获取风控日志 + logs = db_manager.get_risk_control_logs(cookie_id=cookie_id, limit=limit, offset=offset) + total_count = db_manager.get_risk_control_logs_count(cookie_id=cookie_id) + + log_with_user('info', f"风控日志查询成功,共 {len(logs)} 条记录,总计 {total_count} 条", admin_user) + + return { + "success": True, + "data": logs, + "total": total_count, + "limit": limit, + "offset": offset + } + + except Exception as e: + log_with_user('error', f"获取风控日志失败: {str(e)}", admin_user) + return { + "success": False, + "message": f"获取风控日志失败: {str(e)}", + "data": [], + "total": 0 + } + + +@app.delete("/risk-control-logs/{log_id}") +async def delete_risk_control_log( + log_id: int, + admin_user: Dict[str, Any] = Depends(require_admin) +): + """删除风控日志记录(管理员专用)""" + try: + log_with_user('info', f"删除风控日志记录: {log_id}", admin_user) + + success = db_manager.delete_risk_control_log(log_id) + + if success: + log_with_user('info', f"风控日志删除成功: {log_id}", admin_user) + return {"success": True, "message": "删除成功"} + else: + log_with_user('warning', f"风控日志删除失败: {log_id}", admin_user) + return {"success": False, "message": "删除失败,记录可能不存在"} + + except Exception as e: + log_with_user('error', f"删除风控日志失败: {log_id} - {str(e)}", admin_user) + return {"success": False, "message": f"删除失败: {str(e)}"} + + @app.get("/logs/stats") async def get_log_stats(_: None = Depends(require_auth)): """获取日志统计信息""" @@ -3888,6 +4043,85 @@ def delete_user(user_id: int, admin_user: Dict[str, Any] = Depends(require_admin log_with_user('error', f"删除用户异常: {str(e)}", admin_user) raise HTTPException(status_code=500, detail=str(e)) +@app.get('/admin/risk-control-logs') +async def get_admin_risk_control_logs( + cookie_id: str = None, + limit: int = 100, + offset: int = 0, + admin_user: Dict[str, Any] = Depends(require_admin) +): + """获取风控日志(管理员专用)""" + try: + log_with_user('info', f"查询风控日志: cookie_id={cookie_id}, limit={limit}, offset={offset}", admin_user) + + # 获取风控日志 + logs = db_manager.get_risk_control_logs(cookie_id=cookie_id, limit=limit, offset=offset) + total_count = db_manager.get_risk_control_logs_count(cookie_id=cookie_id) + + log_with_user('info', f"风控日志查询成功,共 {len(logs)} 条记录,总计 {total_count} 条", admin_user) + + return { + "success": True, + "data": logs, + "total": total_count, + "limit": limit, + "offset": offset + } + + except Exception as e: + log_with_user('error', f"查询风控日志失败: {str(e)}", admin_user) + return {"success": False, "message": f"查询失败: {str(e)}", "data": [], "total": 0} + + +@app.get('/admin/cookies') +def get_admin_cookies(admin_user: Dict[str, Any] = Depends(require_admin)): + """获取所有Cookie信息(管理员专用)""" + try: + log_with_user('info', "查询所有Cookie信息", admin_user) + + if cookie_manager.manager is None: + return { + "success": True, + "cookies": [], + "message": "CookieManager 未就绪" + } + + # 获取所有用户的cookies + from db_manager import db_manager + all_users = db_manager.get_all_users() + all_cookies = [] + + for user in all_users: + user_id = user['id'] + user_cookies = db_manager.get_all_cookies(user_id) + for cookie_id, cookie_value in user_cookies.items(): + # 获取cookie详细信息 + cookie_details = db_manager.get_cookie_details(cookie_id) + cookie_info = { + 'cookie_id': cookie_id, + 'user_id': user_id, + 'username': user['username'], + 'nickname': cookie_details.get('remark', '') if cookie_details else '', + 'enabled': cookie_manager.manager.get_cookie_status(cookie_id) + } + all_cookies.append(cookie_info) + + log_with_user('info', f"获取到 {len(all_cookies)} 个Cookie", admin_user) + return { + "success": True, + "cookies": all_cookies, + "total": len(all_cookies) + } + + except Exception as e: + log_with_user('error', f"获取Cookie信息失败: {str(e)}", admin_user) + return { + "success": False, + "cookies": [], + "message": f"获取失败: {str(e)}" + } + + @app.get('/admin/logs') def get_system_logs(admin_user: Dict[str, Any] = Depends(require_admin), lines: int = 100, @@ -4432,7 +4666,8 @@ def clear_table_data(table_name: str, admin_user: Dict[str, Any] = Depends(requi 'cookies', 'cookie_status', 'keywords', 'default_replies', 'default_reply_records', 'ai_reply_settings', 'ai_conversations', 'ai_item_cache', 'item_info', 'message_notifications', 'cards', 'delivery_rules', 'notification_channels', - 'user_settings', 'system_settings', 'email_verifications', 'captcha_codes', 'orders', "item_replay" + 'user_settings', 'system_settings', 'email_verifications', 'captcha_codes', 'orders', 'item_replay', + 'risk_control_logs' ] # 不允许清空用户表 diff --git a/requirements.txt b/requirements.txt index 72f2d28..4e8d702 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,7 +32,7 @@ psutil>=5.9.0 python-multipart>=0.0.6 # ==================== AI回复引擎 ==================== -openai>=1.65.5 +openai>=1.50.0 # ==================== 图像处理 ==================== Pillow>=10.0.0 @@ -40,6 +40,7 @@ qrcode[pil]>=7.4.2 # ==================== 浏览器自动化 ==================== playwright>=1.40.0 +DrissionPage>=4.0.0 # ==================== 加密和安全 ==================== PyJWT>=2.8.0 @@ -59,6 +60,13 @@ email-validator>=2.0.0 # ==================== 数据处理和验证 ==================== xlsxwriter>=3.1.0 +# ==================== 构建二进制扩展模块(可选) ==================== +# 用于编译性能关键模块,提升运行效率 +# 如果不需要编译功能,可以注释掉以下依赖 +nuitka>=2.7 +ordered-set>=4.1.0 +zstandard>=0.22.0 + # ==================== 版本说明 ==================== # 本文件包含闲鱼自动回复系统的所有必需依赖 # 版本号已经过测试验证,确保兼容性和稳定性 @@ -71,6 +79,9 @@ xlsxwriter>=3.1.0 # playwright install chromium # playwright install-deps chromium # Linux系统需要 # +# 注意:DrissionPage需要Chrome/Chromium浏览器支持滑块验证功能 +# 如果系统没有Chrome,请先安装Chrome浏览器 +# # ==================== 内置模块说明 ==================== # 以下模块是Python内置模块,无需安装: # sqlite3, json, base64, hashlib, hmac, time, datetime, os, sys, re, urllib diff --git a/static/index.html b/static/index.html index fa359c8..cd2560c 100644 --- a/static/index.html +++ b/static/index.html @@ -114,6 +114,12 @@ 系统日志 + @@ -636,7 +642,9 @@ + + @@ -1382,6 +1390,131 @@ + +
+
+

+ + 风控日志 +

+

查看滑块验证等风控事件的处理记录

+
+ +
+ +
+
+
+ + 筛选条件 +
+
+
+
+
+ + +
+
+ + +
+
+ +
+ + 全部 + + + 处理中 + + + 成功 + + + 失败 + +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ + 风控日志记录 +
+
+ 总计: 0 条 + +
+
+
+
+
+ 加载中... +
+

正在加载风控日志...

+
+ + +
+
+ + +
+
+ 显示第 0-0 条,共 0 条记录 +
+ +
+
+
+
@@ -3382,5 +3515,73 @@
+ + + diff --git a/static/js/app.js b/static/js/app.js index bfa2731..27e183e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -112,6 +112,17 @@ function showSection(sectionName) { } }, 100); break; + case 'risk-control-logs': // 【风控日志菜单】 + // 自动加载风控日志 + setTimeout(() => { + const riskLogContainer = document.getElementById('riskLogContainer'); + if (riskLogContainer) { + console.log('首次进入风控日志页面,自动加载日志...'); + loadRiskControlLogs(); + loadCookieFilterOptions(); + } + }, 100); + break; case 'user-management': // 【用户管理菜单】 loadUserManagement(); break; @@ -1518,71 +1529,86 @@ async function delCookie(id) { } // 内联编辑Cookie -function editCookieInline(id, currentValue) { - const row = event.target.closest('tr'); - const cookieValueCell = row.querySelector('.cookie-value'); - const originalContent = cookieValueCell.innerHTML; - - // 存储原始数据到全局变量,避免HTML注入问题 - window.editingCookieData = { - id: id, - originalContent: originalContent, - originalValue: currentValue || '' - }; - - // 创建编辑界面容器 - const editContainer = document.createElement('div'); - editContainer.className = 'd-flex gap-2'; - - // 创建输入框 - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'form-control form-control-sm'; - input.id = `edit-${id}`; - input.value = currentValue || ''; - input.placeholder = '输入新的Cookie值'; - - // 创建保存按钮 - const saveBtn = document.createElement('button'); - saveBtn.className = 'btn btn-sm btn-success'; - saveBtn.title = '保存'; - saveBtn.innerHTML = ''; - saveBtn.onclick = () => saveCookieInline(id); - - // 创建取消按钮 - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'btn btn-sm btn-secondary'; - cancelBtn.title = '取消'; - cancelBtn.innerHTML = ''; - cancelBtn.onclick = () => cancelCookieEdit(id); - - // 组装编辑界面 - editContainer.appendChild(input); - editContainer.appendChild(saveBtn); - editContainer.appendChild(cancelBtn); - - // 替换原内容 - cookieValueCell.innerHTML = ''; - cookieValueCell.appendChild(editContainer); - - // 聚焦输入框 - input.focus(); - input.select(); - - // 添加键盘事件监听 - input.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - saveCookieInline(id); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelCookieEdit(id); +async function editCookieInline(id, currentValue) { + try { + toggleLoading(true); + + // 获取账号详细信息 + const details = await fetchJSON(apiBase + `/cookie/${id}/details`); + + // 打开编辑模态框 + openAccountEditModal(details); + } catch (err) { + console.error('获取账号详情失败:', err); + showToast(`获取账号详情失败: ${err.message || '未知错误'}`, 'danger'); + } finally { + toggleLoading(false); } - }); +} - // 禁用该行的其他按钮 - const actionButtons = row.querySelectorAll('.btn-group button'); - actionButtons.forEach(btn => btn.disabled = true); +// 打开账号编辑模态框 +function openAccountEditModal(accountData) { + // 设置模态框数据 + document.getElementById('editAccountId').value = accountData.id; + document.getElementById('editAccountCookie').value = accountData.value || ''; + document.getElementById('editAccountUsername').value = accountData.username || ''; + document.getElementById('editAccountPassword').value = accountData.password || ''; + document.getElementById('editAccountShowBrowser').checked = accountData.show_browser || false; + + // 显示账号ID + document.getElementById('editAccountIdDisplay').textContent = accountData.id; + + // 打开模态框 + const modal = new bootstrap.Modal(document.getElementById('accountEditModal')); + modal.show(); + + // 初始化模态框中的 tooltips + setTimeout(() => { + initTooltips(); + }, 100); +} + +// 保存账号编辑 +async function saveAccountEdit() { + const id = document.getElementById('editAccountId').value; + const cookie = document.getElementById('editAccountCookie').value.trim(); + const username = document.getElementById('editAccountUsername').value.trim(); + const password = document.getElementById('editAccountPassword').value.trim(); + const showBrowser = document.getElementById('editAccountShowBrowser').checked; + + if (!cookie) { + showToast('Cookie值不能为空', 'warning'); + return; + } + + try { + toggleLoading(true); + + await fetchJSON(apiBase + `/cookie/${id}/account-info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + value: cookie, + username: username, + password: password, + show_browser: showBrowser + }) + }); + + showToast(`账号 "${id}" 信息已更新`, 'success'); + + // 关闭模态框 + const modal = bootstrap.Modal.getInstance(document.getElementById('accountEditModal')); + modal.hide(); + + // 重新加载账号列表 + loadCookies(); + } catch (err) { + console.error('保存账号信息失败:', err); + showToast(`保存失败: ${err.message || '未知错误'}`, 'danger'); + } finally { + toggleLoading(false); + } } // 保存内联编辑的Cookie @@ -8613,6 +8639,7 @@ function getOrderStatusClass(status) { 'processing': 'bg-warning text-dark', 'processed': 'bg-info text-white', 'completed': 'bg-success text-white', + 'cancelled': 'bg-danger text-white', 'unknown': 'bg-secondary text-white' }; return statusMap[status] || 'bg-secondary text-white'; @@ -8623,7 +8650,9 @@ function getOrderStatusText(status) { const statusMap = { 'processing': '处理中', 'processed': '已处理', + 'shipped': '已发货', 'completed': '已完成', + 'cancelled': '已关闭', 'unknown': '未知' }; return statusMap[status] || '未知'; @@ -9823,6 +9852,263 @@ function scrollLogToBottom() { logContainer.scrollTop = logContainer.scrollHeight; } +// ================================ +// 风控日志管理功能 +// ================================ +let currentRiskLogStatus = ''; +let currentRiskLogOffset = 0; +const riskLogLimit = 100; + +// 加载风控日志 +async function loadRiskControlLogs(offset = 0) { + const token = localStorage.getItem('auth_token'); + const cookieId = document.getElementById('riskLogCookieFilter').value; + const limit = document.getElementById('riskLogLimit').value; + + const loadingDiv = document.getElementById('loadingRiskLogs'); + const logContainer = document.getElementById('riskLogContainer'); + const noLogsDiv = document.getElementById('noRiskLogs'); + + loadingDiv.style.display = 'block'; + logContainer.style.display = 'none'; + noLogsDiv.style.display = 'none'; + + let url = `/admin/risk-control-logs?limit=${limit}&offset=${offset}`; + if (cookieId) { + url += `&cookie_id=${cookieId}`; + } + + try { + const response = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + loadingDiv.style.display = 'none'; + + if (data.success && data.data && data.data.length > 0) { + displayRiskControlLogs(data.data); + updateRiskLogInfo(data); + updateRiskLogPagination(data); + logContainer.style.display = 'block'; + } else { + noLogsDiv.style.display = 'block'; + updateRiskLogInfo({total: 0, data: []}); + } + + currentRiskLogOffset = offset; + } catch (error) { + console.error('加载风控日志失败:', error); + loadingDiv.style.display = 'none'; + noLogsDiv.style.display = 'block'; + showToast('加载风控日志失败', 'danger'); + } +} + +// 显示风控日志 +function displayRiskControlLogs(logs) { + const tableBody = document.getElementById('riskLogTableBody'); + tableBody.innerHTML = ''; + + logs.forEach(log => { + const row = document.createElement('tr'); + + // 格式化时间 + const createdAt = new Date(log.created_at).toLocaleString('zh-CN'); + + // 状态标签 + let statusBadge = ''; + switch(log.processing_status) { + case 'processing': + statusBadge = '处理中'; + break; + case 'success': + statusBadge = '成功'; + break; + case 'failed': + statusBadge = '失败'; + break; + default: + statusBadge = '未知'; + } + + row.innerHTML = ` + ${createdAt} + ${escapeHtml(log.cookie_id || '-')} + ${escapeHtml(log.event_type || '-')} + ${statusBadge} + ${escapeHtml(log.event_description || '-')} + ${escapeHtml(log.processing_result || '-')} + + + + `; + + tableBody.appendChild(row); + }); +} + +// 更新风控日志信息 +function updateRiskLogInfo(data) { + const countElement = document.getElementById('riskLogCount'); + const paginationInfo = document.getElementById('riskLogPaginationInfo'); + + if (countElement) { + countElement.textContent = `总计: ${data.total || 0} 条`; + } + + if (paginationInfo) { + const start = currentRiskLogOffset + 1; + const end = Math.min(currentRiskLogOffset + (data.data ? data.data.length : 0), data.total || 0); + paginationInfo.textContent = `显示第 ${start}-${end} 条,共 ${data.total || 0} 条记录`; + } +} + +// 更新风控日志分页 +function updateRiskLogPagination(data) { + const pagination = document.getElementById('riskLogPagination'); + const limit = parseInt(document.getElementById('riskLogLimit').value); + const total = data.total || 0; + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(currentRiskLogOffset / limit) + 1; + + pagination.innerHTML = ''; + + if (totalPages <= 1) return; + + // 上一页 + const prevLi = document.createElement('li'); + prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`; + prevLi.innerHTML = `
上一页`; + pagination.appendChild(prevLi); + + // 页码 + const startPage = Math.max(1, currentPage - 2); + const endPage = Math.min(totalPages, currentPage + 2); + + for (let i = startPage; i <= endPage; i++) { + const li = document.createElement('li'); + li.className = `page-item ${i === currentPage ? 'active' : ''}`; + li.innerHTML = `${i}`; + pagination.appendChild(li); + } + + // 下一页 + const nextLi = document.createElement('li'); + nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`; + nextLi.innerHTML = `下一页`; + pagination.appendChild(nextLi); +} + +// 按状态过滤风控日志 +function filterRiskLogsByStatus(status) { + currentRiskLogStatus = status; + + // 更新过滤按钮状态 + document.querySelectorAll('.filter-badge[data-status]').forEach(badge => { + badge.classList.remove('active'); + }); + document.querySelector(`.filter-badge[data-status="${status}"]`).classList.add('active'); + + // 重新加载日志 + loadRiskControlLogs(0); +} + +// 加载账号筛选选项 +async function loadCookieFilterOptions() { + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch('/admin/cookies', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + const data = await response.json(); + const select = document.getElementById('riskLogCookieFilter'); + + // 清空现有选项,保留"全部账号" + select.innerHTML = ''; + + if (data.success && data.cookies) { + data.cookies.forEach(cookie => { + const option = document.createElement('option'); + option.value = cookie.cookie_id; + option.textContent = `${cookie.cookie_id} (${cookie.nickname || '未知'})`; + select.appendChild(option); + }); + } + } + } catch (error) { + console.error('加载账号选项失败:', error); + } +} + +// 删除风控日志记录 +async function deleteRiskControlLog(logId) { + if (!confirm('确定要删除这条风控日志记录吗?')) { + return; + } + + try { + const token = localStorage.getItem('auth_token'); + const response = await fetch(`/admin/risk-control-logs/${logId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + + if (data.success) { + showToast('删除成功', 'success'); + loadRiskControlLogs(currentRiskLogOffset); + } else { + showToast(data.message || '删除失败', 'danger'); + } + } catch (error) { + console.error('删除风控日志失败:', error); + showToast('删除失败', 'danger'); + } +} + +// 清空风控日志 +async function clearRiskControlLogs() { + if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) { + return; + } + + try { + const token = localStorage.getItem('auth_token'); + + // 调用后端批量清空接口(管理员) + const response = await fetch('/admin/data/risk_control_logs', { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + const data = await response.json(); + + if (response.ok) { + showToast('风控日志已清空', 'success'); + loadRiskControlLogs(0); + } else { + showToast(data.detail || data.message || '清空失败', 'danger'); + } + } catch (error) { + console.error('清空风控日志失败:', error); + showToast('清空失败', 'danger'); + } +} + // ================================ // 商品搜索功能 // ================================ diff --git a/static/qq-group.png b/static/qq-group.png index ababdd3..f6a10e2 100644 Binary files a/static/qq-group.png and b/static/qq-group.png differ diff --git a/static/version.txt b/static/version.txt index 570c796..e946d6b 100644 --- a/static/version.txt +++ b/static/version.txt @@ -1 +1 @@ -v1.0.2 +v1.0.3 diff --git a/static/wechat-group.png b/static/wechat-group.png index e134c22..fd2e6e8 100644 Binary files a/static/wechat-group.png and b/static/wechat-group.png differ diff --git a/static/wechat-group1.png b/static/wechat-group1.png index 58acd82..fd2e6e8 100644 Binary files a/static/wechat-group1.png and b/static/wechat-group1.png differ diff --git a/utils/item_search.py b/utils/item_search.py index dc1e622..783d17e 100644 --- a/utils/item_search.py +++ b/utils/item_search.py @@ -131,7 +131,8 @@ class XianyuSearcher: if os.getenv('DOCKER_ENV') == 'true': browser_args.extend([ '--disable-gpu', - '--single-process' + # 移除--single-process参数,使用多进程模式提高稳定性 + # '--single-process' # 注释掉,避免崩溃 ]) logger.info("正在启动浏览器...") @@ -154,11 +155,19 @@ class XianyuSearcher: async def close_browser(self): """关闭浏览器""" - if self.browser: - await self.browser.close() - self.browser = None - self.context = None - self.page = None + try: + if self.page: + await self.page.close() + self.page = None + if self.context: + await self.context.close() + self.context = None + if self.browser: + await self.browser.close() + self.browser = None + logger.debug("商品搜索器浏览器已关闭") + except Exception as e: + logger.warning(f"关闭商品搜索器浏览器时出错: {e}") async def search_items(self, keyword: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]: """ diff --git a/utils/order_detail_fetcher.py b/utils/order_detail_fetcher.py index dc56fff..4759299 100644 --- a/utils/order_detail_fetcher.py +++ b/utils/order_detail_fetcher.py @@ -101,9 +101,9 @@ class OrderDetailFetcher: '--no-pings' ] - # 只在Docker环境中使用单进程模式 - if os.getenv('DOCKER_ENV'): - browser_args.append('--single-process') + # 移除--single-process参数,使用多进程模式提高稳定性 + # if os.getenv('DOCKER_ENV'): + # browser_args.append('--single-process') # 注释掉,避免崩溃 # 在Docker环境中添加额外参数 if os.getenv('DOCKER_ENV'): @@ -122,7 +122,18 @@ class OrderDetailFetcher: '--safebrowsing-disable-auto-update', '--enable-automation', '--password-store=basic', - '--use-mock-keychain' + '--use-mock-keychain', + # 添加内存优化和稳定性参数 + '--memory-pressure-off', + '--max_old_space_size=512', + '--disable-ipc-flooding-protection', + '--disable-component-extensions-with-background-pages', + '--disable-features=TranslateUI,BlinkGenPropertyTrees', + '--disable-logging', + '--disable-permissions-api', + '--disable-notifications', + '--no-pings', + '--no-zygote' ]) logger.info(f"启动浏览器,参数: {browser_args}") diff --git a/utils/refresh_util.py b/utils/refresh_util.py new file mode 100644 index 0000000..6b5fc97 --- /dev/null +++ b/utils/refresh_util.py @@ -0,0 +1,2081 @@ +import json +import time +import os +import sys +import re +import hashlib +import base64 +import struct +import math +from typing import Any, Dict, List +import requests +from loguru import logger +import asyncio + +import time +import random +from loguru import logger +from DrissionPage import Chromium, ChromiumOptions + +def log_captcha_event(cookie_id: str, event_type: str, success: bool = None, details: str = ""): + """简单记录滑块验证事件到txt文件""" + try: + import os + log_dir = 'logs' + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, 'captcha_verification.txt') + + timestamp = time.strftime('%Y-%m-%d %H:%M:%S') + status = "成功" if success is True else "失败" if success is False else "进行中" + + log_entry = f"[{timestamp}] 【{cookie_id}】{event_type} - {status}" + if details: + log_entry += f" - {details}" + log_entry += "\n" + + with open(log_file, 'a', encoding='utf-8') as f: + f.write(log_entry) + + except Exception as e: + logger.error(f"记录滑块验证日志失败: {e}") + + +class DrissionHandler: + def __init__( + self, max_retries: int = 3, is_headless: bool = False, maximize_window: bool = True, show_mouse_trace: bool = True + ): + """ + 初始化 Drission 浏览器 + :param max_retries: 最大重试次数 + :param is_headless: 是否开启无头浏览器 + :param maximize_window: 是否最大化窗口(推荐开启以提高滑块通过率) + :param show_mouse_trace: 是否显示鼠标轨迹(调试用) + """ + self.max_retries = max_retries # 失败时的最大重试次数 + self.slide_attempt = 0 # 当前滑动尝试次数 + self.maximize_window = maximize_window + self.show_mouse_trace = show_mouse_trace # 鼠标轨迹可视化 + + # 🎯 垂直偏移量配置(可调整) + self.y_drift_range = 3 # 整体漂移趋势范围 ±3像素(原来是±8) + self.shake_range = 1.5 # 基础抖动范围 ±1.5像素(原来是±3) + self.fast_move_multiplier = 1.8 # 快速移动时的抖动放大倍数(原来是2.5) + self.directional_range = 1.0 # 方向性偏移范围(原来是2.0) + self.max_y_offset = 8 # 最大垂直偏移限制 ±8像素(原来是±15) + + self.co = ChromiumOptions() + + # 根据操作系统设置浏览器路径 + import platform + system = platform.system().lower() + if system == "linux": + # Linux系统 + possible_paths = [ + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable" + ] + browser_path = None + for path in possible_paths: + if os.path.exists(path): + browser_path = path + break + if browser_path: + self.co.set_browser_path(browser_path) + logger.debug(f"使用浏览器路径: {browser_path}") + else: + logger.warning("未找到可用的浏览器路径,使用默认设置") + elif system == "windows": + # Windows系统,通常不需要手动设置路径 + logger.debug("Windows系统,使用默认浏览器路径") + else: + # macOS或其他系统 + logger.debug(f"检测到系统: {system},使用默认浏览器路径") + + # 设置端口,避免端口冲突 + self.co.set_argument("--remote-debugging-port=0") # 让系统自动分配端口 + + self.co.set_argument("--no-sandbox") # 运行无沙盒 + self.co.new_env(True) # 创建新的浏览器环境 + self.co.no_imgs(True) # 禁用图片加载 + self.co.headless(on_off=is_headless) # 是否开启无头模式 + + # 添加更多稳定性参数 + self.co.set_argument("--disable-dev-shm-usage") + self.co.set_argument("--disable-gpu") + self.co.set_argument("--disable-web-security") + self.co.set_argument("--disable-features=VizDisplayCompositor") + self.co.set_argument("--disable-blink-features=AutomationControlled") # 隐藏自动化特征 + self.co.set_argument("--disable-extensions") # 禁用扩展 + self.co.set_argument("--no-first-run") # 跳过首次运行设置 + self.co.set_argument("--disable-default-apps") # 禁用默认应用 + + # 添加更多兼容性参数 + self.co.set_argument("--disable-background-timer-throttling") + self.co.set_argument("--disable-renderer-backgrounding") + self.co.set_argument("--disable-backgrounding-occluded-windows") + self.co.set_argument("--disable-ipc-flooding-protection") + + # 如果需要最大化窗口,设置启动参数 + if maximize_window and not is_headless: + # 设置最大化启动参数 + self.co.set_argument("--start-maximized") # 启动时最大化 + self.co.set_argument("--window-size=1920,1080") # 设置大尺寸作为备用 + self.co.set_argument("--force-device-scale-factor=1") # 强制缩放比例 + self.co.set_argument("--disable-features=TranslateUI") # 禁用可能影响窗口的功能 + logger.info("已设置浏览器最大化启动参数") + elif is_headless: + # 无头模式下设置一个常见的桌面分辨率 + self.co.set_argument("--window-size=1920,1080") + # 模拟真实设备特征 + self.co.set_argument("--force-device-scale-factor=1") + + try: + logger.info("正在启动浏览器...") + + # 尝试多种启动方式 + browser_started = False + + # 方式1: 使用自定义配置 + try: + self.browser = Chromium(self.co) + browser_started = True + logger.info("浏览器启动成功(自定义配置)") + except Exception as e1: + logger.warning(f"自定义配置启动失败: {e1}") + + # 方式2: 使用默认配置 + try: + logger.info("尝试使用默认配置启动浏览器...") + self.browser = Chromium() + browser_started = True + logger.info("浏览器启动成功(默认配置)") + except Exception as e2: + logger.error(f"默认配置启动也失败: {e2}") + raise Exception(f"所有启动方式都失败。自定义配置错误: {e1},默认配置错误: {e2}") + + if browser_started: + self.page = self.browser.latest_tab # 获取最新标签页 + logger.info("获取浏览器标签页成功") + + # 如果是有头模式且需要最大化,在浏览器启动后再次确保最大化 + if maximize_window and not is_headless: + import time + logger.info("正在最大化浏览器窗口...") + + # 等待浏览器完全启动 + time.sleep(1) + + max_attempts = 3 + for attempt in range(max_attempts): + try: + logger.info(f"最大化尝试 {attempt + 1}/{max_attempts}...") + + # 方法1: 先设置窗口位置到左上角 + try: + self.page.set.window.location(0, 0) + time.sleep(0.2) + except: + pass + + # 方法2: 设置一个大尺寸 + try: + self.page.set.window.size(1920, 1080) + time.sleep(0.3) + except: + pass + + # 方法3: 执行最大化 + self.page.set.window.max() + time.sleep(0.5) + + # 方法4: 使用JavaScript强制最大化 + try: + self._javascript_maximize() + except Exception as js_e: + logger.debug(f"JavaScript最大化失败: {js_e}") + + # 方法5: 如果是Windows系统,尝试使用系统API强制最大化 + try: + import platform + if platform.system() == "Windows": + self._force_maximize_windows() + except Exception as win_e: + logger.debug(f"Windows API最大化失败: {win_e}") + + # 验证最大化结果 + try: + current_size = self.page.size + current_pos = self.page.location + logger.info(f"窗口尺寸: {current_size[0]}x{current_size[1]}, 位置: ({current_pos[0]}, {current_pos[1]})") + + # 判断是否成功最大化 + if current_size[0] >= 1400 and current_size[1] >= 900: + logger.info("✅ 浏览器窗口最大化成功!") + break + elif attempt == max_attempts - 1: + logger.warning(f"⚠️ 窗口尺寸较小: {current_size[0]}x{current_size[1]}") + logger.info("继续使用当前窗口尺寸...") + else: + logger.info(f"尺寸不够大,进行第 {attempt + 2} 次尝试...") + + except Exception as check_e: + logger.warning(f"检查窗口状态失败: {check_e}") + if attempt == max_attempts - 1: + logger.info("无法验证窗口状态,继续执行...") + + except Exception as max_e: + logger.warning(f"第 {attempt + 1} 次最大化失败: {max_e}") + if attempt == max_attempts - 1: + logger.warning("所有最大化尝试都失败,使用默认窗口尺寸") + else: + time.sleep(0.5) # 等待后重试 + + # 如果启用鼠标轨迹可视化,注入CSS和JavaScript + if self.show_mouse_trace and not is_headless: + self._inject_mouse_trace_visualization() + + except Exception as e: + logger.error(f"浏览器初始化失败: {e}") + raise + + self.cookies = {} + self.Refresh = False + + def set_cookies_from_string(self, cookies_str: str): + """从cookies字符串设置cookies到浏览器""" + try: + if not cookies_str: + logger.warning("cookies字符串为空,跳过设置") + return + + # 解析cookies字符串 + cookies_dict = {} + for cookie_pair in cookies_str.split('; '): + if '=' in cookie_pair: + name, value = cookie_pair.split('=', 1) + cookies_dict[name.strip()] = value.strip() + + # 设置cookies到浏览器 + for name, value in cookies_dict.items(): + try: + self.page.set.cookies({ + 'name': name, + 'value': value, + 'domain': '.goofish.com', + 'path': '/' + }) + except Exception as e: + logger.debug(f"设置cookie失败 {name}: {e}") + + logger.info(f"已设置 {len(cookies_dict)} 个cookies到浏览器") + self.cookies = cookies_dict + + except Exception as e: + logger.error(f"设置cookies时出错: {e}") + + def get_cookies_string(self) -> str: + """获取当前浏览器的cookies并转换为字符串格式""" + try: + # 获取浏览器中的所有cookies + browser_cookies = self.page.cookies() + + # 转换为字符串格式 + cookie_pairs = [] + for cookie in browser_cookies: + if isinstance(cookie, dict) and 'name' in cookie and 'value' in cookie: + cookie_pairs.append(f"{cookie['name']}={cookie['value']}") + + cookies_str = '; '.join(cookie_pairs) + logger.info(f"获取到 {len(cookie_pairs)} 个cookies") + return cookies_str + + except Exception as e: + logger.error(f"获取cookies字符串时出错: {e}") + return "" + + def _slide(self): + """处理滑动验证码""" + try: + self.slide_attempt += 1 + logger.info(f"尝试处理滑动验证码... (第{self.slide_attempt}次)") + + # 根据循环策略调整行为模式 + cycle_position = (self.slide_attempt - 1) % 3 + is_impatient = cycle_position == 1 # 急躁快速阶段 + is_reflective = cycle_position == 2 # 反思调整阶段 + + # 记录滑块验证尝试到日志文件 + strategy_name = "" + if cycle_position == 0: + strategy_name = "谨慎慢速模式" + elif cycle_position == 1: + strategy_name = "急躁快速模式" + else: + strategy_name = "反思调整模式" + + # 获取cookie_id(如果可用) + cookie_id = getattr(self, 'cookie_id', 'unknown') + + log_captcha_event(cookie_id, f"滑块验证尝试(第{self.slide_attempt}次)", None, f"策略: {strategy_name}") + + ele = self.page.wait.eles_loaded( + "x://span[contains(@id,'nc_1_n1z')]", timeout=10 + ) + if ele: + slider = self.page.ele("#nc_1_n1z") # 滑块 + + # 根据尝试次数调整观察时间 + if is_impatient: + # 急躁模式:观察时间大幅缩短 + observation_time = random.uniform(0.1, 0.5) + logger.info("急躁模式:快速开始滑动") + else: + # 正常模式:仔细观察 + observation_time = random.uniform(0.8, 2.5) + logger.info("正常模式:仔细观察") + + time.sleep(observation_time) + + # 严谨的鼠标模拟活动 + try: + logger.info("开始严谨的鼠标模拟活动...") + + # 第一阶段:页面进入行为模拟 + self._simulate_page_entry() + + # 第二阶段:寻找验证码过程模拟 + self._simulate_looking_for_captcha() + + # 第三阶段:接近滑块的自然移动 + self._simulate_approaching_slider(slider) + + # 第四阶段:操作滑块 + if is_impatient: + # 急躁模式:快速操作 + slider.hover() + time.sleep(random.uniform(0.02, 0.08)) + self.page.actions.hold(slider) + time.sleep(random.uniform(0.02, 0.1)) + else: + # 正常模式:谨慎操作 + slider.hover() + time.sleep(random.uniform(0.1, 0.3)) + self.page.actions.hold(slider) + time.sleep(random.uniform(0.1, 0.4)) + + except Exception as hover_error: + logger.warning(f"滑块hover失败: {hover_error},尝试直接hold") + try: + self.page.actions.hold(slider) + time.sleep(random.uniform(0.1, 0.3)) + except Exception as hold_error: + logger.error(f"滑块hold失败: {hold_error}") + return + + # 智能循环策略:快→慢→中等循环 + import time as time_module + random.seed(int(time_module.time() * 1000000) % 1000000) # 使用微秒作为随机种子 + + # 计算当前策略阶段(3次一个循环) + cycle_position = (self.slide_attempt - 1) % 3 + cycle_number = (self.slide_attempt - 1) // 3 + 1 + + # 判断是否需要刷新页面(每轮开始时的谨慎模式考虑刷新) + if cycle_position == 0 and cycle_number > 1: # 从第二轮开始的谨慎模式 + refresh_probability = min(0.2 + (cycle_number - 2) * 0.15, 0.7) # 概率递增 + if random.random() < refresh_probability: + self.Refresh = True + logger.info(f"第{cycle_number}轮开始 - 计划刷新页面重试 (概率: {refresh_probability:.2f})") + else: + self.Refresh = False + + if cycle_position == 0: # 第1、4、7...次:谨慎慢速 + if cycle_number == 1: + # 第一轮:最谨慎 + target_total_time = random.uniform(2.0, 4.0) + trajectory_points = random.randint(80, 150) + sliding_mode = "初次谨慎模式" + else: + # 后续轮:谨慎但稍快 + target_total_time = random.uniform(1.5, 3.0) + trajectory_points = random.randint(60, 120) + sliding_mode = f"第{cycle_number}轮谨慎模式" + (" [失败后将刷新]" if self.Refresh else "") + + elif cycle_position == 1: # 第2、5、8...次:急躁快速 + base_speed = max(0.2, 1.0 - cycle_number * 0.1) # 随轮次递减,但有底限 + target_total_time = random.uniform(base_speed, base_speed + 0.4) + trajectory_points = random.randint(30, 60) + sliding_mode = f"第{cycle_number}轮急躁模式" + + else: # 第3、6、9...次:中等速度(反思调整) + target_total_time = random.uniform(1.0, 2.0) + trajectory_points = random.randint(50, 90) + sliding_mode = f"第{cycle_number}轮反思模式" + + # 根据策略生成对应数量的轨迹点 + # 动态计算滑动距离,适应不同分辨率 + base_distance = self._calculate_slide_distance() + tracks = self.get_tracks(base_distance, target_points=trajectory_points) # 传入目标点数 + + logger.info(f"{sliding_mode} - 目标时间: {target_total_time:.2f}秒, 预设轨迹点: {trajectory_points}, 实际轨迹点: {len(tracks)}") + + # 记录实际开始时间 + actual_start_time = time.time() + + # 将绝对位置转换为相对移动距离 + for i in range(len(tracks)): + # 计算当前进度 + progress = i / len(tracks) # 当前进度 0-1 + + if i == 0: + offset_x = tracks[i] # 第一步是绝对位置 + else: + offset_x = tracks[i] - tracks[i - 1] # 后续是相对移动 + + # 跳过零移动 + if abs(offset_x) < 0.1: + continue + + # 更真实的垂直偏移模拟(使用可配置参数) + # 人类滑动时会有整体的向上或向下偏移趋势 + if i == 1: # 首次移动时确定整体偏移方向 + self._slide_direction = random.choice([-1, 1]) # -1向上,1向下 + self._cumulative_y_offset = 0 + self._y_drift_trend = random.uniform(-self.y_drift_range, self.y_drift_range) # 使用配置的漂移范围 + + # 基础垂直偏移:结合趋势和随机抖动 + trend_offset = self._y_drift_trend * (progress ** 0.7) # 逐渐累积的趋势偏移 + shake_offset = random.uniform(-self.shake_range, self.shake_range) # 使用配置的抖动范围 + speed_influence = min(abs(offset_x) / 10.0, 2.0) # 速度越快,抖动越大 + + # 人类在快速滑动时垂直偏移会更大 + if abs(offset_x) > 8: # 快速移动时 + shake_offset *= random.uniform(1.2, self.fast_move_multiplier) # 使用配置的放大倍数 + + # 整体偏移方向的影响 + directional_offset = self._slide_direction * random.uniform(0.2, self.directional_range) # 使用配置的方向偏移 + + offset_y = trend_offset + shake_offset + directional_offset + + # 限制偏移范围,防止过度偏移 + offset_y = max(-self.max_y_offset, min(self.max_y_offset, offset_y)) # 使用配置的最大偏移 + + # 累积Y偏移,用于后续调整 + self._cumulative_y_offset += offset_y + + # 基于目标总时间动态分配时间 + # 计算剩余时间和剩余步骤 + elapsed_time = time.time() - actual_start_time + remaining_time = max(target_total_time - elapsed_time, 0.1) + remaining_steps = len(tracks) - i + + # 基础时间分配 + if remaining_steps > 0: + base_time_per_step = remaining_time / remaining_steps + else: + base_time_per_step = 0.01 + + # 根据移动距离调整 + distance_factor = max(abs(offset_x) / 15.0, 0.3) + base_duration = base_time_per_step * distance_factor * 0.7 # 70%用于移动duration + + # 更复杂的速度变化模拟 + # 基于阶段的基础速度调整 + if progress < 0.2: # 起始阶段 - 谨慎启动 + base_phase_multiplier = random.uniform(1.5, 2.5) + elif progress < 0.4: # 加速阶段 - 逐渐加快 + base_phase_multiplier = random.uniform(0.6, 1.2) + elif progress < 0.7: # 快速阶段 - 相对快速 + base_phase_multiplier = random.uniform(0.3, 0.8) + elif progress < 0.9: # 减速阶段 - 开始减速 + base_phase_multiplier = random.uniform(0.8, 1.8) + else: # 精确阶段 - 谨慎定位 + base_phase_multiplier = random.uniform(1.5, 3.0) + + # 添加速度突变(模拟人类的不均匀操作) + speed_burst_chance = 0.15 # 速度突变概率 + if random.random() < speed_burst_chance: + if progress < 0.8: # 前80%可能突然加速 + burst_multiplier = random.uniform(0.2, 0.6) # 突然加速 + else: # 后20%可能突然减速 + burst_multiplier = random.uniform(2.0, 4.0) # 突然减速 + else: + burst_multiplier = 1.0 + + # 基于移动距离的速度调整(大移动通常更快) + distance_speed_factor = 1.0 + if abs(offset_x) > 10: # 大距离移动 + distance_speed_factor = random.uniform(0.4, 0.8) # 更快 + elif abs(offset_x) < 3: # 小距离移动 + distance_speed_factor = random.uniform(1.2, 2.0) # 更慢 + + # 添加周期性的速度波动(模拟手部节律) + rhythm_factor = 1 + 0.3 * math.sin(i * 0.5) * random.uniform(0.5, 1.5) + + # 综合所有速度因子 + phase_multiplier = base_phase_multiplier * burst_multiplier * distance_speed_factor * rhythm_factor + + # 添加随机微调 + random_variation = random.uniform(0.7, 1.3) + + final_duration = base_duration * phase_multiplier * random_variation + final_duration = max(0.005, min(0.15, final_duration)) # 限制在合理范围 + + # 偶尔添加更长的停顿(模拟人类思考/调整),但要考虑剩余时间 + if random.random() < 0.08 and progress > 0.2 and remaining_time > 0.5: + final_duration *= random.uniform(1.5, 2.5) + + # 根据急躁程度调整特殊行为 + if not is_impatient: + # 正常模式:减少特殊行为频率和幅度 + special_behavior_chance = random.random() + + if special_behavior_chance < 0.05 and progress > 0.4: # 降低到5%概率 + if progress < 0.8: # 中途可能有小幅回退调整 + # 小幅回退然后继续,减小幅度 + retreat_distance = random.uniform(1, 3) # 减小回退距离 + try: + self.page.actions.move( + offset_x=int(-retreat_distance), + offset_y=int(random.uniform(-0.5, 0.5)), + duration=max(0.1, float(random.uniform(0.1, 0.2))), + ) + except Exception as retreat_error: + logger.warning(f"回退动作失败: {retreat_error}") + continue + time.sleep(random.uniform(0.02, 0.08)) + # 继续原来的移动,补偿回退距离 + offset_x += retreat_distance + + elif special_behavior_chance < 0.02 and progress > 0.6: # 降低到2%概率暂停观察 + # 短暂停顿(模拟观察缺口位置) + pause_time = random.uniform(0.1, 0.3) # 减少停顿时间 + time.sleep(pause_time) + else: + # 急躁模式:几乎不进行特殊行为 + special_behavior_chance = random.random() + + if special_behavior_chance < 0.02 and progress > 0.6: # 降低到2%概率 + # 急躁的微小调整 + retreat_distance = random.uniform(0.5, 1.5) # 更小的调整 + try: + self.page.actions.move( + offset_x=int(-retreat_distance), + offset_y=int(random.uniform(-0.2, 0.2)), + duration=max(0.02, float(random.uniform(0.02, 0.05))), + ) + except Exception as retreat_error: + logger.warning(f"急躁回退动作失败: {retreat_error}") + continue + time.sleep(random.uniform(0.01, 0.03)) + offset_x += retreat_distance + + try: + self.page.actions.move( + offset_x=int(offset_x), # 确保是整数 + offset_y=int(offset_y), # 确保是整数 + duration=max(0.005, float(final_duration)), # 确保是正数 + ) + except Exception as move_error: + logger.warning(f"滑动步骤失败: {move_error},跳过此步骤") + continue + + # 动态调整步骤间延迟,确保总时间控制 + remaining_delay_time = base_time_per_step * 0.3 # 30%用于延迟 + step_delay = remaining_delay_time * random.uniform(0.5, 1.5) + step_delay = max(0.001, min(0.05, step_delay)) # 限制延迟范围 + + # 根据进度调整延迟模式 + if progress > 0.8: # 接近结束时更谨慎 + step_delay *= random.uniform(1.2, 2.0) + elif 0.3 < progress < 0.7: # 中间阶段可能更快 + step_delay *= random.uniform(0.6, 1.0) + + # 偶尔添加微停顿,但要考虑剩余时间 + if random.random() < 0.05 and remaining_time > 0.3: + step_delay += random.uniform(0.005, 0.02) + + time.sleep(step_delay) + + # 滑动结束后继续保持鼠标在浏览器内活动 + try: + # 在释放前做一些自然的鼠标微动 + micro_movements = random.randint(1, 3) + for i in range(micro_movements): + micro_x = random.uniform(-20, 20) + micro_y = random.uniform(-10, 10) + try: + self.page.actions.move( + offset_x=int(micro_x), + offset_y=int(micro_y), + duration=max(0.05, float(random.uniform(0.05, 0.15))), + ) + time.sleep(random.uniform(0.02, 0.08)) + except Exception as micro_error: + logger.warning(f"微动失败: {micro_error}") + break + except Exception as micro_activity_error: + logger.warning(f"鼠标微动活动失败: {micro_activity_error}") + + # 根据急躁程度调整结束行为 + if is_impatient: + # 急躁模式:快速结束 + final_adjustment_chance = random.random() + if final_adjustment_chance < 0.2: # 只有20%概率微调 + adjustment_distance = random.uniform(-2, 3) # 更小的调整 + try: + self.page.actions.move( + offset_x=int(adjustment_distance), + offset_y=int(random.uniform(-0.5, 0.5)), + duration=max(0.02, float(random.uniform(0.02, 0.1))), + ) + except Exception as adjust_error: + logger.warning(f"急躁最终调整失败: {adjust_error}") + time.sleep(random.uniform(0.02, 0.08)) # 更短停顿 + + # 急躁模式:确认停顿很短 + confirmation_pause = random.uniform(0.05, 0.2) + time.sleep(confirmation_pause) + + self.page.actions.release() + + # 记录实际执行时间 + actual_end_time = time.time() + actual_total_time = actual_end_time - actual_start_time + logger.info(f"急躁模式实际执行时间: {actual_total_time:.2f}秒, 目标时间: {target_total_time:.2f}秒") + + # 急躁模式:释放后行为更少更快 + if random.random() < 0.3: # 只有30%概率 + time.sleep(random.uniform(0.02, 0.1)) + post_move_x = random.uniform(-3, 3) + post_move_y = random.uniform(-2, 2) + try: + self.page.actions.move( + offset_x=int(post_move_x), + offset_y=int(post_move_y), + duration=max(0.05, float(random.uniform(0.05, 0.2))), + ) + except Exception as post_error: + logger.warning(f"急躁释放后移动失败: {post_error}") + + # 急躁模式:等待时间更短 + time.sleep(random.uniform(0.1, 0.3)) + else: + # 正常模式:保持原有行为 + final_adjustment_chance = random.random() + if final_adjustment_chance < 0.6: # 60%概率进行最终微调 + adjustment_distance = random.uniform(-3, 5) # 略微超调或回调 + try: + self.page.actions.move( + offset_x=int(adjustment_distance), + offset_y=int(random.uniform(-1, 1)), + duration=max(0.1, float(random.uniform(0.1, 0.3))), + ) + except Exception as adjust_error: + logger.warning(f"正常最终调整失败: {adjust_error}") + time.sleep(random.uniform(0.1, 0.25)) + + # 释放前的确认停顿(人类会确认位置正确) + confirmation_pause = random.uniform(0.2, 0.8) + time.sleep(confirmation_pause) + + self.page.actions.release() + + # 记录实际执行时间 + actual_end_time = time.time() + actual_total_time = actual_end_time - actual_start_time + logger.info(f"正常模式实际执行时间: {actual_total_time:.2f}秒, 目标时间: {target_total_time:.2f}秒") + + # 释放后的自然行为 + post_release_behavior = random.random() + if post_release_behavior < 0.7: # 70%概率有释放后行为 + time.sleep(random.uniform(0.1, 0.3)) + + post_move_x = random.uniform(-8, 8) + post_move_y = random.uniform(-5, 5) + try: + self.page.actions.move( + offset_x=int(post_move_x), + offset_y=int(post_move_y), + duration=max(0.2, float(random.uniform(0.2, 0.6))), + ) + except Exception as post_error: + logger.warning(f"正常释放后移动失败: {post_error}") + + # 等待验证结果前的停顿 + time.sleep(random.uniform(0.3, 0.8)) + + # 验证完成后的严谨鼠标活动模拟 + self._simulate_post_verification_activity() + + except Exception as e: + logger.error(f"滑动验证码处理失败: {e}") + + def _simulate_page_entry(self): + """模拟用户刚进入页面时的鼠标行为""" + try: + logger.debug("模拟页面进入行为...") + # 模拟从页面边缘进入的鼠标轨迹 + entry_movements = random.randint(3, 6) + + # 起始位置通常从页面边缘开始 + start_positions = [ + (-50, -30), # 左上 + (50, -30), # 右上 + (-30, 50), # 左侧 + (30, 50), # 右侧 + ] + + start_x, start_y = random.choice(start_positions) + + for i in range(entry_movements): + # 逐渐向页面中心移动 + progress = (i + 1) / entry_movements + target_x = start_x + (100 - start_x) * progress + random.uniform(-30, 30) + target_y = start_y + (100 - start_y) * progress + random.uniform(-20, 20) + + # 添加人类鼠标移动的不完美性 + jitter_x = random.uniform(-5, 5) + jitter_y = random.uniform(-5, 5) + + self.page.actions.move( + offset_x=int(target_x + jitter_x), + offset_y=int(target_y + jitter_y), + duration=random.uniform(0.15, 0.4) + ) + time.sleep(random.uniform(0.1, 0.25)) + + except Exception as e: + logger.warning(f"页面进入模拟失败: {e}") + + def _simulate_looking_for_captcha(self): + """模拟用户寻找验证码的鼠标行为""" + try: + logger.debug("模拟寻找验证码行为...") + # 模拟扫视页面寻找验证码 + search_movements = random.randint(2, 4) + + for i in range(search_movements): + # 模拟扫视不同区域 + if i == 0: + # 首先看页面上方 + move_x = random.uniform(-100, 100) + move_y = random.uniform(-80, -20) + elif i == 1: + # 然后看中间区域 + move_x = random.uniform(-80, 80) + move_y = random.uniform(-20, 40) + else: + # 最后聚焦到验证码区域 + move_x = random.uniform(-60, 60) + move_y = random.uniform(20, 80) + + # 添加搜索时的停顿和小幅调整 + self.page.actions.move( + offset_x=int(move_x), + offset_y=int(move_y), + duration=random.uniform(0.2, 0.5) + ) + time.sleep(random.uniform(0.3, 0.8)) # 模拟观察时间 + + # 小幅度的调整移动 + if random.random() < 0.6: + self.page.actions.move( + offset_x=random.randint(-10, 10), + offset_y=random.randint(-8, 8), + duration=random.uniform(0.05, 0.15) + ) + time.sleep(random.uniform(0.1, 0.3)) + + except Exception as e: + logger.warning(f"寻找验证码模拟失败: {e}") + + def _simulate_approaching_slider(self, slider): + """模拟用户接近滑块的自然移动过程""" + try: + logger.debug("模拟接近滑块行为...") + + # 分步骤接近滑块,而不是直接移动过去 + approach_steps = random.randint(2, 4) + + for i in range(approach_steps): + progress = (i + 1) / approach_steps + + # 逐渐接近滑块的位置 + if i == 0: + # 第一步:大致移动到滑块附近 + move_x = random.uniform(-100, -30) + move_y = random.uniform(-30, 30) + elif i == approach_steps - 1: + # 最后一步:精确接近滑块 + move_x = random.uniform(-20, 20) + move_y = random.uniform(-10, 10) + else: + # 中间步骤:逐渐接近 + move_x = random.uniform(-60, 0) + move_y = random.uniform(-20, 20) + + # 添加人类移动的不确定性 + hesitation = random.random() < 0.3 # 30%概率的犹豫 + + if hesitation: + # 犹豫时会有小幅的来回移动 + self.page.actions.move( + offset_x=int(move_x * 0.5), + offset_y=int(move_y * 0.5), + duration=random.uniform(0.1, 0.25) + ) + time.sleep(random.uniform(0.05, 0.15)) + + # 然后继续移动 + self.page.actions.move( + offset_x=int(move_x * 0.5), + offset_y=int(move_y * 0.5), + duration=random.uniform(0.1, 0.25) + ) + else: + # 直接移动 + self.page.actions.move( + offset_x=int(move_x), + offset_y=int(move_y), + duration=random.uniform(0.15, 0.35) + ) + + time.sleep(random.uniform(0.1, 0.3)) + + except Exception as e: + logger.warning(f"接近滑块模拟失败: {e}") + + def _simulate_post_verification_activity(self): + """模拟验证完成后的用户行为""" + try: + logger.debug("模拟验证后用户行为...") + + # 验证完成后的典型用户行为 + behaviors = [ + "check_result", # 检查验证结果 + "move_away", # 移开鼠标 + "return_focus", # 回到原来关注的内容 + "scroll_check" # 滚动查看 + ] + + selected_behaviors = random.sample(behaviors, random.randint(2, 3)) + + for behavior in selected_behaviors: + if behavior == "check_result": + # 短暂停留在验证区域 + self.page.actions.move( + offset_x=random.randint(-20, 20), + offset_y=random.randint(-10, 10), + duration=random.uniform(0.1, 0.2) + ) + time.sleep(random.uniform(0.5, 1.2)) + + elif behavior == "move_away": + # 将鼠标移到其他地方 + self.page.actions.move( + offset_x=random.randint(-200, 200), + offset_y=random.randint(-100, 100), + duration=random.uniform(0.3, 0.6) + ) + time.sleep(random.uniform(0.2, 0.5)) + + elif behavior == "return_focus": + # 回到页面中心或重要区域 + self.page.actions.move( + offset_x=random.randint(-100, 100), + offset_y=random.randint(-80, 80), + duration=random.uniform(0.25, 0.5) + ) + time.sleep(random.uniform(0.3, 0.8)) + + elif behavior == "scroll_check": + # 模拟滚动操作(如果支持) + try: + # 小幅滚动 + scroll_amount = random.randint(-3, 3) + if scroll_amount != 0: + self.page.scroll(delta_y=scroll_amount * 100) + time.sleep(random.uniform(0.2, 0.6)) + except: + # 如果滚动失败,就做个移动代替 + self.page.actions.move( + offset_x=random.randint(-50, 50), + offset_y=random.randint(-30, 30), + duration=random.uniform(0.2, 0.4) + ) + time.sleep(random.uniform(0.2, 0.5)) + + except Exception as e: + logger.warning(f"验证后行为模拟失败: {e}") + + def ease_out_expo(self, t): + """缓动函数,使滑动轨迹更自然""" + return 1 - pow(2, -10 * t) if t != 1 else 1 + + def get_tracks(self, distance, target_points=None): + """ + 生成更真实的人类滑动轨迹 + :param distance: 目标距离 + :param target_points: 目标轨迹点数,如果为None则自动计算 + :return: 绝对位置轨迹列表 + """ + tracks = [] + current = 0.0 + velocity = 0.0 + + # 人类滑动特征参数 + max_velocity = random.uniform(80, 150) # 最大速度 + acceleration_phase = distance * random.uniform(0.3, 0.6) # 加速阶段占比 + deceleration_start = distance * random.uniform(0.6, 0.85) # 减速开始位置 + + # 根据目标点数动态调整时间步长 + if target_points: + # 根据目标点数计算合适的时间步长 + base_dt = distance / (target_points * max_velocity * 0.5) # 估算基础时间步长 + dt = base_dt * random.uniform(0.8, 1.2) # 添加随机变化 + dt = max(0.01, min(0.2, dt)) # 限制在合理范围 + else: + # 默认时间步长 + dt = random.uniform(0.02, 0.12) + hesitation_probability = 0.15 # 犹豫概率 + overshoot_chance = 0.3 # 超调概率 + + tracks.append(0) # 起始位置 + + step = 0 + hesitation_counter = 0 + + while current < distance: + step += 1 + + # 人类滑动的三个阶段 + if current < acceleration_phase: + # 加速阶段:逐渐加速,但不是线性的 + target_accel = random.uniform(15, 35) + # 添加加速度的波动 + if step % random.randint(3, 8) == 0: + target_accel *= random.uniform(0.7, 1.4) + + elif current < deceleration_start: + # 匀速阶段:保持相对稳定的速度,偶有波动 + target_accel = random.uniform(-2, 2) + if random.random() < 0.2: # 偶尔小幅调整速度 + target_accel = random.uniform(-8, 8) + + else: + # 减速阶段:逐渐减速,接近目标时更加小心 + remaining_distance = distance - current + if remaining_distance > 20: + target_accel = random.uniform(-25, -8) + else: + # 接近目标时更加谨慎 + target_accel = random.uniform(-15, -3) + + # 模拟人类的犹豫和调整 - 更真实的犹豫模式 + if random.random() < hesitation_probability and current > acceleration_phase: + hesitation_counter += 1 + if hesitation_counter < 3: + # 犹豫时可能轻微后退或停顿 + if random.random() < 0.4: + target_accel = random.uniform(-8, -2) # 轻微后退 + else: + target_accel = random.uniform(-2, 2) # 停顿摇摆 + else: + hesitation_counter = 0 + + # 更新速度,加入阻尼效果 + velocity = velocity * 0.95 + target_accel * dt + velocity = max(0, min(velocity, max_velocity)) # 限制速度范围 + + # 更新位置 + old_current = current + current += velocity * dt + + # 添加手部微颤(高频小幅震动) + if len(tracks) > 5: + tremor = random.uniform(-0.3, 0.3) * (velocity / max_velocity) + current += tremor + + # 添加更真实的人类修正行为 + if random.random() < 0.12 and current > 50: # 增加修正频率 + correction_type = random.random() + if correction_type < 0.6: # 60%是小幅回退 + current -= random.uniform(1.0, 4.0) + elif correction_type < 0.8: # 20%是停顿 + pass # 不移动,相当于停顿 + else: # 20%是微调前进 + current += random.uniform(0.2, 1.0) + + # 防止负向移动和过大跳跃 + if current < old_current: + current = old_current + random.uniform(0.1, 0.8) + + if current - old_current > 15: # 防止单步移动过大 + current = old_current + random.uniform(8, 15) + + tracks.append(round(current, 1)) + + # 处理可能的超调 + if random.random() < overshoot_chance: + # 轻微超调然后回调 + overshoot = random.uniform(2, 8) + tracks.append(round(distance + overshoot, 1)) + + # 回调过程 + correction_steps = random.randint(2, 5) + for i in range(correction_steps): + correction = overshoot * (1 - (i + 1) / correction_steps) + noise = random.uniform(-0.3, 0.3) + tracks.append(round(distance + correction + noise, 1)) + + # 最终稳定阶段 - 减少调整次数 + final_adjustments = random.randint(1, 3) # 减少最终调整次数 + target_final = distance + random.uniform(-1, 2) + + for i in range(final_adjustments): + # 最终的细微调整 + adjustment = random.uniform(-0.5, 0.5) + target_final += adjustment + tracks.append(round(target_final, 1)) + + # 清理和优化轨迹:减少冗余点 + cleaned_tracks = [tracks[0]] + last_pos = tracks[0] + + for i in range(1, len(tracks)): + current_pos = tracks[i] + + # 跳过变化太小的点,减少轨迹点数 + if abs(current_pos - last_pos) < 1.5: + continue + + # 允许小幅回退,但防止大幅回退 + if current_pos >= last_pos or (last_pos - current_pos) < 3: + cleaned_tracks.append(current_pos) + last_pos = current_pos + else: + # 大幅回退时进行修正 + corrected_pos = last_pos + random.uniform(0.1, 1.0) + cleaned_tracks.append(corrected_pos) + last_pos = corrected_pos + + # 根据目标点数进行智能采样 + if target_points and len(cleaned_tracks) != target_points: + if len(cleaned_tracks) > target_points: + # 点数过多,进行下采样 + step = len(cleaned_tracks) / target_points + optimized_tracks = [cleaned_tracks[0]] # 保持起始点 + + for i in range(1, target_points - 1): + idx = min(int(i * step), len(cleaned_tracks) - 1) + optimized_tracks.append(cleaned_tracks[idx]) + + # 确保包含最后一个点 + if len(cleaned_tracks) > 1: + optimized_tracks.append(cleaned_tracks[-1]) + + cleaned_tracks = optimized_tracks + else: + # 点数过少,进行插值上采样 + while len(cleaned_tracks) < target_points and len(cleaned_tracks) > 1: + new_tracks = [cleaned_tracks[0]] + + for i in range(len(cleaned_tracks) - 1): + new_tracks.append(cleaned_tracks[i]) + # 在两点之间插入中点 + if len(new_tracks) < target_points: + mid_point = (cleaned_tracks[i] + cleaned_tracks[i + 1]) / 2 + mid_point += random.uniform(-0.5, 0.5) # 添加小幅随机 + new_tracks.append(mid_point) + + new_tracks.append(cleaned_tracks[-1]) # 最后一个点 + cleaned_tracks = new_tracks + + if len(cleaned_tracks) >= target_points: + cleaned_tracks = cleaned_tracks[:target_points] + break + elif not target_points and len(cleaned_tracks) > 200: + # 默认情况:控制在200个点以内 + step = max(1, len(cleaned_tracks) // 150) + optimized_tracks = [] + for i in range(0, len(cleaned_tracks), step): + optimized_tracks.append(cleaned_tracks[i]) + if optimized_tracks[-1] != cleaned_tracks[-1]: + optimized_tracks.append(cleaned_tracks[-1]) + cleaned_tracks = optimized_tracks + + return [int(x) for x in cleaned_tracks] + + def get_cookies(self, url, existing_cookies_str: str = None, cookie_id: str = "unknown"): + """ + 获取页面 cookies,增加重试机制 + :param url: 目标页面 URL + :param existing_cookies_str: 现有的cookies字符串,用于设置到浏览器 + :param cookie_id: Cookie ID,用于日志记录 + :return: cookies 字符串或 None + """ + try: + # 设置cookie_id用于日志记录 + self.cookie_id = cookie_id + + # 记录验证开始时间 + verification_start_time = time.time() + # 如果提供了现有cookies,先设置到浏览器 + if existing_cookies_str: + logger.info("设置现有cookies到浏览器") + self.set_cookies_from_string(existing_cookies_str) + + for attempt in range(self.max_retries): + try: + # 启动网络监听(如果支持) + listen_started = False + try: + self.page.listen.start('slide') # 监听包含 'slide' 的请求 + listen_started = True + except Exception as e: + logger.warning(f"无法启动网络监听: {e}") + + # 只在第一次或需要刷新时打开页面 + if attempt == 0: + logger.info("首次打开页面") + self.page.get(url) # 打开页面 + time.sleep(random.uniform(1, 3)) # 随机等待,避免被检测 + + # 在页面加载后注入鼠标轨迹可视化 + if self.show_mouse_trace: + logger.info("页面加载完成,重新注入鼠标轨迹可视化...") + self._inject_mouse_trace_visualization() + elif hasattr(self, 'Refresh') and self.Refresh: + logger.info("根据策略刷新页面") + self.page.refresh() + time.sleep(random.uniform(2, 4)) + self.Refresh = False # 重置刷新标志 + + # 页面刷新后重新注入鼠标轨迹可视化 + if self.show_mouse_trace: + logger.info("页面刷新完成,重新注入鼠标轨迹可视化...") + self._inject_mouse_trace_visualization() + else: + logger.info("不刷新页面,点击重试按钮") + # 尝试点击重试按钮 + try: + # 查找重试按钮 + retry_button = None + retry_selectors = [ + "#nc_1_refresh1", # 主要重试按钮ID + "#nc_1_refresh2", # 图标ID + ".errloading", # 错误提示框class + "x://div[contains(@class,'errloading')]", # xpath查找错误提示框 + "x://div[contains(text(),'验证失败')]", # 包含文本的div + ".nc_iconfont.icon_warn" # 警告图标class + ] + + for selector in retry_selectors: + try: + retry_button = self.page.ele(selector, timeout=2) + if retry_button: + logger.info(f"找到重试按钮: {selector}") + break + except: + continue + + if retry_button: + # 模拟人类点击行为 + retry_button.hover() + time.sleep(random.uniform(0.2, 0.5)) + retry_button.click() + logger.info("成功点击重试按钮") + time.sleep(random.uniform(1, 2)) # 等待重新加载验证码 + else: + logger.warning("未找到重试按钮,等待后直接重试") + time.sleep(random.uniform(1, 2)) + + except Exception as retry_error: + logger.warning(f"点击重试按钮失败: {retry_error}") + time.sleep(random.uniform(0.5, 1.5)) + + # 在滑动前强制重新注入轨迹可视化 + if self.show_mouse_trace: + logger.info("滑动前强制重新注入鼠标轨迹可视化...") + self._inject_mouse_trace_visualization() + + # 等待一下确保注入完成 + time.sleep(0.5) + + self._slide() # 处理滑块验证码 + + if not self._detect_captcha(): + logger.info("滑块验证成功,开始获取 cookies") + + # 方法1: 尝试从监听数据获取新的cookies + new_cookies_from_response = None + if listen_started: + try: + # 使用正确的方式获取监听到的请求 + packet_count = 0 + for packet in self.page.listen.steps(count=10): # 最多检查10个数据包 + packet_count += 1 + if 'slide' in packet.url: + # 获取响应头 + try: + response_headers = packet.response.headers + # 尝试多种可能的Set-Cookie头名称 + set_cookie = (response_headers.get('Set-Cookie') or + response_headers.get('set-cookie') or + response_headers.get('SET-COOKIE')) + + if set_cookie: + logger.info(f"从响应头获取到新的cookies") + new_cookies_from_response = set_cookie + break + except Exception as header_e: + logger.warning(f"获取响应头失败: {header_e}") + + # 如果没有更多数据包,跳出循环 + if packet_count >= 10: + break + + except Exception as e: + logger.warning(f"从监听数据获取 cookies 失败: {e}") + + # 方法2: 直接从浏览器获取当前所有cookies + current_cookies_str = self.get_cookies_string() + + # 优先返回从响应头获取的新cookies,否则返回浏览器当前cookies + result_cookies = new_cookies_from_response or current_cookies_str + + if result_cookies: + logger.info("滑块验证成功,获取到cookies") + + # 记录滑块验证成功到日志文件 + verification_duration = time.time() - verification_start_time + log_captcha_event(self.cookie_id, "滑块验证成功", True, + f"耗时: {verification_duration:.2f}秒, 滑动次数: {self.slide_attempt}, cookies长度: {len(result_cookies)}") + + if listen_started: + self.page.listen.stop() + self.close() + return result_cookies + else: + logger.warning("滑块验证成功但未获取到有效cookies") + + # 记录滑块验证成功但cookies无效的情况 + verification_duration = time.time() - verification_start_time + log_captcha_event(self.cookie_id, "滑块验证失败", False, + f"耗时: {verification_duration:.2f}秒, 滑动次数: {self.slide_attempt}, 原因: 验证成功但未获取到有效cookies") + else: + logger.warning(f"第 {attempt + 1} 次滑动验证失败,页面标题: {self.page.title}") + + # 记录单次滑动验证失败 + if attempt == self.max_retries - 1: # 最后一次尝试失败时记录 + verification_duration = time.time() - verification_start_time + log_captcha_event(self.cookie_id, "滑块验证失败", False, + f"耗时: {verification_duration:.2f}秒, 滑动次数: {self.slide_attempt}, 原因: 所有{self.max_retries}次尝试都失败") + + # 清理监听,准备下次重试 + if attempt < self.max_retries - 1: + logger.info(f"准备第 {attempt + 2} 次重试...") + if listen_started: + self.page.listen.stop() + listen_started = False + + except Exception as e: + logger.error(f"获取 Cookies 失败(第 {attempt + 1} 次): {e}") + + # 记录滑块验证异常 + if attempt == self.max_retries - 1: # 最后一次尝试异常时记录 + verification_duration = time.time() - verification_start_time + log_captcha_event(self.cookie_id, "滑块验证异常", False, + f"耗时: {verification_duration:.2f}秒, 滑动次数: {getattr(self, 'slide_attempt', 0)}, 异常: {str(e)[:100]}") + + # 确保清理监听 + try: + self.page.listen.stop() + except: + pass + + logger.error("超过最大重试次数,获取 cookies 失败") + + # 记录最终失败 + verification_duration = time.time() - verification_start_time + log_captcha_event(self.cookie_id, "滑块验证最终失败", False, + f"耗时: {verification_duration:.2f}秒, 滑动次数: {getattr(self, 'slide_attempt', 0)}, 原因: 超过最大重试次数({self.max_retries})") + + return None + + finally: + # 确保浏览器被关闭 + try: + self.close() + except: + pass + + + def _calculate_slide_distance(self): + """ + 动态计算滑动距离,适应不同分辨率 + """ + try: + # 获取页面尺寸 + page_width = self.page.size[0] if hasattr(self.page, 'size') else 1920 + page_height = self.page.size[1] if hasattr(self.page, 'size') else 1080 + + logger.info(f"检测到页面尺寸: {page_width}x{page_height}") + + # 尝试获取滑块轨道的实际宽度 + try: + # 查找滑块轨道元素 + track_selectors = [ + "#nc_1__scale_text", # 滑块轨道 + ".nc-lang-cnt", # 验证码容器 + "#nc_1_wrapper", # 外层容器 + ".nc_wrapper" # 通用容器 + ] + + track_width = None + for selector in track_selectors: + try: + track_element = self.page.ele(selector, timeout=2) + if track_element: + track_rect = track_element.rect + if track_rect and track_rect.width > 0: + track_width = track_rect.width + logger.info(f"找到轨道元素 {selector},宽度: {track_width}px") + break + except: + continue + + if track_width: + # 基于实际轨道宽度计算滑动距离 + # 通常需要滑动轨道宽度的70%-90% + slide_ratio = random.uniform(0.70, 0.90) + calculated_distance = int(track_width * slide_ratio) + + # 添加小幅随机变化 + distance_variation = random.randint(-20, 20) + final_distance = calculated_distance + distance_variation + + # 确保距离在合理范围内 + final_distance = max(200, min(600, final_distance)) + + logger.info(f"基于轨道宽度计算: {track_width}px * {slide_ratio:.2f} = {calculated_distance}px, 最终距离: {final_distance}px") + return final_distance + + except Exception as track_e: + logger.warning(f"获取轨道宽度失败: {track_e}") + + # 备用方案:基于页面宽度估算 + if page_width <= 1366: # 小屏幕 + base_distance = random.randint(250, 320) + logger.info(f"小屏幕模式 ({page_width}px): 使用距离 {base_distance}px") + elif page_width <= 1920: # 中等屏幕 + base_distance = random.randint(300, 400) + logger.info(f"中等屏幕模式 ({page_width}px): 使用距离 {base_distance}px") + else: # 大屏幕 + base_distance = random.randint(350, 480) + logger.info(f"大屏幕模式 ({page_width}px): 使用距离 {base_distance}px") + + return base_distance + + except Exception as e: + logger.warning(f"动态距离计算失败: {e},使用默认距离") + # 默认距离 + return 300 + random.randint(1, 100) + + def _inject_mouse_trace_visualization(self): + """注入鼠标轨迹可视化代码""" + try: + logger.info("注入鼠标轨迹可视化代码...") + + # CSS样式 - 更醒目的设计 + css_code = """ + + """ + + # JavaScript代码 + js_code = """ + // 创建鼠标轨迹可视化 + window.mouseTracePoints = []; + window.slideInfo = null; + window.traceStatus = null; + + // 静默状态提示 - 不显示遮挡页面的元素 + function createStatusIndicator() { + // 静默模式,不创建状态提示 + console.log('🖱️ 鼠标轨迹可视化已启用(静默模式)'); + } + + // 静默信息面板 - 不显示遮挡页面的元素 + function createInfoPanel() { + // 静默模式,不创建信息面板 + window.slideInfo = null; + } + + // 静默更新信息 + function updateInfo(text) { + // 静默模式,不显示信息面板 + // console.log('轨迹信息:', text); // 可选:输出到控制台用于调试 + } + + // 创建鼠标轨迹点 + function createTracePoint(x, y) { + const point = document.createElement('div'); + point.className = 'mouse-trace'; + point.style.left = x + 'px'; + point.style.top = y + 'px'; + document.body.appendChild(point); + + window.mouseTracePoints.push(point); + + // 限制轨迹点数量 + if (window.mouseTracePoints.length > 100) { + const oldPoint = window.mouseTracePoints.shift(); + if (oldPoint && oldPoint.parentNode) { + oldPoint.parentNode.removeChild(oldPoint); + } + } + + // 设置淡出效果 + setTimeout(() => { + point.classList.add('fade'); + setTimeout(() => { + if (point && point.parentNode) { + point.parentNode.removeChild(point); + } + }, 500); + }, 1000); + } + + // 创建鼠标光标指示器 + function createMouseCursor() { + if (document.querySelector('.mouse-cursor')) return; + const cursor = document.createElement('div'); + cursor.className = 'mouse-cursor'; + document.body.appendChild(cursor); + return cursor; + } + + // 监听鼠标移动 + let lastX = 0, lastY = 0; + let moveCount = 0; + let startTime = null; + + document.addEventListener('mousemove', function(e) { + const cursor = document.querySelector('.mouse-cursor') || createMouseCursor(); + cursor.style.left = e.clientX + 'px'; + cursor.style.top = e.clientY + 'px'; + + // 记录轨迹点 - 降低阈值,显示更多轨迹点 + if (Math.abs(e.clientX - lastX) > 1 || Math.abs(e.clientY - lastY) > 1) { + createTracePoint(e.clientX, e.clientY); + lastX = e.clientX; + lastY = e.clientY; + moveCount++; + + if (!startTime) startTime = Date.now(); + + const elapsed = (Date.now() - startTime) / 1000; + updateInfo(`🖱️ 鼠标轨迹可视化
📊 移动次数: ${moveCount}
⏱️ 经过时间: ${elapsed.toFixed(1)}s
📍 当前位置: (${e.clientX}, ${e.clientY})
🔴 轨迹点: ${window.mouseTracePoints.length}`); + } + }); + + // 监听鼠标按下和释放 + document.addEventListener('mousedown', function(e) { + updateInfo(`鼠标轨迹可视化
鼠标按下: (${e.clientX}, ${e.clientY})
开始滑动...`); + startTime = Date.now(); + moveCount = 0; + }); + + document.addEventListener('mouseup', function(e) { + const elapsed = startTime ? (Date.now() - startTime) / 1000 : 0; + updateInfo(`鼠标轨迹可视化
鼠标释放: (${e.clientX}, ${e.clientY})
滑动完成
总时间: ${elapsed.toFixed(2)}s
总移动: ${moveCount}次`); + }); + + // 静默测试按钮 - 不显示遮挡页面的元素 + function createTestButton() { + // 静默模式,不创建测试按钮 + console.log('🖱️ 测试按钮已禁用(静默模式)'); + } + + // 初始化 + createInfoPanel(); + createMouseCursor(); + createStatusIndicator(); + createTestButton(); + + // 静默模式控制台输出 + console.log('🖱️ 鼠标轨迹可视化已启用(静默模式)- 仅显示轨迹点和光标'); + """ + + # 安全注入CSS - 等待DOM准备好 + css_inject_js = f""" + (function() {{ + function injectCSS() {{ + if (!document.head) {{ + // 如果head不存在,创建一个 + if (!document.documentElement) {{ + return false; + }} + const head = document.createElement('head'); + document.documentElement.appendChild(head); + }} + + // 检查是否已经注入过CSS + if (document.querySelector('style[data-mouse-trace-css]')) {{ + return true; + }} + + const style = document.createElement('style'); + style.setAttribute('data-mouse-trace-css', 'true'); + style.innerHTML = `{css_code.replace('', '')}`; + document.head.appendChild(style); + return true; + }} + + // 如果DOM还没准备好,等待 + if (document.readyState === 'loading') {{ + document.addEventListener('DOMContentLoaded', injectCSS); + }} else {{ + injectCSS(); + }} + }})(); + """ + + self.page.run_js(css_inject_js) + + # 等待一下确保CSS注入完成 + time.sleep(0.2) + + # 安全注入JavaScript + safe_js_code = f""" + (function() {{ + // 确保DOM和body存在 + if (!document.body) {{ + setTimeout(arguments.callee, 100); + return; + }} + + {js_code} + }})(); + """ + + self.page.run_js(safe_js_code) + + logger.info("鼠标轨迹可视化代码注入成功") + + except Exception as e: + logger.warning(f"注入鼠标轨迹可视化失败: {e}") + + def _javascript_maximize(self): + """使用JavaScript尝试最大化窗口""" + try: + js_code = """ + // 尝试多种JavaScript最大化方法 + try { + // 方法1: 移动窗口到左上角并调整大小 + window.moveTo(0, 0); + window.resizeTo(screen.availWidth, screen.availHeight); + + // 方法2: 使用现代API + if (window.screen && window.screen.availWidth) { + window.resizeTo(window.screen.availWidth, window.screen.availHeight); + } + + // 方法3: 设置窗口外观尺寸 + if (window.outerWidth && window.outerHeight) { + var maxWidth = screen.availWidth || 1920; + var maxHeight = screen.availHeight || 1080; + window.resizeTo(maxWidth, maxHeight); + window.moveTo(0, 0); + } + + console.log('JavaScript窗口最大化尝试完成'); + console.log('当前窗口尺寸:', window.outerWidth + 'x' + window.outerHeight); + console.log('屏幕可用尺寸:', screen.availWidth + 'x' + screen.availHeight); + + } catch (e) { + console.log('JavaScript最大化失败:', e); + } + """ + + self.page.run_js(js_code) + logger.debug("JavaScript最大化代码执行完成") + + except Exception as e: + logger.debug(f"JavaScript最大化执行失败: {e}") + + def _force_maximize_windows(self): + """使用Windows API强制最大化浏览器窗口""" + try: + import platform + if platform.system() != "Windows": + return + + # 尝试导入Windows API + try: + import win32gui + import win32con + + # 查找Chrome浏览器窗口 + def enum_windows_callback(hwnd, windows): + if win32gui.IsWindowVisible(hwnd): + window_title = win32gui.GetWindowText(hwnd) + class_name = win32gui.GetClassName(hwnd) + + # 查找Chrome窗口 + if ("Chrome" in class_name or "chrome" in window_title.lower() or + "Google Chrome" in window_title or "Chromium" in window_title): + windows.append(hwnd) + return True + + windows = [] + win32gui.EnumWindows(enum_windows_callback, windows) + + if windows: + # 最大化最新的Chrome窗口 + hwnd = windows[-1] + logger.info(f"找到Chrome窗口,正在强制最大化...") + + # 显示窗口并最大化 + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) # 先恢复 + win32gui.ShowWindow(hwnd, win32con.SW_MAXIMIZE) # 再最大化 + + # 将窗口置于前台 + win32gui.SetForegroundWindow(hwnd) + + logger.info("Windows API强制最大化完成") + else: + logger.debug("未找到Chrome窗口") + + except ImportError: + logger.debug("pywin32未安装,跳过Windows API最大化") + except Exception as api_e: + logger.debug(f"Windows API操作失败: {api_e}") + + except Exception as e: + logger.debug(f"强制最大化失败: {e}") + + def _detect_captcha(self): + """检测页面是否被拦截""" + return self.page.title == "验证码拦截" + + def adjust_y_offset_settings(self, y_drift_range=None, shake_range=None, + fast_move_multiplier=None, directional_range=None, max_y_offset=None): + """ + 调整垂直偏移量设置 + + :param y_drift_range: 整体漂移趋势范围 ±像素(默认3) + :param shake_range: 基础抖动范围 ±像素(默认1.5) + :param fast_move_multiplier: 快速移动时的抖动放大倍数(默认1.8) + :param directional_range: 方向性偏移范围(默认1.0) + :param max_y_offset: 最大垂直偏移限制 ±像素(默认8) + """ + if y_drift_range is not None: + self.y_drift_range = y_drift_range + logger.info(f"整体漂移趋势范围调整为: ±{y_drift_range}像素") + + if shake_range is not None: + self.shake_range = shake_range + logger.info(f"基础抖动范围调整为: ±{shake_range}像素") + + if fast_move_multiplier is not None: + self.fast_move_multiplier = fast_move_multiplier + logger.info(f"快速移动抖动放大倍数调整为: {fast_move_multiplier}") + + if directional_range is not None: + self.directional_range = directional_range + logger.info(f"方向性偏移范围调整为: {directional_range}") + + if max_y_offset is not None: + self.max_y_offset = max_y_offset + logger.info(f"最大垂直偏移限制调整为: ±{max_y_offset}像素") + + logger.info("垂直偏移量设置已更新") + + def close(self): + """关闭浏览器""" + # logger.info("关闭浏览器") + self.browser.quit() + + +class XianyuApis: + def __init__(self): + self.url = 'https://h5api.m.goofish.com/h5/mtop.taobao.idlemessage.pc.login.token/1.0/' + self.session = requests.Session() + self.session.headers.update({ + 'accept': 'application/json', + 'accept-language': 'zh-CN,zh;q=0.9', + 'cache-control': 'no-cache', + 'origin': 'https://www.goofish.com', + 'pragma': 'no-cache', + 'priority': 'u=1, i', + 'referer': 'https://www.goofish.com/', + 'sec-ch-ua': '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-site', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36', + }) + + def clear_duplicate_cookies(self): + """清理重复的cookies""" + # 创建一个新的CookieJar + new_jar = requests.cookies.RequestsCookieJar() + + # 记录已经添加过的cookie名称 + added_cookies = set() + + # 按照cookies列表的逆序遍历(最新的通常在后面) + cookie_list = list(self.session.cookies) + cookie_list.reverse() + + for cookie in cookie_list: + # 如果这个cookie名称还没有添加过,就添加到新jar中 + if cookie.name not in added_cookies: + new_jar.set_cookie(cookie) + added_cookies.add(cookie.name) + + # 替换session的cookies + self.session.cookies = new_jar + + def hasLogin(self, retry_count=0): + """调用hasLogin.do接口进行登录状态检查""" + if retry_count >= 2: + logger.error("Login检查失败,重试次数过多") + return False + + try: + url = 'https://passport.goofish.com/newlogin/silentHasLogin.do' + params = { + 'documentReferer':"https%3A%2F%2Fwww.goofish.com%2F", + 'appEntrance':'xianyu_sdkSilent', + 'appName': 'xianyu', + 'fromSite': '0', + 'ltl':'true' + } + data = { + 'hid': self.session.cookies.get('unb', ''), + 'ltl': 'true', + 'appName': 'xianyu', + 'appEntrance': 'web', + '_csrf_token': self.session.cookies.get('XSRF-TOKEN', ''), + 'umidToken': '', + 'hsiz': self.session.cookies.get('cookie2', ''), + 'mainPage': 'false', + 'isMobile': 'false', + 'lang': 'zh_CN', + 'returnUrl': '', + 'fromSite': '77', + 'isIframe': 'true', + 'documentReferer': 'https://www.goofish.com/', + 'defaultView': 'hasLogin', + 'umidTag': 'SERVER', + 'deviceId': self.session.cookies.get('cna', '') + } + + response = self.session.post(url, params=params, data=data) + res_json = response.json() + if res_json.get('content', {}).get('success'): + logger.debug("Login成功") + # 清理和更新cookies + self.clear_duplicate_cookies() + return True + else: + logger.warning(f"Login失败: {res_json}") + time.sleep(0.5) + return self.hasLogin(retry_count + 1) + + except Exception as e: + logger.error(f"Login请求异常: {str(e)}") + time.sleep(0.5) + return self.hasLogin(retry_count + 1) + + def get_token(self, device_id, retry_count=0): + if retry_count >= 2: # 最多重试3次 + logger.warning("获取token失败,尝试重新登陆") + # 尝试通过hasLogin重新登录 + if self.hasLogin(): + logger.info("重新登录成功,重新尝试获取token") + return self.get_token(device_id, 0) # 重置重试次数 + else: + logger.error("重新登录失败,Cookie已失效") + logger.error("🔴 程序即将退出,请更新.env文件中的COOKIES_STR后重新启动") + return False# sys.exit(1) # 直接退出程序 + + params = { + 'jsv': '2.7.2', + 'appKey': '34839810', + 't': str(int(time.time()) * 1000), + 'sign': '', + 'v': '1.0', + 'type': 'originaljson', + 'accountSite': 'xianyu', + 'dataType': 'json', + 'timeout': '20000', + 'api': 'mtop.taobao.idlemessage.pc.login.token', + 'sessionOption': 'AutoLoginOnly', + 'spm_cnt': 'a21ybx.im.0.0', + } + data_val = '{"appKey":"444e9908a51d1cb236a27862abc769c9","deviceId":"' + device_id + '"}' + data = { + 'data': data_val, + } + + # 简单获取token,信任cookies已清理干净 + token = self.session.cookies.get('_m_h5_tk', '').split('_')[0] + + sign = generate_sign(params['t'], token, data_val) + params['sign'] = sign + + try: + response = self.session.post('https://h5api.m.goofish.com/h5/mtop.taobao.idlemessage.pc.login.token/1.0/', params=params, data=data) + res_json = response.json() + + if isinstance(res_json, dict): + ret_value = res_json.get('ret', []) + # 检查ret是否包含成功信息 + if not any('SUCCESS::调用成功' in ret for ret in ret_value): + logger.warning(f"Token API调用失败,错误信息: {ret_value}") + # 处理响应中的Set-Cookie + if ret_value[0] == 'FAIL_SYS_USER_VALIDATE': + url = res_json["data"]["url"] + drission = DrissionHandler( + is_headless=False, + maximize_window=True, # 启用窗口最大化 + show_mouse_trace=True # 启用鼠标轨迹 + ) + cookies = drission.get_cookies(url) + if cookies: + new_x5sec = trans_cookies(cookies) + self.session.cookies.set("x5sec",new_x5sec["x5sec"]) + if 'Set-Cookie' in response.headers: + # logger.debug("检测到Set-Cookie,更新cookie") # 降级为DEBUG并简化 + self.clear_duplicate_cookies() + time.sleep(0.5) + return self.get_token(device_id, retry_count + 1) + else: + logger.info("Token获取成功") + return res_json + else: + logger.error(f"Token API返回格式异常: {res_json}") + return self.get_token(device_id, retry_count + 1) + + except Exception as e: + logger.error(f"Token API请求异常: {str(e)}") + time.sleep(0.5) + return self.get_token(device_id, retry_count + 1) + + def get_item_info(self, item_id, retry_count=0): + """获取商品信息,自动处理token失效的情况""" + if retry_count >= 3: # 最多重试3次 + logger.error("获取商品信息失败,重试次数过多") + return {"error": "获取商品信息失败,重试次数过多"} + + params = { + 'jsv': '2.7.2', + 'appKey': '34839810', + 't': str(int(time.time()) * 1000), + 'sign': '', + 'v': '1.0', + 'type': 'originaljson', + 'accountSite': 'xianyu', + 'dataType': 'json', + 'timeout': '20000', + 'api': 'mtop.taobao.idle.pc.detail', + 'sessionOption': 'AutoLoginOnly', + 'spm_cnt': 'a21ybx.im.0.0', + } + + data_val = '{"itemId":"' + item_id + '"}' + data = { + 'data': data_val, + } + + # 简单获取token,信任cookies已清理干净 + token = self.session.cookies.get('_m_h5_tk', '').split('_')[0] + + sign = generate_sign(params['t'], token, data_val) + params['sign'] = sign + + try: + response = self.session.post( + 'https://h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail/1.0/', + params=params, + data=data + ) + + res_json = response.json() + # 检查返回状态 + if isinstance(res_json, dict): + ret_value = res_json.get('ret', []) + # 检查ret是否包含成功信息 + if not any('SUCCESS::调用成功' in ret for ret in ret_value): + logger.warning(f"商品信息API调用失败,错误信息: {ret_value}") + # 处理响应中的Set-Cookie + if 'Set-Cookie' in response.headers: + logger.debug("检测到Set-Cookie,更新cookie") + self.clear_duplicate_cookies() + time.sleep(0.5) + return self.get_item_info(item_id, retry_count + 1) + else: + logger.debug(f"商品信息获取成功: {item_id}") + return res_json + else: + logger.error(f"商品信息API返回格式异常: {res_json}") + return self.get_item_info(item_id, retry_count + 1) + + except Exception as e: + logger.error(f"商品信息API请求异常: {str(e)}") + time.sleep(0.5) + return self.get_item_info(item_id, retry_count + 1) + +class XianyuLive: + def __init__(self, cookies_str): + self.xianyu = XianyuApis() + self.base_url = 'wss://wss-goofish.dingtalk.com/' + self.cookies_str = cookies_str + self.cookies = trans_cookies(cookies_str) + self.xianyu.session.cookies.update(self.cookies) # 直接使用 session.cookies.update + self.myid = self.cookies['unb'] + self.device_id = generate_device_id(self.myid) + + async def refresh_token(self): + """刷新token""" + try: + logger.info("开始刷新token...") + + # 获取新token(如果Cookie失效,get_token会直接退出程序) + token_result = self.xianyu.get_token(self.device_id) + if 'data' in token_result and 'accessToken' in token_result['data']: + new_token = token_result['data']['accessToken'] + self.current_token = new_token + self.last_token_refresh_time = time.time() + logger.info("Token刷新成功") + return new_token + else: + logger.error(f"Token刷新失败: {token_result}") + return None + + except Exception as e: + logger.error(f"Token刷新异常: {str(e)}") + return None + + +def trans_cookies(cookies_str: str) -> Dict[str, str]: + """解析cookie字符串为字典""" + cookies = {} + for cookie in cookies_str.split("; "): + try: + parts = cookie.split('=', 1) + if len(parts) == 2: + cookies[parts[0]] = parts[1] + except: + continue + return cookies + + +def generate_mid() -> str: + """生成mid""" + import random + random_part = int(1000 * random.random()) + timestamp = int(time.time() * 1000) + return f"{random_part}{timestamp} 0" + + +def generate_uuid() -> str: + """生成uuid""" + timestamp = int(time.time() * 1000) + return f"-{timestamp}1" + + +def generate_device_id(user_id: str) -> str: + """生成设备ID""" + import random + + # 字符集 + chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + result = [] + + for i in range(36): + if i in [8, 13, 18, 23]: + result.append("-") + elif i == 14: + result.append("4") + else: + if i == 19: + # 对于位置19,需要特殊处理 + rand_val = int(16 * random.random()) + result.append(chars[(rand_val & 0x3) | 0x8]) + else: + rand_val = int(16 * random.random()) + result.append(chars[rand_val]) + + return ''.join(result) + "-" + user_id + + +def generate_sign(t: str, token: str, data: str) -> str: + """生成签名""" + app_key = "34839810" + msg = f"{token}&{t}&{app_key}&{data}" + + # 使用MD5生成签名 + md5_hash = hashlib.md5() + md5_hash.update(msg.encode('utf-8')) + return md5_hash.hexdigest() + + + +async def refresh_token(cookies_str: str): + new = XianyuLive(cookies_str) + token = await new.refresh_token() + cookie_list = list(new.xianyu.session.cookies) + cookie_list.reverse() + res_Cookie = {} + for cookie in cookie_list: + value = cookie.value.split(';')[0] + res_Cookie[cookie.name] = value.strip() + return token,res_Cookie + +# 测试代码已注释,避免与运行中的事件循环冲突 +# ck = 'cna=2x0VIEthuBgCAQFBy07P5aax; t=92064eac9aab68795e909c84b6666cd4; tracknick=xy771982658888; unb=2219383264998; havana_lgc2_77=eyJoaWQiOjIyMTkzODMyNjQ5OTgsInNnIjoiYjk0M2ExZGM5NmRmYjQzMjE3M2ZiOGY4OGU3MTAxNjAiLCJzaXRlIjo3NywidG9rZW4iOiIxb3IzSnhCTEZXR2p1RUtvUjJIanJpUSJ9; _hvn_lgc_=77; havana_lgc_exp=1756475265341; isg=BFtbbqoIt2xEAssYNMFYqNXj6r_FMG8y1w9dDU2YIdpxLHsO1RbTgimvwoSiDMcq; cookie2=195d0616914d49cdc0f3814853a178f5; mtop_partitioned_detect=1; _m_h5_tk=cc382a584eaa9c3199f16b836d65261b_1754570160098; _m_h5_tk_enc=601c990574902ba494c5a64d56c323fc; _samesite_flag_=true; sgcookie=E100SV3cXtpyaSTASi3UJw1CozDY5JER%2FtZyBK%2FGpP70RTciu6SlJpnymRbPfhlmD%2F4lwLWX%2B1i9MA6JoFYX82tG%2FFjvLA3r4Jo9TFBttkpNkSFR6Hji6h2zGCJBo6%2BgXrK9; csg=bf0b371b; _tb_token_=34333886818b6; sdkSilent=1754483979355; x5sec=7b22733b32223a2231306464633338373739333435393936222c22617365727665723b33223a22307c43494353794d5147454d446c2b4e4c362f2f2f2f2f774561447a49794d546b7a4f444d794e6a51354f5467374e4369414244443271636d4c41773d3d227d; tfstk=gBHndvwmpXPBuhWCt32BODnGdSOtOJw7T4B8y8Uy_Pz1JwBK4a0o7qndpYnzr40T54VWAzUzr4nr9hpvHDiQF8ukkKpvyneHa4qFU9ow4oZlahFU81Acc88vkdCObWTLU4EmqsPZbPZu4azEzGya2PrUY9uz75r4DwyzU4-gQoqhzzPzz57amPzzU8urbhq77kyzU4owjuGW4TzmUAMwr4TgRGwn7Aq3trow1TWoxtFUuDzGUNDgx94qYPXPEyKbDMoiveXjXJMmoo3pQ90m4mGzsYbwozMibvlrfwxancctMWcM8T4tpPNxL-jyLc23-SkgggXxucmrM5D90Kw3Lyl8d0IDJcDnJDMi2g5aKJhgiv22hwz-6mDuqYTWIqmrcbPiEwjzz152s367b3HNN_NUfl4YFvklQOvVV0KMjsOQTlZpkhxGN_NUfl4vjhffPWr_vEC..; x5secdata=xg83ae4e91aa7f9ddajaec50940bbd5e854a6fb0457cb4876a021754575403a-717315356a829576438abaac3en2219383264998a0__bx__h5api.m.goofish.com:443/h5/mtop.taobao.idlemessage.pc.login.token/1.0; x5sectag=122733' +# print(asyncio.run(refresh_token(ck))) \ No newline at end of file diff --git a/utils/xianyu_slider_stealth.cp312-win_amd64.pyd b/utils/xianyu_slider_stealth.cp312-win_amd64.pyd new file mode 100644 index 0000000..fc1adf9 Binary files /dev/null and b/utils/xianyu_slider_stealth.cp312-win_amd64.pyd differ diff --git a/utils/xianyu_slider_stealth.cpython-311-x86_64-linux-gnu.so b/utils/xianyu_slider_stealth.cpython-311-x86_64-linux-gnu.so new file mode 100644 index 0000000..4ebc9f4 Binary files /dev/null and b/utils/xianyu_slider_stealth.cpython-311-x86_64-linux-gnu.so differ diff --git a/utils/xianyu_slider_stealth.pyi b/utils/xianyu_slider_stealth.pyi new file mode 100644 index 0000000..cf02885 --- /dev/null +++ b/utils/xianyu_slider_stealth.pyi @@ -0,0 +1,88 @@ +# This file was generated by Nuitka + +# Stubs included by default +from __future__ import annotations +from config import SLIDER_VERIFICATION +from datetime import datetime +from loguru import logger +from playwright.sync_api import ElementHandle, sync_playwright +from typing import Any, Dict, List, Optional, Tuple +from typing_extensions import Self +import json +import os +import random +import shutil +import tempfile +import threading +import time + +SLIDER_MAX_CONCURRENT = 3 +SLIDER_WAIT_TIMEOUT = 60 +class SliderConcurrencyManager: + def __new__(cls: cls) -> Any: ... + def __init__(self: Self) -> None: ... + def can_start_instance(self: Self, user_id: str) -> bool: ... + def wait_for_slot(self: Self, user_id: str, timeout: int) -> bool: ... + def register_instance(self: Self, user_id: str, instance: Any) -> Any: ... + def unregister_instance(self: Self, user_id: str) -> Any: ... + def _extract_pure_user_id(self: Self, user_id: str) -> str: ... + def get_stats(self: Self) -> Any: ... + +class XianyuSliderStealth: + def __init__(self: Self, user_id: str, enable_learning: bool, headless: bool) -> None: ... + def _check_date_validity(self: Self) -> bool: ... + def init_browser(self: Self) -> Any: ... + def _cleanup_on_init_failure(self: Self) -> Any: ... + def _load_success_history(self: Self) -> List[Dict[str, Any]]: ... + def _save_success_record(self: Self, trajectory_data: Dict[str, Any]) -> Any: ... + def _optimize_trajectory_params(self: Self) -> Dict[str, Any]: ... + def _get_cookies_after_success(self: Self) -> Any: ... + def _save_cookies_to_file(self: Self, cookies: Any) -> Any: ... + def _get_random_browser_features(self: Self) -> Any: ... + def _get_stealth_script(self: Self, browser_features: Any) -> Any: ... + def generate_human_trajectory(self: Self, distance: float) -> Any: ... + def simulate_slide(self: Self, slider_button: ElementHandle, trajectory: Any) -> Any: ... + def find_slider_elements(self: Self) -> Any: ... + def calculate_slide_distance(self: Self, slider_button: ElementHandle, slider_track: ElementHandle) -> Any: ... + def check_verification_success(self: Self, slider_button: ElementHandle) -> Any: ... + def check_page_changed(self: Self) -> Any: ... + def check_verification_failure(self: Self) -> Any: ... + def solve_slider(self: Self) -> Any: ... + def close_browser(self: Self) -> Any: ... + def __del__(self: Self) -> Any: ... + def login_with_password_headful(self: Self, account: str, password: str, show_browser: bool) -> Any: ... + def run(self: Self, url: str) -> Any: ... + +def get_slider_stats() -> Any: + ... + + +__name__ = ... + + + +# Modules used internally, to allow implicit dependencies to be seen: +import time +import random +import json +import os +import threading +import tempfile +import shutil +import datetime +import playwright +import playwright.sync_api +import playwright.sync_api.sync_playwright +import playwright.sync_api.ElementHandle +import typing +import loguru +import loguru.logger +import config +import traceback +import re +import DrissionPage +import DrissionPage.ChromiumPage +import DrissionPage.ChromiumOptions +import subprocess +import platform +import sys \ No newline at end of file