新增扫码登录

This commit is contained in:
zhinianboke 2025-08-02 11:29:42 +08:00
parent 215b5dad49
commit 1243b7c17e
7 changed files with 1124 additions and 62 deletions

View File

@ -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 # 实时日志文件
```
## 🚀 快速开始

View File

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

View File

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

View File

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

View File

@ -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内置模块无需安装

View File

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