mirror of
https://github.com/zhinianboke/xianyu-auto-reply.git
synced 2025-08-02 20:47:35 +08:00
新增扫码登录
This commit is contained in:
parent
215b5dad49
commit
1243b7c17e
59
README.md
59
README.md
@ -75,45 +75,48 @@
|
||||
```
|
||||
xianyu-auto-reply/
|
||||
├── 📄 核心文件
|
||||
│ ├── Start.py # 项目启动入口
|
||||
│ ├── XianyuAutoAsync.py # 闲鱼WebSocket连接和消息处理
|
||||
│ ├── reply_server.py # FastAPI Web服务器和API接口
|
||||
│ ├── db_manager.py # SQLite数据库管理
|
||||
│ ├── cookie_manager.py # Cookie和账号管理
|
||||
│ ├── ai_reply_engine.py # AI智能回复引擎
|
||||
│ ├── file_log_collector.py # 日志收集和管理
|
||||
│ ├── config.py # 配置文件管理
|
||||
│ └── secure_confirm_ultra.py # 自动确认发货模块(超级加密保护)
|
||||
│ ├── Start.py # 项目启动入口,初始化所有服务
|
||||
│ ├── XianyuAutoAsync.py # 闲鱼WebSocket连接和消息处理核心
|
||||
│ ├── reply_server.py # FastAPI Web服务器和完整API接口
|
||||
│ ├── db_manager.py # SQLite数据库管理,支持多用户数据隔离
|
||||
│ ├── cookie_manager.py # 多账号Cookie管理和任务调度
|
||||
│ ├── ai_reply_engine.py # AI智能回复引擎,支持多种AI模型
|
||||
│ ├── file_log_collector.py # 实时日志收集和管理系统
|
||||
│ ├── config.py # 全局配置文件管理器
|
||||
│ └── secure_confirm_ultra.py # 自动确认发货模块(多层加密保护)
|
||||
├── 🛠️ 工具模块
|
||||
│ └── utils/
|
||||
│ ├── xianyu_utils.py # 闲鱼API工具函数
|
||||
│ ├── message_utils.py # 消息格式化工具
|
||||
│ ├── ws_utils.py # WebSocket客户端工具
|
||||
│ └── item_search.py # 商品搜索功能
|
||||
│ ├── xianyu_utils.py # 闲鱼API工具函数(加密、签名、解析)
|
||||
│ ├── message_utils.py # 消息格式化和处理工具
|
||||
│ ├── ws_utils.py # WebSocket客户端封装
|
||||
│ ├── qr_login.py # 二维码登录功能
|
||||
│ ├── item_search.py # 商品搜索功能(基于Playwright)
|
||||
│ └── order_detail_fetcher.py # 订单详情获取工具
|
||||
├── 🌐 前端界面
|
||||
│ └── static/
|
||||
│ ├── index.html # 主管理界面
|
||||
│ ├── index.html # 主管理界面(账号管理、系统监控)
|
||||
│ ├── login.html # 用户登录页面
|
||||
│ ├── register.html # 用户注册页面
|
||||
│ ├── user_management.html # 用户管理页面
|
||||
│ ├── data_management.html # 数据管理页面
|
||||
│ ├── log_management.html # 日志管理页面
|
||||
│ ├── item_search.html # 商品搜索页面
|
||||
│ └── lib/ # 前端依赖库
|
||||
│ ├── register.html # 用户注册页面(邮箱验证)
|
||||
│ ├── user_management.html # 用户管理页面(管理员功能)
|
||||
│ ├── data_management.html # 数据管理页面(导入导出)
|
||||
│ ├── log_management.html # 日志管理页面(实时日志查看)
|
||||
│ ├── item_search.html # 商品搜索页面(真实数据获取)
|
||||
│ ├── xianyu_js_version_2.js # 闲鱼JavaScript工具库
|
||||
│ └── lib/ # 前端依赖库(Bootstrap等)
|
||||
├── 🐳 Docker部署
|
||||
│ ├── Dockerfile # Docker镜像构建文件
|
||||
│ ├── docker-compose.yml # Docker Compose配置
|
||||
│ ├── docker-deploy.sh # Docker部署脚本
|
||||
│ ├── .env # 环境变量配置文件
|
||||
│ ├── docker-compose.yml # Docker Compose一键部署配置
|
||||
│ ├── docker-deploy.sh # Docker部署管理脚本
|
||||
│ └── nginx/ # Nginx反向代理配置
|
||||
├── 📋 配置文件
|
||||
│ ├── global_config.yml # 全局配置文件
|
||||
│ ├── requirements.txt # Python依赖包
|
||||
│ ├── global_config.yml # 全局配置文件(WebSocket、API等)
|
||||
│ ├── requirements.txt # Python依赖包列表
|
||||
│ └── README.md # 项目说明文档
|
||||
└── 📊 数据目录
|
||||
├── data/ # 数据库和数据文件
|
||||
├── logs/ # 日志文件
|
||||
└── backups/ # 备份文件
|
||||
├── xianyu_data.db # SQLite数据库文件
|
||||
├── logs/ # 按日期分割的日志文件
|
||||
├── backups/ # 数据备份文件
|
||||
└── realtime.log # 实时日志文件
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
@ -257,12 +257,13 @@ class XianyuLive:
|
||||
# 发送Cookie更新失败通知
|
||||
await self.send_token_refresh_notification(f"Cookie更新失败: {str(e)}", "cookie_update_failed")
|
||||
|
||||
async def save_item_info_to_db(self, item_id: str, item_detail: str = None):
|
||||
async def save_item_info_to_db(self, item_id: str, item_detail: str = None, item_title: str = None):
|
||||
"""保存商品信息到数据库
|
||||
|
||||
Args:
|
||||
item_id: 商品ID
|
||||
item_detail: 商品详情内容(可以是任意格式的文本)
|
||||
item_title: 商品标题
|
||||
"""
|
||||
try:
|
||||
# 跳过以 auto_ 开头的商品ID
|
||||
@ -270,6 +271,16 @@ class XianyuLive:
|
||||
logger.debug(f"跳过保存自动生成的商品ID: {item_id}")
|
||||
return
|
||||
|
||||
# 验证:如果只有商品ID,没有商品标题和商品详情,则不插入数据库
|
||||
if not item_title and not item_detail:
|
||||
logger.debug(f"跳过保存商品信息:缺少商品标题和详情 - {item_id}")
|
||||
return
|
||||
|
||||
# 如果有商品标题但没有详情,也跳过(根据需求,需要同时有标题和详情)
|
||||
if not item_title or not item_detail:
|
||||
logger.debug(f"跳过保存商品信息:商品标题或详情不完整 - {item_id}")
|
||||
return
|
||||
|
||||
from db_manager import db_manager
|
||||
|
||||
# 直接使用传入的详情内容
|
||||
@ -1288,8 +1299,22 @@ class XianyuLive:
|
||||
# 使用第一个匹配的规则(按关键字长度降序排列,优先匹配更精确的规则)
|
||||
rule = delivery_rules[0]
|
||||
|
||||
# 保存商品信息到数据库
|
||||
await self.save_item_info_to_db(item_id, search_text)
|
||||
# 保存商品信息到数据库(需要有商品标题才保存)
|
||||
# 尝试获取商品标题
|
||||
item_title_for_save = None
|
||||
try:
|
||||
from db_manager import db_manager
|
||||
db_item_info = db_manager.get_item_info(self.cookie_id, item_id)
|
||||
if db_item_info:
|
||||
item_title_for_save = db_item_info.get('item_title', '').strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
# 如果有商品标题,则保存商品信息
|
||||
if item_title_for_save:
|
||||
await self.save_item_info_to_db(item_id, search_text, item_title_for_save)
|
||||
else:
|
||||
logger.debug(f"跳过保存商品信息:缺少商品标题 - {item_id}")
|
||||
|
||||
# 详细的匹配结果日志
|
||||
if rule.get('is_multi_spec'):
|
||||
@ -2457,9 +2482,8 @@ class XianyuLive:
|
||||
reply = await self.get_default_reply(send_user_name, send_user_id, send_message)
|
||||
reply_source = '默认' # 标记为默认回复
|
||||
|
||||
# 保存商品信息到数据库(记录通知时传递的item_id)
|
||||
if item_id:
|
||||
await self.save_item_info_to_db(item_id, None)
|
||||
# 注意:这里只有商品ID,没有标题和详情,根据新的规则不保存到数据库
|
||||
# 商品信息会在其他有完整信息的地方保存(如发货规则匹配时)
|
||||
# 发送通知
|
||||
await self.send_notification(send_user_name, send_user_id, send_message, item_id)
|
||||
|
||||
|
105
db_manager.py
105
db_manager.py
@ -125,7 +125,6 @@ class DBManager:
|
||||
keyword TEXT,
|
||||
reply TEXT,
|
||||
item_id TEXT,
|
||||
PRIMARY KEY (cookie_id, keyword),
|
||||
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
@ -434,6 +433,10 @@ class DBManager:
|
||||
self._execute_sql(cursor, "ALTER TABLE item_info ADD COLUMN is_multi_spec BOOLEAN DEFAULT FALSE")
|
||||
logger.info("为item_info表添加多规格字段")
|
||||
|
||||
# 处理keywords表的唯一约束问题
|
||||
# 由于SQLite不支持直接修改约束,我们需要重建表
|
||||
self._migrate_keywords_table_constraints(cursor)
|
||||
|
||||
self.conn.commit()
|
||||
logger.info(f"数据库初始化成功: {self.db_path}")
|
||||
except Exception as e:
|
||||
@ -442,6 +445,66 @@ class DBManager:
|
||||
self.conn.close()
|
||||
raise
|
||||
|
||||
def _migrate_keywords_table_constraints(self, cursor):
|
||||
"""迁移keywords表的约束,支持基于商品ID的唯一性校验"""
|
||||
try:
|
||||
# 检查是否已经迁移过(通过检查是否存在新的唯一索引)
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_keywords_unique_with_item'")
|
||||
if cursor.fetchone():
|
||||
logger.info("keywords表约束已经迁移过,跳过")
|
||||
return
|
||||
|
||||
logger.info("开始迁移keywords表约束...")
|
||||
|
||||
# 1. 创建临时表,不设置主键约束
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS keywords_temp (
|
||||
cookie_id TEXT,
|
||||
keyword TEXT,
|
||||
reply TEXT,
|
||||
item_id TEXT,
|
||||
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# 2. 复制现有数据到临时表
|
||||
cursor.execute('''
|
||||
INSERT INTO keywords_temp (cookie_id, keyword, reply, item_id)
|
||||
SELECT cookie_id, keyword, reply, item_id FROM keywords
|
||||
''')
|
||||
|
||||
# 3. 删除原表
|
||||
cursor.execute('DROP TABLE keywords')
|
||||
|
||||
# 4. 重命名临时表
|
||||
cursor.execute('ALTER TABLE keywords_temp RENAME TO keywords')
|
||||
|
||||
# 5. 创建复合唯一索引来实现我们需要的约束逻辑
|
||||
# 对于item_id为空的情况:(cookie_id, keyword)必须唯一
|
||||
cursor.execute('''
|
||||
CREATE UNIQUE INDEX idx_keywords_unique_no_item
|
||||
ON keywords(cookie_id, keyword)
|
||||
WHERE item_id IS NULL OR item_id = ''
|
||||
''')
|
||||
|
||||
# 对于item_id不为空的情况:(cookie_id, keyword, item_id)必须唯一
|
||||
cursor.execute('''
|
||||
CREATE UNIQUE INDEX idx_keywords_unique_with_item
|
||||
ON keywords(cookie_id, keyword, item_id)
|
||||
WHERE item_id IS NOT NULL AND item_id != ''
|
||||
''')
|
||||
|
||||
logger.info("keywords表约束迁移完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"迁移keywords表约束失败: {e}")
|
||||
# 如果迁移失败,尝试回滚
|
||||
try:
|
||||
cursor.execute('DROP TABLE IF EXISTS keywords_temp')
|
||||
except:
|
||||
pass
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.conn:
|
||||
@ -672,11 +735,20 @@ class DBManager:
|
||||
# 先删除该cookie_id的所有关键字
|
||||
self._execute_sql(cursor, "DELETE FROM keywords WHERE cookie_id = ?", (cookie_id,))
|
||||
|
||||
# 插入新关键字
|
||||
# 插入新关键字,使用INSERT OR REPLACE来处理可能的唯一约束冲突
|
||||
for keyword, reply, item_id in keywords:
|
||||
self._execute_sql(cursor,
|
||||
"INSERT INTO keywords (cookie_id, keyword, reply, item_id) VALUES (?, ?, ?, ?)",
|
||||
(cookie_id, keyword, reply, item_id))
|
||||
# 标准化item_id:空字符串转为NULL
|
||||
normalized_item_id = item_id if item_id and item_id.strip() else None
|
||||
|
||||
try:
|
||||
self._execute_sql(cursor,
|
||||
"INSERT INTO keywords (cookie_id, keyword, reply, item_id) VALUES (?, ?, ?, ?)",
|
||||
(cookie_id, keyword, reply, normalized_item_id))
|
||||
except sqlite3.IntegrityError as ie:
|
||||
# 如果遇到唯一约束冲突,记录详细错误信息
|
||||
item_desc = f"商品ID: {normalized_item_id}" if normalized_item_id else "通用关键词"
|
||||
logger.error(f"关键词唯一约束冲突: Cookie={cookie_id}, 关键词='{keyword}', {item_desc}")
|
||||
raise ie
|
||||
|
||||
self.conn.commit()
|
||||
logger.info(f"关键字保存成功: {cookie_id}, {len(keywords)}条")
|
||||
@ -2563,6 +2635,23 @@ class DBManager:
|
||||
bool: 操作是否成功
|
||||
"""
|
||||
try:
|
||||
# 验证:如果只有商品ID,没有商品详情数据,则不插入数据库
|
||||
if not item_data:
|
||||
logger.debug(f"跳过保存商品信息:缺少商品详情数据 - {item_id}")
|
||||
return False
|
||||
|
||||
# 如果是字典类型,检查是否有标题信息
|
||||
if isinstance(item_data, dict):
|
||||
title = item_data.get('title', '').strip()
|
||||
if not title:
|
||||
logger.debug(f"跳过保存商品信息:缺少商品标题 - {item_id}")
|
||||
return False
|
||||
|
||||
# 如果是字符串类型,检查是否为空
|
||||
if isinstance(item_data, str) and not item_data.strip():
|
||||
logger.debug(f"跳过保存商品信息:商品详情为空 - {item_id}")
|
||||
return False
|
||||
|
||||
with self.lock:
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
@ -2633,6 +2722,7 @@ class DBManager:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存商品信息失败: {e}")
|
||||
self.conn.rollback()
|
||||
return False
|
||||
|
||||
def get_item_info(self, cookie_id: str, item_id: str) -> Optional[Dict]:
|
||||
@ -2897,6 +2987,11 @@ class DBManager:
|
||||
if not cookie_id or not item_id:
|
||||
continue
|
||||
|
||||
# 验证:如果没有商品标题,则跳过保存
|
||||
if not item_title or not item_title.strip():
|
||||
logger.debug(f"跳过批量保存商品信息:缺少商品标题 - {item_id}")
|
||||
continue
|
||||
|
||||
# 使用 INSERT OR IGNORE + UPDATE 模式
|
||||
cursor.execute('''
|
||||
INSERT OR IGNORE INTO item_info (cookie_id, item_id, item_title, item_description,
|
||||
|
132
reply_server.py
132
reply_server.py
@ -19,6 +19,8 @@ import cookie_manager
|
||||
from db_manager import db_manager
|
||||
from file_log_collector import setup_file_logging, get_file_log_collector
|
||||
from ai_reply_engine import ai_reply_engine
|
||||
from utils.qr_login import qr_login_manager
|
||||
from utils.xianyu_utils import trans_cookies
|
||||
from loguru import logger
|
||||
|
||||
# 关键字文件路径
|
||||
@ -966,6 +968,121 @@ def update_cookie(cid: str, item: CookieIn, current_user: Dict[str, Any] = Depen
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
# ========================= 扫码登录相关接口 =========================
|
||||
|
||||
@app.post("/qr-login/generate")
|
||||
async def generate_qr_code(current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""生成扫码登录二维码"""
|
||||
try:
|
||||
log_with_user('info', "请求生成扫码登录二维码", current_user)
|
||||
|
||||
result = await qr_login_manager.generate_qr_code()
|
||||
|
||||
if result['success']:
|
||||
log_with_user('info', f"扫码登录二维码生成成功: {result['session_id']}", current_user)
|
||||
else:
|
||||
log_with_user('warning', f"扫码登录二维码生成失败: {result.get('message', '未知错误')}", current_user)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
log_with_user('error', f"生成扫码登录二维码异常: {str(e)}", current_user)
|
||||
return {'success': False, 'message': f'生成二维码失败: {str(e)}'}
|
||||
|
||||
|
||||
@app.get("/qr-login/check/{session_id}")
|
||||
async def check_qr_code_status(session_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""检查扫码登录状态"""
|
||||
try:
|
||||
# 清理过期会话
|
||||
qr_login_manager.cleanup_expired_sessions()
|
||||
|
||||
# 获取会话状态
|
||||
status_info = qr_login_manager.get_session_status(session_id)
|
||||
|
||||
if status_info['status'] == 'success':
|
||||
# 登录成功,处理Cookie
|
||||
cookies_info = qr_login_manager.get_session_cookies(session_id)
|
||||
if cookies_info:
|
||||
account_info = await process_qr_login_cookies(
|
||||
cookies_info['cookies'],
|
||||
cookies_info['unb'],
|
||||
current_user
|
||||
)
|
||||
status_info['account_info'] = account_info
|
||||
|
||||
log_with_user('info', f"扫码登录成功处理完成: {session_id}, 账号: {account_info.get('account_id', 'unknown')}", current_user)
|
||||
|
||||
return status_info
|
||||
|
||||
except Exception as e:
|
||||
log_with_user('error', f"检查扫码登录状态异常: {str(e)}", current_user)
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
|
||||
async def process_qr_login_cookies(cookies: str, unb: str, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理扫码登录获取的Cookie"""
|
||||
try:
|
||||
user_id = current_user['user_id']
|
||||
|
||||
# 检查是否已存在相同unb的账号
|
||||
existing_cookies = db_manager.get_all_cookies(user_id)
|
||||
existing_account_id = None
|
||||
|
||||
for account_id, cookie_value in existing_cookies.items():
|
||||
try:
|
||||
# 解析现有Cookie中的unb
|
||||
existing_cookie_dict = trans_cookies(cookie_value)
|
||||
if existing_cookie_dict.get('unb') == unb:
|
||||
existing_account_id = account_id
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if existing_account_id:
|
||||
# 更新现有账号的Cookie
|
||||
db_manager.save_cookie(existing_account_id, cookies, user_id)
|
||||
|
||||
# 更新cookie_manager中的Cookie
|
||||
if cookie_manager.manager:
|
||||
cookie_manager.manager.update_cookie(existing_account_id, cookies)
|
||||
|
||||
log_with_user('info', f"扫码登录更新现有账号Cookie: {existing_account_id}, UNB: {unb}", current_user)
|
||||
|
||||
return {
|
||||
'account_id': existing_account_id,
|
||||
'is_new_account': False
|
||||
}
|
||||
else:
|
||||
# 创建新账号,使用unb作为账号ID
|
||||
account_id = unb
|
||||
|
||||
# 确保账号ID唯一
|
||||
counter = 1
|
||||
original_account_id = account_id
|
||||
while account_id in existing_cookies:
|
||||
account_id = f"{original_account_id}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 保存新账号
|
||||
db_manager.save_cookie(account_id, cookies, user_id)
|
||||
|
||||
# 添加到cookie_manager
|
||||
if cookie_manager.manager:
|
||||
cookie_manager.manager.add_cookie(account_id, cookies)
|
||||
|
||||
log_with_user('info', f"扫码登录创建新账号: {account_id}, UNB: {unb}", current_user)
|
||||
|
||||
return {
|
||||
'account_id': account_id,
|
||||
'is_new_account': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log_with_user('error', f"处理扫码登录Cookie失败: {str(e)}", current_user)
|
||||
raise e
|
||||
|
||||
|
||||
@app.put('/cookies/{cid}/status')
|
||||
def update_cookie_status(cid: str, status_data: CookieStatusIn, current_user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""更新账号的启用/禁用状态"""
|
||||
@ -1403,7 +1520,20 @@ def get_keywords(cid: str, current_user: Dict[str, Any] = Depends(get_current_us
|
||||
if cid not in user_cookies:
|
||||
raise HTTPException(status_code=403, detail="无权限访问该Cookie")
|
||||
|
||||
return cookie_manager.manager.get_keywords(cid)
|
||||
# 直接从数据库获取所有关键词(避免重复计算)
|
||||
item_keywords = db_manager.get_keywords_with_item_id(cid)
|
||||
|
||||
# 转换为统一格式
|
||||
all_keywords = []
|
||||
for keyword, reply, item_id in item_keywords:
|
||||
all_keywords.append({
|
||||
"keyword": keyword,
|
||||
"reply": reply,
|
||||
"item_id": item_id,
|
||||
"type": "item" if item_id else "normal"
|
||||
})
|
||||
|
||||
return all_keywords
|
||||
|
||||
|
||||
@app.get("/keywords-with-item-id/{cid}")
|
||||
|
@ -14,6 +14,7 @@ loguru>=0.7.0
|
||||
websockets>=10.0,<13.0
|
||||
aiohttp>=3.9.0
|
||||
requests>=2.31.0
|
||||
httpx>=0.25.0
|
||||
|
||||
# 配置文件处理
|
||||
PyYAML>=6.0.0
|
||||
@ -34,8 +35,9 @@ python-multipart>=0.0.6
|
||||
# AI回复引擎
|
||||
openai>=1.65.5
|
||||
|
||||
# 图像处理(验证码生成)
|
||||
# 图像处理(验证码生成、二维码生成)
|
||||
Pillow>=10.0.0
|
||||
qrcode[pil]>=7.4.2
|
||||
|
||||
# 浏览器自动化(商品搜索、订单详情获取)
|
||||
playwright>=1.40.0
|
||||
@ -55,7 +57,13 @@ regex>=2023.10.3
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
|
||||
# 邮件发送(用户注册验证)
|
||||
email-validator>=2.0.0
|
||||
|
||||
# 其他工具库
|
||||
typing-extensions>=4.7.0
|
||||
|
||||
# 注意:sqlite3 是Python内置模块,无需安装
|
||||
# 注意:
|
||||
# - sqlite3 是Python内置模块,无需安装
|
||||
# - smtplib 是Python内置模块,无需安装
|
||||
# - email 是Python内置模块,无需安装
|
@ -1100,6 +1100,112 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 扫码登录按钮特殊样式 */
|
||||
.qr-login-btn {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
|
||||
background: linear-gradient(135deg, #218838 0%, #1ea085 100%);
|
||||
}
|
||||
|
||||
.qr-login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.qr-login-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.qr-login-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* 二维码容器样式 */
|
||||
.qr-code-wrapper {
|
||||
border: 3px solid #28a745;
|
||||
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.qr-code-wrapper:hover {
|
||||
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
/* 步骤指引样式 */
|
||||
.step-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
/* 手动输入按钮样式 */
|
||||
.manual-input-btn {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.manual-input-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.2);
|
||||
}
|
||||
|
||||
/* 等待提示样式 */
|
||||
.bg-light-warning {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
|
||||
.qr-loading-tip {
|
||||
animation: pulse-warning 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-warning {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.qr-login-btn, .manual-input-btn {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 25px !important;
|
||||
height: 25px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -1300,25 +1406,55 @@
|
||||
<div class="content-body">
|
||||
<!-- 添加Cookie卡片 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-plus-circle me-2"></i>添加新账号</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="addForm" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="cookieId" class="form-label">账号ID</label>
|
||||
<input type="text" class="form-control" id="cookieId" placeholder="唯一标识" required>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label for="cookieValue" class="form-label">Cookie值</label>
|
||||
<input type="text" class="form-control" id="cookieValue" placeholder="完整Cookie字符串" required>
|
||||
</div>
|
||||
<!-- 添加方式选择 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>添加账号
|
||||
</button>
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||
<button type="button" class="btn btn-success btn-lg me-md-2 flex-fill qr-login-btn" onclick="showQRCodeLogin()" style="max-width: 300px;">
|
||||
<i class="bi bi-qr-code me-2"></i>
|
||||
<span class="fw-bold">扫码登录</span>
|
||||
<br>
|
||||
<small class="opacity-75">推荐方式,安全便捷</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-lg flex-fill manual-input-btn" onclick="toggleManualInput()" style="max-width: 300px;">
|
||||
<i class="bi bi-keyboard me-2"></i>
|
||||
<span class="fw-bold">手动输入</span>
|
||||
<br>
|
||||
<small class="opacity-75">输入Cookie信息</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 手动输入表单(默认隐藏) -->
|
||||
<div id="manualInputForm" style="display: none;">
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>提示:</strong>推荐使用扫码登录,更加安全便捷。如需手动输入,请确保Cookie信息的准确性。
|
||||
</div>
|
||||
<form id="addForm" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="cookieId" class="form-label">账号ID</label>
|
||||
<input type="text" class="form-control" id="cookieId" placeholder="唯一标识" required>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label for="cookieValue" class="form-label">Cookie值</label>
|
||||
<input type="text" class="form-control" id="cookieValue" placeholder="完整Cookie字符串" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>添加账号
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary ms-2" onclick="toggleManualInput()">
|
||||
<i class="bi bi-x-circle me-1"></i>取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2064,6 +2200,85 @@
|
||||
|
||||
</div> <!-- 结束 main-content -->
|
||||
|
||||
<!-- 扫码登录模态框 -->
|
||||
<div class="modal fade" id="qrCodeLoginModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-qr-code me-2"></i>扫码登录闲鱼账号
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<!-- 步骤指引 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div class="step-item me-3">
|
||||
<div class="step-number bg-success text-white rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 30px; height: 30px;">1</div>
|
||||
<small class="d-block mt-1">打开闲鱼APP</small>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right text-muted me-3"></i>
|
||||
<div class="step-item me-3">
|
||||
<div class="step-number bg-success text-white rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 30px; height: 30px;">2</div>
|
||||
<small class="d-block mt-1">扫描二维码</small>
|
||||
</div>
|
||||
<i class="bi bi-arrow-right text-muted me-3"></i>
|
||||
<div class="step-item">
|
||||
<div class="step-number bg-success text-white rounded-circle d-inline-flex align-items-center justify-content-center" style="width: 30px; height: 30px;">3</div>
|
||||
<small class="d-block mt-1">自动添加账号</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码区域 -->
|
||||
<div id="qrCodeContainer">
|
||||
<div class="spinner-border text-success mb-3" role="status" style="width: 3rem; height: 3rem;">
|
||||
<span class="visually-hidden">生成二维码中...</span>
|
||||
</div>
|
||||
<p class="text-muted fs-5 mb-2">正在生成二维码...</p>
|
||||
<div class="alert alert-warning border-0 bg-light-warning d-inline-block qr-loading-tip">
|
||||
<i class="bi bi-clock me-2 text-warning"></i>
|
||||
<small class="text-warning fw-bold">二维码生成较慢,请耐心等待</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qrCodeImage" style="display: none;">
|
||||
<div class="qr-code-wrapper p-3 bg-light rounded-3 d-inline-block mb-3">
|
||||
<img id="qrCodeImg" src="" alt="登录二维码" class="img-fluid" style="max-width: 280px;">
|
||||
</div>
|
||||
<h6 class="text-success mb-2">
|
||||
<i class="bi bi-phone me-2"></i>请使用闲鱼APP扫描二维码
|
||||
</h6>
|
||||
<div class="alert alert-info border-0 bg-light">
|
||||
<i class="bi bi-info-circle me-2 text-info"></i>
|
||||
<small>扫码后请等待页面提示,系统会自动获取并保存您的账号信息</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态显示 -->
|
||||
<div id="qrCodeStatus" class="mt-3">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<div class="spinner-border spinner-border-sm text-success me-2" role="status" style="display: none;" id="statusSpinner">
|
||||
<span class="visually-hidden">检查中...</span>
|
||||
</div>
|
||||
<span id="statusText" class="text-muted">等待扫码...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle me-1"></i>关闭
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" onclick="refreshQRCode()" id="refreshQRBtn">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>重新生成二维码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑通知渠道模态框 -->
|
||||
<div class="modal fade" id="editChannelModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -2718,19 +2933,22 @@
|
||||
const keywordsData = await keywordsResponse.json();
|
||||
return {
|
||||
...account,
|
||||
keywords: keywordsData
|
||||
keywords: keywordsData,
|
||||
keywordCount: keywordsData.length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...account,
|
||||
keywords: []
|
||||
keywords: [],
|
||||
keywordCount: 0
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取账号 ${account.id} 关键词失败:`, error);
|
||||
return {
|
||||
...account,
|
||||
keywords: []
|
||||
keywords: [],
|
||||
keywordCount: 0
|
||||
};
|
||||
}
|
||||
})
|
||||
@ -2744,7 +2962,7 @@
|
||||
let enabledAccounts = 0;
|
||||
|
||||
accountsWithKeywords.forEach(account => {
|
||||
const keywordCount = account.keywords ? account.keywords.length : 0;
|
||||
const keywordCount = account.keywordCount || 0;
|
||||
const isEnabled = account.enabled === undefined ? true : account.enabled;
|
||||
|
||||
if (isEnabled) {
|
||||
@ -2795,7 +3013,7 @@
|
||||
}
|
||||
|
||||
accounts.forEach(account => {
|
||||
const keywordCount = account.keywords ? account.keywords.length : 0;
|
||||
const keywordCount = account.keywordCount || 0;
|
||||
const isEnabled = account.enabled === undefined ? true : account.enabled;
|
||||
|
||||
let status = '';
|
||||
@ -2826,7 +3044,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 获取账号关键词数量(带缓存)
|
||||
// 获取账号关键词数量(带缓存)- 包含普通关键词和商品关键词
|
||||
async function getAccountKeywordCount(accountId) {
|
||||
const now = Date.now();
|
||||
|
||||
@ -2844,6 +3062,7 @@
|
||||
|
||||
if (response.ok) {
|
||||
const keywordsData = await response.json();
|
||||
// 现在API返回的是包含普通关键词和商品关键词的完整列表
|
||||
const count = keywordsData.length;
|
||||
|
||||
// 更新缓存
|
||||
@ -2897,6 +3116,7 @@
|
||||
const keywordsData = await keywordsResponse.json();
|
||||
return {
|
||||
...account,
|
||||
keywords: keywordsData,
|
||||
keywordCount: keywordsData.length
|
||||
};
|
||||
} else {
|
||||
@ -7586,6 +7806,201 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= 账号添加相关函数 =========================
|
||||
|
||||
// 切换手动输入表单显示/隐藏
|
||||
function toggleManualInput() {
|
||||
const manualForm = document.getElementById('manualInputForm');
|
||||
if (manualForm.style.display === 'none') {
|
||||
manualForm.style.display = 'block';
|
||||
// 清空表单
|
||||
document.getElementById('addForm').reset();
|
||||
} else {
|
||||
manualForm.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ========================= 扫码登录相关函数 =========================
|
||||
|
||||
let qrCodeCheckInterval = null;
|
||||
let qrCodeSessionId = null;
|
||||
|
||||
// 显示扫码登录模态框
|
||||
function showQRCodeLogin() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('qrCodeLoginModal'));
|
||||
modal.show();
|
||||
|
||||
// 模态框显示后生成二维码
|
||||
modal._element.addEventListener('shown.bs.modal', function () {
|
||||
generateQRCode();
|
||||
});
|
||||
|
||||
// 模态框关闭时清理定时器
|
||||
modal._element.addEventListener('hidden.bs.modal', function () {
|
||||
clearQRCodeCheck();
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新二维码(兼容旧函数名)
|
||||
async function refreshQRCode() {
|
||||
await generateQRCode();
|
||||
}
|
||||
|
||||
// 生成二维码
|
||||
async function generateQRCode() {
|
||||
try {
|
||||
showQRCodeLoading();
|
||||
|
||||
const response = await fetch(`${apiBase}/qr-login/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
qrCodeSessionId = data.session_id;
|
||||
showQRCodeImage(data.qr_code_url);
|
||||
startQRCodeCheck();
|
||||
} else {
|
||||
showQRCodeError(data.message || '生成二维码失败');
|
||||
}
|
||||
} else {
|
||||
showQRCodeError('生成二维码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error);
|
||||
showQRCodeError('网络错误,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示二维码加载状态
|
||||
function showQRCodeLoading() {
|
||||
document.getElementById('qrCodeContainer').style.display = 'block';
|
||||
document.getElementById('qrCodeImage').style.display = 'none';
|
||||
document.getElementById('statusText').textContent = '正在生成二维码,请耐心等待...';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 显示二维码图片
|
||||
function showQRCodeImage(qrCodeUrl) {
|
||||
document.getElementById('qrCodeContainer').style.display = 'none';
|
||||
document.getElementById('qrCodeImage').style.display = 'block';
|
||||
document.getElementById('qrCodeImg').src = qrCodeUrl;
|
||||
document.getElementById('statusText').textContent = '等待扫码...';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 显示二维码错误
|
||||
function showQRCodeError(message) {
|
||||
document.getElementById('qrCodeContainer').innerHTML = `
|
||||
<div class="text-danger">
|
||||
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
||||
<p>${message}</p>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('qrCodeImage').style.display = 'none';
|
||||
document.getElementById('statusText').textContent = '生成失败';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
}
|
||||
|
||||
// 开始检查二维码状态
|
||||
function startQRCodeCheck() {
|
||||
if (qrCodeCheckInterval) {
|
||||
clearInterval(qrCodeCheckInterval);
|
||||
}
|
||||
|
||||
document.getElementById('statusSpinner').style.display = 'inline-block';
|
||||
document.getElementById('statusText').textContent = '等待扫码...';
|
||||
|
||||
qrCodeCheckInterval = setInterval(checkQRCodeStatus, 2000); // 每2秒检查一次
|
||||
}
|
||||
|
||||
// 检查二维码状态
|
||||
async function checkQRCodeStatus() {
|
||||
if (!qrCodeSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/qr-login/check/${qrCodeSessionId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
switch (data.status) {
|
||||
case 'waiting':
|
||||
document.getElementById('statusText').textContent = '等待扫码...';
|
||||
break;
|
||||
case 'scanned':
|
||||
document.getElementById('statusText').textContent = '已扫码,请在手机上确认...';
|
||||
break;
|
||||
case 'success':
|
||||
document.getElementById('statusText').textContent = '登录成功!';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
clearQRCodeCheck();
|
||||
handleQRCodeSuccess(data);
|
||||
break;
|
||||
case 'expired':
|
||||
document.getElementById('statusText').textContent = '二维码已过期';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
clearQRCodeCheck();
|
||||
showQRCodeError('二维码已过期,请刷新重试');
|
||||
break;
|
||||
case 'cancelled':
|
||||
document.getElementById('statusText').textContent = '用户取消登录';
|
||||
document.getElementById('statusSpinner').style.display = 'none';
|
||||
clearQRCodeCheck();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查二维码状态失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理扫码成功
|
||||
function handleQRCodeSuccess(data) {
|
||||
if (data.account_info) {
|
||||
const { account_id, is_new_account } = data.account_info;
|
||||
|
||||
if (is_new_account) {
|
||||
showToast(`新账号添加成功!账号ID: ${account_id}`, 'success');
|
||||
} else {
|
||||
showToast(`账号Cookie已更新!账号ID: ${account_id}`, 'success');
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
setTimeout(() => {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('qrCodeLoginModal'));
|
||||
modal.hide();
|
||||
|
||||
// 刷新账号列表
|
||||
loadCookies();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理二维码检查
|
||||
function clearQRCodeCheck() {
|
||||
if (qrCodeCheckInterval) {
|
||||
clearInterval(qrCodeCheckInterval);
|
||||
qrCodeCheckInterval = null;
|
||||
}
|
||||
qrCodeSessionId = null;
|
||||
}
|
||||
|
||||
// 刷新二维码
|
||||
function refreshQRCode() {
|
||||
clearQRCodeCheck();
|
||||
generateQRCode();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- AI回复配置模态框 -->
|
||||
|
387
utils/qr_login.py
Normal file
387
utils/qr_login.py
Normal file
@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
闲鱼扫码登录工具
|
||||
基于API接口实现二维码生成和Cookie获取(参照myfish-main项目)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
import json
|
||||
import re
|
||||
from random import random
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
import qrcode
|
||||
import qrcode.constants
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def generate_headers():
|
||||
"""生成请求头"""
|
||||
return {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'Sec-Fetch-Dest': 'empty',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
}
|
||||
|
||||
|
||||
class GetLoginParamsError(Exception):
|
||||
"""获取登录参数错误"""
|
||||
|
||||
|
||||
class GetLoginQRCodeError(Exception):
|
||||
"""获取登录二维码失败"""
|
||||
|
||||
|
||||
class NotLoginError(Exception):
|
||||
"""未登录错误"""
|
||||
|
||||
|
||||
class QRLoginSession:
|
||||
"""二维码登录会话"""
|
||||
|
||||
def __init__(self, session_id: str):
|
||||
self.session_id = session_id
|
||||
self.status = 'waiting' # waiting, scanned, success, expired, cancelled
|
||||
self.qr_code_url = None
|
||||
self.qr_content = None
|
||||
self.cookies = {}
|
||||
self.unb = None
|
||||
self.created_time = time.time()
|
||||
self.expire_time = 300 # 5分钟过期
|
||||
self.params = {} # 存储登录参数
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""检查是否过期"""
|
||||
return time.time() - self.created_time > self.expire_time
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'status': self.status,
|
||||
'qr_code_url': self.qr_code_url,
|
||||
'created_time': self.created_time,
|
||||
'is_expired': self.is_expired()
|
||||
}
|
||||
|
||||
|
||||
class QRLoginManager:
|
||||
"""二维码登录管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.sessions: Dict[str, QRLoginSession] = {}
|
||||
self.headers = generate_headers()
|
||||
self.host = "https://passport.goofish.com"
|
||||
self.api_mini_login = f"{self.host}/mini_login.htm"
|
||||
self.api_generate_qr = f"{self.host}/newlogin/qrcode/generate.do"
|
||||
self.api_scan_status = f"{self.host}/newlogin/qrcode/query.do"
|
||||
self.api_h5_tk = "https://h5api.m.goofish.com/h5/mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get/1.0/"
|
||||
|
||||
def _cookie_marshal(self, cookies: dict) -> str:
|
||||
"""将Cookie字典转换为字符串"""
|
||||
return "; ".join([f"{k}={v}" for k, v in cookies.items()])
|
||||
|
||||
async def _get_mh5tk(self, session: QRLoginSession) -> dict:
|
||||
"""获取m_h5_tk和m_h5_tk_enc"""
|
||||
params = {
|
||||
"jsv": "2.7.2",
|
||||
"appKey": "34839810",
|
||||
"t": int(time.time()),
|
||||
"sign": "",
|
||||
"v": "1.0",
|
||||
"type": "originaljson",
|
||||
"accountSite": "xianyu",
|
||||
"dataType": "json",
|
||||
"timeout": 20000,
|
||||
"api": "mtop.gaia.nodejs.gaia.idle.data.gw.v2.index.get",
|
||||
"sessionOption": "AutoLoginOnly",
|
||||
"spm_cnt": "a21ybx.home.0.0",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.post(
|
||||
self.api_h5_tk, params=params, headers=self.headers
|
||||
)
|
||||
cookies = {}
|
||||
for k, v in resp.cookies.items():
|
||||
cookies[k] = v
|
||||
session.cookies[k] = v
|
||||
return cookies
|
||||
|
||||
async def _get_login_params(self, session: QRLoginSession) -> dict:
|
||||
"""获取二维码登录时需要的表单参数"""
|
||||
params = {
|
||||
"lang": "zh_cn",
|
||||
"appName": "xianyu",
|
||||
"appEntrance": "web",
|
||||
"styleType": "vertical",
|
||||
"bizParams": "",
|
||||
"notLoadSsoView": False,
|
||||
"notKeepLogin": False,
|
||||
"isMobile": False,
|
||||
"qrCodeFirst": False,
|
||||
"stie": 77,
|
||||
"rnd": random(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
self.api_mini_login,
|
||||
params=params,
|
||||
cookies=session.cookies,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
# 正则匹配需要的json数据
|
||||
pattern = r"window\.viewData\s*=\s*(\{.*?\});"
|
||||
match = re.search(pattern, resp.text)
|
||||
if match:
|
||||
json_string = match.group(1)
|
||||
view_data = json.loads(json_string)
|
||||
data = view_data.get("loginFormData")
|
||||
if data:
|
||||
data["umidTag"] = "SERVER"
|
||||
session.params.update(data)
|
||||
return data
|
||||
else:
|
||||
raise GetLoginParamsError("未找到loginFormData")
|
||||
else:
|
||||
raise GetLoginParamsError("获取登录参数失败")
|
||||
|
||||
async def generate_qr_code(self) -> Dict[str, Any]:
|
||||
"""生成二维码"""
|
||||
try:
|
||||
# 创建新的会话
|
||||
session_id = str(uuid.uuid4())
|
||||
session = QRLoginSession(session_id)
|
||||
|
||||
# 1. 获取m_h5_tk
|
||||
await self._get_mh5tk(session)
|
||||
logger.info(f"获取m_h5_tk成功: {session_id}")
|
||||
|
||||
# 2. 获取登录参数
|
||||
login_params = await self._get_login_params(session)
|
||||
logger.info(f"获取登录参数成功: {session_id}")
|
||||
|
||||
# 3. 生成二维码
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
self.api_generate_qr,
|
||||
params=login_params,
|
||||
headers=self.headers
|
||||
)
|
||||
results = resp.json()
|
||||
|
||||
if results.get("content", {}).get("success") == True:
|
||||
# 更新会话参数
|
||||
session.params.update({
|
||||
"t": results["content"]["data"]["t"],
|
||||
"ck": results["content"]["data"]["ck"],
|
||||
})
|
||||
|
||||
# 获取二维码内容
|
||||
qr_content = results["content"]["data"]["codeContent"]
|
||||
session.qr_content = qr_content
|
||||
|
||||
# 生成二维码图片(base64格式)
|
||||
qr = qrcode.QRCode(
|
||||
version=5,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(qr_content)
|
||||
qr.make()
|
||||
|
||||
# 将二维码转换为base64
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
qr_img = qr.make_image()
|
||||
buffer = BytesIO()
|
||||
qr_img.save(buffer, format='PNG')
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
qr_data_url = f"data:image/png;base64,{qr_base64}"
|
||||
|
||||
session.qr_code_url = qr_data_url
|
||||
session.status = 'waiting'
|
||||
|
||||
# 保存会话
|
||||
self.sessions[session_id] = session
|
||||
|
||||
# 启动状态检查任务
|
||||
asyncio.create_task(self._monitor_qr_status(session_id))
|
||||
|
||||
logger.info(f"二维码生成成功: {session_id}")
|
||||
return {
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'qr_code_url': qr_data_url
|
||||
}
|
||||
else:
|
||||
raise GetLoginQRCodeError("获取登录二维码失败")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成二维码失败: {e}")
|
||||
return {'success': False, 'message': f'生成二维码失败: {str(e)}'}
|
||||
|
||||
async def _poll_qrcode_status(self, session: QRLoginSession) -> httpx.Response:
|
||||
"""获取二维码扫描状态"""
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.post(
|
||||
self.api_scan_status,
|
||||
data=session.params,
|
||||
cookies=session.cookies,
|
||||
headers=self.headers,
|
||||
)
|
||||
return resp
|
||||
|
||||
async def _monitor_qr_status(self, session_id: str):
|
||||
"""监控二维码状态"""
|
||||
try:
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
logger.info(f"开始监控二维码状态: {session_id}")
|
||||
|
||||
# 监控登录状态
|
||||
max_wait_time = 300 # 5分钟
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < max_wait_time:
|
||||
try:
|
||||
# 检查会话是否还存在
|
||||
if session_id not in self.sessions:
|
||||
break
|
||||
|
||||
# 轮询二维码状态
|
||||
resp = await self._poll_qrcode_status(session)
|
||||
qrcode_status = (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("qrCodeStatus")
|
||||
)
|
||||
|
||||
if qrcode_status == "CONFIRMED":
|
||||
# 登录确认
|
||||
if (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("iframeRedirect")
|
||||
is True
|
||||
):
|
||||
# 账号被风控,需要手机验证
|
||||
session.status = 'cancelled'
|
||||
iframe_url = (
|
||||
resp.json()
|
||||
.get("content", {})
|
||||
.get("data", {})
|
||||
.get("iframeRedirectUrl")
|
||||
)
|
||||
logger.warning(f"账号被风控,需要手机验证: {session_id}, URL: {iframe_url}")
|
||||
break
|
||||
else:
|
||||
# 登录成功
|
||||
session.status = 'success'
|
||||
|
||||
# 保存Cookie
|
||||
for k, v in resp.cookies.items():
|
||||
session.cookies[k] = v
|
||||
if k == 'unb':
|
||||
session.unb = v
|
||||
|
||||
logger.info(f"扫码登录成功: {session_id}, UNB: {session.unb}")
|
||||
break
|
||||
|
||||
elif qrcode_status == "NEW":
|
||||
# 二维码未被扫描,继续轮询
|
||||
continue
|
||||
|
||||
elif qrcode_status == "EXPIRED":
|
||||
# 二维码已过期
|
||||
session.status = 'expired'
|
||||
logger.info(f"二维码已过期: {session_id}")
|
||||
break
|
||||
|
||||
elif qrcode_status == "SCANED":
|
||||
# 二维码已被扫描,等待确认
|
||||
if session.status == 'waiting':
|
||||
session.status = 'scanned'
|
||||
logger.info(f"二维码已扫描,等待确认: {session_id}")
|
||||
else:
|
||||
# 用户取消确认
|
||||
session.status = 'cancelled'
|
||||
logger.info(f"用户取消登录: {session_id}")
|
||||
break
|
||||
|
||||
await asyncio.sleep(0.8) # 每0.8秒检查一次
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"监控二维码状态异常: {e}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 超时处理
|
||||
if session.status not in ['success', 'expired', 'cancelled']:
|
||||
session.status = 'expired'
|
||||
logger.info(f"二维码监控超时,标记为过期: {session_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"监控二维码状态失败: {e}")
|
||||
if session_id in self.sessions:
|
||||
self.sessions[session_id].status = 'expired'
|
||||
|
||||
def get_session_status(self, session_id: str) -> Dict[str, Any]:
|
||||
"""获取会话状态"""
|
||||
session = self.sessions.get(session_id)
|
||||
if not session:
|
||||
return {'status': 'not_found'}
|
||||
|
||||
if session.is_expired() and session.status != 'success':
|
||||
session.status = 'expired'
|
||||
|
||||
result = {
|
||||
'status': session.status,
|
||||
'session_id': session_id
|
||||
}
|
||||
|
||||
# 如果登录成功,返回Cookie信息
|
||||
if session.status == 'success' and session.cookies and session.unb:
|
||||
result['cookies'] = self._cookie_marshal(session.cookies)
|
||||
result['unb'] = session.unb
|
||||
|
||||
return result
|
||||
|
||||
def cleanup_expired_sessions(self):
|
||||
"""清理过期会话"""
|
||||
expired_sessions = []
|
||||
for session_id, session in self.sessions.items():
|
||||
if session.is_expired():
|
||||
expired_sessions.append(session_id)
|
||||
|
||||
for session_id in expired_sessions:
|
||||
del self.sessions[session_id]
|
||||
logger.info(f"清理过期会话: {session_id}")
|
||||
|
||||
def get_session_cookies(self, session_id: str) -> Optional[Dict[str, str]]:
|
||||
"""获取会话Cookie"""
|
||||
session = self.sessions.get(session_id)
|
||||
if session and session.status == 'success':
|
||||
return {
|
||||
'cookies': self._cookie_marshal(session.cookies),
|
||||
'unb': session.unb
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# 全局二维码登录管理器实例
|
||||
qr_login_manager = QRLoginManager()
|
Loading…
x
Reference in New Issue
Block a user