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配置
|
||||||
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
DEEPSEEK_API_KEY=your_deepseek_api_key_here
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
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索引添加完成!")
|
||||||
560
app.py
560
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_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||||
from flask_admin import Admin, AdminIndexView, expose
|
from flask_admin import Admin, AdminIndexView, expose
|
||||||
from flask_admin.contrib.sqla import ModelView
|
from flask_admin.contrib.sqla import ModelView
|
||||||
|
from wtforms import fields
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from config import config
|
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.website_fetcher import WebsiteFetcher
|
||||||
from utils.tag_generator import TagGenerator
|
from utils.tag_generator import TagGenerator
|
||||||
from utils.news_searcher import NewsSearcher
|
from utils.news_searcher import NewsSearcher
|
||||||
@@ -82,6 +83,15 @@ def create_app(config_name='default'):
|
|||||||
return AdminModel.query.get(int(user_id))
|
return AdminModel.query.get(int(user_id))
|
||||||
return None
|
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('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
@@ -110,7 +120,7 @@ def create_app(config_name='default'):
|
|||||||
search_query = request.args.get('q', '').strip()
|
search_query = request.args.get('q', '').strip()
|
||||||
current_tab = request.args.get('tab', 'latest') # 默认为"最新"
|
current_tab = request.args.get('tab', 'latest') # 默认为"最新"
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = 100 # 每页显示100个站点
|
per_page = 20 # 每页显示20个站点
|
||||||
|
|
||||||
selected_tag = None
|
selected_tag = None
|
||||||
|
|
||||||
@@ -163,17 +173,55 @@ def create_app(config_name='default'):
|
|||||||
pagination=pagination, tag_counts=tag_counts,
|
pagination=pagination, tag_counts=tag_counts,
|
||||||
current_tab=current_tab)
|
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>')
|
@app.route('/site/<code>')
|
||||||
def site_detail(code):
|
def site_detail(code):
|
||||||
"""网站详情页"""
|
"""网站详情页 - v3.1性能优化"""
|
||||||
site = Site.query.filter_by(code=code, is_active=True).first_or_404()
|
site = Site.query.filter_by(code=code, is_active=True).first_or_404()
|
||||||
|
|
||||||
# 增加浏览次数
|
# 异步更新浏览次数(不等待提交完成)
|
||||||
site.view_count += 1
|
try:
|
||||||
|
db.session.execute(
|
||||||
|
db.text("UPDATE sites SET view_count = view_count + 1 WHERE id = :id"),
|
||||||
|
{"id": site.id}
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
except:
|
||||||
# v2.6优化:移除自动调用博查API的逻辑,改为按需加载
|
db.session.rollback()
|
||||||
# 只获取数据库中已有的新闻,不再自动调用API
|
|
||||||
|
|
||||||
# 获取该网站的相关新闻(最多显示5条)
|
# 获取该网站的相关新闻(最多显示5条)
|
||||||
news_list = News.query.filter_by(
|
news_list = News.query.filter_by(
|
||||||
@@ -181,17 +229,20 @@ def create_app(config_name='default'):
|
|||||||
is_active=True
|
is_active=True
|
||||||
).order_by(News.published_at.desc()).limit(5).all()
|
).order_by(News.published_at.desc()).limit(5).all()
|
||||||
|
|
||||||
# 检查是否有新闻,如果没有则标记需要加载
|
# 检查是否有新闻
|
||||||
has_news = len(news_list) > 0
|
has_news = len(news_list) > 0
|
||||||
|
|
||||||
# 获取同类工具推荐(通过标签匹配,最多显示4个)
|
# 优化:获取同类工具推荐(使用JOIN代替子查询)
|
||||||
recommended_sites = []
|
recommended_sites = []
|
||||||
if site.tags:
|
if site.tags:
|
||||||
# 获取有相同标签的其他网站
|
tag_ids = [tag.id for tag in site.tags]
|
||||||
recommended_sites = Site.query.filter(
|
# 使用JOIN查询,性能更好
|
||||||
|
recommended_sites = db.session.query(Site).join(
|
||||||
|
site_tags, Site.id == site_tags.c.site_id
|
||||||
|
).filter(
|
||||||
Site.id != site.id,
|
Site.id != site.id,
|
||||||
Site.is_active == True,
|
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()
|
).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)
|
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()
|
db.session.rollback()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
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新增) ==========
|
# ========== 社媒营销路由 (v2.5新增) ==========
|
||||||
@app.route('/api/generate-social-share', methods=['POST'])
|
@app.route('/api/generate-social-share', methods=['POST'])
|
||||||
@@ -1542,6 +1909,56 @@ def create_app(config_name='default'):
|
|||||||
'message': f'抓取失败: {str(e)}'
|
'message': f'抓取失败: {str(e)}'
|
||||||
}), 500
|
}), 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'])
|
@app.route('/api/upload-logo', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def upload_logo():
|
def upload_logo():
|
||||||
@@ -2757,6 +3174,55 @@ Sitemap: {}sitemap.xml
|
|||||||
|
|
||||||
return self.render('admin/index.html', stats=stats, recent_sites=recent_sites)
|
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):
|
class SiteAdmin(SecureModelView):
|
||||||
# 自定义模板
|
# 自定义模板
|
||||||
@@ -2999,6 +3465,72 @@ Sitemap: {}sitemap.xml
|
|||||||
db.orm.joinedload(News.site)
|
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模板管理视图
|
# Prompt模板管理视图
|
||||||
class PromptAdmin(SecureModelView):
|
class PromptAdmin(SecureModelView):
|
||||||
can_edit = True
|
can_edit = True
|
||||||
@@ -3054,6 +3586,8 @@ Sitemap: {}sitemap.xml
|
|||||||
)
|
)
|
||||||
|
|
||||||
admin.add_view(SiteAdmin(Site, db.session, name='网站管理'))
|
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(TagAdmin(Tag, db.session, name='标签管理'))
|
||||||
admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
|
admin.add_view(NewsAdmin(News, db.session, name='新闻管理'))
|
||||||
admin.add_view(PromptAdmin(PromptTemplate, db.session, name='Prompt管理'))
|
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):
|
def __repr__(self):
|
||||||
return f'<Admin {self.username}>'
|
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):
|
class PromptTemplate(db.Model):
|
||||||
"""AI提示词模板模型"""
|
"""AI提示词模板模型"""
|
||||||
__tablename__ = 'prompt_templates'
|
__tablename__ = 'prompt_templates'
|
||||||
@@ -271,3 +311,51 @@ class Collection(db.Model):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Collection user={self.user_id} site={self.site_id}>'
|
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>
|
<span class="nav-text">管理员</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@
|
|||||||
<span class="material-symbols-outlined">add_circle</span>
|
<span class="material-symbols-outlined">add_circle</span>
|
||||||
<span>添加新工具</span>
|
<span>添加新工具</span>
|
||||||
</a>
|
</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">
|
<a href="{{ url_for('tag.index_view') }}" class="quick-action-btn">
|
||||||
<span class="material-symbols-outlined">label</span>
|
<span class="material-symbols-outlined">label</span>
|
||||||
<span>管理标签</span>
|
<span>管理标签</span>
|
||||||
|
|||||||
@@ -55,6 +55,31 @@
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
color: #f87171;
|
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 {
|
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -637,6 +662,137 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
featuresStatusDiv.style.display = 'block';
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -55,6 +55,31 @@
|
|||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
color: #f87171;
|
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 {
|
.auto-fetch-btn .loading-icon, .generate-tags-btn .loading-icon {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -658,6 +683,88 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
featuresStatusDiv.style.display = 'block';
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% 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">
|
<ul class="nav-links">
|
||||||
<li><a href="/">Home</a></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li><a href="/#categories">Categories</a></li>
|
<li><a href="/#categories">Categories</a></li>
|
||||||
|
<li><a href="/skills">Skills</a></li>
|
||||||
<li><a href="/admin/login">Admin</a></li>
|
<li><a href="/admin/login">Admin</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -800,9 +800,11 @@
|
|||||||
<span>📰</span>
|
<span>📰</span>
|
||||||
相关新闻
|
相关新闻
|
||||||
</h2>
|
</h2>
|
||||||
<button id="refreshNewsBtn" class="refresh-news-btn" onclick="showCaptchaModal('{{ site.code }}')">
|
{% if news_list %}
|
||||||
<span class="refresh-icon">↻</span> <span class="btn-text">{% if has_news %}获取最新资讯{% else %}加载资讯{% endif %}</span>
|
<span style="font-size: 13px; color: var(--text-muted);">
|
||||||
</button>
|
共 {{ news_list|length }} 条资讯
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="newsContainer">
|
<div id="newsContainer">
|
||||||
{% if news_list %}
|
{% 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 bs4 import BeautifulSoup
|
||||||
from urllib.parse import urljoin, urlparse
|
from urllib.parse import urljoin, urlparse
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
@@ -54,6 +55,243 @@ class WebsiteFetcher:
|
|||||||
print(f"抓取网站信息失败: {str(e)}")
|
print(f"抓取网站信息失败: {str(e)}")
|
||||||
return None
|
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):
|
def _extract_title(self, soup):
|
||||||
"""提取网站标题"""
|
"""提取网站标题"""
|
||||||
# 优先使用 og:title
|
# 优先使用 og:title
|
||||||
@@ -80,89 +318,83 @@ class WebsiteFetcher:
|
|||||||
if meta_desc and meta_desc.get('content'):
|
if meta_desc and meta_desc.get('content'):
|
||||||
return meta_desc['content'].strip()
|
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 ''
|
return ''
|
||||||
|
|
||||||
def _extract_logo(self, soup, base_url):
|
def _extract_logo(self, soup, base_url):
|
||||||
"""提取网站Logo"""
|
"""提取网站 Logo URL"""
|
||||||
logo_url = None
|
# 优先查找 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
|
# 查找 apple-touch-icon
|
||||||
og_image = soup.find('meta', property='og:image')
|
|
||||||
if og_image and og_image.get('content'):
|
|
||||||
logo_url = og_image['content']
|
|
||||||
|
|
||||||
# 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']
|
|
||||||
|
|
||||||
# 3. 尝试 apple-touch-icon
|
|
||||||
if not logo_url:
|
|
||||||
apple_icon = soup.find('link', rel='apple-touch-icon')
|
apple_icon = soup.find('link', rel='apple-touch-icon')
|
||||||
if apple_icon and apple_icon.get('href'):
|
if apple_icon and apple_icon.get('href'):
|
||||||
logo_url = apple_icon['href']
|
return urljoin(base_url, apple_icon['href'])
|
||||||
|
|
||||||
# 4. 默认使用 /favicon.ico
|
# 使用 /favicon.ico
|
||||||
if not logo_url:
|
parsed = urlparse(base_url)
|
||||||
logo_url = '/favicon.ico'
|
return f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||||
|
|
||||||
# 转换为绝对URL
|
def download_logo(self, logo_url, save_dir='static/logos'):
|
||||||
if logo_url:
|
|
||||||
logo_url = urljoin(base_url, logo_url)
|
|
||||||
|
|
||||||
return logo_url
|
|
||||||
|
|
||||||
def download_logo(self, logo_url, save_dir='static/uploads'):
|
|
||||||
"""
|
"""
|
||||||
下载并保存Logo
|
下载 Logo 到本地
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
logo_url: Logo的URL
|
logo_url: Logo URL
|
||||||
save_dir: 保存目录
|
save_dir: 保存目录
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 保存后的相对路径,失败返回None
|
str: 保存后的文件路径,失败返回 None
|
||||||
"""
|
"""
|
||||||
if not logo_url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 创建保存目录
|
# 确保目录存在
|
||||||
os.makedirs(save_dir, exist_ok=True)
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
# 下载图片
|
# 下载图片
|
||||||
response = requests.get(logo_url, headers=self.headers, timeout=self.timeout)
|
response = requests.get(logo_url, headers=self.headers, timeout=10, stream=True)
|
||||||
response.raise_for_status()
|
if response.status_code != 200:
|
||||||
|
return None
|
||||||
|
|
||||||
# 检查是否是图片
|
# 检查是否是图片
|
||||||
content_type = response.headers.get('content-type', '')
|
content_type = response.headers.get('Content-Type', '').lower()
|
||||||
if not content_type.startswith('image/'):
|
if 'image' not in content_type:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 生成文件名
|
# 生成文件名
|
||||||
parsed_url = urlparse(logo_url)
|
import time
|
||||||
ext = os.path.splitext(parsed_url.path)[1]
|
import hashlib
|
||||||
if not ext or len(ext) > 5:
|
ext = '.png'
|
||||||
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'
|
||||||
|
|
||||||
# 使用域名作为文件名
|
timestamp = str(int(time.time() * 1000))
|
||||||
domain = parsed_url.netloc.replace(':', '_').replace('.', '_')
|
hash_name = hashlib.md5(f"{logo_url}{timestamp}".encode()).hexdigest()[:16]
|
||||||
filename = f"logo_{domain}{ext}"
|
filename = f"logo_{hash_name}{ext}"
|
||||||
filepath = os.path.join(save_dir, filename)
|
filepath = os.path.join(save_dir, filename)
|
||||||
|
|
||||||
# 保存图片
|
# 保存图片
|
||||||
with open(filepath, 'wb') as f:
|
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:
|
except Exception as e:
|
||||||
print(f"下载Logo失败: {str(e)}")
|
print(f"下载 Logo 失败: {str(e)}")
|
||||||
return None
|
return None
|
||||||
Reference in New Issue
Block a user