mirror of
https://github.com/zhinianboke/xianyu-auto-reply.git
synced 2025-08-01 12:07:36 +08:00
新增商品搜索菜单
This commit is contained in:
parent
4510b65840
commit
508537492e
2
.gitignore
vendored
2
.gitignore
vendored
@ -59,6 +59,6 @@ Thumbs.db
|
||||
*~
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
|
||||
.env.local
|
||||
.env.*.local
|
214
AI回复指南.md
214
AI回复指南.md
@ -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回复功能的使用方法。开始享受智能化的客服体验吧!
|
34
README.md
34
README.md
@ -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/ # 备份文件目录
|
||||
|
@ -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
|
||||
|
113
reply_server.py
113
reply_server.py
@ -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的商品信息"""
|
||||
|
@ -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
|
@ -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 = [];
|
||||
|
@ -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
786
static/item_search.html
Normal 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"> </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>
|
@ -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 = '';
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
880
utils/item_search.py
Normal 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
|
BIN
wechat-group.png
BIN
wechat-group.png
Binary file not shown.
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 171 KiB |
121
账号禁用功能修复说明.md
121
账号禁用功能修复说明.md
@ -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. **向后兼容**: 修改不影响现有功能,保持向后兼容性
|
Loading…
x
Reference in New Issue
Block a user