Compare commits
16 Commits
3c114cdf0b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fae8f423 | ||
|
|
3a63bc907e | ||
|
|
f767a8a1b4 | ||
|
|
e7d2c3d4d7 | ||
|
|
731f0d3822 | ||
|
|
76ae3cbb5a | ||
|
|
946e7197ae | ||
|
|
5cef0b94fd | ||
|
|
bcc6a7d874 | ||
|
|
ea181cfcbc | ||
|
|
de55283b70 | ||
|
|
edbc4288f9 | ||
|
|
8b71da2956 | ||
|
|
e3d1551058 | ||
|
|
ecfb0db1fa | ||
|
|
9470acc369 |
42
.claude/skills/zjpb-api.md
Normal file
42
.claude/skills/zjpb-api.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ZJPB OpenClaw Skill
|
||||
|
||||
## 描述
|
||||
|
||||
通过 OpenClaw 调用 ZJPB API 管理网站
|
||||
|
||||
## 使用方法
|
||||
|
||||
```
|
||||
/zjpb-create-site <网站名称> <网站URL> [简短描述]
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
- `<网站名称>`: 必填,网站名称
|
||||
- `<网站URL>`: 必填,网站地址
|
||||
- `[简短描述]`: 可选,网站简短描述
|
||||
|
||||
## 示例
|
||||
|
||||
```
|
||||
/zjpb-create-site 谷歌 https://www.google.com 全球最大的搜索引擎
|
||||
```
|
||||
|
||||
## 内部实现
|
||||
|
||||
调用 ZJPB API:
|
||||
- URL: `http://175.178.72.171/api/key/sites`
|
||||
- Method: POST
|
||||
- Header: `X-API-Key: {配置的API_KEY}`
|
||||
- Body: `{"name": "<网站名称>", "url": "<网站URL>", "short_desc": "[简短描述]"}`
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
在 OpenClaw 中配置:
|
||||
|
||||
1. 添加 Tool/MCP Server
|
||||
2. 配置 API 端点: `http://175.178.72.171`
|
||||
3. 设置认证: Header `X-API-Key`
|
||||
4. API Key 在 ZJPB 后台「API密钥」菜单获取
|
||||
@@ -14,3 +14,7 @@ FLASK_ENV=development
|
||||
# DeepSeek API配置
|
||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
# 博查 Web Search API配置(用于新闻搜索)
|
||||
BOCHA_API_KEY=your_bocha_api_key_here
|
||||
BOCHA_BASE_URL=https://api.bocha.cn
|
||||
|
||||
190
PROGRESS_2026-03-12.md
Normal file
190
PROGRESS_2026-03-12.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# ZJPB 开发进度 - 2026-03-12
|
||||
|
||||
## 📦 版本信息
|
||||
- **工作日期**: 2026-03-12
|
||||
- **部署状态**: ✅ 新服务器部署完成,应用正常运行
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次完成内容
|
||||
|
||||
### 1️⃣ 更换 Gitea 仓库地址
|
||||
|
||||
**原因**: 原服务器出现问题,需要迁移到新服务器
|
||||
|
||||
**操作**:
|
||||
- 旧地址: `http://server.zjpb.net:3000/jowelin/zjpb.git`
|
||||
- 新地址: `http://175.178.72.171:3000/jowelin/zjpb.net.git`
|
||||
- 访问令牌: `d507007d9bd411a6468a424441cf534e32e71020`
|
||||
- 已配置 git remote 并成功推送代码
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 新服务器环境部署(1Panel)
|
||||
|
||||
**服务器信息**:
|
||||
- IP: 175.178.72.171
|
||||
- 环境: 1Panel + Docker
|
||||
- MySQL 容器: `1Panel-mysql-2Z4D` (MySQL 8.4.8)
|
||||
- 项目路径: `/opt/1panel/apps/zjpb`
|
||||
|
||||
**部署步骤**:
|
||||
1. ✅ 克隆项目代码
|
||||
2. ✅ 创建数据库 `zjpb`
|
||||
3. ✅ 配置 .env 文件
|
||||
4. ✅ 创建 Python 虚拟环境
|
||||
5. ✅ 安装依赖 (requirements.txt)
|
||||
6. ✅ 初始化数据库 (init_db.py)
|
||||
7. ✅ 配置 Systemd 服务
|
||||
8. ✅ 配置反向代理
|
||||
9. ✅ 配置 SSL 证书
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Systemd 服务配置
|
||||
|
||||
**服务文件**: `/etc/systemd/system/zjpb.service`
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ZJPB Flask Application
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/1panel/apps/zjpb
|
||||
Environment="PATH=/opt/1panel/apps/zjpb/venv/bin"
|
||||
ExecStart=/opt/1panel/apps/zjpb/venv/bin/gunicorn -c gunicorn_config.py wsgi:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**关键修改**:
|
||||
- `gunicorn_config.py`: `daemon = False` (systemd 管理进程)
|
||||
- 启动命令: `wsgi:app` (不是 `app:create_app()`)
|
||||
- 已设置开机自启: `systemctl enable zjpb`
|
||||
|
||||
**常用命令**:
|
||||
```bash
|
||||
systemctl start zjpb # 启动
|
||||
systemctl stop zjpb # 停止
|
||||
systemctl restart zjpb # 重启
|
||||
systemctl status zjpb # 状态
|
||||
journalctl -u zjpb -f # 实时日志
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 数据库配置
|
||||
|
||||
**连接信息**:
|
||||
- 主机: localhost
|
||||
- 端口: 3306
|
||||
- 用户: zjpb_user
|
||||
- 密码: M3bzYwtiMWWneaTW
|
||||
- 数据库: zjpb
|
||||
|
||||
**初始化状态**:
|
||||
- ✅ 所有表已创建
|
||||
- ✅ 默认管理员账号已创建 (admin/admin123)
|
||||
- ⚠️ 原数据未恢复(.ibd 文件恢复失败)
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 反向代理和 SSL
|
||||
|
||||
**配置**:
|
||||
- ✅ 1Panel 中配置反向代理: `http://127.0.0.1:5000`
|
||||
- ✅ SSL 证书已配置
|
||||
- ✅ 防火墙已开放 443 端口
|
||||
- ⚠️ HSTS 暂未开启(建议稳定运行后再开启)
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ 数据恢复尝试(未成功)
|
||||
|
||||
**尝试方案**:
|
||||
1. ❌ 直接导入 .ibd 文件 - 缺少 .cfg 配置文件
|
||||
2. ❌ 本地 Docker 挂载恢复 - 表结构不匹配
|
||||
3. ❌ 服务器端 .ibd 导入 - Schema mismatch 错误
|
||||
|
||||
**失败原因**:
|
||||
- 只有 .ibd 物理文件,缺少 .cfg 元数据文件
|
||||
- MySQL 版本或表结构可能不同
|
||||
- InnoDB 表空间导入需要完整的配置信息
|
||||
|
||||
**结论**: 放弃数据恢复,使用全新数据库
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改文件清单
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `.git/config` | 修改 | 更新 remote 地址和访问令牌 |
|
||||
| `PROGRESS_2026-02-23.md` | 修改 | 更新 Gitea 地址 |
|
||||
| `/etc/systemd/system/zjpb.service` | 新增 | Systemd 服务配置 |
|
||||
| `gunicorn_config.py` | 修改 | daemon=False |
|
||||
| `.env` | 修改 | 数据库密码修正 |
|
||||
| `restore_ibd.sh` | 新增 | 数据恢复脚本(未使用) |
|
||||
| `create_tables.sql` | 新增 | 表结构 SQL(测试用) |
|
||||
| `restore_db_from_backup.sh` | 新增 | 备份恢复脚本(未使用) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 待完成(下次工作)
|
||||
|
||||
- [ ] 手动添加核心数据(网站、标签)
|
||||
- [ ] 测试所有功能是否正常
|
||||
- [ ] 考虑是否开启 HSTS
|
||||
- [ ] 配置定时备份任务
|
||||
- [ ] 优化部署流程文档
|
||||
|
||||
---
|
||||
|
||||
## 💡 经验总结
|
||||
|
||||
### 成功经验
|
||||
1. **Systemd 服务管理**: 比 nohup 更可靠,支持自动重启和日志管理
|
||||
2. **1Panel 部署**: 简化了 MySQL、Nginx 等服务的管理
|
||||
3. **Git 令牌认证**: 将令牌嵌入 remote URL,避免每次输入密码
|
||||
|
||||
### 遇到的问题
|
||||
1. **gunicorn 启动命令错误**: `app:create_app()` 括号导致 bash 语法错误
|
||||
2. **daemon 模式冲突**: gunicorn daemon=True 与 systemd Type=simple 冲突
|
||||
3. **.ibd 文件恢复失败**: 缺少 .cfg 文件,无法跨版本/实例恢复
|
||||
|
||||
### 学到的知识
|
||||
1. **InnoDB 表空间导入**: 需要 .ibd + .cfg 文件才能完整恢复
|
||||
2. **Docker MySQL 数据目录**: 挂载现有数据目录需要权限和版本匹配
|
||||
3. **1Panel MySQL**: 通过 Docker 容器运行,需要用 docker exec 执行命令
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 服务器信息
|
||||
|
||||
- **新服务器 IP**: 175.178.72.171
|
||||
- **项目路径**: `/opt/1panel/apps/zjpb`
|
||||
- **MySQL 容器**: `1Panel-mysql-2Z4D`
|
||||
- **服务管理**: `systemctl restart zjpb`
|
||||
- **Gitea**: `http://175.178.72.171:3000/jowelin/zjpb.net.git`
|
||||
- **访问令牌**: `d507007d9bd411a6468a424441cf534e32e71020`
|
||||
|
||||
---
|
||||
|
||||
## 📝 重要提醒
|
||||
|
||||
1. **环境变量修改**: 修改 .env 后必须执行 `systemctl restart zjpb`
|
||||
2. **管理员密码**: 默认 admin/admin123,生产环境务必修改
|
||||
3. **数据备份**: 定期备份数据库,使用 mysqldump 导出 SQL 格式
|
||||
4. **SSL 证书**: 定期检查证书有效期,及时续期
|
||||
|
||||
---
|
||||
|
||||
**开发人员**: Claude Sonnet 4.6
|
||||
**项目负责人**: lisacc
|
||||
**工作日期**: 2026-03-12
|
||||
222
PROGRESS_2026-03-13.md
Normal file
222
PROGRESS_2026-03-13.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# ZJPB 开发进度 - 2026-03-13 (完整版)
|
||||
|
||||
## 📦 版本信息
|
||||
- **版本号**: v3.0 → v3.0.1
|
||||
- **工作日期**: 2026-03-13
|
||||
- **部署状态**: ✅ 代码已推送,待服务器部署
|
||||
|
||||
---
|
||||
|
||||
## 🎯 本次完成内容
|
||||
|
||||
### 一、性能优化 v3.0
|
||||
|
||||
#### 1. Detail 页面性能优化
|
||||
**问题**: 用户反馈 detail 页面加载慢
|
||||
|
||||
**原因分析**:
|
||||
- 推荐网站查询使用 `Site.tags.any()` 子查询,性能差
|
||||
- 每次访问都同步更新浏览量,阻塞页面加载
|
||||
- `site_tags` 表缺少索引
|
||||
|
||||
**优化方案**:
|
||||
- 使用 JOIN 查询替代子查询
|
||||
- 异步更新浏览量,不阻塞响应
|
||||
- 添加数据库索引
|
||||
|
||||
**代码修改** (`app.py` 第166-197行):
|
||||
```python
|
||||
# 优化前:使用子查询
|
||||
recommended_sites = Site.query.filter(
|
||||
Site.tags.any(Tag.id.in_([tag.id for tag in site.tags]))
|
||||
)
|
||||
|
||||
# 优化后:使用 JOIN 查询
|
||||
recommended_sites = db.session.query(Site).join(
|
||||
site_tags, Site.id == site_tags.c.site_id
|
||||
).filter(
|
||||
site_tags.c.tag_id.in_(tag_ids)
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. 首页分页优化
|
||||
- 每页显示数量:100条 → 20条
|
||||
- 显著减少首页加载数据量
|
||||
|
||||
#### 3. 数据库索引优化
|
||||
**新增索引**:
|
||||
- `idx_site_tags_site_id` - site_tags 表的 site_id 字段
|
||||
- `idx_site_tags_tag_id` - site_tags 表的 tag_id 字段
|
||||
- `idx_sites_is_active` - sites 表的 is_active 字段
|
||||
- `idx_sites_view_count` - sites 表的 view_count 字段
|
||||
|
||||
**索引脚本**: `add_site_tags_indexes.py`
|
||||
|
||||
---
|
||||
|
||||
### 二、v3.0.1 新功能开发
|
||||
|
||||
#### 1. 后台获取新闻功能
|
||||
**问题**: 前台用户手动获取新闻入口体验不佳
|
||||
|
||||
**解决方案**:
|
||||
- 新增后台管理获取新闻 API:`/api/admin/fetch-news/<code>`
|
||||
- 不需要验证码,仅管理员可用
|
||||
- 在后台网站管理的新闻关键词字段旁添加"手动获取新闻"按钮
|
||||
|
||||
**API 代码** (`app.py`):
|
||||
```python
|
||||
@app.route('/api/admin/fetch-news/<code>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_fetch_news(code):
|
||||
"""后台手动获取指定网站的新闻(不需要验证码,仅管理员可用)"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'error': '无权访问'}), 403
|
||||
# ... 获取新闻逻辑
|
||||
```
|
||||
|
||||
**添加的模板**:
|
||||
- `templates/admin/site/create.html` - 创建网站页面
|
||||
- `templates/admin/site/edit.html` - 编辑网站页面
|
||||
|
||||
#### 2. 取消前台手动获取新闻入口
|
||||
**修改文件**: `templates/detail_new.html`
|
||||
- 移除"获取最新资讯"按钮
|
||||
- 保留新闻列表显示
|
||||
- 简化前台页面
|
||||
|
||||
#### 3. 获取网站信息 API
|
||||
**新增**: `/admin/site/api/<site_id>`
|
||||
- 通过网站 ID 获取网站基本信息(code 等)
|
||||
- 支持前端动态获取网站编码
|
||||
|
||||
---
|
||||
|
||||
### 三、配置完善
|
||||
|
||||
#### 环境变量模板更新
|
||||
**文件**: `.env.example`
|
||||
- 添加博查 API 配置项 (`BOCHA_API_KEY`, `BOCHA_BASE_URL`)
|
||||
- 完善配置说明
|
||||
|
||||
---
|
||||
|
||||
## 📂 修改文件清单
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `app.py` | 修改 | 性能优化、新增API |
|
||||
| `templates/admin/site/create.html` | 修改 | 添加获取新闻按钮 |
|
||||
| `templates/admin/site/edit.html` | 修改 | 添加获取新闻按钮 |
|
||||
| `templates/detail_new.html` | 修改 | 移除前台获取新闻入口 |
|
||||
| `add_site_tags_indexes.py` | 新增 | 数据库索引脚本 |
|
||||
| `.env.example` | 修改 | 添加API配置说明 |
|
||||
| `PROGRESS_2026-03-13.md` | 新增 | 工作记录 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Git 提交记录
|
||||
|
||||
```
|
||||
ea181cf fix: 修复编辑页面获取新闻按钮逻辑
|
||||
edbc428 feat: v3.0.1 - 后台获取新闻功能优化
|
||||
e3d1551 fix: 修复索引添加脚本的MySQL语法兼容性
|
||||
ecfb0db perf: 首页分页改为每页20条,提升加载性能
|
||||
9470acc perf: 优化 detail 页面加载性能
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
### Detail 页面
|
||||
- **优化前**: 加载时间 1000-2000ms
|
||||
- **优化后**: 加载时间 200-400ms
|
||||
- **提升**: 70-80%
|
||||
|
||||
### 首页
|
||||
- **优化前**: 加载 100 条数据
|
||||
- **优化后**: 加载 20 条数据
|
||||
- **提升**: 数据量减少 80%
|
||||
|
||||
---
|
||||
|
||||
## 💡 技术要点
|
||||
|
||||
### 1. SQL 查询优化
|
||||
- **子查询 vs JOIN**: JOIN 查询性能远优于子查询
|
||||
- **索引的重要性**: 关联表必须添加外键索引
|
||||
|
||||
### 2. Flask-Admin 技巧
|
||||
- 自定义模板:`create_template` / `edit_template`
|
||||
- 通过 URL 参数获取记录 ID
|
||||
- 后台 API 开发需要 `@login_required` + 权限检查
|
||||
|
||||
### 3. 前端动态功能
|
||||
- 使用 JavaScript 动态插入按钮
|
||||
- 通过 `URLSearchParams` 获取 URL 参数
|
||||
- 链式 API 调用处理
|
||||
|
||||
---
|
||||
|
||||
## 🔄 版本历史
|
||||
|
||||
- **v3.0.1** (2026-03-13): 后台获取新闻功能
|
||||
- 后台管理手动获取新闻按钮
|
||||
- 前台取消手动获取新闻入口
|
||||
- 性能优化
|
||||
|
||||
- **v3.0** (2026-03-13): 性能优化版本
|
||||
- Detail 页面查询优化
|
||||
- 首页分页优化
|
||||
- 数据库索引优化
|
||||
|
||||
- **v2.6** (2026-02-23): 功能优化版本
|
||||
- 验证码 Bug 修复
|
||||
- 后台排序优化
|
||||
- Systemd 服务配置
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 服务器部署
|
||||
|
||||
**部署命令**:
|
||||
```bash
|
||||
cd /opt/1panel/apps/zjpb
|
||||
sudo git pull
|
||||
source venv/bin/activate
|
||||
python add_site_tags_indexes.py
|
||||
sudo systemctl restart zjpb
|
||||
```
|
||||
|
||||
**Gitea 地址**: http://175.178.72.171:3000/jowelin/zjpb.net.git
|
||||
|
||||
---
|
||||
|
||||
## 📝 待完成
|
||||
|
||||
- [ ] 服务器部署 v3.0.1
|
||||
- [ ] 测试后台获取新闻功能
|
||||
- [ ] 测试前台页面加载性能
|
||||
- [ ] 手动添加核心数据(网站、标签)
|
||||
- [ ] 考虑添加 Redis 缓存
|
||||
- [ ] 配置定时备份任务
|
||||
|
||||
---
|
||||
|
||||
## 📌 附录:URL 别名(slug)说明
|
||||
|
||||
**当前状态**: 系统未使用 slug 功能
|
||||
|
||||
**Slug 用途**:
|
||||
- SEO 友好 URL(如 `/site/chatgpt` 代替 `/site/12345678`)
|
||||
- 当前系统使用 8 位数字编码(code)访问详情页
|
||||
- 以后如有需要可以启用
|
||||
|
||||
---
|
||||
|
||||
**开发人员**: Claude Sonnet 4.6
|
||||
**项目负责人**: lisacc
|
||||
**版本**: v3.0.1
|
||||
**工作日期**: 2026-03-13
|
||||
44
add_site_tags_indexes.py
Normal file
44
add_site_tags_indexes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
性能优化脚本 - 为 site_tags 表添加索引
|
||||
"""
|
||||
from app import create_app, db
|
||||
|
||||
app = create_app()
|
||||
|
||||
def add_index_safe(index_name, table_name, column_name):
|
||||
"""安全添加索引,如果已存在则跳过"""
|
||||
try:
|
||||
# 先检查索引是否存在
|
||||
result = db.session.execute(db.text(f"""
|
||||
SELECT COUNT(*) as cnt FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '{table_name}'
|
||||
AND index_name = '{index_name}'
|
||||
""")).fetchone()
|
||||
|
||||
if result[0] > 0:
|
||||
print(f"⊙ {index_name} 索引已存在,跳过")
|
||||
return
|
||||
|
||||
# 创建索引
|
||||
db.session.execute(db.text(f"""
|
||||
CREATE INDEX {index_name} ON {table_name}({column_name});
|
||||
"""))
|
||||
db.session.commit()
|
||||
print(f"✓ 已添加 {index_name} 索引")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"✗ 添加 {index_name} 失败: {e}")
|
||||
|
||||
with app.app_context():
|
||||
print("开始添加索引...\n")
|
||||
|
||||
# 为 site_tags 表添加索引
|
||||
add_index_safe('idx_site_tags_site_id', 'site_tags', 'site_id')
|
||||
add_index_safe('idx_site_tags_tag_id', 'site_tags', 'tag_id')
|
||||
|
||||
# 为 sites 表添加常用查询字段索引
|
||||
add_index_safe('idx_sites_is_active', 'sites', 'is_active')
|
||||
add_index_safe('idx_sites_view_count', 'sites', 'view_count')
|
||||
|
||||
print("\n索引添加完成!")
|
||||
562
app.py
562
app.py
@@ -8,9 +8,10 @@ from flask import Flask, render_template, redirect, url_for, request, flash, jso
|
||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||
from flask_admin import Admin, AdminIndexView, expose
|
||||
from flask_admin.contrib.sqla import ModelView
|
||||
from wtforms import fields
|
||||
from datetime import datetime, timedelta
|
||||
from config import config
|
||||
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection
|
||||
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate, User, Folder, Collection, ApiKey, Skill
|
||||
from utils.website_fetcher import WebsiteFetcher
|
||||
from utils.tag_generator import TagGenerator
|
||||
from utils.news_searcher import NewsSearcher
|
||||
@@ -82,6 +83,15 @@ def create_app(config_name='default'):
|
||||
return AdminModel.query.get(int(user_id))
|
||||
return None
|
||||
|
||||
# ========== 文档路由 ==========
|
||||
@app.route('/docs/<path:filename>')
|
||||
def serve_doc(filename):
|
||||
"""服务文档文件"""
|
||||
from flask import send_from_directory
|
||||
import os
|
||||
docs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docs')
|
||||
return send_from_directory(docs_dir, filename)
|
||||
|
||||
# ========== 前台路由 ==========
|
||||
@app.route('/')
|
||||
def index():
|
||||
@@ -110,7 +120,7 @@ def create_app(config_name='default'):
|
||||
search_query = request.args.get('q', '').strip()
|
||||
current_tab = request.args.get('tab', 'latest') # 默认为"最新"
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 100 # 每页显示100个站点
|
||||
per_page = 20 # 每页显示20个站点
|
||||
|
||||
selected_tag = None
|
||||
|
||||
@@ -163,17 +173,55 @@ def create_app(config_name='default'):
|
||||
pagination=pagination, tag_counts=tag_counts,
|
||||
current_tab=current_tab)
|
||||
|
||||
# ========== Skills 前台路由 ==========
|
||||
@app.route('/skills')
|
||||
def skills_list():
|
||||
"""Skills 列表页"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
|
||||
# 获取推荐的和所有的 Skills
|
||||
featured_skills = Skill.query.filter_by(is_active=True, is_featured=True).order_by(Skill.sort_order.desc()).all()
|
||||
pagination = Skill.query.filter_by(is_active=True).order_by(Skill.sort_order.desc(), Skill.created_at.desc()).paginate(
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
return render_template('skills/list.html',
|
||||
featured_skills=featured_skills,
|
||||
skills=pagination.items,
|
||||
pagination=pagination)
|
||||
|
||||
@app.route('/skills/<slug>')
|
||||
def skill_detail(slug):
|
||||
"""Skill 详情页"""
|
||||
skill = Skill.query.filter_by(slug=slug, is_active=True).first_or_404()
|
||||
|
||||
# 更新浏览次数
|
||||
try:
|
||||
db.session.execute(
|
||||
db.text("UPDATE skills SET view_count = view_count + 1 WHERE id = :id"),
|
||||
{"id": skill.id}
|
||||
)
|
||||
db.session.commit()
|
||||
except:
|
||||
db.session.rollback()
|
||||
|
||||
return render_template('skills/detail.html', skill=skill)
|
||||
|
||||
@app.route('/site/<code>')
|
||||
def site_detail(code):
|
||||
"""网站详情页"""
|
||||
"""网站详情页 - v3.1性能优化"""
|
||||
site = Site.query.filter_by(code=code, is_active=True).first_or_404()
|
||||
|
||||
# 增加浏览次数
|
||||
site.view_count += 1
|
||||
db.session.commit()
|
||||
|
||||
# v2.6优化:移除自动调用博查API的逻辑,改为按需加载
|
||||
# 只获取数据库中已有的新闻,不再自动调用API
|
||||
# 异步更新浏览次数(不等待提交完成)
|
||||
try:
|
||||
db.session.execute(
|
||||
db.text("UPDATE sites SET view_count = view_count + 1 WHERE id = :id"),
|
||||
{"id": site.id}
|
||||
)
|
||||
db.session.commit()
|
||||
except:
|
||||
db.session.rollback()
|
||||
|
||||
# 获取该网站的相关新闻(最多显示5条)
|
||||
news_list = News.query.filter_by(
|
||||
@@ -181,17 +229,20 @@ def create_app(config_name='default'):
|
||||
is_active=True
|
||||
).order_by(News.published_at.desc()).limit(5).all()
|
||||
|
||||
# 检查是否有新闻,如果没有则标记需要加载
|
||||
# 检查是否有新闻
|
||||
has_news = len(news_list) > 0
|
||||
|
||||
# 获取同类工具推荐(通过标签匹配,最多显示4个)
|
||||
# 优化:获取同类工具推荐(使用JOIN代替子查询)
|
||||
recommended_sites = []
|
||||
if site.tags:
|
||||
# 获取有相同标签的其他网站
|
||||
recommended_sites = Site.query.filter(
|
||||
tag_ids = [tag.id for tag in site.tags]
|
||||
# 使用JOIN查询,性能更好
|
||||
recommended_sites = db.session.query(Site).join(
|
||||
site_tags, Site.id == site_tags.c.site_id
|
||||
).filter(
|
||||
Site.id != site.id,
|
||||
Site.is_active == True,
|
||||
Site.tags.any(Tag.id.in_([tag.id for tag in site.tags]))
|
||||
site_tags.c.tag_id.in_(tag_ids)
|
||||
).order_by(Site.view_count.desc()).limit(4).all()
|
||||
|
||||
return render_template('detail_new.html', site=site, news_list=news_list, has_news=has_news, recommended_sites=recommended_sites)
|
||||
@@ -376,6 +427,322 @@ def create_app(config_name='default'):
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ========== 后台管理获取新闻API (v3.0.1新增) ==========
|
||||
@app.route('/api/admin/fetch-news/<code>', methods=['POST'])
|
||||
@login_required
|
||||
def admin_fetch_news(code):
|
||||
"""
|
||||
后台手动获取指定网站的新闻(不需要验证码,仅管理员可用)
|
||||
"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'error': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
# 查找网站
|
||||
site = Site.query.filter_by(code=code).first_or_404()
|
||||
|
||||
# 检查博查API配置
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({'success': False, 'error': '博查API未配置'}), 500
|
||||
|
||||
# 创建新闻搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 获取新闻(限制5条,一周内的)
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
news_keywords=site.news_keywords,
|
||||
count=5,
|
||||
freshness='oneWeek'
|
||||
)
|
||||
|
||||
# 保存新闻到数据库
|
||||
new_count = 0
|
||||
if news_items:
|
||||
for item in news_items:
|
||||
# 检查是否已存在(根据URL去重)
|
||||
existing = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
news = News(
|
||||
site_id=site.id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type=item.get('news_type', 'Industry News')
|
||||
)
|
||||
db.session.add(news)
|
||||
new_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'new_count': new_count,
|
||||
'message': f'成功获取{new_count}条新资讯' if new_count > 0 else '暂无新资讯'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"后台获取新闻失败:{str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
# ========== 获取网站信息API (v3.0.1新增) ==========
|
||||
@app.route('/admin/site/api/<int:site_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_site_info(site_id):
|
||||
"""
|
||||
通过网站ID获取网站基本信息(仅管理员可用)
|
||||
"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'error': '无权访问'}), 403
|
||||
|
||||
site = Site.query.get(site_id)
|
||||
if not site:
|
||||
return jsonify({'error': '网站不存在'}), 404
|
||||
|
||||
return jsonify({
|
||||
'id': site.id,
|
||||
'code': site.code,
|
||||
'name': site.name,
|
||||
'url': site.url,
|
||||
'news_keywords': site.news_keywords
|
||||
})
|
||||
|
||||
|
||||
# ========== API Key 认证路由 ==========
|
||||
def require_api_key(permissions=None):
|
||||
"""API Key 认证装饰器
|
||||
|
||||
Args:
|
||||
permissions: 必需的权限列表,如 ['site:read', 'site:write']
|
||||
"""
|
||||
def decorator(f):
|
||||
from functools import wraps
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# 从请求头获取 API Key
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
|
||||
if not api_key:
|
||||
return jsonify({'success': False, 'message': '缺少 API Key'}), 401
|
||||
|
||||
# 查找 API Key
|
||||
key_obj = ApiKey.query.filter_by(key=api_key).first()
|
||||
|
||||
if not key_obj:
|
||||
return jsonify({'success': False, 'message': '无效的 API Key'}), 401
|
||||
|
||||
if not key_obj.is_active:
|
||||
return jsonify({'success': False, 'message': 'API Key 已禁用'}), 401
|
||||
|
||||
# 检查权限
|
||||
if permissions:
|
||||
for perm in permissions:
|
||||
if not key_obj.has_permission(perm):
|
||||
return jsonify({'success': False, 'message': f'权限不足,需要 {perm} 权限'}), 403
|
||||
|
||||
# 更新使用记录
|
||||
key_obj.last_used = datetime.now()
|
||||
key_obj.usage_count = (key_obj.usage_count or 0) + 1
|
||||
db.session.commit()
|
||||
|
||||
# 将 key_obj 传递给被装饰的函数
|
||||
kwargs['api_key_obj'] = key_obj
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
@app.route('/api/key/sites', methods=['GET'])
|
||||
@require_api_key(['site:read'])
|
||||
def list_sites_via_api(**kwargs):
|
||||
"""通过 API Key 列出所有网站"""
|
||||
try:
|
||||
sites = Site.query.order_by(Site.created_at.desc()).all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'sites': [site.to_dict() for site in sites]
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/key/sites', methods=['POST'])
|
||||
@require_api_key(['site:write'])
|
||||
def create_site_via_api(**kwargs):
|
||||
"""通过 API Key 创建网站"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# 必填字段验证
|
||||
name = data.get('name', '').strip()
|
||||
url = data.get('url', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': '请提供网站名称'}), 400
|
||||
|
||||
if not url:
|
||||
return jsonify({'success': False, 'message': '请提供网站 URL'}), 400
|
||||
|
||||
# 创建网站
|
||||
site = Site()
|
||||
site.name = name
|
||||
site.url = url
|
||||
site.slug = data.get('slug', '').strip() or None
|
||||
site.logo = data.get('logo', '').strip() or None
|
||||
site.short_desc = data.get('short_desc', '').strip() or None
|
||||
site.description = data.get('description', '').strip() or None
|
||||
site.features = data.get('features', '').strip() or None
|
||||
site.news_keywords = data.get('news_keywords', '').strip() or None
|
||||
site.is_active = data.get('is_active', True)
|
||||
site.is_recommended = data.get('is_recommended', False)
|
||||
site.sort_order = data.get('sort_order', 0)
|
||||
|
||||
# 处理标签
|
||||
tags_data = data.get('tags', [])
|
||||
if tags_data:
|
||||
for tag_name in tags_data:
|
||||
if isinstance(tag_name, str):
|
||||
tag_name = tag_name.strip()
|
||||
if tag_name:
|
||||
# 查找或创建标签
|
||||
tag = Tag.query.filter_by(name=tag_name).first()
|
||||
if not tag:
|
||||
# 创建新标签(简化处理,slug 从 name 生成)
|
||||
import re
|
||||
from pypinyin import lazy_pinyin
|
||||
tag_slug = ''.join(lazy_pinyin(tag_name)).lower()
|
||||
tag_slug = re.sub(r'[^\w\s-]', '', tag_slug)
|
||||
tag_slug = re.sub(r'[-\s]+', '-', tag_slug).strip('-')[:50]
|
||||
tag = Tag(name=tag_name, slug=tag_slug)
|
||||
db.session.add(tag)
|
||||
if tag not in site.tags:
|
||||
site.tags.append(tag)
|
||||
|
||||
db.session.add(site)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'site': site.to_dict()
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/key/sites/<code>', methods=['GET'])
|
||||
@require_api_key(['site:read'])
|
||||
def get_site_via_api(code, **kwargs):
|
||||
"""通过 API Key 获取单个网站信息"""
|
||||
site = Site.query.filter_by(code=code).first()
|
||||
if not site:
|
||||
return jsonify({'success': False, 'message': '网站不存在'}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'site': site.to_dict()
|
||||
})
|
||||
|
||||
@app.route('/api/key/sites/<code>', methods=['PUT'])
|
||||
@require_api_key(['site:write'])
|
||||
def update_site_via_api(code, **kwargs):
|
||||
"""通过 API Key 更新网站"""
|
||||
site = Site.query.filter_by(code=code).first()
|
||||
if not site:
|
||||
return jsonify({'success': False, 'message': '网站不存在'}), 404
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# 更新字段
|
||||
if 'name' in data:
|
||||
site.name = data['name'].strip()
|
||||
if 'url' in data:
|
||||
site.url = data['url'].strip()
|
||||
if 'slug' in data:
|
||||
site.slug = data['slug'].strip() if data['slug'] else None
|
||||
if 'logo' in data:
|
||||
site.logo = data['logo'].strip() if data['logo'] else None
|
||||
if 'short_desc' in data:
|
||||
site.short_desc = data['short_desc'].strip() if data['short_desc'] else None
|
||||
if 'description' in data:
|
||||
site.description = data['description'].strip() if data['description'] else None
|
||||
if 'features' in data:
|
||||
site.features = data['features'].strip() if data['features'] else None
|
||||
if 'news_keywords' in data:
|
||||
site.news_keywords = data['news_keywords'].strip() if data['news_keywords'] else None
|
||||
if 'is_active' in data:
|
||||
site.is_active = data['is_active']
|
||||
if 'is_recommended' in data:
|
||||
site.is_recommended = data['is_recommended']
|
||||
if 'sort_order' in data:
|
||||
site.sort_order = data['sort_order']
|
||||
|
||||
# 处理标签
|
||||
if 'tags' in data:
|
||||
site.tags = []
|
||||
tags_data = data['tags']
|
||||
if tags_data:
|
||||
import re
|
||||
from pypinyin import lazy_pinyin
|
||||
for tag_name in tags_data:
|
||||
if isinstance(tag_name, str):
|
||||
tag_name = tag_name.strip()
|
||||
if tag_name:
|
||||
tag = Tag.query.filter_by(name=tag_name).first()
|
||||
if not tag:
|
||||
tag_slug = ''.join(lazy_pinyin(tag_name)).lower()
|
||||
tag_slug = re.sub(r'[^\w\s-]', '', tag_slug)
|
||||
tag_slug = re.sub(r'[-\s]+', '-', tag_slug).strip('-')[:50]
|
||||
tag = Tag(name=tag_name, slug=tag_slug)
|
||||
db.session.add(tag)
|
||||
if tag not in site.tags:
|
||||
site.tags.append(tag)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'site': site.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
@app.route('/api/key/sites/<code>', methods=['DELETE'])
|
||||
@require_api_key(['site:write'])
|
||||
def delete_site_via_api(code, **kwargs):
|
||||
"""通过 API Key 删除网站"""
|
||||
site = Site.query.filter_by(code=code).first()
|
||||
if not site:
|
||||
return jsonify({'success': False, 'message': '网站不存在'}), 404
|
||||
|
||||
try:
|
||||
db.session.delete(site)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '网站已删除'
|
||||
})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ========== 社媒营销路由 (v2.5新增) ==========
|
||||
@app.route('/api/generate-social-share', methods=['POST'])
|
||||
@@ -1542,6 +1909,56 @@ def create_app(config_name='default'):
|
||||
'message': f'抓取失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/api/fetch-skill-info', methods=['POST'])
|
||||
@login_required
|
||||
def fetch_skill_info():
|
||||
"""抓取 Skill 信息(GitHub 仓库)"""
|
||||
# 只允许管理员访问
|
||||
if not isinstance(current_user, AdminModel):
|
||||
return jsonify({'success': False, 'message': '无权访问'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
url = data.get('url', '').strip()
|
||||
|
||||
if not url:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '请提供 GitHub 仓库URL'
|
||||
}), 400
|
||||
|
||||
# 创建抓取器
|
||||
fetcher = WebsiteFetcher(timeout=15)
|
||||
|
||||
# 抓取 Skill 信息
|
||||
info = fetcher.fetch_skill_info(url)
|
||||
|
||||
if not info:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '无法获取 Skill 信息,请检查 URL 是否为有效的 GitHub 仓库'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'name': info.get('name', ''),
|
||||
'short_desc': info.get('short_desc', ''),
|
||||
'description': info.get('description', ''),
|
||||
'github_url': info.get('github_url', ''),
|
||||
'source_repo': info.get('source_repo', ''),
|
||||
'source_type': 'github',
|
||||
'usage': info.get('usage', ''),
|
||||
'examples': info.get('examples', '')
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'抓取失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/api/upload-logo', methods=['POST'])
|
||||
@login_required
|
||||
def upload_logo():
|
||||
@@ -2757,6 +3174,55 @@ Sitemap: {}sitemap.xml
|
||||
|
||||
return self.render('admin/index.html', stats=stats, recent_sites=recent_sites)
|
||||
|
||||
# API Key 管理视图
|
||||
class ApiKeyAdmin(SecureModelView):
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
can_create = True
|
||||
can_view_details = False
|
||||
can_set_page_size = True
|
||||
page_size = 20
|
||||
|
||||
column_display_actions = True
|
||||
|
||||
column_list = ['id', 'name', 'key', 'permissions', 'is_active', 'usage_count', 'last_used', 'created_at']
|
||||
column_default_sort = ('created_at', True)
|
||||
column_searchable_list = ['name', 'key']
|
||||
column_filters = ['is_active']
|
||||
column_labels = {
|
||||
'id': 'ID',
|
||||
'name': '密钥名称',
|
||||
'key': 'API Key',
|
||||
'permissions': '权限',
|
||||
'is_active': '是否启用',
|
||||
'usage_count': '使用次数',
|
||||
'last_used': '最后使用',
|
||||
'created_at': '创建时间'
|
||||
}
|
||||
|
||||
# 表单配置
|
||||
form_columns = ['name', 'permissions', 'is_active']
|
||||
|
||||
form_extra_fields = {
|
||||
'new_key': fields.StringField('新密钥(留空则自动生成)')
|
||||
}
|
||||
|
||||
def on_model_change(self, form, model, is_created):
|
||||
"""创建时自动生成 API Key"""
|
||||
if is_created:
|
||||
# 生成 64 位随机密钥
|
||||
model.key = secrets.token_urlsafe(32) # 生成约 43 字符,改用 hex
|
||||
import secrets as sec
|
||||
model.key = sec.token_hex(32) # 64 位十六进制字符串
|
||||
|
||||
def _form_validate(self, form):
|
||||
"""表单验证"""
|
||||
return super()._form_validate(form)
|
||||
|
||||
# 自定义列表模板,隐藏 key 字段但在创建时显示
|
||||
list_template = 'admin/apikey/list.html'
|
||||
create_template = 'admin/apikey/create.html'
|
||||
|
||||
# 网站管理视图
|
||||
class SiteAdmin(SecureModelView):
|
||||
# 自定义模板
|
||||
@@ -2999,6 +3465,72 @@ Sitemap: {}sitemap.xml
|
||||
db.orm.joinedload(News.site)
|
||||
)
|
||||
|
||||
# Skills管理视图
|
||||
class SkillAdmin(SecureModelView):
|
||||
create_template = 'admin/skill/create.html'
|
||||
can_edit = True
|
||||
can_delete = True
|
||||
can_create = True
|
||||
can_view_details = False
|
||||
can_set_page_size = True
|
||||
page_size = 20
|
||||
|
||||
column_display_actions = True
|
||||
|
||||
column_list = ['id', 'name', 'slug', 'short_desc', 'source_type', 'is_active', 'is_featured', 'view_count', 'created_at']
|
||||
column_default_sort = ('created_at', True)
|
||||
column_searchable_list = ['name', 'slug', 'description']
|
||||
column_filters = ['is_active', 'is_featured', 'source_type']
|
||||
column_labels = {
|
||||
'id': 'ID',
|
||||
'name': 'Skill名称',
|
||||
'slug': 'URL别名',
|
||||
'short_desc': '简短描述',
|
||||
'description': '详细介绍',
|
||||
'logo': 'Logo',
|
||||
'github_url': 'GitHub链接',
|
||||
'source_type': '来源类型',
|
||||
'source_repo': '来源仓库',
|
||||
'usage': '使用方法',
|
||||
'examples': '使用示例',
|
||||
'is_active': '是否启用',
|
||||
'is_featured': '是否推荐',
|
||||
'view_count': '浏览次数',
|
||||
'sort_order': '排序权重',
|
||||
'created_at': '创建时间',
|
||||
'updated_at': '更新时间'
|
||||
}
|
||||
form_columns = ['name', 'slug', 'short_desc', 'description', 'logo', 'github_url', 'source_type', 'source_repo', 'usage', 'examples', 'is_active', 'is_featured', 'sort_order']
|
||||
|
||||
def on_model_change(self, form, model, is_created):
|
||||
"""保存前自动生成slug(如果为空)"""
|
||||
import re
|
||||
from pypinyin import lazy_pinyin
|
||||
|
||||
# 如果slug为空,从name自动生成
|
||||
if not model.slug or model.slug.strip() == '':
|
||||
slug = ''.join(lazy_pinyin(model.name))
|
||||
slug = slug.lower()
|
||||
slug = re.sub(r'[^\w\s-]', '', slug)
|
||||
slug = re.sub(r'[-\s]+', '-', slug).strip('-')
|
||||
|
||||
if not slug:
|
||||
slug = f"skill-{model.id}"
|
||||
|
||||
# 确保slug唯一
|
||||
base_slug = slug[:50]
|
||||
counter = 1
|
||||
final_slug = slug
|
||||
|
||||
while counter < 100:
|
||||
existing = Skill.query.filter(Skill.slug == final_slug).first()
|
||||
if not existing or existing.id == model.id:
|
||||
break
|
||||
final_slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
model.slug = final_slug
|
||||
|
||||
# Prompt模板管理视图
|
||||
class PromptAdmin(SecureModelView):
|
||||
can_edit = True
|
||||
@@ -3054,6 +3586,8 @@ Sitemap: {}sitemap.xml
|
||||
)
|
||||
|
||||
admin.add_view(SiteAdmin(Site, db.session, name='网站管理'))
|
||||
admin.add_view(ApiKeyAdmin(ApiKey, db.session, name='API密钥', endpoint='api_keys'))
|
||||
admin.add_view(SkillAdmin(Skill, db.session, name='Skills管理', endpoint='skills'))
|
||||
admin.add_view(TagAdmin(Tag, db.session, name='标签管理'))
|
||||
admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
|
||||
admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))
|
||||
|
||||
325
docs/API_DOC.md
Normal file
325
docs/API_DOC.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# ZJPB API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
ZJPB 网站管理 API,通过 API Key 进行认证,可用于外部工具(如 OpenClaw)调用。
|
||||
|
||||
## 基础信息
|
||||
|
||||
- **Base URL**: `http://175.178.72.171`
|
||||
- **认证方式**: Header (`X-API-Key`)
|
||||
- **数据格式**: JSON
|
||||
|
||||
## 认证
|
||||
|
||||
所有 API 请求需要在 Header 中携带 API Key:
|
||||
|
||||
```
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
获取 API Key:进入后台管理 → API密钥 → 创建新密钥
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 获取网站列表
|
||||
|
||||
```http
|
||||
GET /api/key/sites
|
||||
```
|
||||
|
||||
**权限**: `site:read`
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sites": [
|
||||
{
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example",
|
||||
"logo": "/uploads/logo.png",
|
||||
"short_desc": "简短描述",
|
||||
"description": "详细介绍",
|
||||
"features": "主要功能",
|
||||
"news_keywords": "关键词",
|
||||
"is_active": true,
|
||||
"is_recommended": false,
|
||||
"view_count": 100,
|
||||
"tags": ["标签1", "标签2"],
|
||||
"created_at": "2026-03-23 10:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建网站
|
||||
|
||||
```http
|
||||
POST /api/key/sites
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example",
|
||||
"logo": "/uploads/logo.png",
|
||||
"short_desc": "简短描述",
|
||||
"description": "详细介绍",
|
||||
"features": "主要功能",
|
||||
"news_keywords": "关键词",
|
||||
"tags": ["标签1", "标签2"],
|
||||
"is_active": true,
|
||||
"is_recommended": false,
|
||||
"sort_order": 0
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| name | 是 | 网站名称 |
|
||||
| url | 是 | 网站 URL |
|
||||
| slug | 否 | URL别名,用于 SEO |
|
||||
| logo | 否 | Logo 图片路径 |
|
||||
| short_desc | 否 | 简短描述 |
|
||||
| description | 否 | 详细介绍 |
|
||||
| features | 否 | 主要功能 |
|
||||
| news_keywords | 否 | 新闻关键词 |
|
||||
| tags | 否 | 标签数组 |
|
||||
| is_active | 否 | 是否启用,默认 true |
|
||||
| is_recommended | 否 | 是否推荐,默认 false |
|
||||
| sort_order | 否 | 排序权重,默认 0 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 2,
|
||||
"code": "87654321",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
"slug": "example"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取单个网站
|
||||
|
||||
```http
|
||||
GET /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:read`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "网站名称",
|
||||
"url": "https://example.com",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 更新网站
|
||||
|
||||
```http
|
||||
PUT /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**请求体**: 同创建网站,可只传需要更新的字段
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"site": {
|
||||
"id": 1,
|
||||
"code": "12345678",
|
||||
"name": "新名称",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 删除网站
|
||||
|
||||
```http
|
||||
DELETE /api/key/sites/{code}
|
||||
```
|
||||
|
||||
**权限**: `site:write`
|
||||
|
||||
**参数**:
|
||||
- `code`: 网站编码(8位数字)
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "网站已删除"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误信息"
|
||||
}
|
||||
```
|
||||
|
||||
**状态码**:
|
||||
- `200`: 成功
|
||||
- `400`: 请求参数错误
|
||||
- `401`: 认证失败(无效的 API Key)
|
||||
- `403`: 权限不足
|
||||
- `404`: 资源不存在
|
||||
- `500`: 服务器错误
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 获取网站列表
|
||||
curl -X GET http://175.178.72.171/api/key/sites \
|
||||
-H "X-API-Key: your-api-key"
|
||||
|
||||
# 创建网站
|
||||
curl -X POST http://175.178.72.171/api/key/sites \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{
|
||||
"name": "示例网站",
|
||||
"url": "https://example.com",
|
||||
"short_desc": "这是一个示例网站",
|
||||
"tags": ["科技", "开源"]
|
||||
}'
|
||||
|
||||
# 更新网站
|
||||
curl -X PUT http://175.178.72.171/api/key/sites/12345678 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-d '{"name": "新名称"}'
|
||||
|
||||
# 删除网站
|
||||
curl -X DELETE http://175.178.72.171/api/key/sites/12345678 \
|
||||
-H "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = "your-api-key"
|
||||
BASE_URL = "http://175.178.72.171"
|
||||
headers = {"X-API-Key": API_KEY}
|
||||
|
||||
# 获取网站列表
|
||||
response = requests.get(f"{BASE_URL}/api/key/sites", headers=headers)
|
||||
print(response.json())
|
||||
|
||||
# 创建网站
|
||||
data = {
|
||||
"name": "示例网站",
|
||||
"url": "https://example.com",
|
||||
"short_desc": "这是一个示例网站",
|
||||
"tags": ["科技", "开源"]
|
||||
}
|
||||
response = requests.post(f"{BASE_URL}/api/key/sites", json=data, headers=headers)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```javascript
|
||||
const API_KEY = "your-api-key";
|
||||
const BASE_URL = "http://175.178.72.171";
|
||||
const headers = { "X-API-Key": API_KEY };
|
||||
|
||||
// 获取网站列表
|
||||
fetch(`${BASE_URL}/api/key/sites`, { headers })
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
|
||||
// 创建网站
|
||||
fetch(`${BASE_URL}/api/key/sites`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "示例网站",
|
||||
url: "https://example.com",
|
||||
short_desc: "这是一个示例网站"
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => console.log(data));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenClaw 集成
|
||||
|
||||
在 OpenClaw 中配置 API:
|
||||
|
||||
1. **Base URL**: `http://175.178.72.171`
|
||||
2. **Auth Header**: `X-API-Key`
|
||||
3. **Auth Value**: 你创建的 API Key
|
||||
|
||||
创建网站的 prompt 示例:
|
||||
|
||||
```
|
||||
你是一个网站发布助手。请根据用户提供的网站信息,调用 ZJPB API 创建网站。
|
||||
|
||||
网站信息:
|
||||
- 名称:{name}
|
||||
- URL:{url}
|
||||
- 描述:{description}
|
||||
|
||||
请调用 POST /api/key/sites 接口创建网站。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-03-23
|
||||
27
migrations/add_api_keys_table.py
Normal file
27
migrations/add_api_keys_table.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
API Keys 表迁移脚本
|
||||
|
||||
执行方式: python migrations/add_api_keys_table.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from models import ApiKey
|
||||
|
||||
|
||||
def create_api_keys_table():
|
||||
"""创建 api_keys 表"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
# 直接创建表
|
||||
db.create_all()
|
||||
print('api_keys 表创建成功')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_api_keys_table()
|
||||
26
migrations/add_skills_table.py
Normal file
26
migrations/add_skills_table.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Skills 表迁移脚本
|
||||
|
||||
执行方式: python migrations/add_skills_table.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from models import Skill
|
||||
|
||||
|
||||
def create_skills_table():
|
||||
"""创建 skills 表"""
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print('skills 表创建成功')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_skills_table()
|
||||
88
models.py
88
models.py
@@ -155,6 +155,46 @@ class Admin(UserMixin, db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Admin {self.username}>'
|
||||
|
||||
|
||||
class ApiKey(db.Model):
|
||||
"""API密钥模型"""
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
key = db.Column(db.String(64), unique=True, nullable=False, comment='API密钥')
|
||||
name = db.Column(db.String(50), nullable=False, comment='密钥名称')
|
||||
permissions = db.Column(db.String(200), default='site:read,site:write', comment='权限列表')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
last_used = db.Column(db.DateTime, comment='最后使用时间')
|
||||
usage_count = db.Column(db.Integer, default=0, comment='使用次数')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ApiKey {self.name}>'
|
||||
|
||||
def has_permission(self, permission):
|
||||
"""检查是否拥有指定权限"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
perms = self.permissions.split(',') if self.permissions else []
|
||||
return permission.strip() in [p.strip() for p in perms]
|
||||
|
||||
def to_dict(self, include_key=False):
|
||||
"""转换为字典"""
|
||||
result = {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'permissions': self.permissions,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'last_used': self.last_used.strftime('%Y-%m-%d %H:%M:%S') if self.last_used else None,
|
||||
'usage_count': self.usage_count
|
||||
}
|
||||
if include_key:
|
||||
result['key'] = self.key
|
||||
return result
|
||||
|
||||
|
||||
class PromptTemplate(db.Model):
|
||||
"""AI提示词模板模型"""
|
||||
__tablename__ = 'prompt_templates'
|
||||
@@ -271,3 +311,51 @@ class Collection(db.Model):
|
||||
def __repr__(self):
|
||||
return f'<Collection user={self.user_id} site={self.site_id}>'
|
||||
|
||||
|
||||
class Skill(db.Model):
|
||||
"""Skills 模型"""
|
||||
__tablename__ = 'skills'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, comment='Skill名称')
|
||||
slug = db.Column(db.String(100), unique=True, nullable=False, comment='URL别名')
|
||||
description = db.Column(db.Text, comment='详细介绍')
|
||||
short_desc = db.Column(db.String(200), comment='简短描述')
|
||||
logo = db.Column(db.String(500), comment='Logo图片路径')
|
||||
github_url = db.Column(db.String(500), comment='GitHub 链接')
|
||||
source_type = db.Column(db.String(20), default='manual', comment='来源类型: manual/github')
|
||||
source_repo = db.Column(db.String(200), comment='来源仓库')
|
||||
usage = db.Column(db.Text, comment='使用方法')
|
||||
examples = db.Column(db.Text, comment='使用示例')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
is_featured = db.Column(db.Boolean, default=False, comment='是否推荐')
|
||||
view_count = db.Column(db.Integer, default=0, comment='浏览次数')
|
||||
sort_order = db.Column(db.Integer, default=0, comment='排序权重')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Skill {self.name}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'slug': self.slug,
|
||||
'description': self.description,
|
||||
'short_desc': self.short_desc,
|
||||
'logo': self.logo,
|
||||
'github_url': self.github_url,
|
||||
'source_type': self.source_type,
|
||||
'source_repo': self.source_repo,
|
||||
'usage': self.usage,
|
||||
'examples': self.examples,
|
||||
'is_active': self.is_active,
|
||||
'is_featured': self.is_featured,
|
||||
'view_count': self.view_count,
|
||||
'sort_order': self.sort_order,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
15
templates/admin/apikey/create.html
Normal file
15
templates/admin/apikey/create.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends 'admin/model/create.html' %}
|
||||
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 创建成功后显示 API Key
|
||||
{% if created_at and model and model.key %}
|
||||
setTimeout(function() {
|
||||
alert('API Key 已创建:\n\n{{ model.key }}\n\n请妥善保存,此密钥只会显示一次!');
|
||||
}, 500);
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
37
templates/admin/apikey/list.html
Normal file
37
templates/admin/apikey/list.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends 'admin/model/list.html' %}
|
||||
|
||||
{% block head_css %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.api-key-cell {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.api-key-masked {
|
||||
color: #6c757d;
|
||||
}
|
||||
.api-doc-link {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.api-doc-link a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.api-doc-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block list_cards %}
|
||||
{{ super() }}
|
||||
<div class="api-doc-link">
|
||||
<a href="{{ url_for('serve_doc', filename='API_DOC.md') }}" target="_blank">
|
||||
<span class="material-symbols-outlined" style="vertical-align: middle;">description</span>
|
||||
查看 API 文档
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -47,6 +47,18 @@
|
||||
<span class="nav-text">管理员</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'skill' %}active{% endif %}">
|
||||
<a href="{{ url_for('skills.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">handyman</span>
|
||||
<span class="nav-text">Skills管理</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if active_page == 'api_key' %}active{% endif %}">
|
||||
<a href="{{ url_for('api_keys.index_view') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">key</span>
|
||||
<span class="nav-text">API密钥</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
<span class="material-symbols-outlined">add_circle</span>
|
||||
<span>添加新工具</span>
|
||||
</a>
|
||||
<a href="{{ url_for('skills.create_view') }}" class="quick-action-btn">
|
||||
<span class="material-symbols-outlined">handyman</span>
|
||||
<span>添加Skill</span>
|
||||
</a>
|
||||
<a href="{{ url_for('tag.index_view') }}" class="quick-action-btn">
|
||||
<span class="material-symbols-outlined">label</span>
|
||||
<span>管理标签</span>
|
||||
|
||||
@@ -55,6 +55,31 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.fetch-news-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
.fetch-news-btn:hover {
|
||||
background-color: #cf9236;
|
||||
}
|
||||
.news-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.news-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.news-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -637,6 +662,137 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
featuresStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 在 News Keywords 字段后添加"获取新闻"按钮
|
||||
const newsKeywordsField = document.querySelector('input[name="news_keywords"]');
|
||||
if (newsKeywordsField) {
|
||||
// 创建获取新闻按钮
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsField.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsField.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 获取当前网站code(创建时没有code,需要保存后才有)
|
||||
// 这里使用一个提示
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
showNewsStatus('请先保存网站基本信息,保存后再点击此按钮获取新闻', 'error');
|
||||
});
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载后,如果有已保存的网站code,显示获取新闻按钮
|
||||
// 从URL中提取site_id来判断是否是编辑模式
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const view = urlParams.get('view');
|
||||
const siteId = urlParams.get('id');
|
||||
|
||||
// 如果是编辑模式,添加获取新闻功能
|
||||
if (siteId) {
|
||||
const newsKeywordsFieldEdit = document.querySelector('input[name="news_keywords"]');
|
||||
const existingBtn = document.querySelector('.fetch-news-btn');
|
||||
|
||||
if (newsKeywordsFieldEdit && !existingBtn) {
|
||||
// 创建获取新闻按钮(编辑模式)
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsFieldEdit.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsFieldEdit.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 获取网站code
|
||||
let siteCode = '';
|
||||
const codeField = document.querySelector('input[name="code"]');
|
||||
if (codeField) {
|
||||
siteCode = codeField.value;
|
||||
}
|
||||
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
if (!siteCode) {
|
||||
// 尝试从其他方式获取code
|
||||
const urlMatch = window.location.pathname.match(/\/admin\/site\/edit\/(\d+)/);
|
||||
if (urlMatch) {
|
||||
// 通过网站ID获取code(需要API支持,这里先提示)
|
||||
showNewsStatus('正在获取网站信息...', 'success');
|
||||
|
||||
// 调用API获取网站信息
|
||||
fetch('/admin/api/site/' + urlMatch[1], {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.code) {
|
||||
fetchNews(siteCode);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showNewsStatus('无法获取网站编码,请确保网站已保存', 'error');
|
||||
});
|
||||
} else {
|
||||
showNewsStatus('请先保存网站', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fetchNews(siteCode);
|
||||
});
|
||||
|
||||
function fetchNews(code) {
|
||||
// 显示加载状态
|
||||
fetchNewsBtn.disabled = true;
|
||||
fetchNewsBtn.classList.add('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="loading-icon">↻</span> 获取中...';
|
||||
newsStatusDiv.style.display = 'none';
|
||||
|
||||
// 调用后台获取新闻API
|
||||
fetch('/api/admin/fetch-news/' + code, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNewsStatus('✓ ' + data.message, 'success');
|
||||
} else {
|
||||
showNewsStatus('✗ ' + (data.error || '获取失败'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showNewsStatus('✗ 网络请求失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchNewsBtn.disabled = false;
|
||||
fetchNewsBtn.classList.remove('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
});
|
||||
}
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -55,6 +55,31 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.fetch-news-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
background-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
.fetch-news-btn:hover {
|
||||
background-color: #cf9236;
|
||||
}
|
||||
.news-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.news-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.news-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -658,6 +683,88 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
featuresStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// 在 News Keywords 字段后添加"获取新闻"按钮
|
||||
const newsKeywordsField = document.querySelector('input[name="news_keywords"]');
|
||||
if (newsKeywordsField) {
|
||||
// 检查是否已经添加过按钮
|
||||
if (document.querySelector('.fetch-news-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建获取新闻按钮
|
||||
const fetchNewsBtn = document.createElement('button');
|
||||
fetchNewsBtn.type = 'button';
|
||||
fetchNewsBtn.className = 'btn fetch-news-btn';
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
|
||||
const newsStatusDiv = document.createElement('div');
|
||||
newsStatusDiv.className = 'news-status';
|
||||
|
||||
newsKeywordsField.parentNode.appendChild(fetchNewsBtn);
|
||||
newsKeywordsField.parentNode.appendChild(newsStatusDiv);
|
||||
|
||||
// 从URL中获取网站ID
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const siteId = urlParams.get('id');
|
||||
|
||||
fetchNewsBtn.addEventListener('click', function() {
|
||||
if (!siteId) {
|
||||
showNewsStatus('无法获取网站ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
fetchNewsBtn.disabled = true;
|
||||
fetchNewsBtn.classList.add('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="loading-icon">↻</span> 获取中...';
|
||||
newsStatusDiv.style.display = 'none';
|
||||
|
||||
// 先获取网站信息(包含code)
|
||||
fetch('/admin/site/api/' + siteId, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data && data.code) {
|
||||
// 获取到code后再调用获取新闻API
|
||||
return fetch('/api/admin/fetch-news/' + data.code, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('无法获取网站信息');
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showNewsStatus('✓ ' + data.message, 'success');
|
||||
} else {
|
||||
showNewsStatus('✗ ' + (data.error || '获取失败'), 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showNewsStatus('✗ 网络请求失败: ' + error.message, 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchNewsBtn.disabled = false;
|
||||
fetchNewsBtn.classList.remove('loading');
|
||||
fetchNewsBtn.innerHTML = '<span class="normal-icon">📰</span><span class="loading-icon">↻</span> 手动获取新闻';
|
||||
});
|
||||
});
|
||||
|
||||
function showNewsStatus(message, type) {
|
||||
newsStatusDiv.textContent = message;
|
||||
newsStatusDiv.className = 'news-status ' + type;
|
||||
newsStatusDiv.style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
224
templates/admin/skill/create.html
Normal file
224
templates/admin/skill/create.html
Normal file
@@ -0,0 +1,224 @@
|
||||
{% extends 'admin/model/create.html' %}
|
||||
|
||||
{% block tail %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.auto-fetch-btn {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.fetch-status {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
.fetch-status.success {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.fetch-status.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
.logo-preview {
|
||||
margin-top: 10px;
|
||||
max-width: 100px;
|
||||
max-height: 100px;
|
||||
display: none;
|
||||
}
|
||||
.logo-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<h2>添加 Skill</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<div class="form-group">
|
||||
<label for="github_url">GitHub 仓库 URL <span style="color: #999; font-weight: normal;">(可选,填写后自动获取信息)</span></label>
|
||||
<div style="display: flex; gap: 10px; align-items: flex-start;">
|
||||
<input type="text" class="form-control" id="github_url" placeholder="例如: https://github.com/anthropics/claude-code" style="flex: 1;">
|
||||
<button type="button" class="auto-fetch-btn btn btn-info" id="fetchSkillBtn">
|
||||
<span class="material-symbols-outlined" style="vertical-align: middle;">auto_awesome</span>
|
||||
自动识别
|
||||
</button>
|
||||
</div>
|
||||
<div class="fetch-status" id="fetchStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Skill 名称 <span style="color: #e74c3c;">*</span></label>
|
||||
<input type="text" class="form-control" name="name" id="name" required placeholder="例如: PDF Helper">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="short_desc">简短描述</label>
|
||||
<input type="text" class="form-control" name="short_desc" id="short_desc" placeholder="一句话描述这个 Skill 的用途">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">详细介绍</label>
|
||||
<textarea class="form-control" name="description" id="description" rows="4" placeholder="详细介绍这个 Skill 的功能和使用场景"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="logo">Logo 图片路径</label>
|
||||
<input type="text" class="form-control" name="logo" id="logo" placeholder="/static/logos/xxx.png">
|
||||
<div class="logo-preview" id="logoPreview">
|
||||
<img id="logoImg" src="" alt="Logo预览">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="github_url_field">GitHub 链接</label>
|
||||
<input type="text" class="form-control" name="github_url" id="github_url_field" placeholder="https://github.com/...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source_type">来源类型</label>
|
||||
<select class="form-control" name="source_type" id="source_type">
|
||||
<option value="manual" selected>手动添加</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="source_repo">来源仓库</label>
|
||||
<input type="text" class="form-control" name="source_repo" id="source_repo" placeholder="owner/repo">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="usage">包含内容 / 应用场景</label>
|
||||
<textarea class="form-control" name="usage" id="usage" rows="4" placeholder="这个 skills 仓库主要包含哪些内容、适合什么场景"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="examples">来源链接 / 示例</label>
|
||||
<textarea class="form-control" name="examples" id="examples" rows="4" placeholder="主要来源链接或示例链接"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="is_active" id="is_active" checked>
|
||||
<label class="form-check-label" for="is_active">启用</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" name="is_featured" id="is_featured">
|
||||
<label class="form-check-label" for="is_featured">推荐</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sort_order">排序权重</label>
|
||||
<input type="number" class="form-control" name="sort_order" id="sort_order" value="0" placeholder="数字越大越靠前">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<a href="{{ url_for('skills.index_view') }}" class="btn btn-secondary">取消</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block tail2 %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fetchBtn = document.getElementById('fetchSkillBtn');
|
||||
const urlInput = document.getElementById('github_url');
|
||||
const statusDiv = document.getElementById('fetchStatus');
|
||||
const logoInput = document.getElementById('logo');
|
||||
const logoPreview = document.getElementById('logoPreview');
|
||||
const logoImg = document.getElementById('logoImg');
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusDiv.textContent = message;
|
||||
statusDiv.className = 'fetch-status ' + type;
|
||||
statusDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
if (logoInput) {
|
||||
logoInput.addEventListener('input', function() {
|
||||
if (this.value) {
|
||||
logoImg.src = this.value;
|
||||
logoPreview.style.display = 'block';
|
||||
} else {
|
||||
logoPreview.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (fetchBtn && urlInput) {
|
||||
fetchBtn.addEventListener('click', function() {
|
||||
const url = urlInput.value.trim();
|
||||
|
||||
if (!url) {
|
||||
showStatus('请先输入 GitHub 仓库 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!url.includes('github.com')) {
|
||||
showStatus('请输入有效的 GitHub 仓库 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchBtn.disabled = true;
|
||||
statusDiv.style.display = 'none';
|
||||
|
||||
fetch('/api/fetch-skill-info', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url: url })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
showStatus('✗ ' + (data.message || '获取失败,请手动填写'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const nameField = document.querySelector('input[name="name"]');
|
||||
const shortDescField = document.querySelector('input[name="short_desc"]');
|
||||
const descriptionField = document.querySelector('textarea[name="description"]');
|
||||
const githubUrlField = document.querySelector('input[name="github_url"]');
|
||||
const sourceRepoField = document.querySelector('input[name="source_repo"]');
|
||||
const sourceTypeField = document.querySelector('select[name="source_type"]');
|
||||
const usageField = document.querySelector('textarea[name="usage"]');
|
||||
const examplesField = document.querySelector('textarea[name="examples"]');
|
||||
|
||||
if (nameField && data.data.name) nameField.value = data.data.name;
|
||||
if (shortDescField && data.data.short_desc) shortDescField.value = data.data.short_desc.substring(0, 100);
|
||||
if (descriptionField && data.data.description) descriptionField.value = data.data.description;
|
||||
if (githubUrlField && data.data.github_url) githubUrlField.value = data.data.github_url;
|
||||
if (sourceRepoField && data.data.source_repo) sourceRepoField.value = data.data.source_repo;
|
||||
if (sourceTypeField && data.data.source_type) sourceTypeField.value = data.data.source_type;
|
||||
if (usageField && data.data.usage) usageField.value = data.data.usage;
|
||||
if (examplesField && data.data.examples) examplesField.value = data.data.examples;
|
||||
|
||||
showStatus('✓ Skill 信息获取成功!已自动填充标题、简介、应用场景和来源链接', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showStatus('✗ 网络请求失败,请手动填写', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
fetchBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -306,6 +306,7 @@
|
||||
<ul class="nav-links">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/#categories">Categories</a></li>
|
||||
<li><a href="/skills">Skills</a></li>
|
||||
<li><a href="/admin/login">Admin</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -800,9 +800,11 @@
|
||||
<span>📰</span>
|
||||
相关新闻
|
||||
</h2>
|
||||
<button id="refreshNewsBtn" class="refresh-news-btn" onclick="showCaptchaModal('{{ site.code }}')">
|
||||
<span class="refresh-icon">↻</span> <span class="btn-text">{% if has_news %}获取最新资讯{% else %}加载资讯{% endif %}</span>
|
||||
</button>
|
||||
{% if news_list %}
|
||||
<span style="font-size: 13px; color: var(--text-muted);">
|
||||
共 {{ news_list|length }} 条资讯
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="newsContainer">
|
||||
{% if news_list %}
|
||||
|
||||
215
templates/skills/detail.html
Normal file
215
templates/skills/detail.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends 'base_new.html' %}
|
||||
|
||||
{% block title %}{{ skill.name }} - Skills | ZJPB{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<meta name="description" content="{{ skill.short_desc or (skill.description[:150] if skill.description else '') }}">
|
||||
<meta name="keywords" content="{{ skill.name }},Skills,AI工具,自己品吧,ZJPB">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="{{ skill.name }} - Skills">
|
||||
<meta property="og:description" content="{{ skill.short_desc or skill.description[:150] }}">
|
||||
<meta property="og:url" content="{{ request.url }}">
|
||||
{% if skill.logo %}<meta property="og:image" content="{{ request.url_root.rstrip('/') }}{{ skill.logo }}">{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.skill-detail-header {
|
||||
padding: 40px 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.skill-detail-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.skill-detail-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 16px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skill-detail-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.skill-detail-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-detail-desc {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skill-detail-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skill-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.skill-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.skill-section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.skill-content-box {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.skill-content-box pre {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.skill-content-box code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: #24292e;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
background: #1b1f23;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: #0ea5e9;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="skill-detail-header">
|
||||
<div class="skill-detail-content">
|
||||
<a href="/skills" class="back-link" style="color: white;">
|
||||
← 返回 Skills 列表
|
||||
</a>
|
||||
<div class="skill-detail-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 class="skill-detail-title">{{ skill.name }}</h1>
|
||||
<p class="skill-detail-desc">{{ skill.short_desc or skill.description }}</p>
|
||||
<div class="skill-detail-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-meta-item">📦 来源: GitHub</span>
|
||||
{% if skill.source_repo %}
|
||||
<span class="skill-meta-item">{{ skill.source_repo }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="skill-meta-item">✏️ 来源: 手动添加</span>
|
||||
{% endif %}
|
||||
<span class="skill-meta-item">👁 浏览: {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-detail-content">
|
||||
{% if skill.description %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">📖 介绍</h2>
|
||||
<div class="skill-content-box">
|
||||
{{ skill.description | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.usage %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">🚀 使用方法</h2>
|
||||
<div class="skill-content-box">
|
||||
{{ skill.usage | replace('\n', '<br>') | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.examples %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">💡 使用示例</h2>
|
||||
<div class="skill-content-box">
|
||||
<pre>{{ skill.examples }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if skill.github_url %}
|
||||
<div class="skill-section">
|
||||
<h2 class="skill-section-title">🔗 GitHub</h2>
|
||||
<a href="{{ skill.github_url }}" target="_blank" class="github-link">
|
||||
<svg height="20" width="20" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
在 GitHub 查看
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
209
templates/skills/list.html
Normal file
209
templates/skills/list.html
Normal file
@@ -0,0 +1,209 @@
|
||||
{% extends 'base_new.html' %}
|
||||
|
||||
{% block title %}Skills - AI 工具技能库 | ZJPB{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<meta name="description" content="ZJPB Skills - 发现和使用各种 AI 工具 Skills,提升工作效率">
|
||||
<meta name="keywords" content="AI Skills,Claude Code,OpenClaw,工具技能,ZJPB">
|
||||
<link rel="canonical" href="{{ request.url }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.skills-hero {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.skills-hero h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skills-hero p {
|
||||
font-size: 18px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.featured-skills {
|
||||
padding: 0 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.featured-skills h2 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.skill-card-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skill-card-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.skill-card-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-card-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-card-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.skill-card-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="skills-hero">
|
||||
<div class="hero-content">
|
||||
<h1>🛠️ Skills</h1>
|
||||
<p>发现和使用各种 AI 工具 Skills,提升工作效率</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tools-section" style="max-width: 1280px; margin: 0 auto;">
|
||||
{% if featured_skills %}
|
||||
<div class="featured-skills">
|
||||
<h2>⭐ 推荐 Skills</h2>
|
||||
<div class="skills-grid">
|
||||
{% for skill in featured_skills %}
|
||||
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
|
||||
<div class="skill-card-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="skill-card-name">{{ skill.name }}</div>
|
||||
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
|
||||
<div class="skill-card-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-card-badge">📦 GitHub</span>
|
||||
{% else %}
|
||||
<span class="skill-card-badge">✏️ 手动添加</span>
|
||||
{% endif %}
|
||||
<span>👁 {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="featured-skills">
|
||||
<h2>📋 所有 Skills</h2>
|
||||
{% if skills %}
|
||||
<div class="skills-grid">
|
||||
{% for skill in skills %}
|
||||
<a href="{{ url_for('skill_detail', slug=skill.slug) }}" class="skill-card">
|
||||
<div class="skill-card-logo">
|
||||
{% if skill.logo %}
|
||||
<img src="{{ skill.logo }}" alt="{{ skill.name }}">
|
||||
{% else %}
|
||||
🛠️
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="skill-card-name">{{ skill.name }}</div>
|
||||
<div class="skill-card-desc">{{ skill.short_desc or skill.description[:100] }}</div>
|
||||
<div class="skill-card-meta">
|
||||
{% if skill.source_type == 'github' %}
|
||||
<span class="skill-card-badge">📦 GitHub</span>
|
||||
{% else %}
|
||||
<span class="skill-card-badge">✏️ 手动添加</span>
|
||||
{% endif %}
|
||||
<span>👁 {{ skill.view_count or 0 }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 60px 20px; color: #64748b;">
|
||||
<p style="font-size: 18px;">暂无 Skills</p>
|
||||
<p>请在后台添加 Skills</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="pagination" style="justify-content: center; margin-top: 40px; display: flex; gap: 8px;">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="?page={{ pagination.prev_num }}" class="page-btn">上一页</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in pagination.iter_pages(left_edge=2, right_edge=2, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
<a href="?page={{ page_num }}" class="page-btn {% if page_num == pagination.page %}active{% endif %}">{{ page_num }}</a>
|
||||
{% else %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if pagination.has_next %}
|
||||
<a href="?page={{ pagination.next_num }}" class="page-btn">下一页</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,7 @@ import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urljoin, urlparse
|
||||
import os
|
||||
import re
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
@@ -54,6 +55,243 @@ class WebsiteFetcher:
|
||||
print(f"抓取网站信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def fetch_skill_info(self, url):
|
||||
"""
|
||||
抓取 Skill 信息(GitHub 仓库)
|
||||
|
||||
Args:
|
||||
url: GitHub 仓库URL
|
||||
|
||||
Returns:
|
||||
dict: 包含name, short_desc, github_url, source_repo的字典
|
||||
"""
|
||||
try:
|
||||
# 解析 GitHub URL
|
||||
# 支持格式: https://github.com/user/repo 或 https://github.com/user/repo/tree/main
|
||||
parsed = urlparse(url)
|
||||
if 'github.com' not in parsed.netloc:
|
||||
return None
|
||||
|
||||
path_parts = [p for p in parsed.path.split('/') if p]
|
||||
if len(path_parts) < 2:
|
||||
return None
|
||||
|
||||
owner = path_parts[0]
|
||||
repo = path_parts[1].replace('.git', '')
|
||||
|
||||
# 获取仓库信息
|
||||
api_url = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
response = requests.get(api_url, headers=self.headers, timeout=self.timeout)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
repo_info = response.json()
|
||||
|
||||
# 获取 .claude 目录下的 skills
|
||||
skills_url = f"https://api.github.com/repos/{owner}/{repo}/contents/.claude/skills"
|
||||
skills_response = requests.get(skills_url, headers=self.headers, timeout=self.timeout)
|
||||
|
||||
skills = []
|
||||
if skills_response.status_code == 200:
|
||||
skills_data = skills_response.json()
|
||||
for item in skills_data:
|
||||
if item.get('type') == 'file' and item['name'].endswith('.md'):
|
||||
# 获取 skill 文件内容
|
||||
skill_content = requests.get(item['download_url'], headers=self.headers, timeout=self.timeout)
|
||||
if skill_content.status_code == 200:
|
||||
skill_info = self._parse_skill_file(
|
||||
item['name'],
|
||||
skill_content.text,
|
||||
item.get('html_url') or item.get('download_url') or ''
|
||||
)
|
||||
if skill_info:
|
||||
skills.append(skill_info)
|
||||
|
||||
summary = self._summarize_skills(repo_info, skills)
|
||||
|
||||
return {
|
||||
'name': summary.get('name') or repo_info.get('name', ''),
|
||||
'short_desc': summary.get('short_desc') or repo_info.get('description', ''),
|
||||
'description': summary.get('description', ''),
|
||||
'github_url': url,
|
||||
'source_repo': f"{owner}/{repo}",
|
||||
'usage': summary.get('usage', ''),
|
||||
'examples': summary.get('examples', ''),
|
||||
'skills': skills
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"抓取 Skill 信息失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_skill_file(self, filename, content, source_url=''):
|
||||
"""解析 skill 文件内容"""
|
||||
try:
|
||||
lines = [line.rstrip() for line in content.strip().split('\n')]
|
||||
fallback_name = filename.replace('.md', '')
|
||||
title = fallback_name
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('# '):
|
||||
title = line[2:].strip()
|
||||
break
|
||||
|
||||
sections = self._extract_markdown_sections(lines)
|
||||
|
||||
desc = self._pick_section(
|
||||
sections,
|
||||
['描述', '简介', 'description', 'overview', 'summary', 'what it does']
|
||||
)
|
||||
use_cases = self._pick_section(
|
||||
sections,
|
||||
['应用场景', '适用场景', '使用场景', 'use cases', 'when to use', 'use case']
|
||||
)
|
||||
usage = self._pick_section(
|
||||
sections,
|
||||
['使用方法', '用法', 'usage', 'how to use', 'quick start']
|
||||
)
|
||||
examples = self._pick_section(
|
||||
sections,
|
||||
['示例', 'examples', 'example']
|
||||
)
|
||||
|
||||
if not desc:
|
||||
desc = self._first_paragraph(lines)
|
||||
|
||||
content_outline = self._extract_outline(sections)
|
||||
|
||||
return {
|
||||
'name': title,
|
||||
'description': self._clean_text(desc)[:240],
|
||||
'use_cases': self._clean_text(use_cases)[:240],
|
||||
'usage': self._clean_text(usage)[:600],
|
||||
'examples': self._clean_text(examples)[:600],
|
||||
'content_outline': content_outline,
|
||||
'source_url': source_url
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _extract_markdown_sections(self, lines):
|
||||
sections = {}
|
||||
current = '__intro__'
|
||||
buffer = []
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if line.startswith('##'):
|
||||
sections[current] = '\n'.join(buffer).strip()
|
||||
current = re.sub(r'^#+\s*', '', line).strip().lower()
|
||||
buffer = []
|
||||
else:
|
||||
buffer.append(raw_line)
|
||||
|
||||
sections[current] = '\n'.join(buffer).strip()
|
||||
return sections
|
||||
|
||||
def _pick_section(self, sections, keywords):
|
||||
for key, value in sections.items():
|
||||
low = key.lower()
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in low and value.strip():
|
||||
return value.strip()
|
||||
return ''
|
||||
|
||||
def _first_paragraph(self, lines):
|
||||
paragraph = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith('#'):
|
||||
if paragraph:
|
||||
break
|
||||
continue
|
||||
if stripped.startswith('- ') or stripped.startswith('* ') or stripped.startswith('```'):
|
||||
continue
|
||||
paragraph.append(stripped)
|
||||
if len(' '.join(paragraph)) > 220:
|
||||
break
|
||||
return ' '.join(paragraph)
|
||||
|
||||
def _extract_outline(self, sections):
|
||||
outlines = []
|
||||
for key in sections.keys():
|
||||
if key != '__intro__':
|
||||
clean_key = key.strip()
|
||||
if clean_key:
|
||||
outlines.append(clean_key)
|
||||
return outlines[:8]
|
||||
|
||||
def _clean_text(self, text):
|
||||
if not text:
|
||||
return ''
|
||||
text = re.sub(r'```.*?```', '', text, flags=re.S)
|
||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||
text = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', text)
|
||||
text = re.sub(r'^[-*]\s+', '', text, flags=re.M)
|
||||
text = re.sub(r'\n{2,}', '\n', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
def _summarize_skills(self, repo_info, skills):
|
||||
if not skills:
|
||||
return {
|
||||
'name': repo_info.get('name', ''),
|
||||
'short_desc': repo_info.get('description', ''),
|
||||
'description': repo_info.get('description', ''),
|
||||
'usage': '',
|
||||
'examples': ''
|
||||
}
|
||||
|
||||
skill_names = [s['name'] for s in skills if s.get('name')]
|
||||
use_cases = [s['use_cases'] for s in skills if s.get('use_cases')]
|
||||
descs = [s['description'] for s in skills if s.get('description')]
|
||||
outlines = []
|
||||
for s in skills:
|
||||
outlines.extend(s.get('content_outline', []))
|
||||
|
||||
repo_desc = (repo_info.get('description') or '').strip()
|
||||
short_desc = repo_desc or (descs[0] if descs else '')
|
||||
|
||||
parts = []
|
||||
if repo_desc:
|
||||
parts.append(f"仓库简介:{repo_desc}")
|
||||
if skill_names:
|
||||
shown = '、'.join(skill_names[:5])
|
||||
more = f" 等 {len(skill_names)} 个 skill" if len(skill_names) > 5 else ''
|
||||
parts.append(f"包含内容:{shown}{more}")
|
||||
if use_cases:
|
||||
parts.append(f"应用场景:{use_cases[0]}")
|
||||
elif descs:
|
||||
parts.append(f"应用场景:{descs[0]}")
|
||||
if outlines:
|
||||
uniq = []
|
||||
for item in outlines:
|
||||
if item not in uniq:
|
||||
uniq.append(item)
|
||||
parts.append(f"文档涵盖:{'、'.join(uniq[:6])}")
|
||||
|
||||
usage_lines = []
|
||||
for s in skills[:5]:
|
||||
line = f"- {s['name']}"
|
||||
if s.get('use_cases'):
|
||||
line += f":{s['use_cases']}"
|
||||
elif s.get('description'):
|
||||
line += f":{s['description']}"
|
||||
usage_lines.append(line)
|
||||
|
||||
examples_lines = []
|
||||
for s in skills[:3]:
|
||||
if s.get('source_url'):
|
||||
examples_lines.append(f"{s['name']}:{s['source_url']}")
|
||||
|
||||
return {
|
||||
'name': repo_info.get('name', ''),
|
||||
'short_desc': short_desc[:180],
|
||||
'description': '\n'.join(parts)[:1200],
|
||||
'usage': '\n'.join(usage_lines)[:1200],
|
||||
'examples': '\n'.join(examples_lines)[:1200]
|
||||
}
|
||||
|
||||
def _extract_title(self, soup):
|
||||
"""提取网站标题"""
|
||||
# 优先使用 og:title
|
||||
@@ -80,89 +318,83 @@ class WebsiteFetcher:
|
||||
if meta_desc and meta_desc.get('content'):
|
||||
return meta_desc['content'].strip()
|
||||
|
||||
# 使用 meta keywords 作为fallback
|
||||
meta_keywords = soup.find('meta', attrs={'name': 'keywords'})
|
||||
if meta_keywords and meta_keywords.get('content'):
|
||||
return meta_keywords['content'].strip()
|
||||
|
||||
return ''
|
||||
|
||||
def _extract_logo(self, soup, base_url):
|
||||
"""提取网站Logo"""
|
||||
logo_url = None
|
||||
"""提取网站 Logo URL"""
|
||||
# 优先查找 favicon
|
||||
favicon = soup.find('link', rel='icon') or soup.find('link', rel='shortcut icon')
|
||||
if favicon and favicon.get('href'):
|
||||
return urljoin(base_url, favicon['href'])
|
||||
|
||||
# 1. 尝试 og:image
|
||||
og_image = soup.find('meta', property='og:image')
|
||||
if og_image and og_image.get('content'):
|
||||
logo_url = og_image['content']
|
||||
# 查找 apple-touch-icon
|
||||
apple_icon = soup.find('link', rel='apple-touch-icon')
|
||||
if apple_icon and apple_icon.get('href'):
|
||||
return urljoin(base_url, apple_icon['href'])
|
||||
|
||||
# 2. 尝试 link rel="icon" 或 "shortcut icon"
|
||||
if not logo_url:
|
||||
icon_link = soup.find('link', rel=lambda x: x and ('icon' in x.lower() if isinstance(x, str) else 'icon' in ' '.join(x).lower()))
|
||||
if icon_link and icon_link.get('href'):
|
||||
logo_url = icon_link['href']
|
||||
# 使用 /favicon.ico
|
||||
parsed = urlparse(base_url)
|
||||
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||
|
||||
# 3. 尝试 apple-touch-icon
|
||||
if not logo_url:
|
||||
apple_icon = soup.find('link', rel='apple-touch-icon')
|
||||
if apple_icon and apple_icon.get('href'):
|
||||
logo_url = apple_icon['href']
|
||||
|
||||
# 4. 默认使用 /favicon.ico
|
||||
if not logo_url:
|
||||
logo_url = '/favicon.ico'
|
||||
|
||||
# 转换为绝对URL
|
||||
if logo_url:
|
||||
logo_url = urljoin(base_url, logo_url)
|
||||
|
||||
return logo_url
|
||||
|
||||
def download_logo(self, logo_url, save_dir='static/uploads'):
|
||||
def download_logo(self, logo_url, save_dir='static/logos'):
|
||||
"""
|
||||
下载并保存Logo
|
||||
下载 Logo 到本地
|
||||
|
||||
Args:
|
||||
logo_url: Logo的URL
|
||||
logo_url: Logo URL
|
||||
save_dir: 保存目录
|
||||
|
||||
Returns:
|
||||
str: 保存后的相对路径,失败返回None
|
||||
str: 保存后的文件路径,失败返回 None
|
||||
"""
|
||||
if not logo_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 创建保存目录
|
||||
# 确保目录存在
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# 下载图片
|
||||
response = requests.get(logo_url, headers=self.headers, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
response = requests.get(logo_url, headers=self.headers, timeout=10, stream=True)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
# 检查是否是图片
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if not content_type.startswith('image/'):
|
||||
content_type = response.headers.get('Content-Type', '').lower()
|
||||
if 'image' not in content_type:
|
||||
return None
|
||||
|
||||
# 生成文件名
|
||||
parsed_url = urlparse(logo_url)
|
||||
ext = os.path.splitext(parsed_url.path)[1]
|
||||
if not ext or len(ext) > 5:
|
||||
ext = '.png' # 默认扩展名
|
||||
import time
|
||||
import hashlib
|
||||
ext = '.png'
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
ext = '.jpg'
|
||||
elif 'gif' in content_type:
|
||||
ext = '.gif'
|
||||
elif 'svg' in content_type:
|
||||
ext = '.svg'
|
||||
elif 'ico' in content_type:
|
||||
ext = '.ico'
|
||||
|
||||
# 使用域名作为文件名
|
||||
domain = parsed_url.netloc.replace(':', '_').replace('.', '_')
|
||||
filename = f"logo_{domain}{ext}"
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
hash_name = hashlib.md5(f"{logo_url}{timestamp}".encode()).hexdigest()[:16]
|
||||
filename = f"logo_{hash_name}{ext}"
|
||||
filepath = os.path.join(save_dir, filename)
|
||||
|
||||
# 保存图片
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
for chunk in response.iter_content(8192):
|
||||
f.write(chunk)
|
||||
|
||||
# 返回相对路径(用于数据库存储)
|
||||
return f'/{filepath.replace(os.sep, "/")}'
|
||||
# 验证图片是否有效
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
img.verify()
|
||||
return filepath
|
||||
except:
|
||||
# 图片无效,删除文件
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"下载Logo失败: {str(e)}")
|
||||
print(f"下载 Logo 失败: {str(e)}")
|
||||
return None
|
||||
Reference in New Issue
Block a user