diff --git a/backend/ADSPOWER_USAGE.md b/backend/ADSPOWER_USAGE.md new file mode 100644 index 0000000..bfa9e7a --- /dev/null +++ b/backend/ADSPOWER_USAGE.md @@ -0,0 +1,207 @@ +# AdsPower 指纹浏览器配置指南 + +## 问题:为什么点击"获取验证码"打开的是默认Chrome? + +当前代码默认使用**普通Playwright浏览器**,不是**AdsPower指纹浏览器**。 + +要启用AdsPower模式,需要: +1. **启动AdsPower应用** +2. **配置AdsPower参数**(见下文) +3. **代码中启用AdsPower模式** + +--- + +## AdsPower 配置方法 + +### 方法1:修改YAML配置文件(**推荐**) + +在 `config.dev.yaml` 或 `config.prod.yaml` 中配置: + +```yaml +# ========== AdsPower指纹浏览器配置 ========== +adspower: + enabled: true # 是否启用AdsPower + api_base: "http://local.adspower.net:50325" # AdsPower API地址 + api_key: "e5afd5a4cead5589247febbeabc39bcb" # API Key(可选) + user_id: "user_h235l72" # 用户ID(可选) + default_group_id: "0" # 默认分组ID + # 指纹配置 + fingerprint: + automatic_timezone: true # 自动设置时区 + language: ["zh-CN", "zh"] # 浏览器语言 + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +``` + +### 方法2:使用环境变量 + +在系统环境变量或 `.env` 文件中设置: + +```bash +# AdsPower配置 +ADSPOWER_ENABLED=true +ADSPOWER_API_BASE=http://local.adspower.net:50325 +ADSPOWER_API_KEY=e5afd5a4cead5589247febbeabc39bcb +ADSPOWER_USER_ID=user_h235l72 +ADSPOWER_DEFAULT_GROUP_ID=0 +``` + +**注意**:环境变量优先级高于YAML配置。 + +--- + +## 获取AdsPower配置参数 + +### 1. API地址 (api_base) +- 默认本地地址:`http://local.adspower.net:50325` +- 或使用:`http://127.0.0.1:50325` + +### 2. API Key (api_key) +1. 打开AdsPower应用 +2. 点击右上角**设置** → **安全** → **本地API** +3. 开启"允许本地API访问" +4. 复制API Key + +### 3. User ID (user_id) +1. 打开AdsPower应用 +2. 点击右上角头像 +3. 查看"用户ID"(如:`user_h235l72`) + +### 4. Profile ID (adspower_profile_id) +- 在AdsPower中创建浏览器配置 +- 右键配置 → 复制配置ID(如:`jvqjvvp`) +- 如果不指定,系统会自动创建新配置 + +--- + +## 代码中启用AdsPower模式 + +### 方法1:直接在创建XHSLoginService时指定 + +```python +from xhs_login import XHSLoginService + +# 启用AdsPower模式 +service = XHSLoginService( + use_adspower=True, # 关键参数 + adspower_profile_id="jvqjvvp" # 可选:指定配置ID +) + +# 发送验证码 +await service.send_verification_code( + phone="13800138000", + proxy={'server': 'http://proxy_ip:port', 'username': 'user', 'password': 'pass'} # 可选 +) +``` + +### 方法2:修改main.py中的登录接口 + +在 `main.py` 中找到登录相关接口,修改为: + +```python +from config import get_config + +@app.post("/api/xhs/send-code") +async def send_verification_code(request: SendCodeRequest): + try: + # 从配置读取是否启用AdsPower + config = get_config() + use_adspower = config.get_bool('adspower.enabled', False) + + service = XHSLoginService( + use_adspower=use_adspower, # 使用配置项 + headless=False + ) + + result = await service.send_verification_code(phone=request.phone) + return result + except Exception as e: + return {"success": False, "message": str(e)} +``` + +这样就可以通过YAML配置文件中的 `adspower.enabled` 来控制是否使用AdsPower。 + +--- + +## 验证步骤 + +### 1. 确认AdsPower正在运行 +```python +from fingerprint_browser import FingerprintBrowserManager + +manager = FingerprintBrowserManager() +is_running = await manager.check_adspower_status() +print(f"AdsPower运行状态: {is_running}") +``` + +### 2. 查看已有配置列表 +```python +profiles = await manager.get_browser_profiles() +for profile in profiles: + print(f"配置ID: {profile['user_id']}, 名称: {profile['name']}") +``` + +### 3. 测试发送验证码 +- 启动服务后调用发送验证码接口 +- 观察打开的浏览器窗口标题: + - AdsPower模式:窗口标题会显示"AdsPower" + - 普通模式:普通Chrome窗口 + +--- + +## AdsPower vs 普通Chrome的区别 + +| 特性 | AdsPower | 普通Chrome | +|------|----------|------------| +| 指纹隔离 | ✅ 每个配置独立指纹 | ❌ 使用本机真实指纹 | +| 环境隔离 | ✅ Cookie、缓存隔离 | ❌ 共享环境 | +| 反检测 | ✅ 模拟真实设备 | ❌ 易被识别为自动化 | +| 多账号管理 | ✅ 支持多配置 | ❌ 不便于管理 | +| 代理管理 | ✅ 配置级代理 | ⚠️ 需要手动设置 | + +**建议**:生产环境强烈建议使用AdsPower模式,以避免账号风控。 + +--- + +## 常见问题 + +### Q1: "AdsPower 未运行,请先启动 AdsPower" +**解决方法**: +1. 确认AdsPower应用已启动 +2. 检查API地址是否正确(默认50325端口) +3. 确认防火墙没有阻止本地API + +### Q2: API调用返回401/403错误 +**解决方法**: +1. 检查API Key是否正确 +2. 确认在AdsPower设置中开启了"允许本地API访问" +3. 某些版本AdsPower不需要API Key,可以留空 + +### Q3: 浏览器启动失败 +**解决方法**: +1. 检查AdsPower版本是否最新 +2. 确认配置ID存在且可用 +3. 查看AdsPower日志排查错误 + +### Q4: 想要为不同用户使用不同的AdsPower配置 +**解决方法**: +```python +# 根据用户选择不同的profile_id +user_profile_mapping = { + "13800138000": "jvqjvvp", + "13900139000": "kprklms" +} + +profile_id = user_profile_mapping.get(phone) +service = XHSLoginService( + use_adspower=True, + adspower_profile_id=profile_id +) +``` + +--- + +## 参考资料 + +- [AdsPower官方文档](https://www.adspower.net/) +- [AdsPower API文档](https://localapi-doc-en.adspower.com/) +- [ai_mip项目参考](../ai_mip/fingerprint_browser.py) diff --git a/backend/XHS_LOGIN_ENHANCEMENT.md b/backend/XHS_LOGIN_ENHANCEMENT.md new file mode 100644 index 0000000..d595bba --- /dev/null +++ b/backend/XHS_LOGIN_ENHANCEMENT.md @@ -0,0 +1,284 @@ +# 小红书登录增强 - 借鉴ai_mip项目 + +## 概述 +基于ai_mip项目(Playwright + AdsPower 广告自动点击)的优秀实践,对小红书验证码登录流程进行了全面增强。 + +## 借鉴的核心技术 + +### 1. 人类行为模拟 +**来源**: `ai_mip/fingerprint_browser.py` - `human_type` 和 `human_click` 函数 + +**特点**: +- 逐字符输入,随机延迟(50ms-150ms) +- 鼠标轨迹模拟:在元素范围内随机点击位置 +- 触发真实的DOM事件(input, change, focus) + +**应用**: +```python +# 原来的方式:直接填充 +await phone_input.fill(phone) + +# 增强后:模拟人类打字 +await helper.human_type(selector, phone) +``` + +### 2. 智能元素查找 +**来源**: `ai_mip/ad_automation.py` - `_send_consultation_message` 方法 + +**特点**: +- 多选择器降级策略 +- 主选择器 → 降级选择器 → 兜底方案 +- 自动过滤不可见元素 + +**应用**: +```python +# 原来:循环尝试固定的选择器列表 +for selector in selectors: + element = await page.query_selector(selector) + +# 增强后:智能查找带降级 +element = await helper.find_input_with_fallback( + primary_selectors=PRIMARY, + fallback_selectors=FALLBACK +) +``` + +### 3. 结构化选择器管理 +**来源**: `ai_mip/ad_automation.py` - 选择器数组定义 + +**特点**: +- 集中式选择器配置类 +- 按功能和页面类型分组 +- 易于维护和扩展 + +**应用**: +```python +class XHSSelectors: + PHONE_INPUT_CREATOR = [...] + PHONE_INPUT_HOME = [...] + SEND_CODE_BTN_CREATOR = [...] + # ... +``` + +### 4. 按钮状态检测 +**来源**: `ai_mip/ad_automation.py` - 按钮文本验证逻辑 + +**特点**: +- 检测倒计时状态(59s, 58秒等) +- 验证按钮文本是否符合预期 +- 检测按钮激活状态(active class) + +**应用**: +```python +# 检测倒计时 +countdown = await helper.check_button_countdown(button) +if countdown: + return error_response + +# 等待按钮激活 +is_active = await helper.wait_for_button_active(button) +``` + +### 5. 调试辅助功能 +**来源**: `ai_mip/ad_automation.py` - 页面元素调试打印 + +**特点**: +- 打印所有输入框/按钮的属性 +- 帮助快速定位问题 +- 结构化的调试输出 + +**应用**: +```python +if not phone_input: + await helper.debug_print_inputs() + +if not button: + await helper.debug_print_buttons() +``` + +## 新增文件 + +### xhs_login_helper.py +完整的登录辅助工具类,包含: + +1. **XHSLoginHelper类** + - `human_type()` - 人类打字模拟 + - `human_click()` - 人类点击模拟 + - `find_input_with_fallback()` - 智能查找输入框 + - `find_button_with_fallback()` - 智能查找按钮 + - `check_button_countdown()` - 检测按钮倒计时 + - `wait_for_button_active()` - 等待按钮激活 + - `scroll_to_element()` - 平滑滚动 + - `random_delay()` - 随机延迟 + - `debug_print_inputs()` - 调试输入框 + - `debug_print_buttons()` - 调试按钮 + +2. **XHSSelectors类** + - 集中管理所有选择器配置 + - 按页面类型(创作者中心/首页)分组 + - 主选择器 + 降级选择器 + +## 核心改进 + +### 发送验证码流程优化 + +#### Before (原来的方式) +```python +# 1. 查找输入框 +for selector in selectors: + phone_input = await page.query_selector(selector) + if phone_input: + break + +# 2. 直接填充 +await page.evaluate(f'input.value = "{phone}"') + +# 3. 查找按钮 +for selector in selectors: + button = await page.query_selector(selector) + if button: + break + +# 4. 直接点击 +await page.click(selector) +``` + +#### After (增强后的方式) +```python +# 1. 创建辅助器 +helper = get_login_helper(page) + +# 2. 智能查找输入框(多选择器降级) +phone_input = await helper.find_input_with_fallback( + primary_selectors=XHSSelectors.PHONE_INPUT_HOME, + fallback_selectors=XHSSelectors.PHONE_INPUT_FALLBACK +) + +# 3. 人类打字(逐字符+随机延迟) +await helper.human_type(selector, phone) + +# 4. 智能查找按钮(带文本验证) +button = await helper.find_button_with_fallback( + primary_selectors=XHSSelectors.SEND_CODE_BTN_HOME, + expected_texts=["获取验证码"] +) + +# 5. 检测倒计时 +countdown = await helper.check_button_countdown(button) + +# 6. 等待激活 +await helper.wait_for_button_active(button) + +# 7. 人类点击(随机位置+移动轨迹) +await helper.human_click(button_selector) +``` + +## 优势对比 + +| 维度 | 原来 | 增强后 | +|------|------|--------| +| **元素查找** | 单层循环查找 | 多层降级策略 | +| **输入方式** | 直接填充 | 模拟人类打字 | +| **点击方式** | 固定位置点击 | 随机位置+轨迹 | +| **状态检测** | 简单文本检查 | 完整的状态检测 | +| **调试能力** | 手动截图 | 自动打印元素信息 | +| **可维护性** | 选择器分散 | 集中配置管理 | +| **稳定性** | 一般 | 高(多重保护) | + +## 技术亮点 + +### 1. 模拟人类行为 +- ✅ 逐字符输入,随机延迟 +- ✅ 鼠标移动轨迹 +- ✅ 随机点击位置 +- ✅ 真实DOM事件触发 + +### 2. 多重容错机制 +- ✅ 主选择器失败 → 降级选择器 +- ✅ 降级选择器失败 → 兜底方案 +- ✅ 自动过滤不可见元素 +- ✅ 调试信息自动打印 + +### 3. 智能状态检测 +- ✅ 倒计时检测(59s、60秒等) +- ✅ 按钮文本验证 +- ✅ 按钮激活状态检测 +- ✅ 自动等待元素就绪 + +## 使用示例 + +### 基础用法 +```python +from xhs_login_helper import get_login_helper, XHSSelectors + +# 创建辅助器 +helper = get_login_helper(page) + +# 查找并输入 +input_elem = await helper.find_input_with_fallback( + primary_selectors=XHSSelectors.PHONE_INPUT_HOME +) +await helper.human_type(selector, "13800138000") + +# 查找并点击 +button = await helper.find_button_with_fallback( + primary_selectors=XHSSelectors.SEND_CODE_BTN_HOME, + expected_texts=["获取验证码"] +) +await helper.human_click(button_selector) +``` + +### 高级用法 +```python +# 等待按钮激活 +is_active = await helper.wait_for_button_active(button, timeout=5) + +# 检测倒计时 +countdown = await helper.check_button_countdown(button) +if countdown: + print(f"按钮处于倒计时: {countdown}") + +# 调试页面 +await helper.debug_print_inputs() +await helper.debug_print_buttons() + +# 平滑滚动 +await helper.scroll_to_element(element) + +# 随机延迟 +await helper.random_delay(0.5, 1.5) +``` + +## 未来可扩展方向 + +### 1. AdsPower指纹浏览器集成 +借鉴 `ai_mip/fingerprint_browser.py` 和 `ai_mip/adspower_client.py`: +- 指纹浏览器配置管理 +- CDP连接方式 +- 代理动态切换 +- 浏览器配置复用 + +### 2. 代理管理优化 +借鉴 `ai_mip/adspower_client.py`: +- 大麦IP代理集成 +- 白名单代理支持 +- 代理验证机制 +- 代理配置热更新 + +### 3. 更多人类行为模拟 +借鉴 `ai_mip/ad_automation.py`: +- 页面滚动模拟 +- 随机等待时间 +- 鼠标悬停行为 +- 表单填写节奏 + +## 总结 + +通过借鉴ai_mip项目的优秀实践,我们实现了: +1. ✅ 更自然的人类行为模拟 +2. ✅ 更健壮的元素查找策略 +3. ✅ 更完善的状态检测机制 +4. ✅ 更强大的调试辅助功能 +5. ✅ 更易维护的代码结构 + +这些改进大幅提升了小红书验证码登录的成功率和稳定性,同时也为后续的功能扩展奠定了良好的基础。 diff --git a/backend/config.dev.yaml b/backend/config.dev.yaml index 98ea7d0..79afb9a 100644 --- a/backend/config.dev.yaml +++ b/backend/config.dev.yaml @@ -33,6 +33,19 @@ browser_pool: preheat_enabled: true # 是否启用预热 preheat_url: "https://creator.xiaohongshu.com/login" # 预热URL(根据login.page自动调整) +# ========== AdsPower指纹浏览器配置 ========== +adspower: + enabled: true # 是否启用AdsPower指纹浏览器(用于小红书登录等场景) + api_base: "http://127.0.0.1:50325" # AdsPower本地API地址 + api_key: "e5afd5a4cead5589247febbeabc39bcb" # AdsPower API Key(可选) + user_id: "user_h235l72" # AdsPower用户ID(可选) + default_group_id: "0" # 默认分组ID + # 指纹配置 + fingerprint: + automatic_timezone: true # 自动设置时区 + language: ["zh-CN", "zh"] # 浏览器语言 + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" # User-Agent + # ========== 登录/绑定功能配置 ========== login: headless: false # 登录/绑定时的浏览器模式: false=有头模式(方便用户操作),true=无头模式 @@ -40,7 +53,7 @@ login: # ========== 定时发布调度器配置 ========== scheduler: - enabled: false # 是否启用定时任务 + enabled: true # 是否启用定时任务 cron: "*/5 * * * * *" # Cron表达式(秒 分 时 日 月 周) - 每5秒执行一次(开发环境测试) max_concurrent: 2 # 最大并发发布数 publish_timeout: 300 # 发布超时时间(秒) @@ -57,8 +70,11 @@ scheduler: # ========== 代理池配置 ========== proxy_pool: - enabled: false # 默认关闭,按需开启 - api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964" + enabled: true # 启用大麦IP代理池,每次登录使用不同IP + api_url: "https://api2.damaiip.com/index.php?s=/front/user/getIPlist&xsn=2912cb2b22d3b7ae724f045012790479&osn=TC_NO176707424165606223&tiqu=1" + # 大麦IP代理认证信息(白名单模式可留空) + username: "69538fdef04e1" # 代理用户名 + password: "63v0kQBr2yJXnjf" # 代理密码 # ========== 阿里云短信配置 ========== ali_sms: diff --git a/backend/config.prod.yaml b/backend/config.prod.yaml index 618894c..6da0bd5 100644 --- a/backend/config.prod.yaml +++ b/backend/config.prod.yaml @@ -3,7 +3,7 @@ # ========== 服务配置 ========== server: host: "0.0.0.0" - port: 8020 + port: 8080 debug: false reload: false @@ -33,6 +33,19 @@ browser_pool: preheat_enabled: true # 是否启用预热 preheat_url: "https://creator.xiaohongshu.com/login" # 预热URL(根据login.page自动调整) +# ========== AdsPower指纹浏览器配置 ========== +adspower: + enabled: true # 是否启用AdsPower指纹浏览器(生产环境建议启用) + api_base: "http://local.adspower.net:50325" # AdsPower本地API地址 + api_key: "e5afd5a4cead5589247febbeabc39bcb" # AdsPower API Key(可选) + user_id: "user_h235l72" # AdsPower用户ID(可选) + default_group_id: "0" # 默认分组ID + # 指纹配置 + fingerprint: + automatic_timezone: true # 自动设置时区 + language: ["zh-CN", "zh"] # 浏览器语言 + user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" # User-Agent + # ========== 登录/绑定功能配置 ========== login: headless: false # 登录/绑定时的浏览器模式: false=有头模式(配合Xvfb避免被检测),true=无头模式 @@ -59,6 +72,9 @@ scheduler: proxy_pool: enabled: true # 启用代理池,避免IP被风控 api_url: "http://api.tianqiip.com/getip?secret=lu29e593&num=1&type=txt&port=1&mr=1&sign=4b81a62eaed89ba802a8f34053e2c964" + # 代理认证信息(如需) + username: "" # 代理用户名,白名单模式可留空 + password: "" # 代理密码,白名单模式可留空 # ========== 阿里云短信配置 ========== ali_sms: diff --git a/backend/config.py b/backend/config.py index 153d0b1..a2ea5dc 100644 --- a/backend/config.py +++ b/backend/config.py @@ -129,6 +129,18 @@ def load_config(env: str = None) -> Config: if os.getenv('PROXY_POOL_API_URL'): config_dict.setdefault('proxy_pool', {})['api_url'] = os.getenv('PROXY_POOL_API_URL') + # AdsPower指纹浏览器配置 + if os.getenv('ADSPOWER_ENABLED'): + config_dict.setdefault('adspower', {})['enabled'] = os.getenv('ADSPOWER_ENABLED').lower() == 'true' + if os.getenv('ADSPOWER_API_BASE'): + config_dict.setdefault('adspower', {})['api_base'] = os.getenv('ADSPOWER_API_BASE') + if os.getenv('ADSPOWER_API_KEY'): + config_dict.setdefault('adspower', {})['api_key'] = os.getenv('ADSPOWER_API_KEY') + if os.getenv('ADSPOWER_USER_ID'): + config_dict.setdefault('adspower', {})['user_id'] = os.getenv('ADSPOWER_USER_ID') + if os.getenv('ADSPOWER_DEFAULT_GROUP_ID'): + config_dict.setdefault('adspower', {})['default_group_id'] = os.getenv('ADSPOWER_DEFAULT_GROUP_ID') + print(f"[配置] 已加载配置文件: {config_file}") print(f"[配置] 环境: {env}") print(f"[配置] 数据库: {config_dict.get('database', {}).get('host')}:{config_dict.get('database', {}).get('port')}") diff --git a/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png b/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png deleted file mode 100644 index d5c80f7..0000000 Binary files a/backend/error_screenshots/20260109_165402_login_failed_no_cookie.png and /dev/null differ diff --git a/backend/error_screenshots/20260121_125402_send_code_input_phone_failed.png b/backend/error_screenshots/20260121_125402_send_code_input_phone_failed.png new file mode 100644 index 0000000..3ef2f7e Binary files /dev/null and b/backend/error_screenshots/20260121_125402_send_code_input_phone_failed.png differ diff --git a/backend/error_screenshots/20260121_190448_send_code_input_phone_failed.png b/backend/error_screenshots/20260121_190448_send_code_input_phone_failed.png new file mode 100644 index 0000000..a422840 Binary files /dev/null and b/backend/error_screenshots/20260121_190448_send_code_input_phone_failed.png differ diff --git a/backend/error_screenshots/20260122_101440_send_code_input_phone_failed.png b/backend/error_screenshots/20260122_101440_send_code_input_phone_failed.png new file mode 100644 index 0000000..89a4f04 Binary files /dev/null and b/backend/error_screenshots/20260122_101440_send_code_input_phone_failed.png differ diff --git a/backend/error_screenshots/20260122_161235_send_code_input_phone_failed.png b/backend/error_screenshots/20260122_161235_send_code_input_phone_failed.png new file mode 100644 index 0000000..4e4bebe Binary files /dev/null and b/backend/error_screenshots/20260122_161235_send_code_input_phone_failed.png differ diff --git a/backend/error_screenshots/20260123145142_Error.png b/backend/error_screenshots/20260123145142_Error.png new file mode 100644 index 0000000..4e4bebe Binary files /dev/null and b/backend/error_screenshots/20260123145142_Error.png differ diff --git a/backend/error_screenshots/20260123145146_Error.png b/backend/error_screenshots/20260123145146_Error.png new file mode 100644 index 0000000..4e4bebe Binary files /dev/null and b/backend/error_screenshots/20260123145146_Error.png differ diff --git a/backend/error_screenshots/20260123150010_TimeoutError.png b/backend/error_screenshots/20260123150010_TimeoutError.png new file mode 100644 index 0000000..16a95b5 Binary files /dev/null and b/backend/error_screenshots/20260123150010_TimeoutError.png differ diff --git a/backend/fingerprint_browser.py b/backend/fingerprint_browser.py new file mode 100644 index 0000000..f55f8a3 --- /dev/null +++ b/backend/fingerprint_browser.py @@ -0,0 +1,1090 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +指纹浏览器管理模块 +支持 AdsPower 指纹浏览器 + Playwright CDP 连接 +用于绕过小红书风控检测 +""" + +import requests +import time +import random +import logging +import asyncio +import os +from typing import Dict, Any, Optional, Tuple +from playwright.async_api import async_playwright, Browser, Page, BrowserContext + +# 创建不使用代理的 Session(用于本地 AdsPower API) +def get_no_proxy_session(): + """获取不使用代理的 requests Session""" + session = requests.Session() + session.trust_env = False # 禁用环境变量中的代理 + return session + +# 全局无代理 Session +_no_proxy_session = None + +def get_local_session(): + """获取本地API调用专用Session(无代理)""" + global _no_proxy_session + if _no_proxy_session is None: + _no_proxy_session = get_no_proxy_session() + return _no_proxy_session + +from loguru import logger + +# AdsPower 本地API配置(从配置文件读取) +def get_adspower_config(): + """Helper function to get AdsPower config""" + try: + from config import get_config + config = get_config() + return { + 'api_base': config.get_str('adspower.api_base', 'http://local.adspower.net:50325'), + 'enabled': config.get_bool('adspower.enabled', True), + 'default_group_id': config.get_str('adspower.default_group_id', '0'), + 'api_key': config.get_str('adspower.api_key', 'e5afd5a4cead5589247febbeabc39bcb'), + 'user_id': config.get_str('adspower.user_id', 'user_h235l72'), + 'fingerprint': config.get_dict('adspower.fingerprint') or { + 'automatic_timezone': '1', + 'language': ['zh-CN', 'zh'], + 'ua': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + } + except Exception as e: + logger.warning(f"[AdsPower] 无法加载配置,使用默认值: {e}") + return { + 'api_base': 'http://local.adspower.net:50325', + 'enabled': True, + 'default_group_id': '0', + 'api_key': 'e5afd5a4cead5589247febbeabc39bcb', + 'user_id': 'user_h235l72' + } + +ADSPOWER_CONFIG = get_adspower_config() + + +class FingerprintBrowserManager: + """ + 指纹浏览器管理器 + 支持 AdsPower 指纹浏览器的启动、连接和管理 + """ + + def __init__(self): + self.api_base = ADSPOWER_CONFIG['api_base'] + self.api_key = ADSPOWER_CONFIG.get('api_key', '') + self.enabled = ADSPOWER_CONFIG['enabled'] + self.current_browser = None + self.current_context = None + self.current_page = None + self.current_profile_id = None + self.playwright = None + + def _get_headers(self): + """获取API请求头(使用Bearer Token认证)""" + headers = {'Content-Type': 'application/json'} + if self.api_key: + headers['Authorization'] = f'Bearer {self.api_key}' + return headers + + def _add_api_key(self, params: dict) -> dict: + """添加API Key到请求参数(备用方法)""" + # 现在主要使用 Authorization header,这个方法作为备用 + return params + + async def check_adspower_status(self) -> bool: + """ + 检查 AdsPower 是否运行中 + + Returns: + bool: AdsPower 是否可用 + """ + try: + import json as json_module + + logger.info("\n" + "="*70) + logger.info("[AdsPower API] 检查运行状态") + logger.info("="*70) + logger.info(f"URL: {self.api_base}/status") + logger.info(f"Method: GET") + logger.info(f"Timeout: 5s") + + session = get_local_session() + response = session.get(f"{self.api_base}/status", timeout=5) + + logger.info("\n" + "-"*70) + logger.info("[API响应]") + logger.info("-"*70) + logger.info(f"Status Code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + logger.info("Response Body:") + logger.info(json_module.dumps(data, indent=2, ensure_ascii=False)) + logger.info("="*70 + "\n") + + if data.get('code') == 0: + logger.info("[AdsPower] 状态正常") + return True + else: + logger.info(f"Response Body (Raw): {response.text}") + logger.info("="*70 + "\n") + + logger.warning(f"[AdsPower] 状态异常: {response.text}") + return False + except requests.exceptions.ConnectionError: + logger.warning("[AdsPower] 未运行,请先启动 AdsPower") + return False + except Exception as e: + logger.error(f"[AdsPower] 检查状态失败: {e}") + return False + + async def find_profile_by_name(self, name: str) -> Optional[str]: + """ + 根据名称查找配置 + + Args: + name: 配置名称 + + Returns: + str: 配置文件ID,找不到返回None + """ + try: + profiles = await self.get_browser_profiles() + for profile in profiles: + if profile.get('name') == name: + profile_id = profile.get('user_id') + logger.info(f"[指纹浏览器] 找到同名配置: {profile_id} ({name})") + return profile_id + return None + except Exception as e: + logger.error(f"[指纹浏览器] 查找配置异常: {e}") + return None + + async def get_browser_profiles(self) -> list: + """ + 获取所有浏览器配置文件列表 + + Returns: + list: 配置文件列表 + """ + try: + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v1/user/list", + params={'page_size': 100}, + headers=self._get_headers(), + timeout=10 + ) + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + profiles = data.get('data', {}).get('list', []) + logger.info(f"[指纹浏览器] 获取到 {len(profiles)} 个浏览器配置") + return profiles + logger.warning(f"[指纹浏览器] 获取配置列表失败: {response.text}") + return [] + except Exception as e: + logger.error(f"[指纹浏览器] 获取配置列表异常: {e}") + return [] + + async def query_profile_proxy(self, profile_id: str) -> dict: + """ + 查询指定配置的代理信息 + + Args: + profile_id: 配置文件ID + + Returns: + dict: 代理配置信息 + """ + try: + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v1/user/list", + params={'user_id': profile_id}, + headers=self._get_headers(), + timeout=10 + ) + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + profiles = data.get('data', {}).get('list', []) + if profiles: + profile = profiles[0] + proxy_config = profile.get('user_proxy_config', {}) + logger.info(f"[指纹浏览器] 查询到配置 {profile_id} 的代理: {proxy_config}") + return proxy_config + return {} + except Exception as e: + logger.error(f"[指纹浏览器] 查询配置代理异常: {e}") + return {} + + async def create_browser_profile(self, name: str = None, proxy_config: dict = None, cookies: list = None) -> Optional[str]: + """ + 创建新的浏览器配置文件 + + Args: + name: 配置文件名称 + proxy_config: 代理配置 {'server': 'http://ip:port', 'username': '...', 'password': '...'} + cookies: Cookie列表(Playwright格式),创建时直接注入 + + Returns: + str: 配置文件ID,失败返回None + """ + try: + if not name: + name = f"xhs_profile_{int(time.time())}" + + # 从配置获取指纹信息 + fingerprint = ADSPOWER_CONFIG.get('fingerprint', {}) + + # 构建创建参数(使用API v2) + create_params = { + 'name': name, + 'group_id': ADSPOWER_CONFIG['default_group_id'], + 'fingerprint_config': { + 'automatic_timezone': '1' if fingerprint.get('automatic_timezone', True) else '0', + 'language': fingerprint.get('language', ['zh-CN', 'zh']), + 'ua': fingerprint.get('user_agent') or fingerprint.get('ua', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') + } + } + + # 如果有代理配置,添加代理 + if proxy_config: + # 解析代理服务器地址 + server = proxy_config.get('server', '') + if server.startswith('http://'): + server = server[7:] + parts = server.split(':') + if len(parts) == 2: + create_params['user_proxy_config'] = { + 'proxy_soft': 'other', + 'proxy_type': 'http', + 'proxy_host': parts[0], + 'proxy_port': parts[1], + 'proxy_user': proxy_config.get('username', ''), + 'proxy_password': proxy_config.get('password', '') + } + logger.info(f"[指纹浏览器] 配置代理: {parts[0]}:{parts[1]}") + + # 如果有Cookie,转换为AdsPower格式并添加 + if cookies: + logger.info(f"[指纹浏览器] 准备注入 {len(cookies)} 个Cookie到新环境") + # Playwright Cookie格式转换为AdsPower格式 + adspower_cookies = [] + for cookie in cookies: + adspower_cookie = { + 'domain': cookie.get('domain', ''), + 'name': cookie.get('name', ''), + 'value': cookie.get('value', ''), + 'path': cookie.get('path', '/'), + 'secure': cookie.get('secure', False), + 'sameSite': cookie.get('sameSite', 'unspecified') + } + # 如果有过期时间 + if 'expires' in cookie and cookie['expires'] != -1: + adspower_cookie['expirationDate'] = cookie['expires'] + adspower_cookies.append(adspower_cookie) + + # 转换为JSON字符串(AdsPower要求) + import json as json_module + create_params['cookie'] = json_module.dumps(adspower_cookies, ensure_ascii=False) + logger.info(f"[指纹浏览器] Cookie已转换为AdsPower格式(JSON字符串)") + + session = get_local_session() + response = session.post( + f"{self.api_base}/api/v2/browser-profile/create", # 使用v2接口 + json=create_params, + headers=self._get_headers(), + timeout=30 + ) + + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + profile_id = data.get('data', {}).get('profile_id') # v2返回profile_id + if cookies: + logger.success(f"[指纹浏览器] 创建配置成功(已注入Cookie): {profile_id}") + else: + logger.success(f"[指纹浏览器] 创建配置成功: {profile_id}") + return profile_id + + logger.warning(f"[指纹浏览器] 创建配置失败: {response.text}") + return None + except Exception as e: + logger.error(f"[指纹浏览器] 创建配置异常: {e}") + return None + + async def update_browser_proxy(self, profile_id: str, proxy_config: dict) -> bool: + """ + 更新指定配置的代理IP(使用AdsPower API动态更新) + + Args: + profile_id: 配置文件ID + proxy_config: 代理配置 {'server': 'http://ip:port', 'username': '...', 'password': '...'} + + Returns: + bool: 是否更新成功 + """ + try: + import json as json_module + + if not proxy_config: + logger.warning("[指纹浏览器] 没有代理配置,跳过更新") + return False + + # 解析代理服务器地址 + server = proxy_config.get('server', '') + if server.startswith('http://'): + server = server[7:] + elif server.startswith('https://'): + server = server[8:] + + parts = server.split(':') + if len(parts) != 2: + logger.warning(f"[指纹浏览器] 代理地址格式错误: {server}") + return False + + proxy_host = parts[0] + proxy_port = int(parts[1]) # 端口必须是整数 + + logger.info(f"[指纹浏览器] 更新代理配置: {proxy_host}:{proxy_port}") + + session = get_local_session() + + # 注意:不再清除旧代理配置,直接覆盖更新 + # 因为清除后再设置可能导致配置不一致 + + # 第二步:设置新的代理配置 + # 检查是否有认证信息(白名单模式不需要认证) + proxy_user = proxy_config.get('username', '') + proxy_password = proxy_config.get('password', '') + + user_proxy_config = { + 'proxy_soft': 'other', + 'proxy_type': 'http', + 'proxy_host': proxy_host, + 'proxy_port': proxy_port + } + + # 只有在有认证信息时才添加用户名密码 + if proxy_user and proxy_password: + user_proxy_config['proxy_user'] = proxy_user + user_proxy_config['proxy_password'] = proxy_password + logger.info(f"[指纹浏览器] 使用认证代理: {proxy_host}:{proxy_port}") + else: + logger.info(f"[指纹浏览器] 使用白名单代理(无认证): {proxy_host}:{proxy_port}") + + update_params = { + 'user_id': profile_id, + 'user_proxy_config': user_proxy_config + } + + # 打印完整的请求参数用于调试 + logger.info("\n" + "="*70) + logger.info("[AdsPower API] 更新代理配置") + logger.info("="*70) + logger.info(f"URL: {self.api_base}/api/v1/user/update") + logger.info(f"Method: POST") + logger.info(f"Headers:") + headers = self._get_headers() + for k, v in headers.items(): + if k == 'Authorization' and v: + logger.info(f" {k}: Bearer {v.split()[-1][:8]}...") + else: + logger.info(f" {k}: {v}") + logger.info("Request Body:") + logger.info(json_module.dumps(update_params, indent=2, ensure_ascii=False)) + + response = session.post( + f"{self.api_base}/api/v1/user/update", + json=update_params, + headers=headers, + timeout=30 + ) + + # 打印完整的响应用于调试 + logger.info("\n" + "-"*70) + logger.info("[API响应]") + logger.info("-"*70) + logger.info(f"Status Code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + logger.info("Response Body:") + logger.info(json_module.dumps(data, indent=2, ensure_ascii=False)) + logger.info("="*70 + "\n") + + if data.get('code') == 0: + logger.info(f"[指纹浏览器] 代理配置API返回成功: {profile_id}") + + # 验证代理是否真正写入 + await asyncio.sleep(0.5) # 等待配置生效 + verify_config = await self.query_profile_proxy(profile_id) + actual_host = verify_config.get('proxy_host', '') + actual_port = verify_config.get('proxy_port', '') + + if actual_host == proxy_host and str(actual_port) == str(proxy_port): + logger.info(f"[指纹浏览器] >> 代理配置验证通过: {actual_host}:{actual_port}") + return True + else: + logger.warning(f"[指纹浏览器] !! 代理配置验证失败! 期望: {proxy_host}:{proxy_port}, 实际: {actual_host}:{actual_port}") + logger.warning(f"[指纹浏览器] 完整配置: {verify_config}") + return False + else: + logger.warning(f"[指纹浏览器] 更新代理失败: {data.get('msg', '未知错误')}") + return False + else: + logger.info(f"Response Body (Raw): {response.text}") + logger.info("="*70 + "\n") + + logger.warning(f"[指纹浏览器] 更新代理请求失败: {response.text}") + return False + except Exception as e: + logger.error(f"[指纹浏览器] 更新代理异常: {e}") + return False + + async def start_browser(self, profile_id: str, proxy_config: dict = None) -> Optional[str]: + """ + 启动指定配置的浏览器,返回 CDP 调试地址 + + Args: + profile_id: 配置文件ID + proxy_config: 可选的代理配置,在启动前更新 + + Returns: + str: CDP WebSocket URL,失败返回None + """ + try: + logger.info("\n" + "="*70) + logger.info("[指纹浏览器] 启动浏览器") + logger.info("="*70) + logger.info(f"配置ID: {profile_id}") + + # 先停止可能正在运行的旧浏览器,避免状态混乱 + logger.info(f"\n步骤1: 检查并清理旧浏览器实例...") + logger.info(f" 尝试停止: {profile_id}") + await self.stop_browser(profile_id) + await asyncio.sleep(1) # 等待浏览器完全关闭 + logger.success(">> 旧实例清理完成") + + # 如果有代理配置,在启动前更新代理(关键:必须在stop之后、start之前) + if proxy_config: + logger.info(f"\n步骤2: 更新代理配置...") + logger.info(f" 代理服务器: {proxy_config.get('server', 'N/A')}") + if proxy_config.get('username') and proxy_config.get('password'): + logger.info(f" 认证模式: 是") + logger.info(f" 用户名: {proxy_config['username']}") + else: + logger.info(f" 认证模式: 否 (白名单)") + + update_result = await self.update_browser_proxy(profile_id, proxy_config) + if update_result: + logger.success(">> 代理配置更新成功") + else: + logger.warning("!! 代理配置更新失败") + await asyncio.sleep(0.5) # 等待配置生效 + else: + logger.info(f"\n步骤2: 跳过代理配置 (未提供代理)") + + logger.info(f"\n步骤3: 调用AdsPower API启动浏览器...") + + import json as json_module + + logger.info("\n" + "="*70) + logger.info("[AdsPower API] 启动浏览器") + logger.info("="*70) + logger.info(f"URL: {self.api_base}/api/v1/browser/start") + logger.info(f"Method: GET") + logger.info(f"Params:") + logger.info(f" user_id: {profile_id}") + logger.info(f"Headers:") + headers = self._get_headers() + for k, v in headers.items(): + if k == 'Authorization' and v: + logger.info(f" {k}: Bearer {v.split()[-1][:8]}...") + else: + logger.info(f" {k}: {v}") + logger.info(f"Timeout: 60s") + + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v1/browser/start", + params={'user_id': profile_id}, + headers=headers, + timeout=60 + ) + + logger.info("\n" + "-"*70) + logger.info("[API响应]") + logger.info("-"*70) + logger.info(f"Status Code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + logger.info("Response Body:") + logger.info(json_module.dumps(data, indent=2, ensure_ascii=False)) + logger.info("="*70 + "\n") + + if data.get('code') == 0: + ws_url = data.get('data', {}).get('ws', {}).get('puppeteer') + if ws_url: + logger.success(f">> 浏览器启动成功") + logger.success(f" CDP地址: {ws_url}") + logger.success(f" 配置ID: {profile_id}") + self.current_profile_id = profile_id + return ws_url + else: + logger.error("!! 响应中未CDP地址") + else: + logger.error(f"!! 启动失败: {data.get('msg', '未知错误')}") + else: + logger.error(f"!! HTTP请求失败: {response.status_code}") + logger.info(f"Response Body (Raw): {response.text}") + logger.info("="*70 + "\n") + + return None + + except Exception as e: + logger.error("\n" + "="*70) + logger.error("!! [指纹浏览器] 启动浏览器异常") + logger.error("="*70) + logger.error(f"错误信息: {str(e)}") + logger.error("="*70 + "\n") + return None + + async def stop_browser(self, profile_id: str = None) -> bool: + """ + 停止指定配置的浏览器 + + Args: + profile_id: 配置文件ID,不传则使用当前配置 + + Returns: + bool: 是否成功 + """ + try: + pid = profile_id or self.current_profile_id + if not pid: + logger.warning("[指纹浏览器] 没有需要停止的浏览器") + return False + + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v1/browser/stop", + params={'user_id': pid}, + headers=self._get_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + logger.info(f"[指纹浏览器] 浏览器已停止: {pid}") + if pid == self.current_profile_id: + self.current_profile_id = None + return True + + logger.warning(f"[指纹浏览器] 停止浏览器失败: {response.text}") + return False + except Exception as e: + logger.error(f"[指纹浏览器] 停止浏览器异常: {e}") + return False + + async def delete_profile(self, profile_id: str) -> bool: + """ + 删除指定的浏览器配置(使用API v2) + + Args: + profile_id: 配置文件ID + + Returns: + bool: 是否成功 + """ + try: + logger.info(f"[指纹浏览器] 开始删除配置Profile: {profile_id}") + + session = get_local_session() + response = session.post( + f"{self.api_base}/api/v2/browser-profile/delete", + json={"profile_id": [profile_id]}, + headers=self._get_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + logger.success(f"[指纹浏览器] 成功删除Profile: {profile_id}") + return True + else: + logger.error(f"[指纹浏览器] 删除Profile失败: {data.get('msg')}") + return False + else: + logger.error(f"[指纹浏览器] 删除Profile HTTP请求失败: {response.status_code}") + return False + except Exception as e: + logger.error(f"[指纹浏览器] 删除Profile异常: {str(e)}") + return False + + async def get_profile_proxy_id(self, profile_id: str) -> Optional[str]: + """ + 获取Profile关联的代理ID(如果使用了API v2代理池) + + Args: + profile_id: 配置文件ID + + Returns: + str: 代理ID,如果没有或使用的是直接配置则返回None + """ + try: + logger.info(f"[指纹浏览器] 查询Profile的代理ID: {profile_id}") + + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v2/browser-profile/detail", + params={'profile_id': profile_id}, + headers=self._get_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + # 检查是否使用了代理池中的代理 + proxy_id = data.get('data', {}).get('user_proxy_config', {}).get('proxy_id') + if proxy_id: + logger.info(f"[指纹浏览器] Profile使用了代理池代理ID: {proxy_id}") + return proxy_id + else: + logger.info(f"[指纹浏览器] Profile使用直接配置的代理,无需删除代理池记录") + return None + + return None + except Exception as e: + logger.error(f"[指纹浏览器] 查询Profile代理ID异常: {str(e)}") + return None + + async def delete_proxy(self, proxy_id: str) -> bool: + """ + 删除AdsPower代理池中的代理(使用API v2) + + Args: + proxy_id: 代理ID + + Returns: + bool: 是否成功 + """ + try: + logger.info(f"[指纹浏览器] 开始删除代理: {proxy_id}") + + session = get_local_session() + response = session.post( + f"{self.api_base}/api/v2/proxy-list/delete", + json={"proxy_id": [proxy_id]}, + headers=self._get_headers(), + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('code') == 0: + logger.success(f"[指纹浏览器] 成功删除代理: {proxy_id}") + return True + else: + logger.error(f"[指纹浏览器] 删除代理失败: {data.get('msg')}") + return False + else: + logger.error(f"[指纹浏览器] 删除代理HTTP请求失败: {response.status_code}") + return False + except Exception as e: + logger.error(f"[指纹浏览器] 删除代理异常: {str(e)}") + return False + + async def connect_browser(self, cdp_url: str) -> Tuple[Optional[Browser], Optional[BrowserContext], Optional[Page]]: + """ + 通过 CDP 连接到指纹浏览器 + + Args: + cdp_url: CDP WebSocket URL + + Returns: + Tuple[Browser, BrowserContext, Page]: 浏览器、上下文、页面对象 + """ + try: + logger.info("\n" + "="*70) + logger.info("[指纹浏览器] 通过CDP连接浏览器") + logger.info("="*70) + logger.info(f"CDP地址: {cdp_url}") + + logger.info("\n步骤1: 启动Playwright...") + self.playwright = await async_playwright().start() + logger.success(">> Playwright启动成功") + + logger.info("\n步骤2: 连接到指纹浏览器...") + logger.info(f" 使用协议: Chromium CDP") + logger.info(f" WebSocket URL: {cdp_url}") + + # 连接到指纹浏览器 + browser = await self.playwright.chromium.connect_over_cdp(cdp_url) + logger.success(">> 浏览器连接成功") + + logger.info("\n步骤3: 获取浏览器上下文...") + # 获取上下文(指纹浏览器通常只有一个context) + contexts = browser.contexts + logger.info(f" 发现上下文数: {len(contexts)}") + + if contexts: + context = contexts[0] + logger.success(f">> 使用现有上下文: Context[0]") + else: + logger.info(" 未找到现有上下文,创建新上下文...") + context = await browser.new_context() + logger.success(">> 新上下文创建成功") + + logger.info("\n步骤4: 获取或创建页面...") + # 获取或创建页面 + pages = context.pages + logger.info(f" 发现页面数: {len(pages)}") + + if pages: + page = pages[0] + logger.success(f">> 使用现有页面: Page[0]") + logger.info(f" 当前URL: {page.url}") + else: + logger.info(" 未找到现有页面,创建新页面...") + page = await context.new_page() + logger.success(">> 新页面创建成功") + + self.current_browser = browser + self.current_context = context + self.current_page = page + + logger.info("\n" + "-"*70) + logger.info("[连接完成摘要]") + logger.info("-"*70) + logger.success(f">> 浏览器实例: {type(browser).__name__}") + logger.success(f">> 上下文数: {len(browser.contexts)}") + logger.success(f">> 页面数: {len(context.pages)}") + logger.success(f">> 当前页面URL: {page.url}") + logger.info("="*70 + "\n") + + return browser, context, page + + except Exception as e: + logger.error("\n" + "="*70) + logger.error("!! [指纹浏览器] CDP连接失败") + logger.error("="*70) + logger.error(f"错误信息: {str(e)}") + logger.error("提示: 请检查CDP地址是否有效") + logger.error("="*70 + "\n") + return None, None, None + + async def disconnect(self): + """断开浏览器连接(不关闭浏览器)""" + try: + if self.current_browser: + # 注意:connect_over_cdp 模式下不要 close,只 disconnect + await self.current_browser.close() + self.current_browser = None + self.current_context = None + self.current_page = None + logger.info("[指纹浏览器] 已断开连接") + + if self.playwright: + await self.playwright.stop() + self.playwright = None + except Exception as e: + logger.error(f"[指纹浏览器] 断开连接异常: {e}") + + async def get_all_profiles(self) -> list: + """ + 获取所有可用的浏览器配置文件,随机排序 + + Returns: + list: 配置文件ID列表 + """ + profiles = await self.get_browser_profiles() + profile_ids = [] + + for profile in profiles: + user_id = profile.get('user_id', '') + name = profile.get('name', '') + if user_id: + profile_ids.append({'id': user_id, 'name': name}) + + # 随机打乱顺序,支持多配置轮换使用 + random.shuffle(profile_ids) + logger.info(f"[指纹浏览器] 获取到 {len(profile_ids)} 个配置,已随机排序") + return profile_ids + + async def get_or_create_profile(self, proxy_config: dict = None, phone: str = None, force_create: bool = False, cookies: list = None) -> Optional[str]: + """ + 获取或创建浏览器配置文件 + 如果提供了phone参数: + 1. 先查找是否已存在同名配置 + 2. 如果存在,直接返回该配置ID(会在start_browser中更新代理) + 3. 如果不存在,创建新配置 + 否则随机选择已有配置 + + Args: + proxy_config: 代理配置 + phone: 手机号(用作配置名称) + force_create: 强制创建新配置(用于临时发布环境) + cookies: Cookie列表,创建时直接注入 + + Returns: + str: 配置文件ID + """ + # 如果强制创建,直接创建临时配置 + if force_create: + logger.info("[指纹浏览器] 强制创建临时发布环境...") + profile_name = f"XHS_TEMP_{int(time.time())}" + profile_id = await self.create_browser_profile( + name=profile_name, + proxy_config=proxy_config, + cookies=cookies # 传递cookies + ) + if profile_id: + logger.success(f"[指纹浏览器] 临时环境创建成功: {profile_id} ({profile_name})") + return profile_id + else: + logger.error("[指纹浏览器] 临时环境创建失败") + return None + + # 如果提供了手机号 + if phone: + profile_name = f"XHS_{phone}" + logger.info(f"[指纹浏览器] 处理手机号 {phone} 的配置...") + + # 先查找是否已存在同名配置 + existing_profile_id = await self.find_profile_by_name(profile_name) + + if existing_profile_id: + logger.success(f"[指纹浏览器] 复用现有配置: {existing_profile_id} ({profile_name})") + logger.info(" 注意:代理会在start_browser中更新") + return existing_profile_id + + # 不存在,创建新配置 + logger.info(f"[指纹浏览器] 未找到现有配置,为手机号 {phone} 创建新配置...") + profile_id = await self.create_browser_profile( + name=profile_name, + proxy_config=proxy_config, + cookies=cookies # 传递cookies + ) + if profile_id: + logger.success(f"[指纹浏览器] 创建配置成功: {profile_id} ({profile_name})") + return profile_id + else: + logger.error("[指纹浏览器] 创建配置失败") + return None + + # 没有phone参数,使用旧逻辑:随机选择配置 + # 获取所有配置(已随机排序) + profiles = await self.get_all_profiles() + + if profiles: + # 返回第一个(随机选择的) + profile = profiles[0] + logger.info(f"[指纹浏览器] 随机选择配置: {profile['id']} ({profile['name']})") + return profile['id'] + + # 没有可用配置,创建新的 + logger.info("[指纹浏览器] 没有可用配置,创建新配置...") + return await self.create_browser_profile( + proxy_config=proxy_config, + cookies=cookies # 传递cookies + ) + + async def get_profile_cookies(self, profile_id: str) -> Optional[list]: + """ + 查询指定配置的Cookie + + Args: + profile_id: 配置文件ID + + Returns: + list: Cookie列表 (Playwright完整格式): + [ + { + "name": "cookie_name", + "value": "cookie_value", + "domain": ".xiaohongshu.com", + "path": "/", + "httpOnly": false, + "secure": true, + "sameSite": "Lax", + "expires": 1234567890 + } + ] + """ + try: + logger.info(f"[查询Cookie] 开始查询配置ID: {profile_id}") + + session = get_local_session() + response = session.get( + f"{self.api_base}/api/v2/browser-profile/cookies", + params={'profile_id': profile_id}, + headers=self._get_headers(), + timeout=10 + ) + + logger.info(f"[查询Cookie] API响应状态: {response.status_code}") + logger.info(f"[查询Cookie] 响应内容: {response.text[:500]}") + + if response.status_code == 200: + data = response.json() + logger.info(f"[查询Cookie] JSON解析结果: code={data.get('code')}, msg={data.get('msg')}") + + if data.get('code') == 0: + cookies_str = data.get('data', {}).get('cookies', '[]') + logger.info(f"[查询Cookie] cookies字符串长度: {len(cookies_str)}") + logger.info(f"[查询Cookie] cookies字符串前100字符: {cookies_str[:100]}") + + # 如果是空字符串,返回空列表 + if not cookies_str or cookies_str.strip() == '': + logger.warning("[查询Cookie] cookies字符串为空,返回空列表") + return [] + + # 解析JSON字符串 + import json + cookies = json.loads(cookies_str) + + logger.success(f"[查询Cookie] 成功获取 {len(cookies)} 个Cookie") + return cookies + else: + logger.error(f"[查询Cookie] API返回错误: {data.get('msg', 'unknown')}") + return None + else: + logger.error(f"[查询Cookie] HTTP错误: {response.status_code}") + logger.error(f"[查询Cookie] 响应内容: {response.text}") + return None + + except Exception as e: + logger.error(f"[查询Cookie] 异常: {str(e)}") + import traceback + traceback.print_exc() + return None + + +async def human_type(page: Page, selector: str, text: str, clear_first: bool = True): + """ + 模拟人类打字速度输入文本 + + Args: + page: Playwright Page 对象 + selector: 输入框选择器 + text: 要输入的文本 + clear_first: 是否先清空输入框 + """ + try: + # 聚焦输入框 + await page.focus(selector) + + # 先清空 + if clear_first: + await page.fill(selector, '') + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 模拟人类打字 + for char in text: + await page.keyboard.type(char) + # 随机延迟 50ms - 150ms + await asyncio.sleep(random.uniform(0.05, 0.15)) + + logger.info(f"[人类输入] 已输入 {len(text)} 个字符") + except Exception as e: + logger.error(f"[人类输入] 输入失败: {e}") + raise + + +async def human_click(page: Page, selector: str, wait_after: float = 0.5): + """ + 模拟人类点击行为 + + Args: + page: Playwright Page 对象 + selector: 元素选择器 + wait_after: 点击后等待时间 + """ + try: + # 先移动到元素位置 + element = await page.query_selector(selector) + if element: + box = await element.bounding_box() + if box: + # 在元素范围内随机一个点击位置 + x = box['x'] + random.uniform(box['width'] * 0.3, box['width'] * 0.7) + y = box['y'] + random.uniform(box['height'] * 0.3, box['height'] * 0.7) + + # 移动鼠标 + await page.mouse.move(x, y) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 点击 + await page.mouse.click(x, y) + logger.info(f"[人类点击] 点击位置: ({x:.0f}, {y:.0f})") + else: + await page.click(selector) + else: + await page.click(selector) + + await asyncio.sleep(wait_after) + except Exception as e: + logger.error(f"[人类点击] 点击失败: {e}") + raise + + +# 全局单例 +_fingerprint_manager = None + +def get_fingerprint_manager() -> FingerprintBrowserManager: + """获取指纹浏览器管理器单例""" + global _fingerprint_manager + if _fingerprint_manager is None: + _fingerprint_manager = FingerprintBrowserManager() + return _fingerprint_manager + + +if __name__ == "__main__": + # 测试代码 + async def test(): + manager = get_fingerprint_manager() + + # 检查 AdsPower 状态 + if await manager.check_adspower_status(): + print("AdsPower 运行正常") + + # 使用默认配置(不使用代理) + proxy = None # 或者手动指定: {'server': 'http://ip:port', 'username': 'user', 'password': 'pass'} + print(f"代理IP: {proxy or '未配置'}") + + # 获取或创建配置 + profile_id = await manager.get_or_create_profile(proxy_config=proxy) + if profile_id: + print(f"配置ID: {profile_id}") + + # 启动浏览器 + cdp_url = await manager.start_browser(profile_id) + if cdp_url: + print(f"CDP URL: {cdp_url}") + + # 连接浏览器 + browser, context, page = await manager.connect_browser(cdp_url) + if page: + # 访问测试页面 + await page.goto("https://httpbin.org/ip") + content = await page.content() + print(f"页面内容: {content[:200]}") + + # 断开连接 + await manager.disconnect() + + # 停止浏览器 + await manager.stop_browser(profile_id) + else: + print("AdsPower 未运行") + + asyncio.run(test()) diff --git a/backend/main.py b/backend/main.py index 18d9f5c..e36efaf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,6 +51,9 @@ scheduler = None # 全局阿里云短信服务实例 sms_service = None +# 由于禁用了浏览器池,使用一个简单的字典来存储session +temp_sessions = {} + # WebSocket连接管理器 class ConnectionManager: def __init__(self): @@ -58,6 +61,10 @@ class ConnectionManager: self.active_connections: Dict[str, WebSocket] = {} # session_id -> 消息队列(用于缓存连接建立前的消息) self.pending_messages: Dict[str, list] = {} + # 未确认消息:{session_id: {message_id: {message, timestamp, retry_count}}} + self.unconfirmed_messages: Dict[str, Dict[str, dict]] = {} + # 消息重发定时器 + self.retry_tasks: Dict[str, asyncio.Task] = {} async def connect(self, session_id: str, websocket: WebSocket): await websocket.accept() @@ -67,12 +74,34 @@ class ConnectionManager: print(f"[WebSocket] 当前活跃连接数: {len(self.active_connections)}", file=sys.stderr) print(f"[WebSocket] 连接时间: {__import__('datetime').datetime.now()}", file=sys.stderr) print(f"[WebSocket] ===================================", file=sys.stderr) + + # 检查未确认消息(断线期间的消息) + if session_id in self.unconfirmed_messages: + unconfirmed_count = len(self.unconfirmed_messages[session_id]) + print(f"[WebSocket] 发现 {unconfirmed_count} 条未确认消息(断线期间)", file=sys.stderr) - # 立即检查缓存消息(不等待) + # 等待100ms让前端监听器就绪 + await asyncio.sleep(0.1) + + # 立即重发所有未确认消息 + for idx, (message_id, msg_data) in enumerate(self.unconfirmed_messages[session_id].items()): + try: + print(f"[WebSocket] 重发未确认消息 [{idx+1}/{unconfirmed_count}]: {msg_data['message'].get('type')}", file=sys.stderr) + await websocket.send_json(msg_data['message']) + # 每条消息间隔1100ms + await asyncio.sleep(0.1) + except Exception as e: + print(f"[WebSocket] 重发未确认消息失败: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + + print(f"[WebSocket] 已重发所有未确认消息", file=sys.stderr) + + # 检查pending消息(连接建立前的消息) if session_id in self.pending_messages: pending_count = len(self.pending_messages[session_id]) - print(f"[WebSocket] 发现缓存消息: {pending_count} 条", file=sys.stderr) - print(f"[WebSocket] 缓存消息内容: {self.pending_messages[session_id]}", file=sys.stderr) + print(f"[WebSocket] 发现 {pending_count} 条pending消息", file=sys.stderr) + print(f"[WebSocket] pending消息内容: {self.pending_messages[session_id]}", file=sys.stderr) # 等待100ms让前端监听器就绪 await asyncio.sleep(0.1) @@ -81,18 +110,18 @@ class ConnectionManager: try: print(f"[WebSocket] 准备发送第{idx+1}条消息...", file=sys.stderr) await websocket.send_json(message) - print(f"[WebSocket] 已发送缓存消息 [{idx+1}/{pending_count}]: {message.get('type')}", file=sys.stderr) + print(f"[WebSocket] 已发送pending消息 [{idx+1}/{pending_count}]: {message.get('type')}", file=sys.stderr) # 每条消息间隔100ms await asyncio.sleep(0.1) except Exception as e: - print(f"[WebSocket] 发送缓存消息失败: {str(e)}", file=sys.stderr) + print(f"[WebSocket] 发送pending消息失败: {str(e)}", file=sys.stderr) import traceback traceback.print_exc() del self.pending_messages[session_id] - print(f"[WebSocket] 缓存消息已清空: {session_id}", file=sys.stderr) + print(f"[WebSocket] pending消息已清空: {session_id}", file=sys.stderr) else: - print(f"[WebSocket] 没有缓存消息: {session_id}", file=sys.stderr) + print(f"[WebSocket] 没有pending消息: {session_id}", file=sys.stderr) def disconnect(self, session_id: str, reason: str = "未知原因"): """断开WebSocket连接并记录原因""" @@ -102,18 +131,41 @@ class ConnectionManager: print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) print(f"[WebSocket] 断开原因: {reason}", file=sys.stderr) print(f"[WebSocket] 剩余活跃连接数: {len(self.active_connections)}", file=sys.stderr) + + # 检查是否有未确认消息 + if session_id in self.unconfirmed_messages: + unconfirmed_count = len(self.unconfirmed_messages[session_id]) + print(f"[WebSocket] 保留 {unconfirmed_count} 条未确认消息,等待重连后重发", file=sys.stderr) + print(f"[WebSocket] ===================================", file=sys.stderr) - # 清理缓存消息 + + # 清理pending_messages(这些是连接建立前的消息,已经过时) if session_id in self.pending_messages: pending_count = len(self.pending_messages[session_id]) if pending_count > 0: - print(f"[WebSocket] 清理 {pending_count} 条未发送的缓存消息", file=sys.stderr) + print(f"[WebSocket] 清理 {pending_count} 条过时的pending消息", file=sys.stderr) del self.pending_messages[session_id] + + # 不清理unconfirmed_messages和retry_tasks,让它们在重连后继续工作 + # 这样就能实现断线重连后的消息恢复 async def send_message(self, session_id: str, message: dict): + """发送消息并等待确认""" + import uuid + import time + + # 为消息添加唯一ID和时间戳 + if 'message_id' not in message: + message['message_id'] = str(uuid.uuid4()) + if 'timestamp' not in message: + message['timestamp'] = time.time() + + message_id = message['message_id'] + print(f"[WebSocket] ========== 尝试发送消息 ==========", file=sys.stderr) print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) print(f"[WebSocket] 消息类型: {message.get('type')}", file=sys.stderr) + print(f"[WebSocket] 消息ID: {message_id}", file=sys.stderr) print(f"[WebSocket] 当前活跃连接数: {len(self.active_connections)}", file=sys.stderr) print(f"[WebSocket] 活跃连接session_ids: {list(self.active_connections.keys())}", file=sys.stderr) print(f"[WebSocket] session_id在连接中: {session_id in self.active_connections}", file=sys.stderr) @@ -122,7 +174,25 @@ class ConnectionManager: if session_id in self.active_connections: try: await self.active_connections[session_id].send_json(message) - print(f"[WebSocket] 发送消息到 {session_id}: {message.get('type')}", file=sys.stderr) + print(f"[WebSocket] 发送消息到 {session_id}: {message.get('type')}, message_id={message_id}", file=sys.stderr) + + # 存储未确认消息 + if session_id not in self.unconfirmed_messages: + self.unconfirmed_messages[session_id] = {} + + self.unconfirmed_messages[session_id][message_id] = { + 'message': message, + 'timestamp': time.time(), + 'retry_count': 0 + } + + # 启动重发任务 + if session_id not in self.retry_tasks: + self.retry_tasks[session_id] = asyncio.create_task( + self._retry_unconfirmed_messages(session_id) + ) + print(f"[WebSocket] 已启动消息重发任务: {session_id}", file=sys.stderr) + except Exception as e: print(f"[WebSocket] ========== 发送消息失败 ==========", file=sys.stderr) print(f"[WebSocket] Session ID: {session_id}", file=sys.stderr) @@ -139,6 +209,83 @@ class ConnectionManager: # 最多缓存10条消息 if len(self.pending_messages[session_id]) > 10: self.pending_messages[session_id].pop(0) + + async def _retry_unconfirmed_messages(self, session_id: str): + """定期检查并重发未确认的消息""" + import time + + while True: + try: + await asyncio.sleep(3) # 每3秒检查一次 + + if session_id not in self.unconfirmed_messages: + continue + + current_time = time.time() + messages_to_retry = [] + messages_to_remove = [] + + for message_id, msg_data in self.unconfirmed_messages[session_id].items(): + elapsed = current_time - msg_data['timestamp'] + + # 超过60秒未确认,标记为失败并移除(给重连更多时间) + if elapsed > 60: + print(f"[WebSocket] 消息超时未确认: {message_id}, 已等待{elapsed:.1f}秒", file=sys.stderr) + messages_to_remove.append(message_id) + continue + + # 超过3秒未确认且重试次数<5,重发(增加重试次数) + if elapsed > 3 and msg_data['retry_count'] < 5: + messages_to_retry.append((message_id, msg_data)) + + # 移除超时消息 + for message_id in messages_to_remove: + del self.unconfirmed_messages[session_id][message_id] + print(f"[WebSocket] 已移除超时消息: {message_id}", file=sys.stderr) + + # 重发消息(只有在连接活跃时才发送) + for message_id, msg_data in messages_to_retry: + if session_id in self.active_connections: + try: + await self.active_connections[session_id].send_json(msg_data['message']) + msg_data['retry_count'] += 1 + msg_data['timestamp'] = current_time + print(f"[WebSocket] 重发消息: {message_id}, 第{msg_data['retry_count']}次重试", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] 重发消息失败: {message_id}, {str(e)}", file=sys.stderr) + # 不移除,等待下次重试 + else: + # 连接未建立,等待重连 + print(f"[WebSocket] 连接未建立,等待重连后重发: {message_id}", file=sys.stderr) + + except asyncio.CancelledError: + print(f"[WebSocket] 重发任务被取消: {session_id}", file=sys.stderr) + break + except Exception as e: + print(f"[WebSocket] 重发任务异常: {session_id}, {str(e)}", file=sys.stderr) + + def confirm_message(self, session_id: str, message_id: str): + """确认收到消息""" + if session_id in self.unconfirmed_messages: + if message_id in self.unconfirmed_messages[session_id]: + del self.unconfirmed_messages[session_id][message_id] + print(f"[WebSocket] 消息已确认: {session_id}, message_id={message_id}", file=sys.stderr) + print(f"[WebSocket] 剩余未确认消息: {len(self.unconfirmed_messages[session_id])}", file=sys.stderr) + + # 如果没有未确认消息了,取消重发任务 + if len(self.unconfirmed_messages[session_id]) == 0: + if session_id in self.retry_tasks: + self.retry_tasks[session_id].cancel() + del self.retry_tasks[session_id] + print(f"[WebSocket] 所有消息已确认,取消重发任务: {session_id}", file=sys.stderr) + + def get_unconfirmed_messages(self, session_id: str) -> list: + """获取未确认的消息列表""" + if session_id in self.unconfirmed_messages: + messages = [msg_data['message'] for msg_data in self.unconfirmed_messages[session_id].values()] + print(f"[WebSocket] 获取未确认消息: {session_id}, 共{len(messages)}条", file=sys.stderr) + return messages + return [] # 全局WebSocket管理器 ws_manager = ConnectionManager() @@ -256,12 +403,15 @@ async def startup_event(): preheat_url = "https://creator.xiaohongshu.com/login" # 初始化全局浏览器池(使用配置的headless参数) + # 已禁用:使用AdsPower管理浏览器,不需要浏览器池 global browser_pool, login_service, sms_service - browser_pool = get_browser_pool(idle_timeout=1800, headless=headless) - print(f"[服务启动] 浏览器池模式: {'headless(无头模式)' if headless else 'headed(有头模式)'}") + browser_pool = None # 使用AdsPower,不创建浏览器池 + # browser_pool = get_browser_pool(idle_timeout=1800, headless=headless) + # print(f"[服务启动] 浏览器池模式: {'headless(无头模式)' if headless else 'headed(有头模式)'}") + print(f"[服务启动] 浏览器管理: 使用AdsPower") # 初始化登录服务(使用独立的login.headless配置) - login_service = XHSLoginService(use_pool=True, headless=login_headless) + login_service = XHSLoginService(use_pool=False, headless=login_headless) # 使用AdsPower,不需要浏览器池 print(f"[服务启动] 登录服务模式: {'headless(无头模式)' if login_headless else 'headed(有头模式)'}") # 初始化阿里云短信服务 @@ -275,7 +425,8 @@ async def startup_event(): print("[服务启动] 阿里云短信服务已初始化") # 启动浏览器池清理任务 - asyncio.create_task(browser_cleanup_task()) + # 已禁用:使用AdsPower,不需要浏览器池清理 + # asyncio.create_task(browser_cleanup_task()) # 已禁用预热功能,避免干扰正常业务流程 # asyncio.create_task(browser_preheat_task()) @@ -298,12 +449,17 @@ async def startup_event(): scheduler_enabled = config.get_bool('scheduler.enabled', False) proxy_pool_enabled = config.get_bool('proxy_pool.enabled', False) proxy_pool_api_url = config.get_str('proxy_pool.api_url', '') + proxy_username = config.get_str('proxy_pool.username', '') # 新增:代理用户名 + proxy_password = config.get_str('proxy_pool.password', '') # 新增:代理密码 enable_random_ua = config.get_bool('scheduler.enable_random_ua', True) min_publish_interval = config.get_int('scheduler.min_publish_interval', 30) max_publish_interval = config.get_int('scheduler.max_publish_interval', 120) # headless已经在上面读取了 if scheduler_enabled: + # 从配置读取是否启用AdsPower + use_adspower_scheduler = config.get_bool('adspower.enabled', False) + scheduler = XHSScheduler( db_config=db_config, max_concurrent=config.get_int('scheduler.max_concurrent', 2), @@ -314,17 +470,20 @@ async def startup_event(): max_hourly_articles_per_user=config.get_int('scheduler.max_hourly_articles_per_user', 2), proxy_pool_enabled=proxy_pool_enabled, proxy_pool_api_url=proxy_pool_api_url, + proxy_username=proxy_username, # 新增:传递代理用户名 + proxy_password=proxy_password, # 新增:传递代理密码 enable_random_ua=enable_random_ua, min_publish_interval=min_publish_interval, max_publish_interval=max_publish_interval, headless=headless, # 新增: 传递headless参数 + use_adspower=use_adspower_scheduler # 新增:是否使用AdsPower ) cron_expr = config.get_str('scheduler.cron', '*/5 * * * * *') scheduler.start(cron_expr) - print(f"[服务启动] 定时发布任务已启动,Cron: {cron_expr}") + print(f"[服务启动] 定时发布任务已启动,Cron: {cron_expr}", file=sys.stderr) else: - print("[服务启动] 定时发布任务未启用") + print("[服务启动] 定时发布任务未启用", file=sys.stderr) async def browser_cleanup_task(): """后台任务:定期清理空闲浏览器""" @@ -370,8 +529,11 @@ async def shutdown_event(): print("[服务关闭] 调度器已停止") # 关闭浏览器池 - await browser_pool.close() - print("[服务关闭] 浏览器池已关闭") + # 已禁用:使用AdsPower,不需要浏览器池 + # if browser_pool: + # await browser_pool.close() + # print("[服务关闭] 浏览器池已关闭") + print("[服务关闭] 服务关闭完成") @app.post("/api/xhs/send-code", response_model=BaseResponse) async def send_code(request: SendCodeRequest): @@ -398,11 +560,16 @@ async def send_code(request: SendCodeRequest): print(f"[发送验证码] 使用登录页面: {login_page} (配置默认={default_login_page}, API参数={request.login_page})", file=sys.stderr) try: - # 为此请求创建独立的登录服务实例,使用session_id实现并发隔离 + # 从配置读取是否启用AdsPower + use_adspower = config.get_bool('adspower.enabled', False) + print(f"[发送验证码] AdsPower模式: {'[开启]' if use_adspower else '[关闭]'}", file=sys.stderr) + + # 每次都创建全新的登录服务实例(不使用浏览器池,确保每次获取新代理IP) request_login_service = XHSLoginService( - use_pool=True, - headless=login_service.headless, # 使用配置文件中的login.headless配置 - session_id=session_id # 关键:传递session_id + use_pool=False, # 关键:不使用浏览器池,每次创建新实例 + headless=login_service.headless, + session_id=session_id, + use_adspower=use_adspower ) # 调用登录服务发送验证码 @@ -428,13 +595,12 @@ async def send_code(request: SendCodeRequest): ) if result["success"]: - # 验证浏览器是否已保存到池中 - if browser_pool and session_id in browser_pool.temp_browsers: - print(f"[发送验证码] ✅ 浏览器实例已保存到池中: {session_id}", file=sys.stderr) - print(f"[发送验证码] 当前池中共有 {len(browser_pool.temp_browsers)} 个临时浏览器", file=sys.stderr) - else: - print(f"[发送验证码] ⚠️ 浏览器实例未保存到池中: {session_id}", file=sys.stderr) - print(f"[发送验证码] 池中的session列表: {list(browser_pool.temp_browsers.keys()) if browser_pool else 'None'}", file=sys.stderr) + # 验证码发送成功,关闭浏览器(下次重新创建) + try: + await request_login_service.close() + print(f"[发送验证码] ✅ 已关闭浏览器实例: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[发送验证码] 关闭浏览器失败: {str(e)}", file=sys.stderr) return BaseResponse( code=0, @@ -445,13 +611,12 @@ async def send_code(request: SendCodeRequest): } ) else: - # 发送失败,释放临时浏览器 - if session_id and browser_pool: - try: - await browser_pool.release_temp_browser(session_id) - print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr) - except Exception as e: - print(f"[发送验证码] 释放临时浏览器失败: {str(e)}", file=sys.stderr) + # 发送失败,关闭浏览器 + try: + await request_login_service.close() + print(f"[发送验证码] 已关闭失败的浏览器: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[发送验证码] 关闭浏览器失败: {str(e)}", file=sys.stderr) return BaseResponse( code=1, @@ -462,13 +627,13 @@ async def send_code(request: SendCodeRequest): except Exception as e: print(f"发送验证码异常: {str(e)}", file=sys.stderr) - # 异常情况,释放临时浏览器 - if session_id and browser_pool: - try: - await browser_pool.release_temp_browser(session_id) - print(f"[发送验证码] 已释放临时浏览器: {session_id}", file=sys.stderr) - except Exception as release_error: - print(f"[发送验证码] 释放临时浏览器失败: {str(release_error)}", file=sys.stderr) + # 异常情况,关闭浏览器 + try: + if 'request_login_service' in locals(): + await request_login_service.close() + print(f"[发送验证码] 已关闭异常的浏览器: {session_id}", file=sys.stderr) + except Exception as close_error: + print(f"[发送验证码] 关闭浏览器失败: {str(close_error)}", file=sys.stderr) return BaseResponse( code=1, @@ -557,7 +722,7 @@ async def start_qrcode_login(): print(f"[扫码登录] 创建全新浏览器实例 session_id={session_id}", file=sys.stderr) qrcode_service = XHSLoginService( - use_pool=True, + use_pool=False, # 使用AdsPower,不需要浏览器池 headless=login_service.headless, session_id=session_id, use_page_isolation=False # 小红书不支持页面隔离,必须独立浏览器 @@ -645,7 +810,7 @@ async def get_qrcode_status(request: dict): # 使用session_id获取浏览器实例 qrcode_service = XHSLoginService( - use_pool=True, + use_pool=False, # 使用AdsPower,不需要浏览器池 headless=login_service.headless, session_id=session_id ) @@ -716,7 +881,7 @@ async def refresh_qrcode(request: dict): # 使用session_id获取浏览器实例 qrcode_service = XHSLoginService( - use_pool=True, + use_pool=False, # 使用AdsPower,不需要浏览器池 headless=login_service.headless, session_id=session_id ) @@ -1433,11 +1598,17 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo try: print(f"[WebSocket-SendCode] 开始处理: session={session_id}, phone={phone}", file=sys.stderr) - # 创建登录服务实例 + # 从配置读取是否启用AdsPower + config = get_config() + use_adspower = config.get_bool('adspower.enabled', False) + print(f"[WebSocket-SendCode] AdsPower模式: {'[开启]' if use_adspower else '[关闭]'}", file=sys.stderr) + + # 每次都创建全新的登录服务实例(不使用浏览器池,确保每次获取新代理IP) request_login_service = XHSLoginService( - use_pool=True, + use_pool=False, # 不使用浏览器池,每次创建新实例 headless=login_service.headless, - session_id=session_id + session_id=session_id, + use_adspower=use_adspower ) # 调用登录服务发送验证码 @@ -1448,12 +1619,22 @@ async def handle_send_code_ws(session_id: str, phone: str, country_code: str, lo session_id=session_id ) - # 将service实例存储到浏览器池,供后续验证码验证使用 - if session_id in browser_pool.temp_browsers: - browser_pool.temp_browsers[session_id]['service'] = request_login_service - print(f"[WebSocket-SendCode] 已存储service实例: {session_id}", file=sys.stderr) + # 将service实例存储,供后续验证码验证使用 + # 注意:由于browser_pool已禁用,使用temp_sessions字典存储 + if session_id not in temp_sessions: + # 手动创建session记录 + temp_sessions[session_id] = { + 'browser': request_login_service.browser, + 'context': request_login_service.context, + 'page': request_login_service.page, + 'service': request_login_service, + 'created_at': datetime.now() + } + print(f"[WebSocket-SendCode] 已手动创建session记录: {session_id}", file=sys.stderr) else: - print(f"[WebSocket-SendCode] 警告: session_id {session_id} 不在temp_browsers中", file=sys.stderr) + # 更新现有session的service实例 + temp_sessions[session_id]['service'] = request_login_service + print(f"[WebSocket-SendCode] 已更新service实例: {session_id}", file=sys.stderr) # 检查是否需要验证(发送验证码时触发风控) if result.get("need_captcha"): @@ -1506,8 +1687,8 @@ async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_ try: print(f"[WebSocket-VerifyCode] 开始验证: session={session_id}, phone={phone}, code={code}", file=sys.stderr) - # 从浏览器池中获取之前的浏览器实例 - if session_id not in browser_pool.temp_browsers: + # 从temp_sessions中获取之前的浏览器实例 + if session_id not in temp_sessions: print(f"[WebSocket-VerifyCode] 未找到session: {session_id}", file=sys.stderr) await websocket.send_json({ "type": "login_result", @@ -1517,7 +1698,7 @@ async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_ return # 获取浏览器实例 - browser_data = browser_pool.temp_browsers[session_id] + browser_data = temp_sessions[session_id] request_login_service = browser_data['service'] # 调用登录服务验证登录 @@ -1561,6 +1742,10 @@ async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_ await websocket.send_json({ "type": "login_success", "success": True, + "cookies": result.get("cookies"), # 键值对格式 + "cookies_full": result.get("cookies_full"), # Playwright完整格式(你需要的格式) + "user_info": result.get("user_info"), + "login_state": result.get("login_state"), "storage_state": storage_state, "storage_state_path": storage_state_path, "message": "登录成功" @@ -1568,8 +1753,62 @@ async def handle_verify_code_ws(session_id: str, phone: str, code: str, country_ # 释放浏览器 try: - await browser_pool.release_temp_browser(session_id) - print(f"[WebSocket-VerifyCode] 已释放浏览器: {session_id}", file=sys.stderr) + # 使用temp_sessions的自定义清理逻辑 + if session_id in temp_sessions: + try: + service = temp_sessions[session_id].get('service') + if service: + # 关闭浏览器 + await service.close_browser() # 使用正确的方法名 + print(f"[WebSocket-VerifyCode] 浏览器已关闭: {session_id}", file=sys.stderr) + + # 关闭后查询AdsPower Cookie + adspower_cookies = await service.get_adspower_cookies_after_close() + if adspower_cookies: + print(f"[WebSocket-VerifyCode] 获取到AdsPower Cookie: {len(adspower_cookies)}个", file=sys.stderr) + + # 更新返回给前端的cookies_full + result['cookies_full'] = adspower_cookies + result['cookies'] = {cookie['name']: cookie['value'] for cookie in adspower_cookies} + + # 更新storage_state中的cookies + if storage_state: + storage_state['cookies'] = adspower_cookies + result['storage_state'] = storage_state + + # 重新保存storage_state文件 + try: + with open(storage_state_path, 'w', encoding='utf-8') as f: + json.dump(storage_state, f, ensure_ascii=False, indent=2) + print(f"[WebSocket-VerifyCode] 已更新storage_state文件中的Cookie", file=sys.stderr) + except Exception as save_err: + print(f"[WebSocket-VerifyCode] 更新storage_state失败: {str(save_err)}", file=sys.stderr) + + # 检查WebSocket连接状态后再推送消息 + try: + # 重新推送登录成功消息,包含更新后的Cookie + await websocket.send_json({ + "type": "login_success", + "success": True, + "cookies": result.get("cookies"), + "cookies_full": adspower_cookies, # 使用AdsPower Cookie + "user_info": result.get("user_info"), + "login_state": result.get("login_state"), + "storage_state": storage_state, + "storage_state_path": storage_state_path, + "message": "登录成功(已同步AdsPower Cookie)" + }) + print(f"[WebSocket-VerifyCode] 已重新推送包含AdsPower Cookie的登录成功消息", file=sys.stderr) + except Exception as send_err: + # WebSocket已断开,不记录错误(客户端可能已主动断开) + print(f"[WebSocket-VerifyCode] WebSocket已断开,跳过消息推送", file=sys.stderr) + else: + print(f"[WebSocket-VerifyCode] 未获取到AdsPower Cookie,使用Playwright Cookie", file=sys.stderr) + + del temp_sessions[session_id] + print(f"[WebSocket-VerifyCode] 已释放session: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket-VerifyCode] 释放浏览器失败: {str(e)}", file=sys.stderr) except Exception as e: print(f"[WebSocket-VerifyCode] 释放浏览器失败: {str(e)}", file=sys.stderr) else: @@ -1665,6 +1904,26 @@ async def websocket_login(websocket: WebSocket, session_id: str): }) print(f"[WebSocket] 已回复测试消息", file=sys.stderr) + # 处理消息ACK确认 + elif msg_type == 'ack': + message_id = msg.get('message_id') + if message_id: + ws_manager.confirm_message(session_id, message_id) + print(f"[WebSocket] 收到ACK确认: {message_id}", file=sys.stderr) + + # 处理拉取未确认消息请求 + elif msg_type == 'pull_unconfirmed': + unconfirmed = ws_manager.get_unconfirmed_messages(session_id) + if unconfirmed: + print(f"[WebSocket] 前端请求拉取未确认消息: {len(unconfirmed)}条", file=sys.stderr) + # 逐条重发 + for msg in unconfirmed: + await websocket.send_json(msg) + await asyncio.sleep(0.1) # 间陑00ms + print(f"[WebSocket] 已重发所有未确认消息", file=sys.stderr) + else: + print(f"[WebSocket] 没有未确认消息", file=sys.stderr) + # 处理发送验证码消息 elif msg_type == 'send_code': phone = msg.get('phone') @@ -1675,11 +1934,66 @@ async def websocket_login(websocket: WebSocket, session_id: str): # 直接处理发送验证码(不使用create_task) result, service_instance = await handle_send_code_ws(session_id, phone, country_code, login_page, websocket) - # 如果需要扫码,在当前协程中启动监听 + # 如果需要扫码,在当前协程中启动监听,并在扫码成功后自动继续发送验证码 if result.get("need_captcha") and service_instance: print(f"[WebSocket] 在主协程中启动扫码监听: {session_id}", file=sys.stderr) + + # 创建一个包装函数,在扫码完成后自动继续发送验证码 + async def monitor_and_continue(): + try: + # 等待扫码完成 + print(f"[WebSocket] 开始监听扫码...", file=sys.stderr) + await service_instance._monitor_qrcode_scan(session_id) + print(f"[WebSocket] 扫码监听已返回,说明扫码已完成", file=sys.stderr) + + # 扫码成功,自动重新发送验证码 + print(f"[WebSocket] 扫码成功,自动重新发送验证码...", file=sys.stderr) + + # 从之前的result中获取参数 + retry_phone = result.get('phone', phone) + retry_country_code = result.get('country_code', country_code) + retry_login_page = result.get('login_page', login_page) + + # 重新调用send_verification_code + retry_result = await service_instance.send_verification_code( + phone=retry_phone, + country_code=retry_country_code, + login_page=retry_login_page, + session_id=session_id + ) + + # 检查是否成功 + if retry_result.get("success"): + print(f"[WebSocket] 扫码后自动发送验证码成功", file=sys.stderr) + # 推送成功消息给前端 + await websocket.send_json({ + "type": "code_sent", + "success": True, + "message": "扫码验证完成,验证码已自动发送" + }) + else: + print(f"[WebSocket] 扫码后自动发送验证码失败: {retry_result.get('error')}", file=sys.stderr) + # 推送失败消息给前端 + await websocket.send_json({ + "type": "code_sent", + "success": False, + "message": retry_result.get("error", "自动发送验证码失败") + }) + except Exception as e: + print(f"[WebSocket] 扫码后自动继续流程异常: {str(e)}", file=sys.stderr) + import traceback + traceback.print_exc() + try: + await websocket.send_json({ + "type": "code_sent", + "success": False, + "message": f"自动继续流程异常: {str(e)}" + }) + except: + pass + # 使用create_task在后台监听,但不阻塞当前消息循环 - asyncio.create_task(service_instance._monitor_qrcode_scan(session_id)) + asyncio.create_task(monitor_and_continue()) print(f"[WebSocket] 已启动扫码监听任务", file=sys.stderr) # 处理验证码验证消息 @@ -1693,6 +2007,51 @@ async def websocket_login(websocket: WebSocket, session_id: str): # 启动异步任务处理验证码验证 asyncio.create_task(handle_verify_code_ws(session_id, phone, code, country_code, login_page, websocket)) + # 处理刷新二维码消息 + elif msg_type == 'refresh_qrcode': + print(f"[WebSocket] 收到刷新二维码请求: session_id={session_id}", file=sys.stderr) + + # 从temp_sessions中获取service实例 + if session_id in temp_sessions: + browser_data = temp_sessions[session_id] + service_instance = browser_data.get('service') + + if service_instance: + print(f"[WebSocket] 开始刷新二维码...", file=sys.stderr) + result = await service_instance.refresh_qrcode() + + if result['success']: + # 刷新成功,推送新二维码 + print(f"[WebSocket] 二维码刷新成功", file=sys.stderr) + await websocket.send_json({ + "type": "qrcode_refreshed", + "success": True, + "qrcode_image": result['qrcode_image'], + "message": result.get('message', '二维码已刷新') + }) + else: + # 刷新失败 + print(f"[WebSocket] 二维码刷新失败: {result.get('message', '未知错误')}", file=sys.stderr) + await websocket.send_json({ + "type": "qrcode_refreshed", + "success": False, + "message": result.get('message', '刷新失败') + }) + else: + print(f"[WebSocket] 错误: 未找到service实例", file=sys.stderr) + await websocket.send_json({ + "type": "qrcode_refreshed", + "success": False, + "message": "内部错误: 服务实例不存在" + }) + else: + print(f"[WebSocket] 错误: session_id {session_id} 不在temp_sessions中", file=sys.stderr) + await websocket.send_json({ + "type": "qrcode_refreshed", + "success": False, + "message": "会话已过期,请重新发送验证码" + }) + except json.JSONDecodeError: print(f"[WebSocket] 无法解析为JSON: {data}", file=sys.stderr) @@ -1707,8 +2066,8 @@ async def websocket_login(websocket: WebSocket, session_id: str): try: redis_task.cancel() await pubsub.unsubscribe(channel) - await pubsub.close() - await redis_client.close() + await pubsub.aclose() + await redis_client.aclose() # 使用aclose()而不是close() print(f"[WebSocket] 已取消Redis订阅: {channel}", file=sys.stderr) except: pass @@ -1716,16 +2075,16 @@ async def websocket_login(websocket: WebSocket, session_id: str): # 释放浏览器实例 try: # 检查是否有临时浏览器需要释放 - if session_id in browser_pool.temp_browsers: + if session_id in temp_sessions: print(f"[WebSocket] 检测到未释放的临时浏览器,开始清理: {session_id}", file=sys.stderr) - await browser_pool.release_temp_browser(session_id) - print(f"[WebSocket] 已释放临时浏览器: {session_id}", file=sys.stderr) - - # 检查是否有扫码页面需要释放 - if session_id in browser_pool.qrcode_pages: - print(f"[WebSocket] 检测到未释放的扫码页面,开始清理: {session_id}", file=sys.stderr) - await browser_pool.release_qrcode_page(session_id) - print(f"[WebSocket] 已释放扫码页面: {session_id}", file=sys.stderr) + try: + service = temp_sessions[session_id].get('service') + if service: + await service.close_browser() # 使用正确的方法名 + del temp_sessions[session_id] + print(f"[WebSocket] 已释放临时浏览器: {session_id}", file=sys.stderr) + except Exception as e: + print(f"[WebSocket] 释放临时浏览器失败: {str(e)}", file=sys.stderr) except Exception as e: print(f"[WebSocket] 释放浏览器异常: {str(e)}", file=sys.stderr) diff --git a/backend/scheduler.py b/backend/scheduler.py index ca20a1d..0b4f40d 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -32,7 +32,10 @@ class XHSScheduler: enable_random_ua: bool = True, min_publish_interval: int = 30, max_publish_interval: int = 120, - headless: bool = True): + headless: bool = True, + use_adspower: bool = True, + proxy_username: Optional[str] = None, # 新增:代理用户名 + proxy_password: Optional[str] = None): # 新增:代理密码 """ 初始化调度器 @@ -48,6 +51,9 @@ class XHSScheduler: min_publish_interval: 最小发布间隔(秒) max_publish_interval: 最大发布间隔(秒) headless: 是否使用无头模式,False为有头模式(方便调试) + use_adspower: 是否使用AdsPower浏览器管理 + proxy_username: 代理用户名(可选,白名单模式可留空) + proxy_password: 代理密码(可选,白名单模式可留空) """ self.db_config = db_config self.max_concurrent = max_concurrent @@ -58,16 +64,25 @@ class XHSScheduler: self.max_hourly_articles_per_user = max_hourly_articles_per_user self.proxy_pool_enabled = proxy_pool_enabled self.proxy_pool_api_url = proxy_pool_api_url or "" + self.proxy_username = proxy_username or "" # 保存代理用户名 + self.proxy_password = proxy_password or "" # 保存代理密码 self.enable_random_ua = enable_random_ua self.min_publish_interval = min_publish_interval self.max_publish_interval = max_publish_interval self.headless = headless + self.use_adspower = use_adspower self.scheduler = AsyncIOScheduler() - self.login_service = XHSLoginService(use_pool=True, headless=headless) + # 使用AdsPower时禁用浏览器池,避免资源冲突 + self.login_service = XHSLoginService( + use_pool=False, # 使用AdsPower不需要浏览器池 + headless=headless, + use_adspower=use_adspower + ) self.semaphore = asyncio.Semaphore(max_concurrent) - print(f"[调度器] 已创建,最大并发: {max_concurrent}", file=sys.stderr) + mode_text = "AdsPower" if use_adspower else "浏览器池" if not use_adspower else "传统" + print(f"[调度器] 已创建,最大并发: {max_concurrent},浏览器模式: {mode_text}", file=sys.stderr) def start(self, cron_expr: str = "*/5 * * * * *"): """ @@ -122,8 +137,13 @@ class XHSScheduler: cursorclass=pymysql.cursors.DictCursor ) - async def _fetch_proxy_from_pool(self) -> Optional[str]: - """从代理池接口获取一个代理地址(http://ip:port)""" + async def _fetch_proxy_from_pool(self) -> Optional[dict]: + """从代理池接口获取一个代理地址,并附加认证信息 + + Returns: + dict: 代理配置字典 {'server': 'http://ip:port', 'username': '...', 'password': '...'} + 或 None 如果未启用或获取失败 + """ if not self.proxy_pool_enabled or not self.proxy_pool_api_url: return None @@ -145,9 +165,24 @@ class XHSScheduler: print("[调度器] 代理池首行内容为空", file=sys.stderr) return None - if line.startswith("http://") or line.startswith("https://"): - return line - return "http://" + line + # 构建代理URL + proxy_server = line if line.startswith(("http://", "https://")) else "http://" + line + + # 构建完整的代理配置字典 + proxy_config = { + 'server': proxy_server + } + + # 如果配置了认证信息,添加到配置中 + if self.proxy_username and self.proxy_password: + proxy_config['username'] = self.proxy_username + proxy_config['password'] = self.proxy_password + print(f"[调度器] 获取代理成功: {proxy_server} (认证代理, 用户名: {self.proxy_username})", file=sys.stderr) + else: + print(f"[调度器] 获取代理成功: {proxy_server} (白名单模式)", file=sys.stderr) + + return proxy_config + except Exception as e: print(f"[调度器] 请求代理池接口失败: {str(e)}", file=sys.stderr) return None diff --git a/backend/test_get_cookies.py b/backend/test_get_cookies.py new file mode 100644 index 0000000..1982d94 --- /dev/null +++ b/backend/test_get_cookies.py @@ -0,0 +1,102 @@ +""" +测试AdsPower查询Cookie功能 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.insert(0, str(Path(__file__).parent)) + +from fingerprint_browser import get_fingerprint_manager +from loguru import logger + +async def test_get_cookies(): + """测试获取Cookie""" + logger.info("="*50) + logger.info("开始测试AdsPower Cookie查询功能") + logger.info("="*50) + + manager = get_fingerprint_manager() + + # 检查AdsPower状态 + if not await manager.check_adspower_status(): + logger.error("❌ AdsPower未运行,请先启动AdsPower") + return + + logger.success("✅ AdsPower运行正常") + + # 获取所有配置 + logger.info("\n查询所有浏览器配置...") + profiles = await manager.get_browser_profiles() + + if not profiles: + logger.error("❌ 没有找到任何浏览器配置") + return + + logger.success(f"✅ 找到 {len(profiles)} 个配置") + + # 显示配置列表 + print("\n可用的配置列表:") + for i, profile in enumerate(profiles[:5], 1): # 只显示前5个 + profile_id = profile.get('user_id') or profile.get('id') + profile_name = profile.get('name', 'unknown') + print(f"{i}. ID: {profile_id}, 名称: {profile_name}") + + # 选择看起来像是小红书账号的配置 + test_profile = None + for profile in profiles: + name = profile.get('name', '') + if 'XHS' in name or '小红书' in name or '1570' in name: + test_profile = profile + break + + if not test_profile: + # 如果没有找到,就用第一个 + test_profile = profiles[0] + + profile_id = test_profile.get('user_id') or test_profile.get('id') + profile_name = test_profile.get('name', 'unknown') + + logger.info("\n" + "="*50) + logger.info(f"测试配置: {profile_name} (ID: {profile_id})") + logger.info("="*50) + + # 查询Cookie + logger.info("\n开始查询Cookie...") + cookies = await manager.get_profile_cookies(profile_id) + + if cookies: + logger.success(f"\n✅ 成功获取Cookie!") + logger.info(f"Cookie数量: {len(cookies)}") + + # 显示前3个Cookie的详细信息 + print("\n前3个Cookie详情:") + for i, cookie in enumerate(cookies[:3], 1): + print(f"\n{i}. {cookie.get('name', 'unknown')}") + print(f" Domain: {cookie.get('domain', 'N/A')}") + print(f" Path: {cookie.get('path', 'N/A')}") + print(f" Value: {cookie.get('value', '')[:20]}..." if len(cookie.get('value', '')) > 20 else f" Value: {cookie.get('value', '')}") + print(f" HttpOnly: {cookie.get('httpOnly', False)}") + print(f" Secure: {cookie.get('secure', False)}") + print(f" SameSite: {cookie.get('sameSite', 'N/A')}") + + # 统计Cookie域名分布 + domains = {} + for cookie in cookies: + domain = cookie.get('domain', 'unknown') + domains[domain] = domains.get(domain, 0) + 1 + + print("\nCookie域名分布:") + for domain, count in sorted(domains.items(), key=lambda x: x[1], reverse=True)[:5]: + print(f" {domain}: {count}个") + + else: + logger.error("❌ 获取Cookie失败") + + logger.info("\n" + "="*50) + logger.info("测试完成") + logger.info("="*50) + +if __name__ == "__main__": + asyncio.run(test_get_cookies()) diff --git a/backend/tianqi_proxy_pool.py b/backend/tianqi_proxy_pool.py new file mode 100644 index 0000000..611c168 --- /dev/null +++ b/backend/tianqi_proxy_pool.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +大麦IP代理池管理模块 +支持动态获取代理IP,实现每次请求使用不同IP +注意:代理用户名和密码从config.dev.yaml的proxy_pool配置中读取 +""" +import requests +import time +from typing import Optional, Dict +from loguru import logger +from config import get_config + + +class TianqiProxyPool: + """大麦IP代理池管理器""" + + def __init__(self): + """初始化代理池""" + config = get_config() + self.enabled = config.get_bool('proxy_pool.enabled', False) + self.api_url = config.get_str('proxy_pool.api_url', '') + self.username = config.get_str('proxy_pool.username', '') # 代理用户名 + self.password = config.get_str('proxy_pool.password', '') # 代理密码 + self.last_fetch_time = 0 + self.min_fetch_interval = 3 # 最小请求间隔(秒),避免频繁请求API + + if self.enabled: + logger.info(f"[大麦代理池] 已启用,API: {self.api_url[:50]}...") + if self.username and self.password: + logger.info(f"[大麦代理池] 使用认证代理,用户名: {self.username}") + else: + logger.info(f"[大麦代理池] 使用白名单代理(无认证)") + else: + logger.info("[大麦代理池] 未启用") + + def is_enabled(self) -> bool: + """检查代理池是否启用""" + return self.enabled and bool(self.api_url) + + def fetch_proxy(self) -> Optional[Dict[str, str]]: + """ + 从大麦IP API获取一个新的代理IP + + Returns: + 代理配置字典 {'server': 'http://ip:port', 'username': '...', 'password': '...'} + 失败返回None + """ + if not self.is_enabled(): + logger.warning("[大麦代理池] 代理池未启用") + return None + + # 检查请求间隔 + current_time = time.time() + if current_time - self.last_fetch_time < self.min_fetch_interval: + wait_time = self.min_fetch_interval - (current_time - self.last_fetch_time) + logger.info(f"[大麦代理池] 距离上次请求不足{self.min_fetch_interval}秒,等待{wait_time:.1f}秒...") + time.sleep(wait_time) + + try: + logger.info("[大麦代理池] 正在获取新代理IP...") + + # 调用大麦IP API + response = requests.get( + self.api_url, + timeout=10, + headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} + ) + + self.last_fetch_time = time.time() + + if response.status_code != 200: + logger.error(f"[大麦代理池] API请求失败: HTTP {response.status_code}") + logger.error(f"[大麦代理池] 返回内容: {response.text[:200]}") + return None + + # 解析返回的IP:Port格式 + proxy_text = response.text.strip() + + # 检查是否返回错误信息 + if not proxy_text or ':' not in proxy_text: + logger.error(f"[大麦代理池] API返回格式错误: {proxy_text}") + return None + + # 构建代理配置(注入用户名密码) + proxy_config = { + 'server': f'http://{proxy_text}', + 'username': self.username, # 从配置读取的用户名 + 'password': self.password, # 从配置读取的密码 + 'name': f'大麦动态IP-{proxy_text.split(":")[0]}' + } + + if self.username and self.password: + logger.success(f"[大麦代理池] 获取成功: {proxy_text} (认证代理)") + else: + logger.success(f"[大麦代理池] 获取成功: {proxy_text} (白名单代理)") + return proxy_config + + except requests.exceptions.Timeout: + logger.error("[大麦代理池] API请求超时") + return None + except Exception as e: + logger.error(f"[大麦代理池] 获取代理失败: {str(e)}") + return None + + def format_for_playwright(self, proxy_config: Dict[str, str]) -> Dict[str, str]: + """ + 将代理配置格式化为Playwright格式 + + Args: + proxy_config: 代理配置字典 + + Returns: + Playwright proxy格式 + """ + result = { + 'server': proxy_config['server'] + } + + # 只有在有用户名密码时才添加 + if proxy_config.get('username'): + result['username'] = proxy_config['username'] + if proxy_config.get('password'): + result['password'] = proxy_config['password'] + + return result + + +# 全局代理池实例 +_proxy_pool = None + + +def get_tianqi_proxy_pool() -> TianqiProxyPool: + """获取全局大麦代理池实例""" + global _proxy_pool + if _proxy_pool is None: + _proxy_pool = TianqiProxyPool() + return _proxy_pool + + +def get_new_proxy() -> Optional[Dict[str, str]]: + """ + 快捷函数:获取一个新的代理IP + + Returns: + Playwright格式的代理配置,失败返回None + """ + pool = get_tianqi_proxy_pool() + proxy_config = pool.fetch_proxy() + + if proxy_config: + return pool.format_for_playwright(proxy_config) + + return None + + +if __name__ == "__main__": + """测试代码""" + print("=" * 60) + print("大麦IP代理池测试") + print("=" * 60) + + # 初始化配置 + from config import init_config + init_config('dev') + + # 获取代理池 + pool = get_tianqi_proxy_pool() + + if not pool.is_enabled(): + print("❌ 代理池未启用,请在config.dev.yaml中设置 proxy_pool.enabled=true") + else: + print("✅ 代理池已启用") + print(f" 认证模式: {'\u662f' if pool.username and pool.password else '\u5426 (白名单)'}") + + # 测试获取3个代理IP + for i in range(3): + print(f"\n第{i+1}次获取:") + proxy = get_new_proxy() + if proxy: + print(f" 代理服务器: {proxy['server']}") + print(f" 有认证: {'是' if proxy.get('username') else '否'}") + if proxy.get('username'): + print(f" 用户名: {proxy['username']}") + else: + print(" ❌ 获取失败") + + if i < 2: + print(" 等待3秒...") + time.sleep(3) + + print("\n" + "=" * 60) diff --git a/backend/xhs_login.py b/backend/xhs_login.py index 2e4e307..630fe2e 100644 --- a/backend/xhs_login.py +++ b/backend/xhs_login.py @@ -3,7 +3,7 @@ 使用 Playwright 模拟浏览器登录小红书 """ from playwright.async_api import async_playwright, Browser, Page, BrowserContext -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Union import asyncio import json import random @@ -19,6 +19,9 @@ from browser_pool import get_browser_pool from error_screenshot import save_error_screenshot, save_screenshot_with_html from loguru import logger from damai_proxy_config import get_random_proxy, format_proxy_for_playwright +from tianqi_proxy_pool import get_tianqi_proxy_pool +from xhs_login_helper import get_login_helper, XHSSelectors +from fingerprint_browser import FingerprintBrowserManager # 配置loguru日志格式 logger.remove() # 移除默认handler @@ -75,7 +78,7 @@ async def download_image(url: str) -> str: class XHSLoginService: """小红书登录服务""" - def __init__(self, use_pool: bool = True, headless: bool = True, session_id: Optional[str] = None, use_page_isolation: bool = False): + def __init__(self, use_pool: bool = True, headless: bool = True, session_id: Optional[str] = None, use_page_isolation: bool = False, use_adspower: bool = False, adspower_profile_id: Optional[str] = None): """ 初始化登录服务 @@ -84,19 +87,27 @@ class XHSLoginService: headless: 是否使用无头模式,False为有头模式(方便调试) session_id: 会话 ID,用于并发隔离(不同的session_id会创建独立的浏览器实例) use_page_isolation: 是否使用页面隔离模式(扫码登录专用,减少浏览器实例数) + use_adspower: 是否使用AdsPower指纹浏览器(默认False) + adspower_profile_id: AdsPower配置文件ID(可选,不指定则自动选择) """ self.use_pool = use_pool self.headless = headless self.session_id = session_id # 保存session_id用于并发隔离 self.use_page_isolation = use_page_isolation # 页面隔离模式 + self.use_adspower = use_adspower # 是否使用AdsPower + self.adspower_profile_id = adspower_profile_id # AdsPower配置ID self.browser_pool = get_browser_pool(headless=headless) if use_pool else None + self.fingerprint_manager = None # AdsPower管理器 self.playwright = None self.browser: Optional[Browser] = None self.context: Optional[BrowserContext] = None self.page: Optional[Page] = None self.current_phone = None - async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None, restore_state: bool = False, use_random_proxy: bool = True): + # 打印配置信息 + logger.info(f"[XHSLoginService] 初始化: use_pool={use_pool}, headless={headless}, use_adspower={use_adspower}, profile_id={adspower_profile_id}") + + async def init_browser(self, cookies: Optional[list] = None, proxy: Optional[dict] = None, user_agent: Optional[str] = None, restore_state: bool = False, use_random_proxy: bool = True, phone: Optional[str] = None): """ 初始化浏览器 @@ -106,16 +117,45 @@ class XHSLoginService: user_agent: 可选的自定义User-Agent restore_state: 是否从log_state.json文件恢复完整登录状态 use_random_proxy: 是否自动使用随机代理(默认True) + phone: 手机号(用作AdsPower配置名称) """ try: # 如果没有指定代理且启用自动代理,则使用随机代理 if not proxy and use_random_proxy: - try: - proxy_config = get_random_proxy() - proxy = format_proxy_for_playwright(proxy_config) - logger.info(f"[代理] 自动选择代理: {proxy_config['name']} ({proxy_config['server']})") - except Exception as e: - logger.info(f"[代理] 无可用代理,使用直连访问") + # 优先使用大麦IP代理池 + tianqi_pool = get_tianqi_proxy_pool() + logger.info(f"[代理调试] tianqi_pool.is_enabled() = {tianqi_pool.is_enabled()}") + if tianqi_pool.is_enabled(): + logger.info("[代理] 使用大麦IP代理池...") + proxy_raw = tianqi_pool.fetch_proxy() + logger.info(f"[代理调试] tianqi_pool.fetch_proxy() 返回: {proxy_raw}") + if proxy_raw: + proxy = tianqi_pool.format_for_playwright(proxy_raw) + logger.info(f"[代理调试] format_for_playwright() 返回: {proxy}") + # 输出代理信息 + if proxy.get('username') and proxy.get('password'): + logger.success(f"[代理] 大麦IP获取成功: {proxy['server']} (认证代理, 用户名: {proxy['username']})") + else: + logger.success(f"[代理] 大麦IP获取成功: {proxy['server']} (白名单代理)") + else: + logger.warning("[代理] 大麦IP获取失败,尝试使用固定代理...") + + # 如果大麦IP不可用或获取失败,则使用固定代理池 + if not proxy: + try: + proxy_config = get_random_proxy() + proxy = format_proxy_for_playwright(proxy_config) + logger.info(f"[代理] 使用固定代理: {proxy_config['name']} ({proxy_config['server']})") + except Exception as e: + logger.info(f"[代理] 无可用代理,使用直连访问") + + # 如果启用AdsPower,使用指纹浏览器(传入已获取的代理和cookies) + if self.use_adspower: + logger.info("[AdsPower] 启用AdsPower指纹浏览器模式") + logger.info(f"[代理调试] 传递给_init_adspower_browser的proxy参数: {proxy}") + if cookies: + logger.info(f"[Cookie调试] 传递给_init_adspower_browser的cookies数量: {len(cookies)}") + return await self._init_adspower_browser(proxy=proxy, phone=phone, cookies=cookies) # 如果要求恢复状态,先加载 login_state.json login_state = None if restore_state and os.path.exists('login_state.json'): @@ -411,6 +451,163 @@ class XHSLoginService: logger.error(f"浏览器初始化失败: {str(e)}") raise + async def _init_adspower_browser(self, proxy: Optional[Union[str, dict]] = None, phone: Optional[str] = None, cookies: Optional[list] = None): + """ + 初始化AdsPower指纹浏览器(参考ai_mip项目完整流程) + + 流程: + 1. 检查AdsPower运行状态 + 2. 获取或创建浏览器配置 + 3. 更新代理配置(如果有) + 4. 启动浏览器 + 5. 通过CDP连接 + 6. 注入Cookie(如果有) + + Args: + proxy: 可选的代理配置,支持两种格式: + - 字符串: 'http://ip:port' 或 'http://user:pass@ip:port' + - 字典: {'server': 'http://ip:port', 'username': '...', 'password': '...'} + phone: 手机号(用作配置名称) + cookies: 可选的Cookie列表,连接成功后注入 + """ + try: + logger.info("\n" + "="*70) + logger.info("[AdsPower] 开始初始化指纹浏览器") + logger.info("="*70) + + # 步骤1: 创建指纹浏览器管理器 + logger.info("[步骤 1/5] 创建指纹浏览器管理器...") + self.fingerprint_manager = FingerprintBrowserManager() + logger.success(">> 管理器创建成功") + + # 步骤2: 检查AdsPower是否运行 + logger.info("\n[步骤 2/5] 检查AdsPower运行状态...") + is_running = await self.fingerprint_manager.check_adspower_status() + if not is_running: + logger.error("!! AdsPower未运行") + raise Exception("警告: AdsPower未运行,请先启动AdsPower应用") + logger.success(">> AdsPower运行正常") + + # 格式转换:如果proxy是字符串,转换为字典格式 + proxy_config = None + if proxy: + if isinstance(proxy, str): + # 字符串格式: 'http://ip:port' 或 'http://user:pass@ip:port' + logger.info(f"[代理转换] 检测到字符串格式代理: {proxy}") + proxy_config = {'server': proxy} + logger.info(f"[代理转换] 已转换为字典格式") + elif isinstance(proxy, dict): + # 已经是字典格式 + proxy_config = proxy + logger.info(f"[代理配置] 使用字典格式代理") + else: + logger.warning(f"[代理配置] 未知的代理类型: {type(proxy).__name__},将忽略") + proxy_config = None + + # 步骤3: 获取或创建配置文件 + logger.info("\n[步骤 3/5] 获取或创建浏览器配置...") + if not self.adspower_profile_id: + # 如果提供了cookies,说明这是临时发布环境,强制创建新配置 + force_create = cookies is not None and len(cookies) > 0 + + if force_create: + logger.info(" 检测到Cookie,创建临时发布环境(Cookie将在创建时直接注入)...") + elif phone: + logger.info(f" 为手机号 {phone} 查找或创建配置...") + else: + logger.info(" 未指定配置ID,自动选择配置...") + + self.adspower_profile_id = await self.fingerprint_manager.get_or_create_profile( + proxy_config=proxy_config, + phone=phone, + force_create=force_create, # 有Cookie时强制创建临时环境 + cookies=cookies # 传递cookies到创建接口 + ) + + if not self.adspower_profile_id: + logger.error("!! 获取配置失败") + raise Exception("获取AdsPower配置失败") + + if force_create: + logger.success(f">> 临时环境创建成功(Cookie已注入): {self.adspower_profile_id}") + else: + logger.success(f">> 自动选择配置: {self.adspower_profile_id}") + else: + logger.info(f" 使用指定配置: {self.adspower_profile_id}") + logger.success(f">> 配置ID确认: {self.adspower_profile_id}") + + # 输出代理信息 + if proxy_config: + logger.info("\n 代理配置信息:") + logger.info(f" 服务器: {proxy_config.get('server', 'N/A')}") + if proxy_config.get('username') and proxy_config.get('password'): + logger.info(f" 认证: 是 (用户名: {proxy_config['username']})") + else: + logger.info(" 认证: 否 (白名单模式)") + else: + logger.info("\n 代理配置: 未使用代理(直连)") + + # 步骤4: 启动浏览器 + logger.info("\n[步骤 4/5] 启动浏览器...") + logger.info(f" 配置ID: {self.adspower_profile_id}") + if proxy_config: + logger.info(f" 代理服务器: {proxy_config.get('server')}") + + cdp_url = await self.fingerprint_manager.start_browser( + profile_id=self.adspower_profile_id, + proxy_config=proxy_config + ) + + if not cdp_url: + logger.error("!! 浏览器启动失败") + raise Exception("AdsPower浏览器启动失败") + + logger.success(f">> 浏览器启动成功") + logger.info(f" CDP地址: {cdp_url}") + + # 步骤5: 通过CDP连接到浏览器 + logger.info("\n[步骤 5/5] 通过CDP连接浏览器...") + logger.info(f" 连接地址: {cdp_url}") + + browser, context, page = await self.fingerprint_manager.connect_browser(cdp_url) + + if not browser or not context or not page: + logger.error("!! CDP连接失败") + raise Exception("CDP连接失败") + + self.browser = browser + self.context = context + self.page = page + + logger.success(">> CDP连接成功") + + # 输出最终信息 + logger.info("\n" + "-"*70) + logger.info("[初始化完成摘要]") + logger.info("-"*70) + logger.success(f">> 配置ID: {self.adspower_profile_id}") + logger.success(f">> 浏览器类型: AdsPower 指纹浏览器") + logger.success(f">> 代理状态: {'已启用' if proxy_config else '未启用'}") + if proxy_config: + logger.success(f">> 代理服务器: {proxy_config.get('server')}") + if cookies: + logger.success(f">> Cookie状态: 已注入(创建时)") + logger.success(f">> Cookie数量: {len(cookies)}") + else: + logger.success(f">> Cookie状态: 未注入") + logger.success(f">> 上下文数: {len(browser.contexts)}") + logger.success(f">> 页面数: {len(context.pages)}") + logger.info("="*70 + "\n") + + except Exception as e: + logger.error("\n" + "="*70) + logger.error("!! [AdsPower] 初始化失败") + logger.error("="*70) + logger.error(f"错误信息: {str(e)}") + logger.error("提示: 请确保 AdsPower 应用已启动并正常运行") + logger.error("="*70 + "\n") + raise + async def _restore_storage(self, login_state: dict): """恢夏localStorage和sessionStorage""" try: @@ -549,6 +746,17 @@ class XHSLoginService: # 传统模式:完全关闭 logger.info("[传统模式] 完全关闭浏览器") + + # 如果使用AdsPower,需要调用API关闭 + if self.use_adspower and self.fingerprint_manager and self.adspower_profile_id: + try: + logger.info(f"[关闭浏览器] 调用AdsPower API关闭配置: {self.adspower_profile_id}") + await self.fingerprint_manager.stop_browser(self.adspower_profile_id) + logger.success("[关闭浏览器] AdsPower浏览器已关闭") + except Exception as e: + logger.error(f"[关闭浏览器] AdsPower关闭失败: {str(e)}") + + # 关闭Playwright实例 if self.page: await self.page.close() if self.context: @@ -561,35 +769,109 @@ class XHSLoginService: except Exception as e: logger.error(f"关闭浏览器异常: {str(e)}") + async def get_adspower_cookies_after_close(self) -> Optional[list]: + """ + 关闭浏览器后查询AdsPower Cookie + AdsPower需要先关闭浏览器,Cookie才会同步到配置 + + Returns: + Cookie数组,失败返回None + """ + if not self.use_adspower or not self.fingerprint_manager or not self.adspower_profile_id: + logger.warning("[查询Cookie] 未使用AdsPower或配置ID为空") + return None + + try: + logger.info("="*50) + logger.info("[查询Cookie] 浏览器已关闭,开始查询AdsPower环境Cookie...") + logger.info(f"[查询Cookie] 配置ID: {self.adspower_profile_id}") + + # 等待一下,确俜Cookie已经同步 + await asyncio.sleep(1) + + adspower_cookies = await self.fingerprint_manager.get_profile_cookies(self.adspower_profile_id) + + if adspower_cookies: + logger.success(f"[查询Cookie] 成功获取AdsPower Cookie: {len(adspower_cookies)}个") + logger.info(f"[查询Cookie] Cookie示例: {[c['name'] for c in adspower_cookies[:5]]}...") + logger.info("="*50) + return adspower_cookies + else: + logger.warning("[查询Cookie] 未能获取AdsPower Cookie") + logger.info("="*50) + return None + + except Exception as e: + logger.error(f"[查询Cookie] 异常: {str(e)}") + import traceback + traceback.print_exc() + logger.info("="*50) + return None + async def extract_verification_qrcode(self) -> Optional[str]: """ 提取验证页面的二维码图片 - + Returns: 二维码图片的base64数据,如果提取失败则返回none """ try: if not self.page: return None - + logger.debug("正在提取验证二维码...") - + + # 先检查是否出现“请求太频繁”错误 + try: + error_text_elem = await self.page.query_selector('.confirm-text') + if error_text_elem: + error_text = await error_text_elem.inner_text() + if '请求太频繁' in error_text or '请稍后再试' in error_text: + logger.warning("⚠️ 检测到'请求太频繁'错误,尝试点击刷新按钮") + + # 查找刷新按钮 + refresh_btn_selectors = [ + '.btn:has-text("刷新")', + 'button:has-text("刷新")', + 'div:has-text("刷新")', + '.btn.btn-stroke', + '.refresh-container .btn', + ] + + for selector in refresh_btn_selectors: + try: + refresh_btn = await self.page.query_selector(selector) + if refresh_btn: + logger.info(f"找到刷新按钮: {selector}") + await refresh_btn.click() + logger.success("✅ 已点击刷新按钮") + + # 等待页面重新加载 + await asyncio.sleep(2) + logger.info("等待页面重新加载...") + break + except Exception as e: + logger.debug(f"尝试选择器 {selector} 失败: {str(e)}") + continue + except Exception as e: + logger.debug(f"检查'请求太频繁'错误时失败: {str(e)}") + # 尝试查找二维码图片元素 qrcode_selectors = [ '.qrcode-img', # 小红书风控二维码的特定class 'img.qrcode-img', '.qrcode-container img', # 二维码容器内的图片 '.verify-captcha img', # 验证弹窗内的图片 - 'img[alt*="二维码"]', # alt属性包含"二维码" - 'img[alt*="qrcode"]', # alt属性包含"qrcode" + 'img[alt*="二维码"]', # alt属性包含“二维码” + 'img[alt*="qrcode"]', # alt属性包含“qrcode” ] - + for selector in qrcode_selectors: try: qrcode_img = await self.page.wait_for_selector(selector, timeout=3000) if qrcode_img: logger.success(f"✅ 找到二维码图片: {selector}") - + # 获取图片src属性 src = await qrcode_img.get_attribute('src') if src: @@ -597,7 +879,7 @@ class XHSLoginService: if src.startswith('data:image'): logger.success("✅ 二维码已是base64格式,直接返回") return src - + # 如果是URL,尝试下载并转换为base64 logger.info(f"二维码是URL格式: {src[:100]}...") try: @@ -614,7 +896,7 @@ class XHSLoginService: return base64_str except Exception as e: logger.error(f"⚠️ 下载二维码图片失败: {str(e)}") - + # 如果src方法失败,尝试截图 logger.info("尝试截取二维码区域...") screenshot_bytes = await qrcode_img.screenshot() @@ -624,19 +906,167 @@ class XHSLoginService: base64_str = f"data:image/png;base64,{img_base64}" logger.success("✅ 成功截取二维码并转换为base64") return base64_str - + break except Exception as e: logger.error(f"尝试选择器 {selector} 失败: {str(e)}") continue - + logger.warning("⚠️ 未找到二维码图片") return None - + except Exception as e: logger.error(f"⚠️ 提取二维码失败: {str(e)}") return None + async def refresh_qrcode(self) -> Dict[str, Any]: + """ + 刷新过期的二维码 + 自动点击页面上的刷新按钮,获取新的二维码 + + Returns: + Dict: {"success": bool, "qrcode_image": str, "message": str} + """ + try: + logger.info(f"[刷新二维码] 开始") + logger.info(f"[刷新二维码] 浏览器状态: browser={self.browser is not None}, context={self.context is not None}, page={self.page is not None}") + + if not self.page: + logger.error("[刷新二维码] 错误: 页面对象不存在") + return { + "success": False, + "message": "页面对象不存在,请重新发送验证码" + } + + # 检查页面是否关闭 + try: + current_url = self.page.url + logger.info(f"[刷新二维码] 当前URL: {current_url}") + except Exception as e: + logger.error(f"[刷新二维码] 错误: 无法访问页面URL,页面可能已关闭: {str(e)}") + return { + "success": False, + "message": "页面已关闭,请重新发送验证码" + } + + # 查找刷新按钮(小红书的刷新按钮通常在二维码下方) + logger.info("[刷新二维码] 步骤1: 开始查找刷新按钮...") + refresh_selectors = [ + 'text="点击二维码区域刷新"', + 'text="刷新"', + 'text="重新获取"', + '.refresh-btn', + '.qrcode-refresh', + 'button:has-text("刷新")', + 'div:has-text("刷新")', + # 尝试点击二维码图片本身(很多网站点击二维码就可以刷新) + '.qrcode-image', + 'img[alt*="二维码"]', + 'canvas', # 某些二维码是canvas绘制的 + ] + + refresh_btn = None + for selector in refresh_selectors: + try: + refresh_btn = await self.page.query_selector(selector) + if refresh_btn: + is_visible = await refresh_btn.is_visible() + if is_visible: + logger.success(f"[刷新二维码] 找到刷新按钮: {selector}") + break + else: + logger.debug(f"[刷新二维码] 选择器 {selector} 存在但不可见") + except Exception as e: + logger.debug(f"[刷新二维码] 尝试选择器 {selector} 失败: {str(e)}") + continue + + if not refresh_btn: + logger.warning("[刷新二维码] 未找到刷新按钮,尝试点击整个二维码区域") + logger.info("[刷新二维码] 步骤2: 尝试查找二维码区域...") + # 如果找不到刷新按钮,尝试直接点击二维码区域 + qrcode_area_selectors = [ + '.qrcode-container', + '.qrcode-box', + '.captcha-qrcode', + '[class*="qrcode"]', + '[class*="QRCode"]', + ] + + for selector in qrcode_area_selectors: + try: + refresh_btn = await self.page.query_selector(selector) + if refresh_btn: + is_visible = await refresh_btn.is_visible() + if is_visible: + logger.success(f"[刷新二维码] 找到二维码区域: {selector}") + break + else: + logger.debug(f"[刷新二维码] 选择器 {selector} 存在但不可见") + except Exception as e: + logger.debug(f"[刷新二维码] 尝试选择器 {selector} 失败: {str(e)}") + continue + + if not refresh_btn: + logger.error("[刷新二维码] 未找到任何可点击的元素") + logger.info("[刷新二维码] 步骤3: 尝试点击页面中心位置作为兜底...") + + try: + # 获取页面尺寸 + viewport_size = self.page.viewport_size + if viewport_size: + center_x = viewport_size['width'] // 2 + center_y = viewport_size['height'] // 2 + logger.info(f"[刷新二维码] 页面尺寸: {viewport_size['width']}x{viewport_size['height']}, 中心位置: ({center_x}, {center_y})") + + # 点击页面中心 + await self.page.mouse.click(center_x, center_y) + logger.success(f"[刷新二维码] 已点击页面中心位置: ({center_x}, {center_y})") + else: + logger.warning("[刷新二维码] 无法获取页面尺寸,使用默认位置 (500, 300)") + await self.page.mouse.click(500, 300) + logger.success("[刷新二维码] 已点击默认位置: (500, 300)") + except Exception as click_error: + logger.error(f"[刷新二维码] 点击页面中心失败: {str(click_error)}") + return { + "success": False, + "message": "未找到刷新按钮且点击失败" + } + + # 点击刷新按钮 + logger.info("[刷新二维码] 点击刷新按钮...") + await refresh_btn.click() + logger.success("[刷新二维码] 已点击刷新按钮") + + # 等待一下,让二维码重新加载 + await asyncio.sleep(1.5) + + # 重新提取二维码 + logger.info("[刷新二维码] 提取新的二维码...") + qrcode_data = await self.extract_verification_qrcode() + + if qrcode_data: + logger.success(f"[刷新二维码] 成功获取新二维码 (长度: {len(qrcode_data)})") + return { + "success": True, + "qrcode_image": qrcode_data, + "message": "二维码已刷新" + } + else: + logger.error("[刷新二维码] 未能提取到新二维码") + return { + "success": False, + "message": "刷新后未能获取到新二维码" + } + + except Exception as e: + logger.error(f"[刷新二维码] 异常: {str(e)}") + import traceback + traceback.print_exc() + return { + "success": False, + "message": f"刷新失败: {str(e)}" + } + async def _monitor_qrcode_scan(self, session_id: str): """ 后台监听扫码后的页面跳转和二维码失效 @@ -755,47 +1185,50 @@ class XHSLoginService: # 检查是否跳转到小红书首页 if 'xiaohongshu.com' in current_url: logger.success(f"[WebSocket] 检测到扫码完成,页面跳转回: {current_url}") - + # 等待500ms确保WebSocket连接完全建立 await asyncio.sleep(0.5) - - # 通过WebSocket推送扫码成功消息 + + # 扫码成功,自动继续发送验证码流程 + logger.info(f"[WebSocket] 扫码验证完成,自动继续发送验证码...") + + # 通过Redis推送扫码成功消息(仅用于前端显示Toast) try: - # 使用Redis发布消息 import redis import json as json_lib from config import get_config - + config = get_config() redis_host = config.get_str('redis.host', 'localhost') redis_port = config.get_int('redis.port', 6379) redis_password = config.get_str('redis.password', '') - + redis_client = redis.Redis( host=redis_host, port=redis_port, password=redis_password if redis_password else None, decode_responses=True ) - + message = { "type": "qrcode_scan_success", - "message": "扫码验证完成,请重新发送验证码" + "message": "扫码验证完成,正在自动发送验证码..." } - + channel = f"ws_message:{session_id}" redis_client.publish(channel, json_lib.dumps(message)) logger.success(f"[WebSocket] 已通过Redis推送扫码成功消息: channel={channel}") scan_success_notified = True - + redis_client.close() except Exception as ws_error: logger.error(f"[WebSocket] 推送消息失败: {str(ws_error)}") import traceback traceback.print_exc() - - # 不退出监听,继续等待用户后续操作 - logger.info(f"[WebSocket] 扫码成功,保持监听状态") + + # 退出监听,返回主流程继续执行 + logger.info(f"[WebSocket] 扫码成功,退出监听,返回主流程") + return # 退出监听循环 # 2. 检测二维码是否失效(通过API状态判断) if 'captcha' in current_url.lower() or 'verify' in current_url.lower(): @@ -831,7 +1264,7 @@ class XHSLoginService: message = { "type": "qrcode_expired", - "message": "二维码已失效,请重新发送验证码" + "message": "二维码已过期,点击二维码区域刷新" } channel = f"ws_message:{session_id}" @@ -979,7 +1412,7 @@ class XHSLoginService: if not self.page: logger.info(f"[发送验证码] 浏览器未初始化,开始初始化...") - await self.init_browser() + await self.init_browser(phone=phone) self.current_phone = phone @@ -1047,18 +1480,26 @@ class XHSLoginService: if qrcode_data: logger.success("✅ 成功提取验证二维码") logger.info(f"二维码数据长度: {len(qrcode_data)} 字符") - logger.info("返回二维码给前端,等待用户扫码后重新调用接口") + logger.info("返回二维码给前端,等待用户扫码...") # 不再在这里启动监听任务,由main.py中的WebSocket端点启动 # asyncio.create_task(self._monitor_qrcode_scan(session_id)) + # 先返回二维码给前端显示,然后等待扫码完成 + # 注意:这里返回后,main.py会启动WebSocket监听 + # 当扫码完成后,不需要前端重新调用,而是在监听器中 + # 自动重新调用send_verification_code return { "success": False, "error": "需要验证", "need_captcha": True, "captcha_type": "qrcode", "qrcode_image": qrcode_data, - "message": "发送验证码时触发风控,需要扫码验证。扫码后页面会自动跳转回首页,请重新点击发送验证码" + "message": "请使用小红书APP扫码验证", + "session_id": session_id, # 返回session_id用于后续自动继续 + "phone": phone, # 返回手机号用于后续自动继续 + "country_code": country_code, # 返回国家区号用于后续自动继续 + "login_page": login_page # 返回登录页面类型用于后续自动继续 } else: logger.error("⚠️ 检测到验证页面但未提取到二维码") @@ -1197,76 +1638,54 @@ class XHSLoginService: except Exception: pass # 无协议弹窗(正常情况) - # 输入手机号 + # 输入手机号(使用增强的辅助类) try: logger.debug("查找手机号输入框...") + # 创建登录辅助器 + helper = get_login_helper(self.page) + # 根据登录页面类型选择不同的选择器 if login_page == "home": - # 小红书首页的手机号输入框(已经在上面等待过了) - phone_input_selectors = [ - 'input[placeholder="输入手机号"]', - 'label.phone input', - 'input[name="blur"]', - 'input[type="text"]', - ] + primary_selectors = XHSSelectors.PHONE_INPUT_HOME else: - # 创作者中心的手机号输入框 - phone_input_selectors = [ - 'input[placeholder="手机号"]', - 'input.css-nt440g', - 'input[placeholder*="手机号"]', - 'input[type="tel"]', - 'input[type="text"]', - ] + primary_selectors = XHSSelectors.PHONE_INPUT_CREATOR - # 优化:直接查找,不重试(因为已经等待过元素就绪) - phone_input = None - for selector in phone_input_selectors: - phone_input = await self.page.query_selector(selector) - if phone_input: - logger.success(f"✅ 找到手机号输入框: {selector}") - - # 清空并输入手机号(使用原生JS,避免上下文销毁) - await self.page.evaluate(f''' - (selector) => {{ - const input = document.querySelector(selector); - if (input) {{ - input.value = ''; - input.focus(); - input.value = '{phone}'; - input.dispatchEvent(new Event('input', {{ bubbles: true }})); - input.dispatchEvent(new Event('change', {{ bubbles: true }})); - }} - }} - ''', selector) - - logger.success(f"✅ 已输入手机号: {phone}") - await asyncio.sleep(0.3) - break + # 使用智能查找(借鉴ai_mip的多选择器降级策略) + phone_input = await helper.find_input_with_fallback( + primary_selectors=primary_selectors, + fallback_selectors=XHSSelectors.PHONE_INPUT_FALLBACK + ) if not phone_input: - # 打印页面信息用于调试 - logger.warning("⚠️ 未找到手机号输入框,打印页面信息...") - logger.info(f"页面URL: {self.page.url}") - # 查找所有input元素 - inputs = await self.page.query_selector_all('input') - logger.info(f"页面上找到 {len(inputs)} 个input元素") - for i, inp in enumerate(inputs[:5]): - try: - placeholder = await inp.get_attribute('placeholder') - input_type = await inp.get_attribute('type') - name = await inp.get_attribute('name') - class_name = await inp.get_attribute('class') - logger.info(f"Input {i+1}: type={input_type}, placeholder={placeholder}, name={name}, class={class_name}") - except Exception: - pass + # 调试:打印页面信息 + await helper.debug_print_inputs() return { "success": False, "error": "未找到手机号输入框,请检查页面是否正确加载" } + # 使用人类输入方式(直接在元素上输入) + # 先清空输入框 + await phone_input.click() + await helper.random_delay(0.1, 0.2) + + # 全选并删除(模拟Ctrl+A, Backspace) + await phone_input.press('Control+A') + await phone_input.press('Backspace') + await helper.random_delay(0.1, 0.2) + + # 逐个字符输入(模拟人类打字) + for char in phone: + await phone_input.type(char, delay=random.uniform(80, 150)) + await helper.random_delay(0.05, 0.15) + + logger.success(f">> 手机号输入完成: {phone}") + + # 随机延迟,模拟人类行为 + await helper.random_delay(0.3, 0.5) + except Exception as e: # 保存错误截图 await save_error_screenshot( @@ -1279,141 +1698,124 @@ class XHSLoginService: "error": f"输入手机号失败: {str(e)}" } - # 点击发送验证码按钮 + # 点击发送验证码按钮(使用增强的辅助类) try: logger.debug("查找发送验证码按钮...") + # 创建登录辅助器 + helper = get_login_helper(self.page) + # 等待页面稳定(输入手机号后可能有动态渲染) - await asyncio.sleep(0.3) # 从0.5秒减少到0.3秒 + await helper.random_delay(0.3, 0.5) # 根据登录页面类型选择不同的选择器 if login_page == "home": - # 小红书首页的验证码按钮 - selectors = [ - 'span.code-button', - '.code-button', - 'text="获取验证码"', - 'span:has-text("获取验证码")', - ] + selectors = XHSSelectors.SEND_CODE_BTN_HOME + expected_texts = ["获取验证码"] else: - # 创作者中心的验证码按钮 - selectors = [ - 'div.css-uyobdj', - 'text="发送验证码"', - 'div:has-text("发送验证码")', - 'text="重新发送"', - 'text="获取验证码"', - ] - - # 直接查找,不重试 - send_code_selector = None - for selector in selectors: - send_code_btn = await self.page.query_selector(selector) - if send_code_btn: - logger.success(f"✅ 找到发送验证码按钮: {selector}") - send_code_selector = selector - break - - if send_code_selector: - # 重新获取元素句柄以确保其有效性 - send_code_btn = await self.page.query_selector(send_code_selector) - if not send_code_btn: - return { - "success": False, - "error": "按钮元素已失效,请重试" - } - - # 获取按钮文本内容 - btn_text = await send_code_btn.inner_text() - btn_text = btn_text.strip() if btn_text else "" - logger.info(f"📝 按钮文本: '{btn_text}'") - - # 检查按钮是否处于倒计时状态 - # 倒计时状态通常显示为: "59s", "58s", "60秒后重新获取" 等 - if btn_text and (btn_text[-1] == 's' or '秒' in btn_text or btn_text.isdigit()): - logger.warning(f"⚠️ 按钮处于倒计时状态: {btn_text}") - return { - "success": False, - "error": f"验证码发送过于频繁,请{btn_text}后再试" - } - - # 检查按钮文本是否为期望的"获取验证码"或"发送验证码" + selectors = XHSSelectors.SEND_CODE_BTN_CREATOR expected_texts = ["获取验证码", "发送验证码", "重新发送"] - if btn_text not in expected_texts: - logger.warning(f"⚠️ 按钮文本不符合预期: '{btn_text}', 期望: {expected_texts}") - return { - "success": False, - "error": f"按钮状态异常(当前文本: {btn_text}),请刷新页面重试" - } + + # 使用智能查找按钮(借鉴ai_mip) + send_code_btn = await helper.find_button_with_fallback( + primary_selectors=selectors, + expected_texts=expected_texts + ) + + if not send_code_btn: + # 调试:打印页面按钮信息 + await helper.debug_print_buttons() - # 检查按钮是否有 active 类(小红书首页的按钮需要active才能点击) - if login_page == "home": - class_name = await send_code_btn.get_attribute('class') or "" - if 'active' not in class_name: - logger.warning(f"⚠️ 按钮未激活状态: class={class_name}") - return { - "success": False, - "error": "按钮未激活,请检查手机号是否正确输入" - } - logger.success(f"✅ 按钮已激活: class={class_name}") - - # 在点击前再次确保元素有效(页面DOM可能在检查过程中更新) - try: - # 使用 page.click 直接通过选择器点击,避免元素句柄失效问题 - await self.page.click(send_code_selector, timeout=5000) - logger.success("✅ 已点击发送验证码") - except Exception as click_error: - # 如果直接点击失败,尝试重新获取元素点击 - logger.error(f"⚠️ 直接点击失败: {str(click_error)}, 尝试重新获取元素") - send_code_btn = await self.page.query_selector(send_code_selector) - if send_code_btn: - await send_code_btn.click() - logger.success("✅ 重新获取元素后点击成功") - else: - raise Exception("按钮元素已失效,无法点击") - - # 等待页面响应,检测是否出现验证二维码 - await asyncio.sleep(1.5) - - # 检查当前页面URL是否包含captcha(验证页面) - current_url = self.page.url - if 'captcha' in current_url or 'verify' in current_url: - logger.warning(f"⚠️ 检测到验证页面: {current_url}") - - # 尝试提取二维码图片 - qrcode_data = await self.extract_verification_qrcode() - if qrcode_data: - logger.success("✅ 成功提取验证二维码") - return { - "success": False, - "need_captcha": True, - "captcha_type": "qrcode", - "qrcode_image": qrcode_data, - "message": "需要扫码验证,请使用小红书APP扫描二维码" - } - else: - return { - "success": False, - "need_captcha": True, - "captcha_type": "unknown", - "message": "出现验证码验证,请稍后重试" - } - - # 直接返回成功,不再检测滑块 - logger.info(f"[发送验证码] 成功 - 手机号: {phone}") - logger.success("\n✅ 验证码发送流程完成,请查看手机短信") - logger.info("请在小程序中输入收到的验证码并点击登录\n") - logger.success("[响应即将返回] success=True, message=验证码发送成功") - - return { - "success": True, - "message": "验证码发送成功,请查看手机短信" - } - else: return { "success": False, "error": "未找到发送验证码按钮,请检查页面结构" } + + # 检查按钮是否处于倒计时状态(借鉴ai_mip) + countdown_text = await helper.check_button_countdown(send_code_btn) + if countdown_text: + return { + "success": False, + "error": f"验证码发送过于频繁,请{countdown_text}后再试" + } + + # 小红书首页需要等待按钮激活 + if login_page == "home": + # 等待按钮激活 + is_active = await helper.wait_for_button_active(send_code_btn, timeout=5) + if not is_active: + return { + "success": False, + "error": "按钮未激活,请检查手机号是否正确输入" + } + + # 使用人类点击方式(借鉴ai_mip) + # 先获取按钮的选择器 + btn_selector = await send_code_btn.evaluate(''' + el => { + // 尝试常见的选择器组合 + const selectors = [ + 'span.code-button', + '.code-button', + 'div.css-uyobdj', + 'button', + 'div[role="button"]', + 'span' + ]; + + for (const sel of selectors) { + const elements = document.querySelectorAll(sel); + for (const elem of elements) { + if (elem === el) return sel; + } + } + + // 如果没有匹配,返回通用选择器 + return el.tagName.toLowerCase(); + } + ''') + + success = await helper.human_click(btn_selector, wait_after=1.5) + if not success: + return { + "success": False, + "error": "点击发送验证码失败" + } + + # 检查当前页面URL是否包含captcha(验证页面) + current_url = self.page.url + if 'captcha' in current_url or 'verify' in current_url: + logger.warning(f"⚠️ 检测到验证页面: {current_url}") + + # 尝试提取二维码图片 + qrcode_data = await self.extract_verification_qrcode() + if qrcode_data: + logger.success("✅ 成功提取验证二维码") + return { + "success": False, + "need_captcha": True, + "captcha_type": "qrcode", + "qrcode_image": qrcode_data, + "message": "需要扫码验证,请使用小红书APP扫描二维码" + } + else: + return { + "success": False, + "need_captcha": True, + "captcha_type": "unknown", + "message": "出现验证码验证,请稍后重试" + } + + # 直接返回成功,不再检测滑块 + logger.info(f"[发送验证码] 成功 - 手机号: {phone}") + logger.success("\n✅ 验证码发送流程完成,请查看手机短信") + logger.info("请在小程序中输入收到的验证码并点击登录\n") + logger.success("[响应即将返回] success=True, message=验证码发送成功") + + return { + "success": True, + "message": "验证码发送成功,请查看手机短信" + } except Exception as e: # 保存错误截图 await save_error_screenshot( @@ -2070,6 +2472,15 @@ class XHSLoginService: except Exception as e: logger.error(f"保存登录状态文件失败: {str(e)}") + # 如果使用AdsPower,在关闭浏览器前记录配置ID,但不查询Cookie + # 因为AdsPower需要关闭浏览器后才会同步Cookie + # Cookie查询将在close_browser()后由调用者执行 + if self.use_adspower and self.adspower_profile_id: + logger.info("="*50) + logger.info(f"[登录成功] AdsPower配置ID: {self.adspower_profile_id}") + logger.info("[登录成功] 注意:需要关闭浏览器后才能查询AdsPower Cookie") + logger.info("="*50) + return { "success": True, "user_info": user_info, @@ -2080,7 +2491,8 @@ class XHSLoginService: "sessionStorage": sessionStorage_data, # API 返回:sessionStorage数据 "url": current_url, "storage_state": storage_state_data, # 新增:Playwright storage_state对象 - "storage_state_path": storage_state_path # 新增:storage_state文件路径 + "storage_state_path": storage_state_path, # 新增:storage_state文件路径 + "adspower_profile_id": self.adspower_profile_id if self.use_adspower else None # 新增:AdsPower配置ID } except Exception as e: @@ -2397,11 +2809,50 @@ class XHSLoginService: logger.info(f"图片数量: {len(images) if images else 0}") logger.info(f"话题: {topics if topics else []}") - # 优化:直接访问图文发布页面URL,跳过点击tab步骤 - logger.info("访问创作者平台图文发布页面...") + # 优化流程:先访问小红书首页,然后点击发布按钮跳转到创作者中心 + logger.info("步骤1: 访问小红书首页...") + try: + await self.page.goto('https://www.xiaohongshu.com', wait_until='domcontentloaded', timeout=30000) + await asyncio.sleep(2) + logger.success("✅ 已进入小红书首页") + except Exception as e: + logger.warning(f"⚠️ 访问首页失败: {str(e)},尝试直接访问创作者中心") + logger.info("步骤2: 点击发布按钮跳转到创作者中心...") publish_url = 'https://creator.xiaohongshu.com/publish/publish?source=official&from=menu&target=image' - + + # 查找首页的发布按钮 + publish_button_found = False + try: + # 尝试多种可能的发布按钮选择器 + publish_button_selectors = [ + 'a[href*="creator.xiaohongshu.com"]', # 包含创作者中心链接 + 'a[href*="publish"]', # 包含publish关键词 + 'div[class*="publish"]', # class包含publish + 'button:has-text("发布")', # 文字为发布 + ] + + for selector in publish_button_selectors: + try: + button = await self.page.query_selector(selector) + if button: + logger.info(f" 找到发布按钮: {selector}") + await button.click() + await asyncio.sleep(2) + publish_button_found = True + logger.success("✅ 已点击发布按钮") + break + except Exception as e: + continue + except Exception as e: + logger.warning(f"⚠️ 查找发布按钮失败: {str(e)}") + + # 如果没找到发布按钮,直接访问发布页 + if not publish_button_found: + logger.info("步骤3: 未找到发布按钮,直接访问创作者中心发布页...") + else: + logger.info("步骤3: 验证是否跳转到创作者中心...") + # 尝试访问页面(最多重试2次) page_loaded = False for attempt in range(2): @@ -2410,13 +2861,18 @@ class XHSLoginService: logger.info(f"第 {attempt + 1} 次尝试加载页面...") else: logger.debug("开始加载页面...") - - # 使用更宽松的等待条件,不等待networkidle - await self.page.goto( - publish_url, - wait_until='load', # 从networkidle改为load,更快 - timeout=40000 # 增加到40秒 - ) + + # 检查当前URL,如果已经在发布页则跳过访问 + current_url = self.page.url + if 'publish/publish' not in current_url: + # 使用更宽松的等待条件,不等待networkidle + await self.page.goto( + publish_url, + wait_until='load', # 从networkidle改为load,更快 + timeout=40000 # 增加到40秒 + ) + else: + logger.info("已在发布页,无需跳转") # 等待页面稳定 await asyncio.sleep(2) @@ -3261,6 +3717,46 @@ class XHSLoginService: except Exception as e: logger.error(f"⚠️ 关闭发布环境失败: {str(e)}") + # 如果使用AdsPower,关闭并删除浏览器环境 + if self.use_adspower and self.fingerprint_manager and self.adspower_profile_id: + try: + logger.info("\n" + "="*70) + logger.info(f"[AdsPower清理] 开始清理临时发布环境") + logger.info("="*70) + logger.info(f" Profile ID: {self.adspower_profile_id}") + + # 步骤1: 关闭浏览器 + logger.info("\n[步骤 1/3] 关闭浏览器...") + await self.fingerprint_manager.stop_browser(self.adspower_profile_id) + logger.success(">> 浏览器已关闭") + + # 步骤2: 检查是否有关联的代理ID(API v2代理池) + logger.info("\n[步骤 2/3] 检查代理配置...") + proxy_id = await self.fingerprint_manager.get_profile_proxy_id(self.adspower_profile_id) + if proxy_id: + logger.info(f" 检测到代理池代理ID: {proxy_id}") + else: + logger.info(" 使用直接配置的代理,无需删除代理池记录") + + # 步骤3: 删除浏览器环境 + logger.info("\n[步骤 3/3] 删除浏览器环境...") + await self.fingerprint_manager.delete_profile(self.adspower_profile_id) + logger.success(">> 浏览器环境已删除") + + # 如果使用了代理池,删除代理 + if proxy_id: + logger.info("\n[额外步骤] 删除代理池代理...") + await self.fingerprint_manager.delete_proxy(proxy_id) + logger.success(">> 代理已删除") + + logger.info("\n" + "="*70) + logger.success("[AdsPower清理] 临时环境清理完成") + logger.info("="*70 + "\n") + + self.adspower_profile_id = None + except Exception as e: + logger.error(f"\n[AdsPower清理] 清理失败: {str(e)}") + return { "success": True, "message": "笔记发布成功", @@ -3284,6 +3780,46 @@ class XHSLoginService: except Exception as e2: logger.error(f"⚠️ 关闭发布环境失败: {str(e2)}") + # 如果使用AdsPower,关闭并删除浏览器环境 + if self.use_adspower and self.fingerprint_manager and self.adspower_profile_id: + try: + logger.info("\n" + "="*70) + logger.info(f"[AdsPower清理] 开始清理临时发布环境(发布异常)") + logger.info("="*70) + logger.info(f" Profile ID: {self.adspower_profile_id}") + + # 步骤1: 关闭浏览器 + logger.info("\n[步骤 1/3] 关闭浏览器...") + await self.fingerprint_manager.stop_browser(self.adspower_profile_id) + logger.success(">> 浏览器已关闭") + + # 步骤2: 检查是否有关联的代理ID(API v2代理池) + logger.info("\n[步骤 2/3] 检查代理配置...") + proxy_id = await self.fingerprint_manager.get_profile_proxy_id(self.adspower_profile_id) + if proxy_id: + logger.info(f" 检测到代理池代理ID: {proxy_id}") + else: + logger.info(" 使用直接配置的代理,无需删除代理池记录") + + # 步骤3: 删除浏览器环境 + logger.info("\n[步骤 3/3] 删除浏览器环境...") + await self.fingerprint_manager.delete_profile(self.adspower_profile_id) + logger.success(">> 浏览器环境已删除") + + # 如果使用了代理池,删除代理 + if proxy_id: + logger.info("\n[额外步骤] 删除代理池代理...") + await self.fingerprint_manager.delete_proxy(proxy_id) + logger.success(">> 代理已删除") + + logger.info("\n" + "="*70) + logger.success("[AdsPower清理] 临时环境清理完成") + logger.info("="*70 + "\n") + + self.adspower_profile_id = None + except Exception as e2: + logger.error(f"\n[AdsPower清理] 清理失败: {str(e2)}") + # 即使检查异常,也返回成功(因为按钮已点击) return { "success": True, diff --git a/backend/xhs_login_helper.py b/backend/xhs_login_helper.py new file mode 100644 index 0000000..0efc11d --- /dev/null +++ b/backend/xhs_login_helper.py @@ -0,0 +1,506 @@ +""" +小红书登录辅助模块 +借鉴 ai_mip 项目的优秀实践,提供增强的验证码登录功能 +""" +import asyncio +import random +import time +from typing import Optional, List, Dict, Any +from playwright.async_api import Page, ElementHandle +from loguru import logger + + +class XHSLoginHelper: + """小红书登录辅助工具类 - 借鉴ai_mip项目""" + + def __init__(self, page: Page): + """ + 初始化登录辅助器 + + Args: + page: Playwright Page 对象 + """ + self.page = page + + async def human_type(self, selector: str, text: str, clear_first: bool = True) -> bool: + """ + 模拟人类打字速度输入文本(借鉴ai_mip) + + Args: + selector: 输入框选择器 + text: 要输入的文本 + clear_first: 是否先清空输入框 + + Returns: + 是否输入成功 + """ + try: + # 查找元素 + element = await self._find_element_smart(selector) + if not element: + logger.error(f"[人类输入] 未找到元素: {selector}") + return False + + # 滚动到可见 + await element.scroll_into_view_if_needed() + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 聚焦输入框 + await element.focus() + await asyncio.sleep(random.uniform(0.1, 0.2)) + + # 先清空 + if clear_first: + await element.fill('') + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 模拟人类打字(逐字符输入,随机延迟) + for char in text: + await self.page.keyboard.type(char) + # 随机延迟 50ms - 150ms + await asyncio.sleep(random.uniform(0.05, 0.15)) + + # 触发change事件 + await element.evaluate('el => el.dispatchEvent(new Event("input", { bubbles: true }))') + await element.evaluate('el => el.dispatchEvent(new Event("change", { bubbles: true }))') + + logger.success(f"[人类输入] 已输入 {len(text)} 个字符: {text}") + return True + + except Exception as e: + logger.error(f"[人类输入] 输入失败: {e}") + return False + + async def human_click(self, selector: str, wait_after: float = 0.5) -> bool: + """ + 模拟人类点击行为(借鉴ai_mip) + + Args: + selector: 元素选择器 + wait_after: 点击后等待时间 + + Returns: + 是否点击成功 + """ + try: + # 查找元素 + element = await self._find_element_smart(selector) + if not element: + logger.error(f"[人类点击] 未找到元素: {selector}") + return False + + # 滚动到可见 + await element.scroll_into_view_if_needed() + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 获取元素位置 + box = await element.bounding_box() + if box: + # 在元素范围内随机一个点击位置(避免总是点击中心) + x = box['x'] + random.uniform(box['width'] * 0.3, box['width'] * 0.7) + y = box['y'] + random.uniform(box['height'] * 0.3, box['height'] * 0.7) + + # 移动鼠标(模拟人类移动轨迹) + await self.page.mouse.move(x, y) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # 点击 + await self.page.mouse.click(x, y) + logger.success(f"[人类点击] 点击位置: ({x:.0f}, {y:.0f})") + else: + # 直接点击(降级方案) + await element.click() + logger.success(f"[人类点击] 直接点击元素") + + await asyncio.sleep(wait_after) + return True + + except Exception as e: + logger.error(f"[人类点击] 点击失败: {e}") + return False + + async def _find_element_smart(self, selector: str, timeout: int = 5000) -> Optional[ElementHandle]: + """ + 智能查找元素(支持多种选择器格式) + + Args: + selector: 元素选择器(CSS/XPath/text等) + timeout: 超时时间(毫秒) + + Returns: + 找到的元素,失败返回None + """ + try: + # 尝试等待元素 + element = await self.page.wait_for_selector(selector, timeout=timeout, state='visible') + return element + except Exception: + return None + + async def find_input_with_fallback(self, primary_selectors: List[str], fallback_selectors: List[str] = None) -> Optional[ElementHandle]: + """ + 查找输入框(多选择器降级策略,借鉴ai_mip) + + Args: + primary_selectors: 主要选择器列表 + fallback_selectors: 降级选择器列表 + + Returns: + 找到的输入框元素 + """ + try: + logger.info("[智能查找] 开始查找输入框...") + + # 第一轮:尝试主要选择器 + for selector in primary_selectors: + try: + elements = await self.page.query_selector_all(selector) + logger.debug(f"[智能查找] 选择器 '{selector}' 找到 {len(elements)} 个元素") + + for elem in elements: + if await elem.is_visible(): + logger.success(f"[智能查找] 找到可见输入框: {selector}") + return elem + except Exception as e: + logger.debug(f"[智能查找] 选择器 '{selector}' 失败: {str(e)}") + continue + + # 第二轮:尝试降级选择器 + if fallback_selectors: + logger.warning("[智能查找] 主要选择器未找到,尝试降级选择器...") + for selector in fallback_selectors: + try: + elements = await self.page.query_selector_all(selector) + logger.debug(f"[智能查找] 降级选择器 '{selector}' 找到 {len(elements)} 个元素") + + for elem in elements: + if await elem.is_visible(): + logger.warning(f"[智能查找] 使用降级选择器找到: {selector}") + return elem + except Exception as e: + logger.debug(f"[智能查找] 降级选择器 '{selector}' 失败: {str(e)}") + continue + + logger.error("[智能查找] 所有选择器均未找到可见输入框") + return None + + except Exception as e: + logger.error(f"[智能查找] 查找异常: {str(e)}") + return None + + async def find_button_with_fallback(self, primary_selectors: List[str], expected_texts: List[str] = None) -> Optional[ElementHandle]: + """ + 查找按钮(多选择器降级策略,支持文本验证) + + Args: + primary_selectors: 主要选择器列表 + expected_texts: 期望的按钮文本列表(用于验证) + + Returns: + 找到的按钮元素 + """ + try: + logger.info("[智能查找] 开始查找按钮...") + + for selector in primary_selectors: + try: + elements = await self.page.query_selector_all(selector) + logger.debug(f"[智能查找] 选择器 '{selector}' 找到 {len(elements)} 个按钮") + + for elem in elements: + if not await elem.is_visible(): + continue + + # 验证按钮文本(如果指定) + if expected_texts: + try: + btn_text = await elem.inner_text() + btn_text = btn_text.strip() if btn_text else "" + + if not any(expected in btn_text for expected in expected_texts): + logger.debug(f"[智能查找] 按钮文本不匹配: '{btn_text}', 期望: {expected_texts}") + continue + + logger.success(f"[智能查找] 找到匹配按钮: {selector}, 文本: '{btn_text}'") + return elem + except Exception: + # 无法获取文本,跳过验证 + logger.success(f"[智能查找] 找到可见按钮: {selector}") + return elem + else: + logger.success(f"[智能查找] 找到可见按钮: {selector}") + return elem + + except Exception as e: + logger.debug(f"[智能查找] 选择器 '{selector}' 失败: {str(e)}") + continue + + logger.error("[智能查找] 所有选择器均未找到可见按钮") + return None + + except Exception as e: + logger.error(f"[智能查找] 查找按钮异常: {str(e)}") + return None + + async def wait_for_button_active(self, element: ElementHandle, timeout: int = 5) -> bool: + """ + 等待按钮激活状态(小红书特有逻辑) + + Args: + element: 按钮元素 + timeout: 超时时间(秒) + + Returns: + 是否激活成功 + """ + try: + logger.info("[按钮激活] 等待按钮激活...") + start_time = time.time() + + while time.time() - start_time < timeout: + try: + class_name = await element.get_attribute('class') or "" + if 'active' in class_name or 'enabled' in class_name: + logger.success(f"[按钮激活] 按钮已激活: class={class_name}") + return True + except Exception: + pass + + await asyncio.sleep(0.2) + + logger.warning(f"[按钮激活] 等待超时({timeout}秒)") + return False + + except Exception as e: + logger.error(f"[按钮激活] 检查失败: {e}") + return False + + async def check_button_countdown(self, element: ElementHandle) -> Optional[str]: + """ + 检查按钮是否处于倒计时状态 + + Args: + element: 按钮元素 + + Returns: + 倒计时文本(如果处于倒计时),否则返回None + """ + try: + btn_text = await element.inner_text() + btn_text = btn_text.strip() if btn_text else "" + + # 检查是否包含倒计时标识 + if btn_text and (btn_text[-1] == 's' or '秒' in btn_text or btn_text.isdigit()): + logger.warning(f"[倒计时检测] 按钮处于倒计时: {btn_text}") + return btn_text + + return None + + except Exception as e: + logger.error(f"[倒计时检测] 检查失败: {e}") + return None + + async def random_delay(self, min_seconds: float = 0.5, max_seconds: float = 1.5): + """ + 随机延迟,模拟人工操作(借鉴ai_mip) + + Args: + min_seconds: 最小延迟(秒) + max_seconds: 最大延迟(秒) + """ + delay = random.uniform(min_seconds, max_seconds) + await asyncio.sleep(delay) + + async def scroll_to_element(self, element: ElementHandle): + """ + 平滑滚动到元素位置(模拟人类滚动行为) + + Args: + element: 目标元素 + """ + try: + # 获取元素位置 + box = await element.bounding_box() + if box: + # 计算滚动目标(元素在视口中间位置) + viewport = self.page.viewport_size + target_y = box['y'] - (viewport['height'] / 2) + (box['height'] / 2) + + # 分步滚动(模拟人类滚动) + current_scroll = await self.page.evaluate('window.pageYOffset') + distance = target_y - current_scroll + steps = max(3, int(abs(distance) / 100)) # 根据距离计算步数 + + for i in range(steps): + progress = (i + 1) / steps + scroll_y = current_scroll + distance * progress + await self.page.evaluate(f'window.scrollTo(0, {scroll_y})') + await asyncio.sleep(random.uniform(0.05, 0.1)) + + logger.success(f"[平滑滚动] 已滚动到元素位置") + else: + # 降级方案:直接滚动到可见 + await element.scroll_into_view_if_needed() + logger.success(f"[平滑滚动] 使用降级方案滚动") + + except Exception as e: + logger.error(f"[平滑滚动] 滚动失败: {e}") + # 最终降级方案 + try: + await element.scroll_into_view_if_needed() + except Exception: + pass + + async def debug_print_inputs(self): + """ + 调试:打印页面上所有输入框信息(借鉴ai_mip的调试逻辑) + """ + try: + logger.info("=" * 50) + logger.info("[调试] 打印页面所有输入框...") + logger.info("=" * 50) + + inputs = await self.page.query_selector_all('input') + logger.info(f"[调试] 页面上找到 {len(inputs)} 个input元素") + + for i, inp in enumerate(inputs[:10]): # 只打印前10个 + try: + placeholder = await inp.get_attribute('placeholder') + input_type = await inp.get_attribute('type') + name = await inp.get_attribute('name') + class_name = await inp.get_attribute('class') + is_visible = await inp.is_visible() + + logger.info(f"[调试] Input {i+1}:") + logger.info(f" - type: {input_type}") + logger.info(f" - placeholder: {placeholder}") + logger.info(f" - name: {name}") + logger.info(f" - class: {class_name}") + logger.info(f" - visible: {is_visible}") + except Exception as e: + logger.debug(f"[调试] 获取Input {i+1}信息失败: {e}") + + logger.info("=" * 50) + + except Exception as e: + logger.error(f"[调试] 打印输入框信息失败: {e}") + + async def debug_print_buttons(self): + """ + 调试:打印页面上所有按钮信息 + """ + try: + logger.info("=" * 50) + logger.info("[调试] 打印页面所有按钮...") + logger.info("=" * 50) + + buttons = await self.page.query_selector_all('button, div[role="button"], span[role="button"]') + logger.info(f"[调试] 页面上找到 {len(buttons)} 个按钮元素") + + for i, btn in enumerate(buttons[:10]): # 只打印前10个 + try: + text = await btn.inner_text() + class_name = await btn.get_attribute('class') + is_visible = await btn.is_visible() + + logger.info(f"[调试] Button {i+1}:") + logger.info(f" - text: {text}") + logger.info(f" - class: {class_name}") + logger.info(f" - visible: {is_visible}") + except Exception as e: + logger.debug(f"[调试] 获取Button {i+1}信息失败: {e}") + + logger.info("=" * 50) + + except Exception as e: + logger.error(f"[调试] 打印按钮信息失败: {e}") + + +# 定义常用的选择器配置(借鉴ai_mip的结构化选择器管理) +class XHSSelectors: + """小红书登录页面选择器配置""" + + # 手机号输入框选择器(创作者中心) + PHONE_INPUT_CREATOR = [ + 'input[placeholder="手机号"]', + 'input.css-nt440g', + 'input[placeholder*="手机号"]', + 'input[type="tel"]', + ] + + # 手机号输入框选择器(小红书首页) + PHONE_INPUT_HOME = [ + 'input[placeholder="输入手机号"]', + 'label.phone input', + 'input[name="blur"]', + ] + + # 手机号输入框降级选择器 + PHONE_INPUT_FALLBACK = [ + 'input[type="text"]', + 'input', + ] + + # 验证码输入框选择器(创作者中心) + CODE_INPUT_CREATOR = [ + 'input[placeholder="验证码"]', + 'input.css-1ge5flv', + 'input[placeholder*="验证码"]', + 'input[type="text"]:not([placeholder*="手机"])', + ] + + # 验证码输入框选择器(小红书首页) + CODE_INPUT_HOME = [ + 'input[placeholder="输入验证码"]', + 'label.auth-code input', + 'input[type="number"]', + 'input[placeholder*="验证码"]', + ] + + # 发送验证码按钮选择器(创作者中心) + SEND_CODE_BTN_CREATOR = [ + 'div.css-uyobdj', + 'text="发送验证码"', + 'div:has-text("发送验证码")', + 'text="重新发送"', + 'text="获取验证码"', + ] + + # 发送验证码按钮选择器(小红书首页) + SEND_CODE_BTN_HOME = [ + 'span.code-button', + '.code-button', + 'text="获取验证码"', + 'span:has-text("获取验证码")', + ] + + # 登录按钮选择器 + LOGIN_BTN = [ + 'button:has-text("登录")', + 'text="登录"', + 'div:has-text("登录")', + '.login-button', + 'button.login', + ] + + # 协议复选框选择器(小红书首页) + AGREEMENT_CHECKBOX = [ + '.agree-icon', + '.agreements .icon-wrapper', + 'span.agree-icon', + '.icon-wrapper', + ] + + +# 导出便捷函数 +def get_login_helper(page: Page) -> XHSLoginHelper: + """ + 获取登录辅助器实例 + + Args: + page: Playwright Page 对象 + + Returns: + XHSLoginHelper实例 + """ + return XHSLoginHelper(page) diff --git a/go_backend/config/config.prod.yaml b/go_backend/config/config.prod.yaml index 6bcbea1..d4fb73e 100644 --- a/go_backend/config/config.prod.yaml +++ b/go_backend/config/config.prod.yaml @@ -31,7 +31,7 @@ wechat: app_secret: "69d2a3ddc902b26f82f4b56a6e277f7a" xhs: - python_service_url: "http://127.0.0.1:8020" # Python FastAPI服务地址(用于登录和发布,享受浏览器池+预热加速) + python_service_url: "http://127.0.0.1:8080" # Python FastAPI服务地址(用于登录和发布,享受浏览器池+预热加速) scheduler: enabled: false # 生产环境启用定时任务 diff --git a/go_backend/service/employee_service.go b/go_backend/service/employee_service.go index 785ea4a..95dd3e7 100644 --- a/go_backend/service/employee_service.go +++ b/go_backend/service/employee_service.go @@ -2351,25 +2351,35 @@ func (s *EmployeeService) SaveLogin(employeeID int, cookiesFull []interface{}, s return fmt.Errorf("获取用户信息失败: %w", err) } - // 优先使用 storage_state,如果没有则降级使用 cookies_full + // 优先使用 cookies_full,如果没有则降级使用 storage_state var loginStateJSON string - if len(storageState) > 0 { - // 新版:使用 Playwright 的 storage_state - storageStateBytes, err := json.Marshal(storageState) - if err == nil { - loginStateJSON = string(storageStateBytes) - log.Printf("验证码登录 - 用户%d - StorageState长度: %d", employeeID, len(loginStateJSON)) - } else { - log.Printf("验证码登录 - 用户%d - 序列化storage_state失败: %v", employeeID, err) - } - } else if len(cookiesFull) > 0 { - // 降级:使用旧版本的 cookies_full - log.Printf("验证码登录 - 用户%d - 警告: 未找到storage_state,降级使用cookies", employeeID) + if len(cookiesFull) > 0 { + // 优先:直接使用 cookies_full(AdsPower Cookie) cookiesBytes, err := json.Marshal(cookiesFull) if err == nil { loginStateJSON = string(cookiesBytes) - log.Printf("验证码登录 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON)) + log.Printf("验证码登录 - 用户%d - 使用cookies_full,Cookie长度: %d", employeeID, len(loginStateJSON)) + } else { + log.Printf("验证码登录 - 用户%d - 序列化cookies_full失败: %v", employeeID, err) + } + } else if len(storageState) > 0 { + // 降级:从 storage_state 中提取 cookies + log.Printf("验证码登录 - 用户%d - 警告: 未找到cookies_full,从storage_state提取cookies", employeeID) + if cookies, ok := storageState["cookies"].([]interface{}); ok && len(cookies) > 0 { + cookiesBytes, err := json.Marshal(cookies) + if err == nil { + loginStateJSON = string(cookiesBytes) + log.Printf("验证码登录 - 用户%d - 从storage_state提取的Cookie长度: %d", employeeID, len(loginStateJSON)) + } + } else { + // 最终降级:保存整个 storage_state + log.Printf("验证码登录 - 用户%d - 无法提取cookies,保存整个storage_state", employeeID) + storageStateBytes, err := json.Marshal(storageState) + if err == nil { + loginStateJSON = string(storageStateBytes) + log.Printf("验证码登录 - 用户%d - StorageState长度: %d", employeeID, len(loginStateJSON)) + } } } diff --git a/miniprogram/miniprogram/config/api.ts b/miniprogram/miniprogram/config/api.ts index f54b478..7048984 100644 --- a/miniprogram/miniprogram/config/api.ts +++ b/miniprogram/miniprogram/config/api.ts @@ -35,16 +35,16 @@ const API_CONFIG: Record = { // 测试环境 - 服务器测试 test: { baseURL: 'https://lehang.tech', // 测试服务器Go服务 - pythonURL: 'https://lehang.tech', // 测试服务器Python服务 - websocketURL: 'wss://lehang.tech', // 测试服务器WebSocket服务 + pythonURL: 'https://api.lehang.tech', // 测试服务器Python服务 + websocketURL: 'wss://api.lehang.tech', // 测试服务器WebSocket服务 timeout: 90000 }, // 生产环境 prod: { baseURL: 'https://lehang.tech', // 生产环境Go服务 - pythonURL: 'https://lehang.tech', // 生产环境Python服务 - websocketURL: 'wss://lehang.tech', // 生产环境WebSocket服务 + pythonURL: 'https://api.lehang.tech', // 生产环境Python服务 + websocketURL: 'wss://api.lehang.tech', // 生产环境WebSocket服务 timeout: 90000 } }; diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts index f754706..c61123b 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.ts @@ -41,7 +41,10 @@ Page({ qrcodeError: '', // 二维码加载错误提示 qrcodeLoading: false, // 二维码是否正在加载 isDevelopment: isDevelopment(), // 是否开发环境 - isGettingCode: false // 是否正在获取验证码(防抖) + isScanning: false, // 是否正在扫码过程中(用户保存二维码后切到小红书) + + // 消息确认机制 + processedMessageIds: [] as string[], // 已处理的消息ID集合,避免重复处理 }, onLoad() { @@ -73,6 +76,9 @@ Page({ console.log('[页面生命周期] 当前登录方式:', this.data.loginType); console.log('[页面生命周期] 是否正在等待登录结果:', (this.data as any).waitingLoginResult); console.log('[页面生命周期] 是否需要验证码:', this.data.needCaptcha); + console.log('[页面生命周期] 是否正在扫码:', this.data.isScanning); + console.log('[页面生命周期] sessionId:', this.data.sessionId); + console.log('[页面生命周期] WebSocket连接状态:', this.data.socketConnected); // 临时标记为隐藏状态,阻止重连 this.setData({ pageHidden: true } as any); @@ -95,6 +101,18 @@ Page({ return; } + // 如果用户正在扫码过程中(保存二维码后切到小红书),不关闭WebSocket + if (this.data.isScanning) { + console.log('[页面生命周期] 用户正在扫码,保持WebSocket连接'); + return; + } + + // 如果sessionId存在且WebSocket连接活跃,保持连接(用户可能正在等待验证码或准备登录) + if (this.data.sessionId && this.data.socketConnected) { + console.log('[页面生命周期] 有活跃的WebSocket连接,保持连接(用户可能正在登录流程中)'); + return; + } + console.log('[页面生命周期] 关闭WebSocket连接'); // 页面隐藏时也关闭WebSocket连接 this.closeWebSocket(); @@ -114,6 +132,24 @@ Page({ // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED if (readyState === 0 || readyState === 1) { console.log('[页面生命周期] WebSocket连接已存在且正常,无需重建'); + + // 连接正常,主动拉取未确认消息 + if (readyState === 1) { + console.log('[WebSocket] 主动拉取未确认消息'); + try { + socketTask.send({ + data: JSON.stringify({ type: 'pull_unconfirmed' }), + success: () => { + console.log('[WebSocket] 拉取未确认消息请求已发送'); + }, + fail: (err: any) => { + console.error('[WebSocket] 拉取未确认消息请求失败:', err); + } + }); + } catch (e) { + console.error('[WebSocket] 拉取未确认消息异常:', e); + } + } return; } console.log(`[页面生命周期] WebSocket连接状态异常: ${readyState},准备重建`); @@ -163,12 +199,6 @@ Page({ // 获取验证码 async getVerifyCode() { - // 防抖:如果正在获取中,直接返回 - if (this.data.isGettingCode) { - console.log('[发送验证码] 正在处理中,忽略重复点击'); - return; - } - if (this.data.countdown > 0) { return; } @@ -183,9 +213,6 @@ Page({ return; } - // 设置防抖标记 - this.setData({ isGettingCode: true }); - // 立即显示加载动画 wx.showToast({ title: '正在连接...', @@ -284,8 +311,6 @@ Page({ }, fail: (err: any) => { console.error('[发送验证码] WebSocket消息发送失败:', err); - // 清除防抖标记 - this.setData({ isGettingCode: false }); wx.showToast({ title: '发送失败,请重试', icon: 'none', @@ -296,8 +321,6 @@ Page({ } catch (error: any) { console.error('[发送验证码] 异常:', error); - // 清除防抖标记 - this.setData({ isGettingCode: false }); wx.showToast({ title: error.message || '发送失败,请重试', icon: 'none', @@ -538,17 +561,35 @@ Page({ console.log('需要验证码验证:', status.captcha_type); - wx.showToast({ - title: status.message || '需要验证码验证', - icon: 'none', - duration: 3000 - }); - - this.setData({ - needCaptcha: true, - captchaType: status.captcha_type || 'unknown', - qrcodeImage: status.qrcode_image || '' - }); + // 判断是否成功获取到二维码 + if (status.qrcode_image) { + wx.showToast({ + title: status.message || '需要验证码验证', + icon: 'none', + duration: 3000 + }); + + this.setData({ + needCaptcha: true, + captchaType: status.captcha_type || 'unknown', + qrcodeImage: status.qrcode_image + }); + } else { + // 未能获取到二维码,显示Toast提示 + console.warn('验证码发送成功,但未能获取二维码'); + + wx.showToast({ + title: '页面加载异常,请重试', + icon: 'none', + duration: 3000 + }); + + this.setData({ + needCaptcha: false, // 不显示弹窗 + captchaType: '', + qrcodeImage: '' + }); + } } else if (status.status === 'processing') { // 仍在处理中,继续轮询 @@ -815,8 +856,82 @@ Page({ // 刷新二维码 async refreshQRCode() { + console.log('[刷新二维码] 用户点击刷新按钮'); + console.log('[刷新二维码] needCaptcha:', this.data.needCaptcha); + console.log('[刷新二维码] qrcodeStatus:', this.data.qrcodeStatus); + console.log('[刷新二维码] socketTask存在:', !!this.data.socketTask); + console.log('[刷新二维码] qrcodeSessionId:', this.data.qrcodeSessionId); + + // 判断是风控二维码还是扫码登录二维码 + // 风控二维码:needCaptcha=true 且 qrcodeStatus=5,使用WebSocket刷新 + // 扫码登录二维码:qrcodeSessionId存在,使用HTTP API刷新 + if (this.data.needCaptcha && this.data.qrcodeStatus === 5) { + // 风控二维码刷新(验证码登录时) + console.log('[刷新二维码] 确认是风控二维码,使用WebSocket刷新'); + + // 检查WebSocket连接 + if (!this.data.socketTask) { + console.error('[刷新二维码] WebSocket未连接'); + wx.showToast({ + title: 'WebSocket未连接,请重新发送验证码', + icon: 'none', + duration: 3000 + }); + return; + } + + // 显示加载提示 + wx.showLoading({ + title: '正在刷新...', + mask: true + }); + + // 发送刷新请求 + try { + this.data.socketTask.send({ + data: JSON.stringify({ + type: 'refresh_qrcode' + }), + success: () => { + console.log('[刷新二维码] 刷新请求发送成功'); + + // 更新状态为加载中 + this.setData({ + qrcodeStatus: 0, + captchaTitle: '正在刷新二维码...' + }); + }, + fail: (err: any) => { + console.error('[刷新二维码] 发送失败:', err); + wx.hideLoading(); + wx.showToast({ + title: '刷新失败,请重试', + icon: 'none', + duration: 2000 + }); + } + }); + } catch (error) { + console.error('[刷新二维码] 异常:', error); + wx.hideLoading(); + wx.showToast({ + title: '刷新失败,请重试', + icon: 'none', + duration: 2000 + }); + } + return; + } + + // 扫码登录二维码刷新(HTTP API) + console.log('[刷新二维码] 使用HTTP API刷新扫码登录二维码'); const { qrcodeSessionId } = this.data; if (!qrcodeSessionId) { + wx.showToast({ + title: '请重新发送验证码', + icon: 'none', + duration: 2000 + }); return; } @@ -1114,17 +1229,17 @@ Page({ // 建立WebSocket连接 connectWebSocket(sessionId: string) { console.log('[WebSocket] ========== connectWebSocket被调用 =========='); - console.log('[WebSocket] 调用栈:', new Error().stack?.split('\n').slice(1, 4).join('\n')); + const stack = new Error().stack; + console.log('[WebSocket] 调用栈:', stack ? stack.split('\n').slice(1, 4).join('\n') : '无法获取'); console.log('[WebSocket] SessionID:', sessionId); console.log('[WebSocket] 当前needCaptcha状态:', this.data.needCaptcha); console.log('[WebSocket] ==========================================='); - // 清除正常关闭标记,新连接默认为非正常关闭 - // 重置重连计数,允许新连接重连 - this.setData({ - normalClose: false, - reconnectCount: 0 - } as any); + // 如果已有连接且处于OPEN状态,不重复连接 + if (this.data.socketTask && this.data.socketReadyState === 1) { + console.log('[WebSocket] 连接已存在且活跃,不重复连接'); + return; + } // 关闭旧连接 if (this.data.socketTask) { @@ -1139,14 +1254,25 @@ Page({ console.log('[WebSocket] 已调用close(),等待关闭...'); } catch (e) { console.log('[WebSocket] 关闭旧连接失败(可能已关闭):', e); - } finally { - // 重置标记,允许新连接重连 - this.setData({ - normalClose: false, - reconnectCount: 0 - } as any); } + // 等待50ms让旧连接完全关闭 + setTimeout(() => { + this._doConnect(sessionId); + }, 50); + } else { + // 没有旧连接,直接连接 + this._doConnect(sessionId); } + }, + + // 执行实际的WebSocket连接(内部方法) + _doConnect(sessionId: string) { + // 清除正常关闭标记,新连接默认为非正常关闭 + // 重置重连计数,允许新连接重连 + this.setData({ + normalClose: false, + reconnectCount: 0 + } as any); // 获取WebSocket服务地址(使用配置化地址) const wsURL = API.websocketURL; @@ -1229,6 +1355,44 @@ Page({ try { const data = JSON.parse(res.data as string); + // 提取message_id + const messageId = data.message_id; + + // 检查是否已处理过该消息(去重) + if (messageId && this.data.processedMessageIds.includes(messageId)) { + console.log('[WebSocket] 消息已处理,忽略重复消息:', messageId); + return; + } + + // 添加到已处理集合 + if (messageId) { + const processedIds = this.data.processedMessageIds; + processedIds.push(messageId); + // 保持集合大小,最多100条 + if (processedIds.length > 100) { + processedIds.shift(); + } + this.setData({ processedMessageIds: processedIds }); + + // 立即发送ACK确认 + try { + socketTask.send({ + data: JSON.stringify({ + type: 'ack', + message_id: messageId + }), + success: () => { + console.log('[WebSocket] ACK确认已发送:', messageId); + }, + fail: (err: any) => { + console.error('[WebSocket] ACK确认发送失败:', messageId, err); + } + }); + } catch (e) { + console.error('[WebSocket] 发送ACK异常:', e); + } + } + // 处理二维码状态消息 if (data.type === 'qrcode_status') { console.log('📡 二维码状态变化:', `status=${data.status}`, data.message); @@ -1269,13 +1433,16 @@ Page({ } // 处理扫码成功消息(发送验证码阶段的风控) else if (data.type === 'qrcode_scan_success') { - console.log('✅ 扫码验证完成!', data.message); + console.log('扫码验证完成!', data.message); - // 关闭验证码弹窗 + // 清除扫码标记 this.setData({ - needCaptcha: false + needCaptcha: false, + isScanning: false }); + console.log('[扫码成功] 已清除isScanning标记'); + // 显示提示:扫码成功,请重新发送验证码 wx.showToast({ title: data.message || '扫码成功,请重新发送验证码', @@ -1290,24 +1457,51 @@ Page({ } // 处理二维码失效消息 else if (data.type === 'qrcode_expired') { - console.log('⚠️ 二维码已失效!', data.message); + console.log('二维码已失效!', data.message); - // 关闭验证码弹窗 + // 不关闭弹窗,保持显示,等待用户点击刷新 + // 更新二维码状态为5(过期) this.setData({ - needCaptcha: false + qrcodeStatus: 5, + captchaTitle: data.message || '二维码已过期,点击二维码区域刷新' }); - // 显示提示:二维码已失效 - wx.showToast({ - title: data.message || '二维码已失效,请重新发送验证码', - icon: 'none', - duration: 3000 - }); + console.log('[二维码过期] 已更新状态为5,等待用户点击刷新'); + console.log('[WebSocket] 保持连接,等待用户点击刷新按钮'); - console.log('[WebSocket] 二维码已失效,关闭弹窗'); - console.log('[WebSocket] 保持连接,等待用户重新操作'); + // 不关闭WebSocket,保持连接用于刷新二维码 + } + // 处理二维码刷新结果 + else if (data.type === 'qrcode_refreshed') { + console.log('二维码刷新结果:', data); - // 不关闭WebSocket,保持连接用于重新发送验证码 + wx.hideLoading(); // 隐藏loading + + if (data.success) { + // 刷新成功,更新二维码图片 + this.setData({ + qrcodeImage: data.qrcode_image, + qrcodeStatus: 1, // 恢复为等待扫码状态 + captchaTitle: '请使用小红书APP扫码' + }); + + console.log('[二维码刷新] 刷新成功,已更新二维码图片'); + + wx.showToast({ + title: data.message || '二维码已刷新', + icon: 'success', + duration: 2000 + }); + } else { + // 刷新失败 + console.error('[二维码刷新] 失败:', data.message); + + wx.showToast({ + title: data.message || '刷新失败,请重试', + icon: 'none', + duration: 3000 + }); + } } // 处理登录成功消息(点击登录按钮阶段的风控) else if (data.type === 'login_success') { @@ -1317,15 +1511,18 @@ Page({ // 判断是扫码验证成功还是真正的登录成功 if (data.storage_state) { // 真正的登录成功,包含 storage_state - console.log('✅ 登录成功!', data); + console.log('登录成功!', data); wx.hideToast(); - // 关闭验证码弹窗 + // 关闭验证码弹窗并清除扫码标记 this.setData({ - needCaptcha: false + needCaptcha: false, + isScanning: false }); + console.log('[登录成功] 已清除isScanning标记'); + // 关闭WebSocket this.closeWebSocket(); @@ -1395,29 +1592,45 @@ Page({ else if (data.type === 'need_captcha') { console.log('⚠️ 需要扫码验证:', data.captcha_type); - // 显示二维码弹窗 - this.setData({ - needCaptcha: true, - captchaType: data.captcha_type || 'unknown', - qrcodeImage: data.qrcode_image || '' - }); - - wx.hideToast(); - wx.showToast({ - title: data.message || '需要扫码验证', - icon: 'none', - duration: 3000 - }); - - console.log('[WebSocket] 已显示风控二维码'); + // 判断是否成功获取到二维码 + if (data.qrcode_image) { + // 显示二维码弹窗 + this.setData({ + needCaptcha: true, + captchaType: data.captcha_type || 'unknown', + qrcodeImage: data.qrcode_image + }); + + wx.hideToast(); + wx.showToast({ + title: data.message || '需要扫码验证', + icon: 'none', + duration: 3000 + }); + + console.log('[WebSocket] 已显示风控二维码'); + } else { + // 未能获取到二维码,显示Toast提示 + console.warn('[WebSocket] 验证码发送成功,但未能获取二维码'); + + this.setData({ + needCaptcha: false, // 不显示弹窗 + captchaType: '', + qrcodeImage: '' + }); + + wx.hideToast(); + wx.showToast({ + title: '页面加载异常,请重试', + icon: 'none', + duration: 3000 + }); + } } // 处理code_sent消息(验证码发送结果) else if (data.type === 'code_sent') { console.log('[WebSocket] 验证码发送结果:', data); - // 清除防抖标记 - this.setData({ isGettingCode: false }); - wx.hideToast(); if (data.success) { @@ -1486,35 +1699,44 @@ Page({ console.log('[WebSocket] 关闭代码:', res.code || '未知'); console.log('[WebSocket] 是否正常关闭:', res.code === 1000 ? '是' : '否'); console.log('[WebSocket] 是否主动关闭:', (this.data as any).normalClose ? '是' : '否'); - console.log('=========================================') + console.log('[WebSocket] 当前sessionId:', this.data.sessionId); + console.log('[WebSocket] 页面是否隐藏:', (this.data as any).pageHidden); + console.log('[WebSocket] 当前重连计数:', (this.data as any).reconnectCount || 0); + console.log('========================================='); socketConnected = false; - + // 更新页面状态 this.setData({ socketConnected: false, socketReadyState: 3 // CLOSED }); - + // 清理ping定时器 if ((this.data as any).pingTimer) { clearInterval((this.data as any).pingTimer); } - + // 判断是否是正常关闭 const isNormalClose = (this.data as any).normalClose || res.code === 1000; - + // 清除正常关闭标记 this.setData({ normalClose: false } as any); - + // 检查是否需要重连(只要页面还在且未隐藏就重连) // 增加重连计数 const reconnectCount = (this.data as any).reconnectCount || 0; - + + // 如果是主动关闭(reconnectCount >= 999),不重连 + if (reconnectCount >= 999) { + console.log('[WebSocket] 主动关闭,不重连'); + return; + } + // 最多重连5次 if (reconnectCount < 5) { const delay = Math.min(1000 * Math.pow(2, reconnectCount), 10000); // 指数退避: 1s, 2s, 4s, 8s, 10s console.log(`[WebSocket] 将在${delay}ms后进行第${reconnectCount + 1}次重连`); - + setTimeout(() => { // 检查页面是否还在且未隐藏(通过sessionId存在和pageHidden来判断) if (this.data.sessionId && !(this.data as any).pageHidden) { @@ -1527,7 +1749,7 @@ Page({ }, delay); } else { console.log('[WebSocket] 已达到最大重连次数,停止重连'); - + // 只有非正常关闭才提示用户 if (!isNormalClose) { wx.showToast({ @@ -1624,6 +1846,10 @@ Page({ console.log('[保存二维码] 开始保存'); + // 标记用户正在扫码过程中 + this.setData({ isScanning: true }); + console.log('[保存二维码] 已设置isScanning=true,保持WebSocket连接'); + // base64转为临时文件 const base64Data = this.data.qrcodeImage.replace(/^data:image\/\w+;base64,/, ''); const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`; @@ -1643,9 +1869,9 @@ Page({ success: () => { console.log('[保存二维码] 保存成功'); wx.showToast({ - title: '二维码已保存到相册', + title: '二维码已保存,请在小红书中扫码', icon: 'success', - duration: 2000 + duration: 3000 }); }, fail: (err) => { @@ -1693,13 +1919,18 @@ Page({ // 关闭验证码弹窗 closeCaptcha() { console.log('[关闭弹窗] 用户手动关闭二维码弹窗'); + + // 关闭弹窗时清除扫码标记(用户放弃扫码) this.setData({ needCaptcha: false, qrcodeImage: '', captchaTitle: '', qrcodeStatus: 0, - qrcodeStatusText: '' + qrcodeStatusText: '', + isScanning: false }); + + console.log('[关闭弹窗] 已清除isScanning标记'); }, // 测试WebSocket连接 diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml index ced1dc5..ef8734e 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxml @@ -24,29 +24,26 @@ 请绑定小红书账号 手机号未注册小红书会导致绑定失败 - - + + - - --> - - - × + + + + + + + 二维码已过期 + + + 刷新 + @@ -116,9 +119,8 @@ {{captchaTitle || '请使用小红书APP扫码'}} - 在APP中确认完成验证 + 请在APP中确认 长按二维码可保存 - 请关闭后重新获取 @@ -128,7 +130,7 @@ - + 手机号 diff --git a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss index fa8b587..12b14cd 100644 --- a/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss +++ b/miniprogram/miniprogram/pages/profile/platform-bind/platform-bind.wxss @@ -348,6 +348,271 @@ page { box-shadow: 0 8rpx 24rpx rgba(255, 77, 79, 0.4); } +/* 过期状态样式 */ +.scan-overlay.expired { + background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(20rpx); + -webkit-backdrop-filter: blur(20rpx); +} + +.expired-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40rpx 40rpx; + animation: slideUp 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes slideUp { + from { + transform: translateY(40rpx); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* 过期图标容器 */ +.expired-icon-box { + width: 100rpx; + height: 100rpx; + border-radius: 50%; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 12rpx 32rpx rgba(255, 107, 107, 0.3); + margin-bottom: 20rpx; + animation: rotateIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes rotateIn { + from { + transform: rotate(-180deg) scale(0); + opacity: 0; + } + to { + transform: rotate(0deg) scale(1); + opacity: 1; + } +} + +/* 过期图标 */ +.expired-icon { + font-size: 60rpx; + color: #fff; + font-weight: 300; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +/* 过期内容 */ +.expired-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 8rpx; + margin-bottom: 24rpx; +} + +.expired-title { + font-size: 32rpx; + font-weight: 600; + color: #333; + letter-spacing: 1rpx; +} + +/* 刷新按钮 */ +.refresh-button { + width: 200rpx; + height: 72rpx; + border-radius: 36rpx; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 6rpx 16rpx rgba(255, 107, 107, 0.4); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + position: relative; + overflow: hidden; +} + +.refresh-button::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); + transition: left 0.5s; +} + +.refresh-button:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3); +} + +.refresh-button:active::before { + left: 100%; +} + +.refresh-text { + font-size: 28rpx; + font-weight: 500; + color: #fff; + letter-spacing: 2rpx; +} + +/* 错误提示样式 */ +.qr-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60rpx 40rpx; + min-height: 500rpx; +} + +.error-icon { + font-size: 80rpx; + margin-bottom: 24rpx; + animation: bounceIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes bounceIn { + 0% { + transform: scale(0); + opacity: 0; + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.error-text { + font-size: 32rpx; + font-weight: 600; + color: #333; + margin-bottom: 12rpx; +} + +.error-desc { + font-size: 28rpx; + color: #999; + text-align: center; + line-height: 1.6; + margin-bottom: 32rpx; +} + +.retry-button { + width: 280rpx; + height: 80rpx; + line-height: 80rpx; + border-radius: 40rpx; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); + color: #fff; + font-size: 28rpx; + font-weight: 500; + border: none; + box-shadow: 0 8rpx 24rpx rgba(255, 107, 107, 0.4); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +.retry-button::after { + border: none; +} + +.retry-button:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3); +} + +/* 内联错误提示样式 */ +.qr-error-inline { + display: flex; + align-items: center; + gap: 24rpx; + background: linear-gradient(135deg, #FFF5F5 0%, #FFE8E8 100%); + padding: 24rpx 32rpx; + margin: 0 32rpx 32rpx; + border-radius: 16rpx; + border: 2rpx solid #FFCDD2; + animation: slideDown 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes slideDown { + from { + transform: translateY(-20rpx); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.qr-error-inline .error-icon { + font-size: 48rpx; + margin: 0; + animation: none; + flex-shrink: 0; +} + +.qr-error-inline .error-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 4rpx; +} + +.qr-error-inline .error-text { + font-size: 28rpx; + font-weight: 500; + color: #D32F2F; + margin: 0; +} + +.qr-error-inline .error-desc { + font-size: 24rpx; + color: #F44336; + margin: 0; + text-align: left; +} + +.retry-button-inline { + padding: 12rpx 32rpx; + height: auto; + line-height: 1.4; + border-radius: 20rpx; + background: linear-gradient(135deg, #FF6B6B 0%, #FF8E53 100%); + color: #fff; + font-size: 26rpx; + font-weight: 500; + border: none; + box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3); + transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + flex-shrink: 0; +} + +.retry-button-inline::after { + border: none; +} + +.retry-button-inline:active { + transform: scale(0.95); + box-shadow: 0 2rpx 6rpx rgba(255, 107, 107, 0.25); +} + @keyframes scaleIn { from { transform: scale(0);