新增商品搜索菜单

This commit is contained in:
zhinianboke 2025-07-29 08:39:45 +08:00
parent 4510b65840
commit 508537492e
16 changed files with 1855 additions and 356 deletions

View File

2
.gitignore vendored
View File

@ -59,6 +59,6 @@ Thumbs.db
*~
# Local environment files
.env
.env.local
.env.*.local

View File

@ -1,214 +0,0 @@
# 🤖 AI回复功能使用指南
## 📋 功能概述
AI回复功能集成了先进的人工智能技术为每个闲鱼账号提供智能化的客服回复能力。系统支持意图识别、智能议价、技术咨询等多种场景让您的客服更加专业和高效。
## ✨ 核心特性
### 🎯 智能意图识别
- **价格咨询**: 自动识别议价、优惠、降价等价格相关询问
- **技术咨询**: 识别产品参数、使用方法、故障等技术问题
- **通用咨询**: 处理商品介绍、物流、售后等常见问题
### 💰 智能议价系统
- **阶梯式降价**: 根据议价轮数智能调整优惠幅度
- **价值强调**: 突出商品优势,提高成交率
- **议价限制**: 可设置最大优惠百分比和金额
### 🧠 多Agent专家系统
- **分类Agent**: 负责意图识别和路由
- **价格Agent**: 专业的议价专家
- **技术Agent**: 产品技术支持专家
- **默认Agent**: 通用客服助手
### 📝 上下文感知
- **对话历史**: 记住完整的对话上下文
- **商品信息**: 结合具体商品信息回复
- **个性化**: 根据用户行为调整回复策略
## 🚀 快速开始
### 1. 启用AI回复
1. **进入账号管理页面**
2. **找到目标账号**,点击 🤖 **配置AI回复** 按钮
3. **开启AI回复开关**
4. **配置API密钥**
### 2. 配置API密钥
#### 通义千问 (推荐)
```
模型: qwen-plus
API地址: https://dashscope.aliyuncs.com/compatible-mode/v1
API密钥: 在阿里云DashScope控制台获取
```
#### OpenAI GPT
```
模型: gpt-3.5-turbo 或 gpt-4
API地址: https://api.openai.com/v1
API密钥: 在OpenAI控制台获取
```
### 3. 议价策略配置
- **最大优惠百分比**: 建议设置5-15%
- **最大优惠金额**: 根据商品价值设置
- **最大议价轮数**: 建议3-5轮
## ⚙️ 详细配置
### 基本设置
| 配置项 | 说明 | 推荐值 |
|--------|------|--------|
| AI模型 | 选择使用的AI模型 | qwen-plus |
| API地址 | AI服务的API地址 | 默认值 |
| API密钥 | 访问AI服务的密钥 | 必填 |
### 议价设置
| 配置项 | 说明 | 推荐值 |
|--------|------|--------|
| 最大优惠百分比 | 最大可优惠的百分比 | 10% |
| 最大优惠金额 | 最大可优惠的金额 | 100元 |
| 最大议价轮数 | 最多允许议价的次数 | 3轮 |
### 提示词自定义
可以自定义四种类型的提示词:
```json
{
"classify": "意图分类提示词",
"price": "议价专家提示词",
"tech": "技术专家提示词",
"default": "通用客服提示词"
}
```
## 🔄 工作流程
### 1. 消息接收
系统接收到用户消息后首先检查该账号是否启用AI回复。
### 2. 意图识别
AI分析消息内容识别用户意图
- 价格相关 → 路由到价格专家
- 技术相关 → 路由到技术专家
- 其他咨询 → 路由到通用客服
### 3. 上下文构建
- 获取对话历史
- 加载商品信息
- 统计议价次数
### 4. 智能回复
根据意图类型和上下文信息,生成个性化回复。
### 5. 回复发送
在回复前添加 `[AI回复]` 标识,便于区分。
## 📊 优先级说明
当账号启用AI回复后系统回复优先级为
1. **🔥 API回复** (最高优先级)
2. **🤖 AI回复** (AI启用时)
3. **📝 关键词匹配** (AI禁用时)
4. **💬 默认回复** (最低优先级)
> ⚠️ **重要**: 启用AI回复后关键词匹配和默认回复将自动失效
## 🧪 功能测试
### 测试步骤
1. 在AI回复配置页面找到 **功能测试** 区域
2. 输入测试消息,如:"这个商品能便宜点吗?"
3. 设置商品价格
4. 点击 **测试AI回复** 按钮
5. 查看生成的回复效果
### 测试场景
- **议价测试**: "能便宜点吗?"、"最低多少钱?"
- **技术测试**: "这个怎么用?"、"有什么功能?"
- **通用测试**: "包邮吗?"、"什么时候发货?"
## 💡 最佳实践
### 1. API密钥管理
- 定期更换API密钥
- 监控API使用量和费用
- 设置合理的调用频率限制
### 2. 议价策略
- 根据商品类型调整议价幅度
- 高价值商品可设置更高的优惠金额
- 低价商品建议限制议价轮数
### 3. 提示词优化
- 根据实际业务场景自定义提示词
- 定期分析回复效果,优化提示词
- 保持回复风格的一致性
### 4. 监控和优化
- 定期查看AI回复的效果
- 分析用户反馈,调整配置
- 监控API调用成本
## ⚠️ 注意事项
### 安全性
- API密钥加密存储但请定期更换
- 建议使用子账号API密钥限制权限
- 监控异常调用,防止滥用
### 成本控制
- AI回复会产生API调用费用
- 建议设置合理的调用限制
- 监控每日/每月的使用量
### 合规性
- AI生成的内容可能需要人工审核
- 确保回复内容符合平台规则
- 避免过度承诺或误导用户
## 🔧 故障排除
### 常见问题
**Q: AI回复不生效**
A: 检查以下项目:
- 账号是否启用AI回复
- API密钥是否正确
- 网络连接是否正常
- API服务是否可用
**Q: 回复质量不佳?**
A: 尝试以下优化:
- 调整AI模型选择
- 自定义提示词
- 优化商品信息描述
- 增加测试和调优
**Q: API调用失败**
A: 检查以下方面:
- API密钥是否有效
- API地址是否正确
- 账户余额是否充足
- 请求频率是否超限
## 📞 技术支持
如果您在使用过程中遇到问题,可以:
1. 查看系统日志获取详细错误信息
2. 使用测试功能验证配置是否正确
3. 检查API服务商的状态页面
4. 联系技术支持获取帮助
---
🎉 **恭喜!** 您已经掌握了AI回复功能的使用方法。开始享受智能化的客服体验吧

View File

@ -37,7 +37,14 @@
- **批量管理** - 支持批量查看、编辑商品信息
- **智能去重** - 自动去重,避免重复存储
### 📊 系统监控
### <20> 商品搜索功能
- **真实数据获取** - 基于Playwright技术获取真实闲鱼商品数据
- **智能排序** - 按"人想要"数量自动倒序排列
- **多页搜索** - 支持一次性获取多页商品数据
- **前端分页** - 灵活的前端分页显示
- **商品详情** - 支持查看完整商品详情信息
### <20>📊 系统监控
- **实时日志** - 完整的操作日志记录和查看
- **性能监控** - 系统资源使用情况监控
- **健康检查** - 服务状态健康检查
@ -87,10 +94,13 @@ cd xianyu-auto-reply
# 2. 安装依赖
pip install -r requirements.txt
# 3. 启动系统
# 3. 安装Playwright浏览器商品搜索功能需要
playwright install chromium
# 4. 启动系统
python Start.py
# 4. 访问系统
# 5. 访问系统
# http://localhost:8080
```
@ -143,6 +153,13 @@ docker rm -f xianyu-auto-reply
- 支持文本内容和卡密文件两种发货方式
- 系统检测到付款消息时自动发货
### 5. 使用商品搜索功能
- 访问商品搜索页面(需要登录)
- 输入搜索关键词和查询页数
- 系统自动获取真实闲鱼商品数据
- 商品按"人想要"数量自动排序
- 支持查看商品详情和跳转到闲鱼页面
## 🏗️ 系统架构
```
@ -183,11 +200,20 @@ xianyu-auto-reply/
├── requirements.txt # Python依赖
├── docker-compose.yml # Docker编排配置
├── Dockerfile # Docker镜像构建
├── utils/ # 工具模块
│ ├── item_search.py # 商品搜索功能
│ └── ... # 其他工具模块
├── static/ # 前端静态文件
│ ├── index.html # 主界面
│ ├── login.html # 登录页面
│ ├── register.html # 注册页面
│ └── ... # 其他页面和资源
│ ├── item_search.html # 商品搜索页面
│ ├── user_management.html # 用户管理页面
│ ├── data_management.html # 数据管理页面
│ ├── log_management.html # 日志管理页面
│ └── lib/ # 本地静态资源库
│ ├── bootstrap/ # Bootstrap框架
│ └── bootstrap-icons/ # Bootstrap图标
├── logs/ # 日志文件目录
├── data/ # 数据库文件目录
└── backups/ # 备份文件目录

View File

@ -174,8 +174,7 @@ class XianyuLive:
# 获取token
token = None
if '_m_h5_tk' in self.cookies:
token = self.cookies['_m_h5_tk'].split('_')[0]
token = trans_cookies(self.cookies_str).get('_m_h5_tk', '').split('_')[0] if trans_cookies(self.cookies_str).get('_m_h5_tk') else ''
sign = generate_sign(params['t'], token, data_val)
params['sign'] = sign

View File

@ -180,6 +180,11 @@ def get_current_user(user_info: Dict[str, Any] = Depends(require_auth)) -> Dict[
return user_info
def get_current_user_optional(user_info: Optional[Dict[str, Any]] = Depends(verify_token)) -> Optional[Dict[str, Any]]:
"""获取当前用户信息(可选,不强制要求登录)"""
return user_info
def get_user_log_prefix(user_info: Dict[str, Any] = None) -> str:
"""获取用户日志前缀"""
if user_info:
@ -433,6 +438,17 @@ async def data_management_page():
return HTMLResponse('<h3>Data management page not found</h3>')
# 商品搜索页面路由
@app.get('/item_search.html', response_class=HTMLResponse)
async def item_search_page():
page_path = os.path.join(static_dir, 'item_search.html')
if os.path.exists(page_path):
with open(page_path, 'r', encoding='utf-8') as f:
return HTMLResponse(f.read())
else:
return HTMLResponse('<h3>Item search page not found</h3>')
# 登录接口
@app.post('/login')
async def login(request: LoginRequest):
@ -1622,6 +1638,103 @@ def get_all_items(current_user: Dict[str, Any] = Depends(get_current_user)):
raise HTTPException(status_code=500, detail=f"获取商品信息失败: {str(e)}")
# ==================== 商品搜索 API ====================
class ItemSearchRequest(BaseModel):
keyword: str
page: int = 1
page_size: int = 20
class ItemSearchMultipleRequest(BaseModel):
keyword: str
total_pages: int = 1
@app.post("/items/search")
async def search_items(
search_request: ItemSearchRequest,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user_optional)
):
"""搜索闲鱼商品"""
try:
from utils.item_search import search_xianyu_items
# 执行搜索
result = await search_xianyu_items(
keyword=search_request.keyword,
page=search_request.page,
page_size=search_request.page_size
)
return {
"success": True,
"data": result.get("items", []),
"total": result.get("total", 0),
"page": search_request.page,
"page_size": search_request.page_size,
"keyword": search_request.keyword
}
except Exception as e:
logger.error(f"商品搜索失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"商品搜索失败: {str(e)}")
@app.post("/items/search_multiple")
async def search_multiple_pages(
search_request: ItemSearchMultipleRequest,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user_optional)
):
"""搜索多页闲鱼商品"""
try:
from utils.item_search import search_multiple_pages_xianyu
# 执行多页搜索
result = await search_multiple_pages_xianyu(
keyword=search_request.keyword,
total_pages=search_request.total_pages
)
return {
"success": True,
"data": result.get("items", []),
"total": result.get("total", 0),
"total_pages": search_request.total_pages,
"keyword": search_request.keyword,
"is_real_data": result.get("is_real_data", False),
"is_fallback": result.get("is_fallback", False),
"source": result.get("source", "unknown")
}
except Exception as e:
logger.error(f"多页商品搜索失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"多页商品搜索失败: {str(e)}")
@app.get("/items/detail/{item_id}")
async def get_public_item_detail(
item_id: str,
current_user: Optional[Dict[str, Any]] = Depends(get_current_user_optional)
):
"""获取公开商品详情通过外部API"""
try:
from utils.item_search import get_item_detail_from_api
# 从外部API获取商品详情
detail = await get_item_detail_from_api(item_id)
if detail:
return {
"success": True,
"data": detail
}
else:
raise HTTPException(status_code=404, detail="商品详情获取失败")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取商品详情失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取商品详情失败: {str(e)}")
@app.get("/items/cookie/{cookie_id}")
def get_items_by_cookie(cookie_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""获取指定Cookie的商品信息"""

View File

@ -33,4 +33,18 @@ openai>=1.65.5
python-dotenv>=1.0.1
# 图像处理(图形验证码)
Pillow>=10.0.0
Pillow>=10.0.0
# 浏览器自动化(商品搜索功能)
playwright>=1.40.0
# 加密和安全
PyJWT>=2.8.0
passlib>=1.7.4
bcrypt>=4.0.1
# 时间处理
python-dateutil>=2.8.2
# 正则表达式增强
regex>=2023.10.3

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>数据管理 - 闲鱼自动回复系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/lib/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/static/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<style>
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@ -242,7 +242,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/lib/bootstrap/bootstrap.bundle.min.js"></script>
<script>
let currentTable = '';
let currentData = [];

View File

@ -1165,6 +1165,12 @@
消息通知
</a>
</div>
<div class="nav-item">
<a href="/item_search.html" class="nav-link" target="_blank">
<i class="bi bi-search"></i>
商品搜索
</a>
</div>
<div class="nav-item">
<a href="#" class="nav-link" onclick="showSection('system-settings')">
<i class="bi bi-gear"></i>
@ -2016,7 +2022,7 @@
<i class="bi bi-wechat me-2"></i>微信群
</div>
<div class="card-body text-center">
<img src="https://img.zhinianboke.com/img/5527" alt="微信群二维码" class="img-fluid" style="max-width: 200px;">
<img src="../wechat-group.png" alt="微信群二维码" class="img-fluid" style="max-width: 200px;">
<p class="mt-3 text-muted">扫码加入微信技术交流群</p>
</div>
</div>
@ -2027,7 +2033,7 @@
<i class="bi bi-chat-square me-2"></i>QQ群
</div>
<div class="card-body text-center">
<img src="https://img.zhinianboke.com/img/5526" alt="QQ群二维码" class="img-fluid" style="max-width: 200px;">
<img src="../qq-group.png" alt="QQ群二维码" class="img-fluid" style="max-width: 200px;">
<p class="mt-3 text-muted">扫码加入QQ技术交流群</p>
</div>
</div>

786
static/item_search.html Normal file
View File

@ -0,0 +1,786 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>商品搜索 - 闲鱼管理系统</title>
<link href="/static/lib/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/static/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background-color: #f8f9fa;
}
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card {
border: none;
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.search-form {
background: white;
padding: 30px;
border-radius: 15px;
margin-bottom: 30px;
}
.item-card {
transition: transform 0.2s;
}
.item-card:hover {
transform: translateY(-2px);
}
.item-image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 10px;
}
.price {
color: #e74c3c;
font-weight: bold;
font-size: 1.2em;
}
.seller-name {
color: #6c757d;
font-size: 0.9em;
}
.tags {
margin-top: 10px;
}
.tag {
background-color: #e9ecef;
color: #495057;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8em;
margin-right: 5px;
margin-bottom: 5px;
display: inline-block;
}
.loading {
text-align: center;
padding: 50px;
}
.no-results {
text-align: center;
padding: 50px;
color: #6c757d;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 30px;
}
</style>
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="/index.html">
<i class="bi bi-search me-2"></i>
商品搜索
</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="http://localhost:8080/index.html">
<i class="bi bi-house"></i> 返回主页
</a>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- 加载提示 -->
<div id="loadingAuth" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">验证登录状态...</span>
</div>
<p class="mt-3">正在验证登录状态...</p>
</div>
<!-- 主要内容(初始隐藏) -->
<div id="mainContent" style="display: none;">
<!-- 搜索表单 -->
<div class="search-form">
<h4 class="mb-4">
<i class="bi bi-search me-2"></i>
闲鱼商品搜索
</h4>
<div class="alert alert-info mb-4">
<i class="bi bi-info-circle me-2"></i>
<strong>功能说明:</strong>
<ul class="mb-0 mt-2">
<li><strong>查询总页数:</strong>输入要获取的页数1-20页系统会一次性获取所有页面的数据</li>
<li><strong>每页显示:</strong>前端分页显示的每页商品数量</li>
<li><strong>数据来源:</strong>使用真实的闲鱼数据,通过浏览器自动化技术获取</li>
</ul>
</div>
<form id="searchForm">
<div class="row">
<div class="col-md-5">
<label for="keyword" class="form-label">搜索关键词</label>
<input type="text" class="form-control" id="keyword" placeholder="请输入商品关键词" required>
</div>
<div class="col-md-3">
<label for="totalPages" class="form-label">查询总页数</label>
<input type="number" class="form-control" id="totalPages" value="1" min="1" max="20" placeholder="输入页数">
</div>
<div class="col-md-2">
<label for="pageSize" class="form-label">每页显示</label>
<select class="form-select" id="pageSize">
<option value="10">10条</option>
<option value="20" selected>20条</option>
<option value="30">30条</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-2"></i>
搜索
</button>
</div>
</div>
</div>
</form>
</div>
<!-- 搜索结果统计 -->
<div id="searchStats" class="alert alert-info" style="display: none;">
<i class="bi bi-info-circle me-2"></i>
<span id="statsText"></span>
</div>
<!-- 加载状态 -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">搜索中...</span>
</div>
<p class="mt-3">正在搜索商品,请稍候...</p>
</div>
<!-- 搜索结果 -->
<div id="searchResults" class="row">
<!-- 商品卡片将在这里动态生成 -->
</div>
<!-- 无结果提示 -->
<div id="noResults" class="no-results" style="display: none;">
<i class="bi bi-search" style="font-size: 3em; color: #dee2e6;"></i>
<h5 class="mt-3">没有找到相关商品</h5>
<p class="text-muted">请尝试使用其他关键词搜索</p>
</div>
<!-- 分页 -->
<div id="paginationContainer" class="pagination-container" style="display: none;">
<nav>
<ul class="pagination" id="pagination">
<!-- 分页按钮将在这里动态生成 -->
</ul>
</nav>
</div>
</div>
<!-- 商品详情模态框 -->
<div class="modal fade" id="itemDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-box-seam me-2"></i>
商品详情
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="itemDetailContent">
<!-- 详情内容将在这里动态加载 -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
</div> <!-- 结束 mainContent -->
<script src="/lib/bootstrap/bootstrap.bundle.min.js"></script>
<script>
// 全局变量
let currentPage = 1;
let currentKeyword = '';
let allItems = []; // 存储所有获取的商品数据
let currentPageSize = 20; // 前端分页每页显示数量
let totalResults = 0;
let authToken = localStorage.getItem('auth_token');
const apiBase = 'http://localhost:8080';
// HTML转义函数全局函数
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 显示商品详情(全局函数)
async function showItemDetail(itemId) {
try {
const modal = new bootstrap.Modal(document.getElementById('itemDetailModal'));
const content = document.getElementById('itemDetailContent');
// 显示加载状态
content.innerHTML = `
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p class="mt-2">正在加载商品详情...</p>
</div>
`;
modal.show();
// 调用API获取商品详情
const headers = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${apiBase}/items/detail/${itemId}`, {
headers: headers
});
if (response.ok) {
const result = await response.json();
if (result.success && result.data) {
content.innerHTML = `
<div class="item-detail">
<h6>商品详情</h6>
<pre style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(result.data)}</pre>
</div>
`;
} else {
content.innerHTML = `
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
${escapeHtml(result.message || '获取商品详情失败')}
</div>
`;
}
} else {
content.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-x-circle me-2"></i>
获取商品详情失败,请稍后重试
</div>
`;
}
} catch (error) {
console.error('获取商品详情失败:', error);
const content = document.getElementById('itemDetailContent');
content.innerHTML = `
<div class="alert alert-danger">
<i class="bi bi-x-circle me-2"></i>
网络错误,请检查网络连接
</div>
`;
}
}
// 检查用户登录状态
async function checkLoginStatus() {
if (!authToken) {
// 未登录,直接重定向到登录页面
redirectToLogin();
return false;
}
// 验证token是否有效
try {
const response = await fetch(`${apiBase}/verify`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${authToken}`
}
});
if (response.ok) {
const result = await response.json();
if (result.authenticated) {
return true;
}
}
// token无效清除并重定向
localStorage.removeItem('auth_token');
redirectToLogin();
return false;
} catch (error) {
console.error('Token验证失败:', error);
// 网络错误时也清除token并重定向
localStorage.removeItem('auth_token');
redirectToLogin();
return false;
}
}
// 重定向到登录页面
function redirectToLogin() {
// 保存当前页面URL登录后可以返回
const currentUrl = window.location.href;
localStorage.setItem('redirectAfterLogin', currentUrl);
// 立即重定向到登录页面
window.location.href = 'login.html';
}
// 页面加载时立即检查登录状态
(function() {
// 在页面内容加载前就检查登录状态
if (!authToken) {
redirectToLogin();
return;
}
})();
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', async function() {
// 异步检查登录状态验证token有效性
const isLoggedIn = await checkLoginStatus();
if (!isLoggedIn) {
return;
}
// 隐藏加载提示,显示主要内容
document.getElementById('loadingAuth').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
// 如果已登录,继续原有的初始化逻辑
initializePage();
});
// 初始化页面
function initializePage() {
// 搜索表单提交
const searchForm = document.getElementById('searchForm');
if (searchForm) {
searchForm.addEventListener('submit', function(e) {
e.preventDefault();
const keyword = document.getElementById('keyword').value.trim();
const totalPages = parseInt(document.getElementById('totalPages').value);
const pageSize = parseInt(document.getElementById('pageSize').value);
if (!keyword) {
alert('请输入搜索关键词');
return;
}
if (isNaN(totalPages) || totalPages < 1 || totalPages > 20) {
alert('请输入有效的页数1-20页');
return;
}
currentKeyword = keyword;
currentPageSize = pageSize;
currentPage = 1; // 重置到第一页
searchAllPages(keyword, totalPages);
});
} else {
console.error('找不到搜索表单元素!');
}
// 监听每页显示数量的变化
const pageSizeSelect = document.getElementById('pageSize');
if (pageSizeSelect) {
pageSizeSelect.addEventListener('change', function() {
const newPageSize = parseInt(this.value);
// 如果已经有搜索结果,立即更新分页显示
if (allItems.length > 0) {
currentPageSize = newPageSize;
currentPage = 1; // 重置到第一页
displayCurrentPage();
updateFrontendPagination();
// 更新统计信息
updateStatsDisplay();
}
});
}
// 搜索多页商品数据
async function searchAllPages(keyword, totalPages) {
try {
// 显示加载状态
showLoading(true);
hideResults();
// 构建请求头
const headers = {
'Content-Type': 'application/json'
};
// 如果有token则添加认证头
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const requestData = {
keyword: keyword,
total_pages: totalPages // 传递总页数参数
};
console.log('发送多页搜索API请求:', {
url: `${apiBase}/items/search_multiple`,
method: 'POST',
headers: headers,
body: requestData
});
const response = await fetch(`${apiBase}/items/search_multiple`, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestData)
});
console.log('API响应状态:', response.status);
if (response.ok) {
const data = await response.json();
if (data.success) {
allItems = data.data || [];
totalResults = allItems.length;
displayPaginatedResults(data);
updateFrontendPagination();
} else {
throw new Error(data.message || '搜索失败');
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('搜索失败:', error);
alert('搜索失败: ' + error.message);
} finally {
showLoading(false);
}
}
// 搜索单页商品(保留原有功能)
async function searchItems(page = 1) {
console.log('searchItems函数被调用参数:', { page, currentKeyword, currentPageSize });
try {
// 显示加载状态
showLoading(true);
hideResults();
// 构建请求头
const headers = {
'Content-Type': 'application/json'
};
// 如果有token则添加认证头
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const requestData = {
keyword: currentKeyword,
page: page,
page_size: 20 // 固定每页20条数据
};
console.log('发送API请求:', {
url: `${apiBase}/items/search`,
method: 'POST',
headers: headers,
body: requestData
});
const response = await fetch(`${apiBase}/items/search`, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestData)
});
console.log('API响应状态:', response.status);
if (response.ok) {
const data = await response.json();
if (data.success) {
displayResults(data);
currentPage = page;
totalResults = data.total;
updatePagination();
} else {
throw new Error(data.message || '搜索失败');
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error('搜索失败:', error);
alert('搜索失败: ' + error.message);
} finally {
showLoading(false);
}
}
// 显示/隐藏加载状态
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
// 隐藏结果
function hideResults() {
document.getElementById('searchResults').innerHTML = '';
document.getElementById('searchStats').style.display = 'none';
document.getElementById('noResults').style.display = 'none';
document.getElementById('paginationContainer').style.display = 'none';
}
// 显示分页搜索结果
function displayPaginatedResults(data) {
// 显示统计信息
const statsElement = document.getElementById('searchStats');
const statsText = document.getElementById('statsText');
// 检查数据来源
let dataSource = '';
if (data.is_real_data) {
dataSource = ' [真实数据]';
statsElement.className = 'alert alert-success';
} else {
dataSource = ' [数据获取异常]';
statsElement.className = 'alert alert-danger';
}
statsElement.style.display = 'block';
// 使用统一的统计信息更新函数
updateStatsDisplay();
// 显示当前页的数据
displayCurrentPage();
}
// 显示当前页数据
function displayCurrentPage() {
const resultsContainer = document.getElementById('searchResults');
// 计算当前页的数据范围
const startIndex = (currentPage - 1) * currentPageSize;
const endIndex = startIndex + currentPageSize;
const currentItems = allItems.slice(startIndex, endIndex);
if (currentItems.length === 0) {
document.getElementById('noResults').style.display = 'block';
resultsContainer.innerHTML = '';
return;
}
document.getElementById('noResults').style.display = 'none';
// 生成商品卡片
resultsContainer.innerHTML = currentItems.map(item => createItemCard(item)).join('');
}
// 显示搜索结果(保留原有功能)
function displayResults(data) {
const resultsContainer = document.getElementById('searchResults');
const items = data.data || [];
// 显示统计信息
const statsElement = document.getElementById('searchStats');
const statsText = document.getElementById('statsText');
// 检查数据来源
let dataSource = '';
if (data.is_real_data) {
dataSource = ' [真实数据]';
statsElement.className = 'alert alert-success';
} else {
dataSource = ' [数据获取异常]';
statsElement.className = 'alert alert-danger';
}
statsText.textContent = `搜索"${data.keyword}",第 ${data.page} 页结果,共显示 ${data.data.length} 个商品${dataSource}`;
statsElement.style.display = 'block';
if (items.length === 0) {
document.getElementById('noResults').style.display = 'block';
return;
}
// 生成商品卡片
resultsContainer.innerHTML = items.map(item => createItemCard(item)).join('');
}
// 创建商品卡片HTML
function createItemCard(item) {
const tags = item.tags ? item.tags.map(tag => `<span class="tag">${escapeHtml(tag)}</span>`).join('') : '';
const imageUrl = item.main_image || 'https://via.placeholder.com/200x200?text=暂无图片';
const wantCount = item.want_count || 0;
return `
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
<div class="card item-card h-100">
<img src="${escapeHtml(imageUrl)}" class="item-image" alt="${escapeHtml(item.title)}" onerror="this.src='https://via.placeholder.com/200x200?text=图片加载失败'">
<div class="card-body d-flex flex-column">
<h6 class="card-title" title="${escapeHtml(item.title)}">${escapeHtml(item.title.length > 50 ? item.title.substring(0, 50) + '...' : item.title)}</h6>
<div class="price mb-2">${escapeHtml(item.price)}</div>
<div class="seller-name mb-2">
<i class="bi bi-person me-1"></i>
${escapeHtml(item.seller_name)}
</div>
${wantCount > 0 ? `<div class="want-count mb-2">
<i class="bi bi-heart-fill me-1" style="color: #ff6b6b;"></i>
<span class="badge bg-danger">${wantCount}人想要</span>
</div>` : ''}
${item.publish_time ? `<div class="text-muted small mb-2">
<i class="bi bi-clock me-1"></i>
${escapeHtml(item.publish_time)}
</div>` : ''}
<div class="tags mb-3">${tags}</div>
<div class="mt-auto">
<div class="btn-group w-100" role="group">
<a href="${escapeHtml(item.item_url)}" target="_blank" class="btn btn-outline-primary btn-sm">
<i class="bi bi-link-45deg me-1"></i>
查看商品
</a>
<button class="btn btn-outline-info btn-sm" onclick="showItemDetail('${escapeHtml(item.item_id)}')">
<i class="bi bi-info-circle me-1"></i>
详情
</button>
</div>
</div>
</div>
</div>
</div>
`;
}
// 更新前端分页
function updateFrontendPagination() {
const totalPages = Math.ceil(allItems.length / currentPageSize);
const paginationContainer = document.getElementById('paginationContainer');
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
paginationContainer.style.display = 'none';
return;
}
paginationContainer.style.display = 'block';
let paginationHtml = '';
// 上一页
if (currentPage > 1) {
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(${currentPage - 1})">上一页</a></li>`;
}
// 页码显示逻辑
if (totalPages <= 7) {
// 如果总页数不超过7页显示所有页码
for (let i = 1; i <= totalPages; i++) {
const active = i === currentPage ? 'active' : '';
paginationHtml += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
} else {
// 如果总页数超过7页使用省略号显示
if (currentPage <= 4) {
// 当前页在前面,显示 1 2 3 4 5 ... 最后页
for (let i = 1; i <= 5; i++) {
const active = i === currentPage ? 'active' : '';
paginationHtml += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
paginationHtml += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(${totalPages})">${totalPages}</a></li>`;
} else if (currentPage >= totalPages - 3) {
// 当前页在后面,显示 1 ... 倒数5页
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(1)">1</a></li>`;
paginationHtml += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
for (let i = totalPages - 4; i <= totalPages; i++) {
const active = i === currentPage ? 'active' : '';
paginationHtml += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
} else {
// 当前页在中间,显示 1 ... 当前页前后2页 ... 最后页
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(1)">1</a></li>`;
paginationHtml += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
for (let i = currentPage - 2; i <= currentPage + 2; i++) {
const active = i === currentPage ? 'active' : '';
paginationHtml += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="goToPage(${i})">${i}</a></li>`;
}
paginationHtml += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(${totalPages})">${totalPages}</a></li>`;
}
}
// 下一页
if (currentPage < totalPages) {
paginationHtml += `<li class="page-item"><a class="page-link" href="#" onclick="goToPage(${currentPage + 1})">下一页</a></li>`;
}
pagination.innerHTML = paginationHtml;
}
// 跳转到指定页
function goToPage(page) {
currentPage = page;
displayCurrentPage();
updateFrontendPagination();
// 更新统计信息
updateStatsDisplay();
}
// 更新统计信息显示
function updateStatsDisplay() {
const statsText = document.getElementById('statsText');
let dataSource = '';
if (allItems.length > 0) {
dataSource = ' [真实数据]';
}
const totalPages = Math.ceil(allItems.length / currentPageSize);
statsText.textContent = `搜索"${currentKeyword}",共获取 ${allItems.length} 个商品${dataSource},当前显示第 ${currentPage}/${totalPages} 页(每页${currentPageSize}条)`;
}
// 更新分页提示(保留原有功能)
function updatePagination() {
const paginationContainer = document.getElementById('paginationContainer');
const pagination = document.getElementById('pagination');
// 显示页码选择提示
paginationContainer.style.display = 'block';
let paginationHtml = `
<li class="page-item disabled">
<span class="page-link">
<i class="bi bi-info-circle me-2"></i>
要查看其他页面,请在上方选择页码后重新搜索
</span>
</li>
`;
pagination.innerHTML = paginationHtml;
}
} // 结束 initializePage 函数
</script>
</body>
</html>

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>日志管理 - 闲鱼自动回复系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/lib/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/static/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<style>
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@ -211,7 +211,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/lib/bootstrap/bootstrap.bundle.min.js"></script>
<script>
let autoRefreshInterval = null;
let currentLevel = '';

View File

@ -376,8 +376,18 @@
if (result.success) {
// 保存token到localStorage
localStorage.setItem('auth_token', result.token);
// 跳转到管理页面
window.location.href = '/admin';
// 检查是否有重定向URL
const redirectUrl = localStorage.getItem('redirectAfterLogin');
if (redirectUrl) {
// 清除重定向URL
localStorage.removeItem('redirectAfterLogin');
// 跳转到原来的页面
window.location.href = redirectUrl;
} else {
// 默认跳转到管理页面
window.location.href = '/admin';
}
} else {
showError(result.message);
}

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户管理 - 闲鱼自动回复系统</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/lib/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/static/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet">
<style>
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@ -169,7 +169,7 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/lib/bootstrap/bootstrap.bundle.min.js"></script>
<script>
let currentDeleteUserId = null;
let deleteUserModal = null;

880
utils/item_search.py Normal file
View File

@ -0,0 +1,880 @@
#!/usr/bin/env python3
"""
闲鱼商品搜索模块
基于 Playwright 实现真实的闲鱼商品搜索功能
"""
import asyncio
import json
import time
from datetime import datetime
from typing import Dict, List, Any, Optional
from loguru import logger
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
logger.warning("Playwright 未安装,将使用模拟数据")
class XianyuSearcher:
"""闲鱼商品搜索器 - 基于 Playwright"""
def __init__(self):
self.browser = None
self.context = None
self.page = None
self.api_responses = []
async def safe_get(self, data, *keys, default="暂无"):
"""安全获取嵌套字典值"""
for key in keys:
try:
data = data[key]
except (KeyError, TypeError, IndexError):
return default
return data
async def init_browser(self):
"""初始化浏览器"""
if not PLAYWRIGHT_AVAILABLE:
raise Exception("Playwright 未安装,无法使用真实搜索功能")
if not self.browser:
playwright = await async_playwright().start()
logger.info("正在启动浏览器...")
self.browser = await playwright.chromium.launch(
headless=True, # 无头模式
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu'
]
)
self.context = await self.browser.new_context(
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"
)
self.page = await self.context.new_page()
async def close_browser(self):
"""关闭浏览器"""
if self.browser:
await self.browser.close()
self.browser = None
self.context = None
self.page = None
async def search_items(self, keyword: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""
搜索闲鱼商品 - 使用 Playwright 获取真实数据
Args:
keyword: 搜索关键词
page: 页码从1开始
page_size: 每页数量
Returns:
搜索结果字典包含items列表和总数
"""
try:
if not PLAYWRIGHT_AVAILABLE:
logger.error("Playwright 不可用,无法获取真实数据")
return {
'items': [],
'total': 0,
'error': 'Playwright 不可用,无法获取真实数据'
}
logger.info(f"使用 Playwright 搜索闲鱼商品: 关键词='{keyword}', 页码={page}, 每页={page_size}")
await self.init_browser()
# 清空之前的API响应
self.api_responses = []
data_list = []
# 设置API响应监听器
async def on_response(response):
"""处理API响应解析数据"""
if "h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search" in response.url:
try:
# 检查响应状态
if response.status != 200:
logger.warning(f"API响应状态异常: {response.status}")
return
# 安全地获取响应内容
try:
result_json = await response.json()
except Exception as json_error:
logger.warning(f"无法解析响应JSON: {str(json_error)}")
return
self.api_responses.append(result_json)
logger.info(f"捕获到API响应URL: {response.url}")
items = result_json.get("data", {}).get("resultList", [])
logger.info(f"从API获取到 {len(items)} 条原始数据")
for item in items:
try:
parsed_item = await self._parse_real_item(item)
if parsed_item:
data_list.append(parsed_item)
except Exception as parse_error:
logger.warning(f"解析单个商品失败: {str(parse_error)}")
continue
except Exception as e:
logger.warning(f"响应处理异常: {str(e)}")
try:
logger.info("正在访问闲鱼首页...")
await self.page.goto("https://www.goofish.com", timeout=30000)
await self.page.wait_for_load_state("networkidle", timeout=10000)
logger.info(f"正在搜索关键词: {keyword}")
await self.page.fill('input[class*="search-input"]', keyword)
# 注册响应监听
self.page.on("response", on_response)
await self.page.click('button[type="submit"]')
await self.page.wait_for_load_state("networkidle", timeout=15000)
# 等待第一页API响应
logger.info("等待第一页API响应...")
await asyncio.sleep(5)
# 尝试处理弹窗
try:
await self.page.keyboard.press('Escape')
await asyncio.sleep(1)
except:
pass
# 等待更多数据
await asyncio.sleep(3)
first_page_count = len(data_list)
logger.info(f"第1页完成获取到 {first_page_count} 条数据")
# 如果需要获取指定页数据,实现翻页逻辑
if page > 1:
# 清空之前的数据,只保留目标页的数据
data_list.clear()
await self._navigate_to_page(page)
# 根据"人想要"数量进行倒序排列
data_list.sort(key=lambda x: x.get('want_count', 0), reverse=True)
total_count = len(data_list)
logger.info(f"搜索完成,总共获取到 {total_count} 条真实数据,已按想要人数排序")
return {
'items': data_list,
'total': total_count,
'is_real_data': True,
'source': 'playwright'
}
finally:
await self.close_browser()
except Exception as e:
logger.error(f"Playwright 搜索失败: {str(e)}")
# 如果 Playwright 失败,返回错误信息
return {
'items': [],
'total': 0,
'error': f'搜索失败: {str(e)}'
}
async def _get_fallback_data(self, keyword: str, page: int, page_size: int) -> Dict[str, Any]:
"""获取备选数据(模拟数据)"""
logger.info(f"使用备选数据: 关键词='{keyword}', 页码={page}, 每页={page_size}")
# 模拟搜索延迟
await asyncio.sleep(0.5)
# 生成模拟数据
mock_items = []
start_index = (page - 1) * page_size
for i in range(page_size):
item_index = start_index + i + 1
mock_items.append({
'item_id': f'mock_{keyword}_{item_index}',
'title': f'{keyword}相关商品 #{item_index} [模拟数据]',
'price': f'{100 + item_index * 10}',
'seller_name': f'卖家{item_index}',
'item_url': f'https://www.goofish.com/item?id=mock_{keyword}_{item_index}',
'publish_time': '2025-07-28',
'tags': [f'标签{i+1}', f'分类{i+1}'],
'main_image': f'https://via.placeholder.com/200x200?text={keyword}商品{item_index}',
'raw_data': {
'mock': True,
'keyword': keyword,
'index': item_index
}
})
# 模拟总数
total_items = 100 + hash(keyword) % 500
logger.info(f"备选数据生成完成: 找到{len(mock_items)}个商品,总计{total_items}")
return {
'items': mock_items,
'total': total_items,
'is_fallback': True
}
async def _parse_real_item(self, item_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""解析真实的闲鱼商品数据"""
try:
main_data = await self.safe_get(item_data, "data", "item", "main", "exContent", default={})
click_params = await self.safe_get(item_data, "data", "item", "main", "clickParam", "args", default={})
# 解析商品信息
title = await self.safe_get(main_data, "title", default="未知标题")
# 价格处理
price_parts = await self.safe_get(main_data, "price", default=[])
price = "价格异常"
if isinstance(price_parts, list):
price = "".join([str(p.get("text", "")) for p in price_parts if isinstance(p, dict)])
price = price.replace("当前价", "").strip()
# 统一价格格式处理
if price and price != "价格异常":
# 先移除所有¥符号,避免重复
clean_price = price.replace('¥', '').strip()
# 处理万单位的价格
if "" in clean_price:
try:
numeric_price = clean_price.replace('', '').strip()
price_value = float(numeric_price) * 10000
price = f"¥{price_value:.0f}"
except:
price = f"¥{clean_price}" # 如果转换失败,保持原样但确保有¥符号
else:
# 普通价格,确保有¥符号
if clean_price and (clean_price[0].isdigit() or clean_price.replace('.', '').isdigit()):
price = f"¥{clean_price}"
else:
price = clean_price if clean_price else "价格异常"
# 只提取"想要人数"标签
fish_tags_content = ""
fish_tags = await self.safe_get(main_data, "fishTags", default={})
# 遍历所有类型的标签 (r2, r3, r4等)
for tag_type, tag_data in fish_tags.items():
if isinstance(tag_data, dict) and "tagList" in tag_data:
tag_list = tag_data.get("tagList", [])
for tag_item in tag_list:
if isinstance(tag_item, dict) and "data" in tag_item:
content = tag_item["data"].get("content", "")
# 只保留包含"人想要"的标签
if content and "人想要" in content:
fish_tags_content = content
break
if fish_tags_content: # 找到后就退出
break
# 其他字段解析
area = await self.safe_get(main_data, "area", default="地区未知")
seller = await self.safe_get(main_data, "userNickName", default="匿名卖家")
raw_link = await self.safe_get(item_data, "data", "item", "main", "targetUrl", default="")
image_url = await self.safe_get(main_data, "picUrl", default="")
# 获取商品ID
item_id = await self.safe_get(click_params, "item_id", default="未知ID")
# 处理发布时间
publish_time = "未知时间"
publish_timestamp = click_params.get("publishTime", "")
if publish_timestamp and publish_timestamp.isdigit():
try:
publish_time = datetime.fromtimestamp(
int(publish_timestamp)/1000
).strftime("%Y-%m-%d %H:%M")
except:
pass
# 提取"人想要"的数字用于排序
want_count = self._extract_want_count(fish_tags_content)
return {
"item_id": item_id,
"title": title,
"price": price,
"seller_name": seller,
"item_url": raw_link.replace("fleamarket://", "https://www.goofish.com/"),
"main_image": f"https:{image_url}" if image_url and not image_url.startswith("http") else image_url,
"publish_time": publish_time,
"tags": [fish_tags_content] if fish_tags_content else [],
"area": area,
"want_count": want_count, # 添加想要人数用于排序
"raw_data": item_data
}
except Exception as e:
logger.warning(f"解析真实商品数据失败: {str(e)}")
return None
def _extract_want_count(self, tags_content: str) -> int:
"""从标签内容中提取"人想要"的数字"""
try:
if not tags_content or "人想要" not in tags_content:
return 0
# 使用正则表达式提取数字
import re
# 匹配类似 "123人想要" 或 "1.2万人想要" 的格式
pattern = r'(\d+(?:\.\d+)?(?:万)?)\s*人想要'
match = re.search(pattern, tags_content)
if match:
number_str = match.group(1)
if '' in number_str:
# 处理万单位
number = float(number_str.replace('', '')) * 10000
return int(number)
else:
return int(float(number_str))
return 0
except Exception as e:
logger.warning(f"提取想要人数失败: {str(e)}")
return 0
async def _navigate_to_page(self, target_page: int):
"""导航到指定页面"""
try:
logger.info(f"正在导航到第 {target_page} 页...")
# 等待页面稳定
await asyncio.sleep(2)
# 查找并点击下一页按钮
next_button_selectors = [
'.search-page-tiny-arrow-right--oXVFaRao', # 用户找到的正确选择器
'[class*="search-page-tiny-arrow-right"]', # 更通用的版本
'button[aria-label="下一页"]',
'button:has-text("下一页")',
'a:has-text("下一页")',
'.ant-pagination-next',
'li.ant-pagination-next a',
'a[aria-label="下一页"]',
'[class*="next"]',
'[class*="pagination-next"]',
'button[title="下一页"]',
'a[title="下一页"]'
]
# 从第2页开始点击
for current_page in range(2, target_page + 1):
logger.info(f"正在点击到第 {current_page} 页...")
next_button_found = False
for selector in next_button_selectors:
try:
next_button = self.page.locator(selector).first
if await next_button.is_visible(timeout=3000):
# 检查按钮是否可点击(不是禁用状态)
is_disabled = await next_button.get_attribute("disabled")
has_disabled_class = await next_button.evaluate("el => el.classList.contains('ant-pagination-disabled') || el.classList.contains('disabled')")
if not is_disabled and not has_disabled_class:
logger.info(f"找到下一页按钮,正在点击...")
# 滚动到按钮位置
await next_button.scroll_into_view_if_needed()
await asyncio.sleep(1)
# 点击下一页
await next_button.click()
await self.page.wait_for_load_state("networkidle", timeout=15000)
# 等待新数据加载
await asyncio.sleep(5)
logger.info(f"成功导航到第 {current_page}")
next_button_found = True
break
except Exception as e:
continue
if not next_button_found:
logger.warning(f"无法找到下一页按钮,停止在第 {current_page-1}")
break
except Exception as e:
logger.error(f"导航到第 {target_page} 页失败: {str(e)}")
async def search_multiple_pages(self, keyword: str, total_pages: int = 1) -> Dict[str, Any]:
"""
搜索多页闲鱼商品
Args:
keyword: 搜索关键词
total_pages: 总页数
Returns:
搜索结果字典包含所有页面的items列表和总数
"""
try:
if not PLAYWRIGHT_AVAILABLE:
logger.error("Playwright 不可用,无法获取真实数据")
return {
'items': [],
'total': 0,
'error': 'Playwright 不可用,无法获取真实数据'
}
logger.info(f"使用 Playwright 搜索多页闲鱼商品: 关键词='{keyword}', 总页数={total_pages}")
await self.init_browser()
# 清空之前的API响应
self.api_responses = []
all_data_list = []
# 设置API响应监听器
async def on_response(response):
"""处理API响应解析数据"""
if "h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search" in response.url:
try:
# 检查响应状态
if response.status != 200:
logger.warning(f"API响应状态异常: {response.status}")
return
# 安全地获取响应内容
try:
result_json = await response.json()
except Exception as json_error:
logger.warning(f"无法解析响应JSON: {str(json_error)}")
return
self.api_responses.append(result_json)
logger.info(f"捕获到API响应URL: {response.url}")
items = result_json.get("data", {}).get("resultList", [])
logger.info(f"从API获取到 {len(items)} 条原始数据")
for item in items:
try:
parsed_item = await self._parse_real_item(item)
if parsed_item:
all_data_list.append(parsed_item)
except Exception as parse_error:
logger.warning(f"解析单个商品失败: {str(parse_error)}")
continue
except Exception as e:
logger.warning(f"响应处理异常: {str(e)}")
try:
logger.info("正在访问闲鱼首页...")
await self.page.goto("https://www.goofish.com", timeout=30000)
await self.page.wait_for_load_state("networkidle", timeout=10000)
logger.info(f"正在搜索关键词: {keyword}")
await self.page.fill('input[class*="search-input"]', keyword)
# 注册响应监听
self.page.on("response", on_response)
await self.page.click('button[type="submit"]')
await self.page.wait_for_load_state("networkidle", timeout=15000)
# 等待第一页API响应
logger.info("等待第一页API响应...")
await asyncio.sleep(5)
# 尝试处理弹窗
try:
await self.page.keyboard.press('Escape')
await asyncio.sleep(1)
except:
pass
# 等待更多数据
await asyncio.sleep(3)
first_page_count = len(all_data_list)
logger.info(f"第1页完成获取到 {first_page_count} 条数据")
# 如果需要获取更多页数据
if total_pages > 1:
for page_num in range(2, total_pages + 1):
logger.info(f"正在获取第 {page_num} 页数据...")
# 等待页面稳定
await asyncio.sleep(2)
# 查找并点击下一页按钮
next_button_found = False
next_button_selectors = [
'.search-page-tiny-arrow-right--oXVFaRao',
'[class*="search-page-tiny-arrow-right"]',
'button[aria-label="下一页"]',
'button:has-text("下一页")',
'a:has-text("下一页")',
'.ant-pagination-next',
'li.ant-pagination-next a',
'a[aria-label="下一页"]'
]
for selector in next_button_selectors:
try:
next_button = self.page.locator(selector).first
if await next_button.is_visible(timeout=3000):
# 检查按钮是否可点击
is_disabled = await next_button.get_attribute("disabled")
has_disabled_class = await next_button.evaluate("el => el.classList.contains('ant-pagination-disabled') || el.classList.contains('disabled')")
if not is_disabled and not has_disabled_class:
logger.info(f"找到下一页按钮,正在点击到第 {page_num} 页...")
# 记录点击前的数据量
before_click_count = len(all_data_list)
# 滚动到按钮位置并点击
await next_button.scroll_into_view_if_needed()
await asyncio.sleep(1)
await next_button.click()
await self.page.wait_for_load_state("networkidle", timeout=15000)
# 等待新数据加载
await asyncio.sleep(5)
# 检查是否有新数据
after_click_count = len(all_data_list)
new_items = after_click_count - before_click_count
if new_items > 0:
logger.info(f"{page_num} 页成功,新增 {new_items} 条数据")
next_button_found = True
break
else:
logger.warning(f"{page_num} 页点击后没有新数据,可能已到最后一页")
next_button_found = False
break
except Exception as e:
continue
if not next_button_found:
logger.warning(f"无法获取第 {page_num} 页数据,停止在第 {page_num-1}")
break
# 根据"人想要"数量进行倒序排列
all_data_list.sort(key=lambda x: x.get('want_count', 0), reverse=True)
total_count = len(all_data_list)
logger.info(f"多页搜索完成,总共获取到 {total_count} 条真实数据,已按想要人数排序")
return {
'items': all_data_list,
'total': total_count,
'is_real_data': True,
'source': 'playwright'
}
finally:
await self.close_browser()
except Exception as e:
logger.error(f"Playwright 多页搜索失败: {str(e)}")
# 如果 Playwright 失败,返回错误信息
return {
'items': [],
'total': 0,
'error': f'多页搜索失败: {str(e)}'
}
async def _get_multiple_fallback_data(self, keyword: str, total_pages: int) -> Dict[str, Any]:
"""获取多页备选数据(模拟数据)"""
logger.info(f"使用多页备选数据: 关键词='{keyword}', 总页数={total_pages}")
# 模拟搜索延迟
await asyncio.sleep(1)
# 生成多页模拟数据
all_mock_items = []
for page in range(1, total_pages + 1):
page_size = 20 # 每页20条
start_index = (page - 1) * page_size
for i in range(page_size):
item_index = start_index + i + 1
all_mock_items.append({
'item_id': f'mock_{keyword}_{item_index}',
'title': f'{keyword}相关商品 #{item_index} [模拟数据-第{page}页]',
'price': f'{100 + item_index * 10}',
'seller_name': f'卖家{item_index}',
'item_url': f'https://www.goofish.com/item?id=mock_{keyword}_{item_index}',
'publish_time': '2025-07-28',
'tags': [f'标签{i+1}', f'分类{i+1}'],
'main_image': f'https://via.placeholder.com/200x200?text={keyword}商品{item_index}',
'raw_data': {
'mock': True,
'keyword': keyword,
'index': item_index,
'page': page
}
})
total_count = len(all_mock_items)
logger.info(f"多页备选数据生成完成: 找到{total_count}个商品,共{total_pages}")
return {
'items': all_mock_items,
'total': total_count,
'is_fallback': True
}
async def _parse_item_old(self, item_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
解析单个商品数据
Args:
item_data: 商品原始数据
Returns:
解析后的商品信息
"""
try:
# 基本信息 - 适配多种可能的字段名
item_id = (item_data.get('id') or
item_data.get('itemId') or
item_data.get('item_id') or
item_data.get('spuId') or '')
title = (item_data.get('title') or
item_data.get('name') or
item_data.get('itemTitle') or
item_data.get('subject') or '')
# 价格处理
price_raw = (item_data.get('price') or
item_data.get('priceText') or
item_data.get('currentPrice') or
item_data.get('realPrice') or '')
# 清理价格格式
if isinstance(price_raw, (int, float)):
price = str(price_raw)
elif isinstance(price_raw, str):
price = price_raw.replace('¥', '').replace('', '').strip()
else:
price = '价格面议'
# 卖家信息
seller_info = (item_data.get('seller') or
item_data.get('user') or
item_data.get('owner') or {})
seller_name = ''
if seller_info:
seller_name = (seller_info.get('nick') or
seller_info.get('nickname') or
seller_info.get('name') or
seller_info.get('userName') or '匿名用户')
# 商品链接
if item_id:
item_url = f"https://www.goofish.com/item?id={item_id}"
else:
item_url = item_data.get('url', item_data.get('link', ''))
# 发布时间
publish_time = (item_data.get('publishTime') or
item_data.get('createTime') or
item_data.get('gmtCreate') or
item_data.get('time') or '')
# 格式化时间
if publish_time and isinstance(publish_time, (int, float)):
import datetime
publish_time = datetime.datetime.fromtimestamp(publish_time / 1000).strftime('%Y-%m-%d')
# 商品标签
tags = item_data.get('tags', item_data.get('labels', []))
tag_names = []
if isinstance(tags, list):
for tag in tags:
if isinstance(tag, dict):
tag_name = (tag.get('name') or
tag.get('text') or
tag.get('label') or '')
if tag_name:
tag_names.append(tag_name)
elif isinstance(tag, str):
tag_names.append(tag)
# 图片
images = (item_data.get('images') or
item_data.get('pics') or
item_data.get('pictures') or
item_data.get('imgList') or [])
main_image = ''
if images and len(images) > 0:
if isinstance(images[0], str):
main_image = images[0]
elif isinstance(images[0], dict):
main_image = (images[0].get('url') or
images[0].get('src') or
images[0].get('image') or '')
# 如果没有图片,使用默认占位图
if not main_image:
main_image = f'https://via.placeholder.com/200x200?text={title[:10] if title else "商品"}...'
return {
'item_id': item_id,
'title': title,
'price': price,
'seller_name': seller_name,
'item_url': item_url,
'publish_time': publish_time,
'tags': tag_names,
'main_image': main_image,
'raw_data': item_data # 保留原始数据以备后用
}
except Exception as e:
logger.warning(f"解析商品数据失败: {str(e)}")
return None
# 全局搜索器实例
_searcher = None
async def search_xianyu_items(keyword: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""
搜索闲鱼商品的便捷函数
Args:
keyword: 搜索关键词
page: 页码
page_size: 每页数量
Returns:
搜索结果
"""
global _searcher
if not _searcher:
_searcher = XianyuSearcher()
try:
return await _searcher.search_items(keyword, page, page_size)
except Exception as e:
logger.error(f"搜索商品失败: {str(e)}")
return {
'items': [],
'total': 0,
'error': str(e)
}
async def search_multiple_pages_xianyu(keyword: str, total_pages: int = 1) -> Dict[str, Any]:
"""
搜索多页闲鱼商品的便捷函数
Args:
keyword: 搜索关键词
total_pages: 总页数
Returns:
搜索结果
"""
global _searcher
if not _searcher:
_searcher = XianyuSearcher()
try:
return await _searcher.search_multiple_pages(keyword, total_pages)
except Exception as e:
logger.error(f"多页搜索商品失败: {str(e)}")
return {
'items': [],
'total': 0,
'error': str(e)
}
async def close_searcher():
"""关闭搜索器"""
global _searcher
if _searcher:
await _searcher.close_session()
_searcher = None
async def get_item_detail_from_api(item_id: str) -> Optional[str]:
"""
从外部API获取商品详情
Args:
item_id: 商品ID
Returns:
商品详情文本获取失败返回None
"""
try:
# 使用默认的API配置
api_base_url = 'https://selfapi.zhinianboke.com/api/getItemDetail'
timeout_seconds = 10
api_url = f"{api_base_url}/{item_id}"
logger.info(f"正在从外部API获取商品详情: {item_id}")
# 使用简单的HTTP请求
import aiohttp
timeout = aiohttp.ClientTimeout(total=timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(api_url) as response:
if response.status == 200:
result = await response.json()
# 检查返回状态
if result.get('status') == '200' and result.get('data'):
item_detail = result['data']
logger.info(f"成功获取商品详情: {item_id}, 长度: {len(item_detail)}")
return item_detail
else:
logger.warning(f"API返回状态异常: {result.get('status')}, message: {result.get('message')}")
return None
else:
logger.warning(f"API请求失败: HTTP {response.status}")
return None
except asyncio.TimeoutError:
logger.warning(f"获取商品详情超时: {item_id}")
return None
except Exception as e:
logger.error(f"获取商品详情异常: {item_id}, 错误: {str(e)}")
return None

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

@ -1,121 +0,0 @@
# 账号禁用功能修复说明
## 问题分析
在原有的代码中,账号禁用功能存在以下问题:
### 1. 账号状态检查不完整
- `reply_server.py` 中的 `match_reply` 函数虽然有账号状态检查,但只影响关键词匹配回复
- `XianyuAutoAsync.py` 的消息处理流程中没有检查账号的启用/禁用状态
- Token刷新、心跳、WebSocket连接等核心功能都没有账号状态检查
### 2. 任务管理机制缺陷
- 账号禁用时,对应的 `XianyuLive` 任务WebSocket连接、token刷新任务等并没有被停止
- `cookie_manager.py` 中的 `update_cookie_status` 方法只更新了状态,但没有停止或启动相应的任务
- 任务一旦启动就会持续运行,不受账号状态影响
### 3. 重启后token刷新的原因
- `Start.py` 启动时会为所有数据库中的Cookie启动任务不管其启用状态
- `XianyuAutoAsync.py` 中的token刷新是独立的定时任务一旦启动就会持续运行
## 解决方案
### 1. 修改XianyuAutoAsync消息处理流程
**文件**: `XianyuAutoAsync.py`
**修改内容**:
- 在 `handle_message` 方法开头添加账号状态检查
- 在 `main` 方法的主循环中添加账号状态检查
- 在 `token_refresh_loop` 方法中添加账号状态检查
- 在 `heartbeat_loop` 方法中添加账号状态检查
**效果**: 禁用的账号将不再处理消息、刷新token或发送心跳
### 2. 修改CookieManager任务管理
**文件**: `cookie_manager.py`
**修改内容**:
- 在 `update_cookie_status` 方法中添加任务启动/停止逻辑
- 新增 `_start_cookie_task` 方法用于启动指定账号的任务
- 新增 `_stop_cookie_task` 方法用于停止指定账号的任务
**效果**:
- 禁用账号时立即停止相关任务
- 启用账号时立即启动相关任务
### 3. 修改Start.py启动逻辑
**文件**: `Start.py`
**修改内容**:
- 启动时检查每个账号的启用状态
- 只为启用状态的账号创建任务
- 跳过禁用的账号
**效果**: 重启后禁用的账号不会自动启动任务
## 修改后的工作流程
### 账号禁用时
1. 用户在管理界面点击禁用账号
2. 前端调用 `/cookies/{cid}/status` API
3. `cookie_manager.update_cookie_status` 被调用
4. 状态更新到数据库和内存
5. 检测到状态变化,调用 `_stop_cookie_task`
6. 取消对应的异步任务
7. 正在运行的 `XianyuLive` 实例在下次循环时检测到状态变化并退出
### 账号启用时
1. 用户在管理界面点击启用账号
2. 前端调用 `/cookies/{cid}/status` API
3. `cookie_manager.update_cookie_status` 被调用
4. 状态更新到数据库和内存
5. 检测到状态变化,调用 `_start_cookie_task`
6. 创建新的异步任务启动 `XianyuLive` 实例
### 系统重启时
1. `Start.py` 从数据库加载所有Cookie
2. 检查每个Cookie的启用状态
3. 只为启用状态的Cookie创建任务
4. 禁用的Cookie被跳过
## 关键代码片段
### 消息处理中的状态检查
```python
async def handle_message(self, message_data, websocket):
# 检查账号是否启用
from cookie_manager import manager as cookie_manager
if cookie_manager and not cookie_manager.get_cookie_status(self.cookie_id):
logger.debug(f"【{self.cookie_id}】账号已禁用,跳过消息处理")
return
```
### 任务管理逻辑
```python
def update_cookie_status(self, cookie_id: str, enabled: bool):
old_status = self.cookie_status.get(cookie_id, True)
self.cookie_status[cookie_id] = enabled
db_manager.save_cookie_status(cookie_id, enabled)
# 如果状态发生变化,需要启动或停止任务
if old_status != enabled:
if enabled:
self._start_cookie_task(cookie_id)
else:
self._stop_cookie_task(cookie_id)
```
## 测试验证
可以使用提供的 `test_account_disable.py` 脚本来测试账号禁用功能是否正常工作。
## 注意事项
1. **立即生效**: 修改后账号禁用/启用将立即生效,无需重启系统
2. **资源清理**: 禁用账号时会正确清理相关的异步任务和资源
3. **状态持久化**: 账号状态会保存到数据库,重启后保持一致
4. **错误处理**: 添加了完善的错误处理和日志记录
5. **向后兼容**: 修改不影响现有功能,保持向后兼容性