commit
207
backend/ADSPOWER_USAGE.md
Normal file
@@ -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)
|
||||
284
backend/XHS_LOGIN_ENHANCEMENT.md
Normal file
@@ -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. ✅ 更易维护的代码结构
|
||||
|
||||
这些改进大幅提升了小红书验证码登录的成功率和稳定性,同时也为后续的功能扩展奠定了良好的基础。
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')}")
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 7.3 KiB |
BIN
backend/error_screenshots/20260123145142_Error.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
backend/error_screenshots/20260123145146_Error.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
backend/error_screenshots/20260123150010_TimeoutError.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
1090
backend/fingerprint_browser.py
Normal file
503
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
102
backend/test_get_cookies.py
Normal file
@@ -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())
|
||||
192
backend/tianqi_proxy_pool.py
Normal file
@@ -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)
|
||||
506
backend/xhs_login_helper.py
Normal file
@@ -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)
|
||||
@@ -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 # 生产环境启用定时任务
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,16 +35,16 @@ const API_CONFIG: Record<EnvType, EnvConfig> = {
|
||||
// 测试环境 - 服务器测试
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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连接
|
||||
|
||||
@@ -24,29 +24,26 @@
|
||||
<text class="page-title">请绑定小红书账号</text>
|
||||
<text class="page-subtitle">手机号未注册小红书会导致绑定失败</text>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<view class="login-type-tabs">
|
||||
<!-- 登录方式切换 - 暂时隐藏扫码登录 -->
|
||||
<!-- <view class="login-type-tabs">
|
||||
<view class="tab {{loginType === 'phone' ? 'active' : ''}}" bindtap="switchToPhone">
|
||||
<text>手机号登录</text>
|
||||
</view>
|
||||
<view class="tab {{loginType === 'qrcode' ? 'active' : ''}}" bindtap="switchToQRCode">
|
||||
<text>扫码登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 二维码扫码登录区域 -->
|
||||
<view class="qrcode-login-section" wx:if="{{loginType === 'qrcode'}}">
|
||||
<!-- 二维码扫码登录区域 - 暂时隐藏 -->
|
||||
<!-- <view class="qrcode-login-section" wx:if="{{loginType === 'qrcode'}}">
|
||||
<view class="qrcode-container">
|
||||
<!-- 加载中 -->
|
||||
<view class="qrcode-loading" wx:if="{{qrcodeLoading}}">
|
||||
<view class="loading-spinner"></view>
|
||||
<text class="loading-text">{{loadingText}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 二维码图片 -->
|
||||
<image class="qrcode-img" src="{{qrcodeImage}}" mode="aspectFit" wx:if="{{qrcodeImage && !qrcodeLoading}}"></image>
|
||||
|
||||
<!-- 二维码状态覆盖层(只在过期或出错时显示) -->
|
||||
<view class="qrcode-status" wx:if="{{qrcodeExpired && !qrcodeLoading}}">
|
||||
<view class="status-content">
|
||||
<text class="status-text">{{statusText || '二维码已过期'}}</text>
|
||||
@@ -54,7 +51,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<view class="qrcode-error" wx:if="{{qrcodeError && !qrcodeLoading}}">
|
||||
<view class="error-content">
|
||||
<text class="error-icon">⚠️</text>
|
||||
@@ -64,7 +60,6 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录链接显示区域 -->
|
||||
<view class="qr-url-section" wx:if="{{qrUrl}}">
|
||||
<view class="url-label">登录链接(可复制到浏览器或小红书APP)</view>
|
||||
<view class="url-content">
|
||||
@@ -77,7 +72,7 @@
|
||||
<text class="tip-text">请使用小红书APP扫描二维码</text>
|
||||
<text class="tip-desc">扫码后即可完成绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
|
||||
<!-- 手机号登录区域 -->
|
||||
<view class="phone-login-section" wx:if="{{loginType === 'phone'}}">
|
||||
@@ -106,9 +101,17 @@
|
||||
</view>
|
||||
|
||||
<!-- 已过期透明层 -->
|
||||
<view class="scan-overlay error" wx:if="{{qrcodeStatus === 5}}">
|
||||
<view class="scan-icon">
|
||||
<text class="icon-cross">×</text>
|
||||
<view class="scan-overlay expired" wx:if="{{qrcodeStatus === 5}}">
|
||||
<view class="expired-container">
|
||||
<view class="expired-icon-box">
|
||||
<text class="expired-icon">⟳</text>
|
||||
</view>
|
||||
<view class="expired-content">
|
||||
<text class="expired-title">二维码已过期</text>
|
||||
</view>
|
||||
<view class="refresh-button" bindtap="refreshQRCode">
|
||||
<text class="refresh-text">刷新</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -116,9 +119,8 @@
|
||||
<!-- 提示文本 -->
|
||||
<view class="qr-tips">
|
||||
<text class="tip-main">{{captchaTitle || '请使用小红书APP扫码'}}</text>
|
||||
<text class="tip-sub" wx:if="{{qrcodeStatus === 2}}">在APP中确认完成验证</text>
|
||||
<text class="tip-sub" wx:if="{{qrcodeStatus === 2}}">请在APP中确认</text>
|
||||
<text class="tip-sub" wx:if="{{qrcodeStatus !== 2 && qrcodeStatus !== 5}}">长按二维码可保存</text>
|
||||
<text class="tip-sub error" wx:if="{{qrcodeStatus === 5}}">请关闭后重新获取</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -128,7 +130,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="bind-form" wx:if="{{!needCaptcha}}">
|
||||
<view class="bind-form">
|
||||
<view class="input-row">
|
||||
<text class="label">手机号</text>
|
||||
<picker mode="selector" range="{{countryCodes}}" value="{{countryCodeIndex}}" bindchange="onCountryCodeChange">
|
||||
|
||||
@@ -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);
|
||||
|
||||