This commit is contained in:
sjk
2026-01-23 16:27:47 +08:00
parent 213229953b
commit e8e6d913df
26 changed files with 4294 additions and 431 deletions

207
backend/ADSPOWER_USAGE.md Normal file
View 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)

View 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. ✅ 更易维护的代码结构
这些改进大幅提升了小红书验证码登录的成功率和稳定性,同时也为后续的功能扩展奠定了良好的基础。

View File

@@ -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:

View File

@@ -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:

View File

@@ -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')}")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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
View 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())

View 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)

File diff suppressed because it is too large Load Diff

506
backend/xhs_login_helper.py Normal file
View 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)

View File

@@ -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 # 生产环境启用定时任务

View File

@@ -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_fullAdsPower Cookie
cookiesBytes, err := json.Marshal(cookiesFull)
if err == nil {
loginStateJSON = string(cookiesBytes)
log.Printf("验证码登录 - 用户%d - Cookie长度: %d", employeeID, len(loginStateJSON))
log.Printf("验证码登录 - 用户%d - 使用cookies_fullCookie长度: %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))
}
}
}

View File

@@ -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
}
};

View File

@@ -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连接

View File

@@ -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">

View File

@@ -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);