From d7d21e19c9b578c8684051539b2ff3664e2c4ca5 Mon Sep 17 00:00:00 2001 From: Jowe <123822645+Selei1983@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:04:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?release:=20v2.2.0=20-=20=E5=8D=9A=E6=9F=A5?= =?UTF-8?q?=E6=96=B0=E9=97=BB=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 集成博查Web Search API,自动获取网站相关新闻 - News模型添加source_name和source_icon字段 - 新闻管理后台界面优化 - 网站详情页新闻展示(标题、摘要、来源、链接) - 定期任务脚本支持批量获取新闻 - 完整的API路由和测试脚本 技术实现: - NewsSearcher工具类封装博查API - 智能新闻搜索和去重机制 - 数据库迁移脚本migrate_news_fields.py - API路由:/api/fetch-site-news 和 /api/fetch-all-news - Cron任务脚本:fetch_news_cron.py 修改文件: - config.py: 添加博查API配置 - models.py: News模型扩展 - app.py: 新闻获取路由和NewsAdmin优化 - templates/detail_new.html: 新闻展示UI 新增文件: - utils/news_searcher.py (271行) - migrate_news_fields.py (99行) - fetch_news_cron.py (167行) - test_news_feature.py (142行) - NEWS_FEATURE_v2.2.md (408行) 统计:9个文件,1348行新增 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- NEWS_FEATURE_v2.2.md | 408 ++++++++++++++++++++++++++++++++++++++ app.py | 214 +++++++++++++++++++- config.py | 10 + fetch_news_cron.py | 167 ++++++++++++++++ migrate_news_fields.py | 99 +++++++++ models.py | 4 + templates/detail_new.html | 41 +++- test_news_feature.py | 142 +++++++++++++ utils/news_searcher.py | 271 +++++++++++++++++++++++++ 9 files changed, 1348 insertions(+), 8 deletions(-) create mode 100644 NEWS_FEATURE_v2.2.md create mode 100644 fetch_news_cron.py create mode 100644 migrate_news_fields.py create mode 100644 test_news_feature.py create mode 100644 utils/news_searcher.py diff --git a/NEWS_FEATURE_v2.2.md b/NEWS_FEATURE_v2.2.md new file mode 100644 index 0000000..c16bbe0 --- /dev/null +++ b/NEWS_FEATURE_v2.2.md @@ -0,0 +1,408 @@ +# ZJPB v2.2.0 - 新闻搜索功能 + +**版本**: v2.2.0 +**发布日期**: 2025-01-30 +**主要功能**: 集成博查Web Search API,自动获取网站相关新闻 + +--- + +## 📋 功能概述 + +v2.2.0版本引入了全新的新闻搜索和展示功能,通过博查AI搜索引擎API,自动为每个网站获取最新的相关新闻,并在网站详情页进行展示。 + +### ✨ 核心特性 + +1. **智能新闻搜索** 🔍 + - 基于网站名称自动搜索相关新闻 + - 支持自定义搜索时间范围(一天、一周、一月、一年) + - 自动排除网站自身的内容,确保新闻来源多样性 + +2. **新闻管理系统** 📰 + - 后台新闻列表管理 + - 支持手动编辑和删除新闻 + - 新闻来源信息展示(网站名称、图标) + +3. **前台新闻展示** 🎨 + - 网站详情页展示最多5条相关新闻 + - 显示新闻标题、摘要、来源、发布时间 + - 点击新闻直接跳转到原文链接 + +4. **定期任务支持** ⏰ + - 提供cron任务脚本,支持定期批量获取新闻 + - 可配置获取数量、时间范围等参数 + +--- + +## 🚀 快速开始 + +### 1. 配置博查API + +在`.env`文件中添加博查API配置: + +```env +# 博查 Web Search API配置 +BOCHA_API_KEY=sk-your-api-key-here +BOCHA_BASE_URL=https://api.bocha.cn +``` + +获取API Key:访问 [博查AI开放平台](https://open.bocha.cn) 注册并获取API密钥。 + +### 2. 数据库迁移 + +运行迁移脚本,为News表添加新字段: + +```bash +python migrate_news_fields.py +``` + +迁移会添加以下字段: +- `source_name`: 新闻来源网站名称 +- `source_icon`: 新闻来源网站图标URL + +### 3. 测试新闻获取 + +#### 方法1:使用Python测试脚本 + +```bash +# 测试NewsSearcher类 +python utils/news_searcher.py +``` + +#### 方法2:使用后台API + +登录后台后,使用API接口: + +**单个网站获取新闻**: +```bash +curl -X POST http://localhost:5000/api/fetch-site-news \ + -H "Content-Type: application/json" \ + -d '{ + "site_id": 1, + "count": 10, + "freshness": "oneMonth" + }' +``` + +**批量获取新闻**: +```bash +curl -X POST http://localhost:5000/api/fetch-all-news \ + -H "Content-Type: application/json" \ + -d '{ + "count": 5, + "freshness": "oneMonth", + "limit": 10 + }' +``` + +### 4. 查看新闻 + +1. 访问任意网站详情页(例如:`http://localhost:5000/site/12345678`) +2. 滚动到页面下方,查看"相关新闻"部分 +3. 点击新闻标题或"阅读全文"链接跳转到原文 + +--- + +## 📖 使用指南 + +### 后台管理 + +#### 新闻管理界面 + +1. 登录后台:`http://localhost:5000/admin` +2. 点击左侧菜单"新闻管理" +3. 可以查看、编辑、删除已获取的新闻 + +**新闻列表字段**: +- ID +- 关联网站 +- 新闻标题 +- 来源网站 +- 新闻类型 +- 发布时间 +- 是否启用 + +#### 手动获取新闻 + +虽然提供了API接口,但目前没有直接的后台UI按钮。可以通过以下方式触发: + +1. 使用API接口(参见快速开始) +2. 使用定期任务脚本(参见下文) + +### 定期任务设置 + +使用`fetch_news_cron.py`脚本定期自动获取新闻。 + +#### 手动执行 + +```bash +# 默认参数:处理10个网站,每个网站获取5条新闻,时间范围一个月 +python fetch_news_cron.py + +# 自定义参数 +python fetch_news_cron.py --limit 20 --count 10 --freshness oneWeek +``` + +**参数说明**: +- `--limit`: 处理的网站数量限制(默认:10) +- `--count`: 每个网站获取的新闻数量(默认:5) +- `--freshness`: 新闻时间范围(可选:noLimit, oneDay, oneWeek, oneMonth, oneYear) + +#### 配置Crontab + +在Linux服务器上配置定期任务: + +```bash +# 编辑crontab +crontab -e + +# 添加以下行(每天早上8点执行) +0 8 * * * cd /opt/1panel/apps/zjpb && /opt/1panel/apps/zjpb/venv/bin/python fetch_news_cron.py --limit 10 >> logs/news_fetch.log 2>&1 + +# 或每6小时执行一次 +0 */6 * * * cd /opt/1panel/apps/zjpb && /opt/1panel/apps/zjpb/venv/bin/python fetch_news_cron.py --limit 20 >> logs/news_fetch.log 2>&1 +``` + +**注意**: +- 确保创建`logs`目录:`mkdir -p logs` +- 修改路径为实际的项目路径 +- 根据API配额合理设置执行频率 + +--- + +## 🗂️ 文件结构 + +### 新增文件 + +``` +zjpb/ +├── utils/ +│ └── news_searcher.py # 博查API封装类 +├── migrate_news_fields.py # 数据库迁移脚本 +├── fetch_news_cron.py # 定期任务脚本 +└── NEWS_FEATURE_v2.2.md # 本文档 +``` + +### 修改文件 + +``` +zjpb/ +├── config.py # 添加博查API配置 +├── models.py # News模型添加source_name/source_icon字段 +├── app.py # 添加新闻获取API路由,更新NewsAdmin +└── templates/ + └── detail_new.html # 优化新闻展示UI +``` + +--- + +## 🔧 技术实现 + +### API集成 + +使用博查Web Search API进行新闻搜索: + +- **接口地址**: `https://api.bocha.cn/v1/web-search` +- **认证方式**: Bearer Token +- **请求方法**: POST +- **返回格式**: JSON + +### 数据模型 + +News模型字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | Integer | 主键 | +| site_id | Integer | 关联网站ID(外键) | +| title | String(200) | 新闻标题 | +| content | Text | 新闻内容/摘要 | +| news_type | String(50) | 新闻类型 | +| url | String(500) | 新闻链接 | +| source_name | String(100) | ⭐ 来源网站名称(新增) | +| source_icon | String(500) | ⭐ 来源网站图标(新增) | +| published_at | DateTime | 发布时间 | +| is_active | Boolean | 是否启用 | +| created_at | DateTime | 创建时间 | +| updated_at | DateTime | 更新时间 | + +### API路由 + +**1. 单个网站获取新闻** + +- **路径**: `/api/fetch-site-news` +- **方法**: POST +- **权限**: 需要登录 +- **参数**: + ```json + { + "site_id": 1, + "count": 10, + "freshness": "oneMonth" + } + ``` + +**2. 批量获取新闻** + +- **路径**: `/api/fetch-all-news` +- **方法**: POST +- **权限**: 需要登录 +- **参数**: + ```json + { + "count": 5, + "freshness": "oneMonth", + "limit": 10 + } + ``` + +--- + +## 📊 配置选项 + +在`config.py`中的新闻相关配置: + +```python +# 博查 Web Search API配置 +BOCHA_API_KEY = os.environ.get('BOCHA_API_KEY') +BOCHA_BASE_URL = os.environ.get('BOCHA_BASE_URL') or 'https://api.bocha.cn' +BOCHA_SEARCH_ENDPOINT = '/v1/web-search' + +# 新闻搜索配置 +NEWS_SEARCH_COUNT = 10 # 每次搜索返回的新闻数量 +NEWS_SEARCH_FRESHNESS = 'oneMonth' # 默认搜索一个月内的新闻 +NEWS_SEARCH_SUMMARY = True # 是否显示摘要 +``` + +--- + +## ⚠️ 注意事项 + +### API配额限制 + +- 博查API根据充值金额有请求频率限制 +- 建议合理设置定期任务频率,避免过度消耗配额 +- 详情参见:[博查API定价](https://open.bocha.cn) + +### 去重机制 + +系统会根据新闻URL自动去重,同一条新闻不会重复保存。 + +### 搜索策略 + +- 搜索关键词:`{网站名称} 最新 新闻` +- 自动排除:网站自身域名的内容 +- 时间优先:优先显示最新发布的新闻 + +### 性能优化 + +- 批量获取时建议限制数量(`--limit 10-20`) +- 避免短时间内频繁调用API +- 数据库查询已优化,使用索引和去重 + +--- + +## 🐛 故障排查 + +### 1. 新闻获取失败 + +**可能原因**: +- 博查API Key未配置或无效 +- API配额不足 +- 网络连接问题 + +**解决方法**: +```bash +# 检查环境变量 +python -c "import os; from dotenv import load_dotenv; load_dotenv(); print(os.getenv('BOCHA_API_KEY'))" + +# 测试API连接 +python utils/news_searcher.py +``` + +### 2. 数据库字段不存在 + +**错误信息**:`Unknown column 'source_name' in 'field list'` + +**解决方法**: +```bash +# 运行数据库迁移 +python migrate_news_fields.py +``` + +### 3. 详情页不显示新闻 + +**可能原因**: +- 数据库中没有该网站的新闻记录 +- 新闻被设置为不启用(is_active=False) + +**解决方法**: +```bash +# 为该网站获取新闻 +curl -X POST http://localhost:5000/api/fetch-site-news \ + -H "Content-Type: application/json" \ + -d '{"site_id": YOUR_SITE_ID}' +``` + +### 4. 定期任务不执行 + +**检查清单**: +- [ ] Crontab配置是否正确 +- [ ] Python路径是否正确 +- [ ] 日志文件是否有写入权限 +- [ ] 查看cron日志:`grep CRON /var/log/syslog` + +--- + +## 📈 未来改进 + +### 计划功能 + +- [ ] 后台UI按钮直接触发新闻获取 +- [ ] 新闻分类和标签支持 +- [ ] 新闻摘要AI优化 +- [ ] 新闻热度排序 +- [ ] 用户收藏新闻功能 +- [ ] 新闻RSS订阅 + +### 性能优化 + +- [ ] 使用异步任务队列(Celery) +- [ ] 新闻缓存机制 +- [ ] 图片CDN加速 + +--- + +## 📞 技术支持 + +- **项目名称**: ZJPB - 焦提示词 | AI工具导航 +- **版本**: v2.2.0 +- **发布日期**: 2025-01-30 +- **博查API文档**: https://bocha-ai.feishu.cn/wiki/RXEOw02rFiwzGSkd9mUcqoeAnNK + +--- + +## 📝 更新日志 + +### v2.2.0 (2025-01-30) + +**新增**: +- ✨ 集成博查Web Search API +- ✨ 新闻自动搜索和存储功能 +- ✨ News模型添加source_name和source_icon字段 +- ✨ 网站详情页新闻展示优化 +- ✨ 后台新闻管理界面增强 +- ✨ 定期任务脚本(fetch_news_cron.py) +- ✨ API路由:/api/fetch-site-news 和 /api/fetch-all-news + +**修改**: +- 🔧 config.py添加博查API配置 +- 🔧 NewsAdmin添加source_name字段显示 +- 🔧 detail_new.html优化新闻展示UI + +**文档**: +- 📖 NEWS_FEATURE_v2.2.md 功能文档 +- 📖 migrate_news_fields.py 迁移脚本文档 + +--- + +**祝您使用愉快!** 🎉 diff --git a/app.py b/app.py index 7a2e392..b0f27d4 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,7 @@ from config import config from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate from utils.website_fetcher import WebsiteFetcher from utils.tag_generator import TagGenerator +from utils.news_searcher import NewsSearcher def create_app(config_name='default'): """应用工厂函数""" @@ -442,6 +443,205 @@ def create_app(config_name='default'): 'message': f'生成失败: {str(e)}' }), 500 + # ========== 新闻获取路由 ========== + @app.route('/api/fetch-site-news', methods=['POST']) + @login_required + def fetch_site_news(): + """为指定网站获取最新新闻""" + try: + data = request.get_json() + site_id = data.get('site_id') + count = data.get('count', app.config.get('NEWS_SEARCH_COUNT', 10)) + freshness = data.get('freshness', app.config.get('NEWS_SEARCH_FRESHNESS', 'oneMonth')) + + if not site_id: + return jsonify({ + 'success': False, + 'message': '请提供网站ID' + }), 400 + + # 获取网站信息 + site = Site.query.get(site_id) + if not site: + return jsonify({ + 'success': False, + 'message': '网站不存在' + }), 404 + + # 检查博查API配置 + api_key = app.config.get('BOCHA_API_KEY') + if not api_key: + return jsonify({ + 'success': False, + 'message': '博查API未配置,请在.env文件中设置BOCHA_API_KEY' + }), 500 + + # 创建新闻搜索器 + searcher = NewsSearcher(api_key) + + # 搜索新闻 + news_items = searcher.search_site_news( + site_name=site.name, + site_url=site.url, + count=count, + freshness=freshness + ) + + if not news_items: + return jsonify({ + 'success': False, + 'message': '未找到相关新闻' + }), 404 + + # 保存新闻到数据库 + saved_count = 0 + for item in news_items: + # 检查新闻是否已存在(根据URL判断) + existing_news = News.query.filter_by( + site_id=site_id, + url=item['url'] + ).first() + + if not existing_news: + # 创建新闻记录 + 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='Search Result', + is_active=True + ) + db.session.add(news) + saved_count += 1 + + # 提交事务 + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'成功获取并保存 {saved_count} 条新闻', + 'total_found': len(news_items), + 'saved': saved_count, + 'news_items': searcher.format_news_for_display(news_items) + }) + + except Exception as e: + db.session.rollback() + return jsonify({ + 'success': False, + 'message': f'获取失败: {str(e)}' + }), 500 + + @app.route('/api/fetch-all-news', methods=['POST']) + @login_required + def fetch_all_news(): + """批量为所有网站获取新闻""" + try: + data = request.get_json() + count_per_site = data.get('count', 5) # 每个网站获取的新闻数量 + freshness = data.get('freshness', app.config.get('NEWS_SEARCH_FRESHNESS', 'oneMonth')) + limit = data.get('limit', 10) # 限制处理的网站数量 + + # 检查博查API配置 + api_key = app.config.get('BOCHA_API_KEY') + if not api_key: + return jsonify({ + 'success': False, + 'message': '博查API未配置,请在.env文件中设置BOCHA_API_KEY' + }), 500 + + # 获取启用的网站(按更新时间排序,优先处理旧的) + sites = Site.query.filter_by(is_active=True).order_by(Site.updated_at).limit(limit).all() + + if not sites: + return jsonify({ + 'success': False, + 'message': '没有可用的网站' + }), 404 + + # 创建新闻搜索器 + searcher = NewsSearcher(api_key) + + # 统计信息 + total_saved = 0 + total_found = 0 + processed_sites = [] + + # 为每个网站获取新闻 + for site in sites: + try: + # 搜索新闻 + news_items = searcher.search_site_news( + site_name=site.name, + site_url=site.url, + count=count_per_site, + freshness=freshness + ) + + site_saved = 0 + for item in news_items: + # 检查是否已存在 + existing_news = News.query.filter_by( + site_id=site.id, + url=item['url'] + ).first() + + if not existing_news: + 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='Search Result', + is_active=True + ) + db.session.add(news) + site_saved += 1 + + total_found += len(news_items) + total_saved += site_saved + + processed_sites.append({ + 'id': site.id, + 'name': site.name, + 'found': len(news_items), + 'saved': site_saved + }) + + except Exception as e: + # 单个网站失败不影响其他网站 + processed_sites.append({ + 'id': site.id, + 'name': site.name, + 'error': str(e) + }) + continue + + # 提交事务 + db.session.commit() + + return jsonify({ + 'success': True, + 'message': f'批量获取完成,共处理 {len(processed_sites)} 个网站', + 'total_found': total_found, + 'total_saved': total_saved, + 'processed_sites': processed_sites + }) + + except Exception as e: + db.session.rollback() + return jsonify({ + 'success': False, + 'message': f'批量获取失败: {str(e)}' + }), 500 + # ========== 批量导入路由 ========== @app.route('/admin/batch-import', methods=['GET', 'POST']) @login_required @@ -908,9 +1108,9 @@ def create_app(config_name='default'): # 显示操作列 column_display_actions = True - column_list = ['id', 'site', 'title', 'news_type', 'published_at', 'is_active'] - column_searchable_list = ['title', 'content'] - column_filters = ['site', 'news_type', 'is_active', 'published_at'] + column_list = ['id', 'site', 'title', 'source_name', 'news_type', 'published_at', 'is_active'] + column_searchable_list = ['title', 'content', 'source_name'] + column_filters = ['site', 'news_type', 'source_name', 'is_active', 'published_at'] column_labels = { 'id': 'ID', 'site': '关联网站', @@ -918,16 +1118,19 @@ def create_app(config_name='default'): 'content': '新闻内容', 'news_type': '新闻类型', 'url': '新闻链接', + 'source_name': '来源网站', + 'source_icon': '来源图标', 'published_at': '发布时间', 'is_active': '是否启用', 'created_at': '创建时间', 'updated_at': '更新时间' } - form_columns = ['site', 'title', 'content', 'news_type', 'url', 'published_at', 'is_active'] + form_columns = ['site', 'title', 'content', 'news_type', 'url', 'source_name', 'source_icon', 'published_at', 'is_active'] # 可选的新闻类型 form_choices = { 'news_type': [ + ('Search Result', 'Search Result'), ('Product Update', 'Product Update'), ('Industry News', 'Industry News'), ('Company News', 'Company News'), @@ -935,6 +1138,9 @@ def create_app(config_name='default'): ] } + # 默认排序 + column_default_sort = ('published_at', True) # 按发布时间倒序排列 + # Prompt模板管理视图 class PromptAdmin(SecureModelView): can_edit = True diff --git a/config.py b/config.py index 9cde07b..7a44182 100644 --- a/config.py +++ b/config.py @@ -46,6 +46,16 @@ class Config: DEEPSEEK_API_KEY = os.environ.get('DEEPSEEK_API_KEY') DEEPSEEK_BASE_URL = os.environ.get('DEEPSEEK_BASE_URL') or 'https://api.deepseek.com' + # 博查 Web Search API配置 + BOCHA_API_KEY = os.environ.get('BOCHA_API_KEY') + BOCHA_BASE_URL = os.environ.get('BOCHA_BASE_URL') or 'https://api.bocha.cn' + BOCHA_SEARCH_ENDPOINT = '/v1/web-search' + + # 新闻搜索配置 + NEWS_SEARCH_COUNT = 10 # 每次搜索返回的新闻数量 + NEWS_SEARCH_FRESHNESS = 'oneMonth' # 默认搜索一个月内的新闻 + NEWS_SEARCH_SUMMARY = True # 是否显示摘要 + class DevelopmentConfig(Config): """开发环境配置""" DEBUG = True diff --git a/fetch_news_cron.py b/fetch_news_cron.py new file mode 100644 index 0000000..9e6b092 --- /dev/null +++ b/fetch_news_cron.py @@ -0,0 +1,167 @@ +""" +定期新闻获取任务脚本 +用途:定期为网站批量获取最新新闻 +使用:python fetch_news_cron.py [options] + +可以通过crontab定时执行: +# 每天早上8点执行,获取10个网站的新闻 +0 8 * * * cd /path/to/zjpb && /path/to/venv/bin/python fetch_news_cron.py --limit 10 >> logs/news_fetch.log 2>&1 +""" +import os +import sys +import argparse +from datetime import datetime +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +# 添加项目根目录到Python路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import create_app +from models import db, Site, News +from utils.news_searcher import NewsSearcher + + +def fetch_news_for_sites(limit=10, count_per_site=5, freshness='oneMonth'): + """ + 批量为网站获取新闻 + + Args: + limit: 处理的网站数量限制 + count_per_site: 每个网站获取的新闻数量 + freshness: 新闻时间范围 + """ + # 创建Flask应用上下文 + app = create_app(os.getenv('FLASK_ENV', 'production')) + + with app.app_context(): + # 检查博查API配置 + api_key = app.config.get('BOCHA_API_KEY') + if not api_key: + print(f"[{datetime.now()}] 错误:未配置BOCHA_API_KEY") + return False + + # 获取启用的网站(按更新时间排序,优先处理旧的) + sites = Site.query.filter_by(is_active=True).order_by(Site.updated_at).limit(limit).all() + + if not sites: + print(f"[{datetime.now()}] 没有可处理的网站") + return False + + print(f"[{datetime.now()}] 开始批量获取新闻,共 {len(sites)} 个网站") + print(f"配置:每个网站 {count_per_site} 条新闻,时间范围:{freshness}") + print("-" * 60) + + # 创建新闻搜索器 + searcher = NewsSearcher(api_key) + + # 统计信息 + total_saved = 0 + total_found = 0 + success_count = 0 + error_count = 0 + + # 为每个网站获取新闻 + for i, site in enumerate(sites, 1): + print(f"[{i}/{len(sites)}] 处理网站: {site.name}") + + try: + # 搜索新闻 + news_items = searcher.search_site_news( + site_name=site.name, + site_url=site.url, + count=count_per_site, + freshness=freshness + ) + + if not news_items: + print(f" └─ 未找到新闻") + continue + + site_saved = 0 + for item in news_items: + # 检查是否已存在 + existing_news = News.query.filter_by( + site_id=site.id, + url=item['url'] + ).first() + + if not existing_news: + 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='Search Result', + is_active=True + ) + db.session.add(news) + site_saved += 1 + + # 提交该网站的新闻 + db.session.commit() + + total_found += len(news_items) + total_saved += site_saved + success_count += 1 + + print(f" └─ 找到 {len(news_items)} 条,保存 {site_saved} 条新闻") + + except Exception as e: + error_count += 1 + print(f" └─ 错误: {str(e)}") + db.session.rollback() + continue + + print("-" * 60) + print(f"[{datetime.now()}] 批量获取完成") + print(f"成功: {success_count} 个网站, 失败: {error_count} 个网站") + print(f"共找到 {total_found} 条新闻,保存 {total_saved} 条新新闻") + print("=" * 60) + + return True + + +def main(): + """主函数""" + parser = argparse.ArgumentParser(description='定期新闻获取任务') + parser.add_argument('--limit', type=int, default=10, help='处理的网站数量限制(默认:10)') + parser.add_argument('--count', type=int, default=5, help='每个网站获取的新闻数量(默认:5)') + parser.add_argument('--freshness', type=str, default='oneMonth', + choices=['noLimit', 'oneDay', 'oneWeek', 'oneMonth', 'oneYear'], + help='新闻时间范围(默认:oneMonth)') + + args = parser.parse_args() + + print("=" * 60) + print(f"定期新闻获取任务 - 开始时间: {datetime.now()}") + print("=" * 60) + + try: + success = fetch_news_for_sites( + limit=args.limit, + count_per_site=args.count, + freshness=args.freshness + ) + + if success: + print(f"\n任务执行成功!") + sys.exit(0) + else: + print(f"\n任务执行失败!") + sys.exit(1) + + except Exception as e: + print(f"\n[{datetime.now()}] 严重错误: {str(e)}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/migrate_news_fields.py b/migrate_news_fields.py new file mode 100644 index 0000000..5020516 --- /dev/null +++ b/migrate_news_fields.py @@ -0,0 +1,99 @@ +""" +数据库迁移脚本 - 为News表添加source_name和source_icon字段 +版本:v2.2.0 +日期:2025-01-30 +""" +import pymysql +import os +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +def migrate(): + """执行数据库迁移""" + # 数据库配置 + db_config = { + 'host': os.environ.get('DB_HOST', 'localhost'), + 'port': int(os.environ.get('DB_PORT', 3306)), + 'user': os.environ.get('DB_USER', 'root'), + 'password': os.environ.get('DB_PASSWORD', ''), + 'database': os.environ.get('DB_NAME', 'ai_nav'), + 'charset': 'utf8mb4' + } + + try: + # 连接数据库 + connection = pymysql.connect(**db_config) + cursor = connection.cursor() + + print("=" * 60) + print("开始执行数据库迁移 v2.2.0") + print("=" * 60) + + # 检查字段是否已存在 + cursor.execute(""" + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = 'news' + AND COLUMN_NAME IN ('source_name', 'source_icon') + """, (db_config['database'],)) + + existing_columns = [row[0] for row in cursor.fetchall()] + + # 添加 source_name 字段 + if 'source_name' not in existing_columns: + print("\n1. 添加 source_name 字段...") + cursor.execute(""" + ALTER TABLE news + ADD COLUMN source_name VARCHAR(100) + COMMENT '新闻来源网站名称' + AFTER url + """) + print(">>> source_name 字段添加成功") + else: + print("\n1. source_name 字段已存在,跳过") + + # 添加 source_icon 字段 + if 'source_icon' not in existing_columns: + print("\n2. 添加 source_icon 字段...") + cursor.execute(""" + ALTER TABLE news + ADD COLUMN source_icon VARCHAR(500) + COMMENT '新闻来源网站图标URL' + AFTER source_name + """) + print(">>> source_icon 字段添加成功") + else: + print("\n2. source_icon 字段已存在,跳过") + + # 提交事务 + connection.commit() + + print("\n" + "=" * 60) + print(">>> 数据库迁移完成!") + print("=" * 60) + + # 显示表结构 + print("\n当前 news 表结构:") + cursor.execute("DESCRIBE news") + for row in cursor.fetchall(): + print(f" - {row[0]}: {row[1]} {row[2]}") + + except Exception as e: + print(f"\n>>> 迁移失败:{str(e)}") + if 'connection' in locals(): + connection.rollback() + raise + + finally: + if 'cursor' in locals(): + cursor.close() + if 'connection' in locals(): + connection.close() + print("\n数据库连接已关闭") + + +if __name__ == '__main__': + migrate() diff --git a/models.py b/models.py index fae1c97..6de887f 100644 --- a/models.py +++ b/models.py @@ -90,6 +90,8 @@ class News(db.Model): content = db.Column(db.Text, comment='新闻内容') news_type = db.Column(db.String(50), default='Industry News', comment='新闻类型') url = db.Column(db.String(500), comment='新闻链接') + source_name = db.Column(db.String(100), comment='新闻来源网站名称') + source_icon = db.Column(db.String(500), comment='新闻来源网站图标URL') published_at = db.Column(db.DateTime, default=datetime.now, comment='发布时间') is_active = db.Column(db.Boolean, default=True, comment='是否启用') created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间') @@ -110,6 +112,8 @@ class News(db.Model): 'content': self.content, 'news_type': self.news_type, 'url': self.url, + 'source_name': self.source_name, + 'source_icon': self.source_icon, 'published_at': self.published_at.strftime('%Y-%m-%d') if self.published_at else None, 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None } diff --git a/templates/detail_new.html b/templates/detail_new.html index c84192f..18b43a1 100644 --- a/templates/detail_new.html +++ b/templates/detail_new.html @@ -628,10 +628,43 @@ {% for news in news_list %}
- {{ news.news_type }} -

{{ news.title }}

-

{{ news.content[:200] }}...

-
{{ news.published_at.strftime('%b %d, %Y') }}
+
+ {{ news.news_type }} + {% if news.source_name %} +
+ {% if news.source_icon %} + {{ news.source_name }} + {% endif %} + {{ news.source_name }} +
+ {% endif %} +
+

+ {% if news.url %} + + {{ news.title }} + + {% else %} + {{ news.title }} + {% endif %} +

+ {% if news.content %} +

{{ news.content[:200] }}{% if news.content|length > 200 %}...{% endif %}

+ {% endif %} +
+
+ {% if news.published_at %} + {{ news.published_at.strftime('%Y年%m月%d日') }} + {% else %} + 未知日期 + {% endif %} +
+ {% if news.url %} + + 阅读全文 ↗ + + {% endif %} +
{% endfor %} diff --git a/test_news_feature.py b/test_news_feature.py new file mode 100644 index 0000000..3ac30e3 --- /dev/null +++ b/test_news_feature.py @@ -0,0 +1,142 @@ +""" +测试v2.2新闻功能 - 完整流程测试 +""" +import os +import sys +from dotenv import load_dotenv + +# 加载环境变量 +load_dotenv() + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import create_app +from models import db, Site, News +from utils.news_searcher import NewsSearcher + +def test_news_feature(): + """测试新闻功能""" + print("=" * 60) + print("v2.2 新闻功能测试") + print("=" * 60) + + # 创建应用上下文 + app = create_app('development') + + with app.app_context(): + # 1. 测试API配置 + print("\n[1/4] 检查API配置...") + api_key = app.config.get('BOCHA_API_KEY') + if not api_key: + print(">>> 错误:BOCHA_API_KEY未配置") + return False + print(f">>> API Key: {api_key[:20]}...") + + # 2. 测试数据库连接 + print("\n[2/4] 检查数据库...") + try: + site_count = Site.query.filter_by(is_active=True).count() + print(f">>> 找到 {site_count} 个启用的网站") + + if site_count == 0: + print(">>> 警告:没有可用的网站") + return False + + except Exception as e: + print(f">>> 数据库错误:{e}") + return False + + # 3. 测试新闻搜索 + print("\n[3/4] 测试新闻搜索...") + searcher = NewsSearcher(api_key) + + # 获取第一个网站 + site = Site.query.filter_by(is_active=True).first() + print(f">>> 测试网站:{site.name}") + + try: + news_items = searcher.search_site_news( + site_name=site.name, + site_url=site.url, + count=3, + freshness='oneWeek' + ) + + print(f">>> 找到 {len(news_items)} 条新闻") + + if news_items: + print("\n新闻列表:") + for i, item in enumerate(news_items, 1): + print(f" {i}. {item['title'][:50]}...") + print(f" 来源:{item.get('site_name', '未知')}") + print(f" URL:{item['url'][:60]}...") + + except Exception as e: + print(f">>> 搜索失败:{e}") + return False + + # 4. 测试保存到数据库 + print(f"\n[4/4] 测试保存到数据库...") + + if not news_items: + print(">>> 没有新闻可保存") + return True + + try: + saved_count = 0 + for item in news_items[:2]: # 只保存前2条作为测试 + # 检查是否已存在 + 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='Search Result', + is_active=True + ) + db.session.add(news) + saved_count += 1 + + db.session.commit() + print(f">>> 成功保存 {saved_count} 条新闻") + + # 验证保存 + total_news = News.query.filter_by(site_id=site.id).count() + print(f">>> 该网站共有 {total_news} 条新闻记录") + + except Exception as e: + db.session.rollback() + print(f">>> 保存失败:{e}") + return False + + print("\n" + "=" * 60) + print(">>> 所有测试通过!") + print("=" * 60) + + # 提供下一步建议 + print("\n下一步操作:") + print(f"1. 访问网站详情页查看新闻:http://localhost:5000/site/{site.code}") + print(f"2. 访问后台新闻管理:http://localhost:5000/admin/newsadmin/") + print(f"3. 运行定期任务脚本:python fetch_news_cron.py --limit 5") + + return True + +if __name__ == '__main__': + try: + success = test_news_feature() + sys.exit(0 if success else 1) + except Exception as e: + print(f"\n严重错误:{e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/utils/news_searcher.py b/utils/news_searcher.py new file mode 100644 index 0000000..452eb13 --- /dev/null +++ b/utils/news_searcher.py @@ -0,0 +1,271 @@ +""" +新闻搜索工具 - 使用博查 Web Search API +""" +import requests +import json +from datetime import datetime +from typing import List, Dict, Optional + + +class NewsSearcher: + """博查新闻搜索器""" + + def __init__(self, api_key: str, base_url: str = 'https://api.bocha.cn'): + """ + 初始化新闻搜索器 + + Args: + api_key: 博查API密钥 + base_url: API基础URL + """ + self.api_key = api_key + self.base_url = base_url + self.endpoint = f"{base_url}/v1/web-search" + + def search_news( + self, + query: str, + count: int = 10, + freshness: str = 'oneMonth', + summary: bool = True, + include: Optional[str] = None, + exclude: Optional[str] = None + ) -> Dict: + """ + 搜索新闻 + + Args: + query: 搜索关键词 + count: 返回结果数量(1-50) + freshness: 时间范围(noLimit/oneDay/oneWeek/oneMonth/oneYear) + summary: 是否显示摘要 + include: 指定搜索的网站范围(多个域名用|或,分隔) + exclude: 排除搜索的网站范围(多个域名用|或,分隔) + + Returns: + 搜索结果字典 + """ + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + payload = { + 'query': query, + 'count': count, + 'freshness': freshness, + 'summary': summary + } + + # 添加可选参数 + if include: + payload['include'] = include + if exclude: + payload['exclude'] = exclude + + try: + response = requests.post( + self.endpoint, + headers=headers, + data=json.dumps(payload), + timeout=30 + ) + response.raise_for_status() + return response.json() + + except requests.exceptions.RequestException as e: + return { + 'success': False, + 'error': str(e), + 'code': getattr(response, 'status_code', None) if 'response' in locals() else None + } + + def parse_news_items(self, search_result: Dict) -> List[Dict]: + """ + 解析搜索结果为新闻列表 + + Args: + search_result: 博查API返回的搜索结果 + + Returns: + 新闻列表,每个新闻包含:title, url, snippet, summary, site_name, published_at等 + """ + news_items = [] + + # 检查返回数据格式 + if 'data' not in search_result: + return news_items + + data = search_result['data'] + if 'webPages' not in data or 'value' not in data['webPages']: + return news_items + + # 解析每条新闻 + for item in data['webPages']['value']: + news_item = { + 'title': item.get('name', ''), + 'url': item.get('url', ''), + 'snippet': item.get('snippet', ''), + 'summary': item.get('summary', ''), + 'site_name': item.get('siteName', ''), + 'site_icon': item.get('siteIcon', ''), + 'published_at': self._parse_date(item.get('datePublished')), + 'display_url': item.get('displayUrl', ''), + 'language': item.get('language', ''), + } + news_items.append(news_item) + + return news_items + + def search_site_news( + self, + site_name: str, + site_url: Optional[str] = None, + count: int = 10, + freshness: str = 'oneMonth' + ) -> List[Dict]: + """ + 搜索特定网站的相关新闻 + + Args: + site_name: 网站名称(用于搜索关键词) + site_url: 网站URL(可选,用于排除网站自身) + count: 返回结果数量 + freshness: 时间范围 + + Returns: + 新闻列表 + """ + # 构建搜索关键词:网站名称 + "最新" + "新闻" + query = f"{site_name} 最新 新闻" + + # 如果提供了网站URL,排除网站自身的结果 + exclude = None + if site_url: + # 提取域名 + try: + from urllib.parse import urlparse + parsed = urlparse(site_url) + domain = parsed.netloc or parsed.path + # 移除 www. 前缀 + domain = domain.replace('www.', '') + exclude = domain + except Exception: + pass + + # 执行搜索 + search_result = self.search_news( + query=query, + count=count, + freshness=freshness, + summary=True, + exclude=exclude + ) + + # 解析结果 + return self.parse_news_items(search_result) + + def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]: + """ + 解析日期字符串 + + Args: + date_str: 日期字符串(例如:2025-02-23T08:18:30+08:00) + + Returns: + datetime对象,如果解析失败返回None + """ + if not date_str: + return None + + try: + # 尝试解析 ISO 8601 格式 + # 博查API返回格式:2025-02-23T08:18:30+08:00 + if '+' in date_str or 'Z' in date_str: + # 使用 fromisoformat(Python 3.7+) + return datetime.fromisoformat(date_str.replace('Z', '+00:00')) + else: + # 简单格式 + return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S') + except Exception: + # 如果解析失败,返回None + return None + + def format_news_for_display(self, news_items: List[Dict]) -> List[Dict]: + """ + 格式化新闻用于前端展示 + + Args: + news_items: 新闻列表 + + Returns: + 格式化后的新闻列表 + """ + formatted_news = [] + + for item in news_items: + formatted_item = { + 'title': item['title'], + 'url': item['url'], + 'description': item.get('summary') or item.get('snippet', ''), + 'source': item.get('site_name', '未知来源'), + 'published_date': self._format_date(item.get('published_at')), + 'icon': item.get('site_icon', '') + } + formatted_news.append(formatted_item) + + return formatted_news + + def _format_date(self, dt: Optional[datetime]) -> str: + """ + 格式化日期用于显示 + + Args: + dt: datetime对象 + + Returns: + 格式化的日期字符串 + """ + if not dt: + return '未知日期' + + try: + # 返回格式:2025-01-30 + return dt.strftime('%Y-%m-%d') + except Exception: + return '未知日期' + + +# 测试代码 +if __name__ == '__main__': + import os + from dotenv import load_dotenv + + load_dotenv() + + # 从环境变量获取API密钥 + api_key = os.environ.get('BOCHA_API_KEY') + if not api_key: + print("错误:未设置BOCHA_API_KEY环境变量") + exit(1) + + # 创建搜索器 + searcher = NewsSearcher(api_key) + + # 测试搜索 + print("正在搜索:ChatGPT 最新新闻...") + news_items = searcher.search_site_news( + site_name='ChatGPT', + count=5, + freshness='oneWeek' + ) + + # 显示结果 + print(f"\n找到 {len(news_items)} 条新闻:\n") + for i, news in enumerate(news_items, 1): + print(f"{i}. {news['title']}") + print(f" 来源:{news['site_name']}") + print(f" 日期:{searcher._format_date(news['published_at'])}") + print(f" URL:{news['url']}") + print(f" 摘要:{news.get('summary', news.get('snippet', ''))[:100]}...") + print() -- 2.50.1.windows.1 From 495248bf5f161d83fb20246fdbf7c59d88959b27 Mon Sep 17 00:00:00 2001 From: Jowe <123822645+Selei1983@users.noreply.github.com> Date: Tue, 30 Dec 2025 22:31:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20v2.2.0=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E6=96=B0=E9=97=BB=E6=9B=B4=E6=96=B0=E5=92=8C=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现每日首次访问自动更新新闻功能 - 每个网站获取3条一周内的新闻 - 新闻模块放置在左侧主栏 - 相似推荐移至右侧边栏 - 自动去重防止重复新闻 --- app.py | 64 +++++++++++++++++++++++++++++++++++++++ templates/detail_new.html | 42 ++++++++++--------------- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/app.py b/app.py index b0f27d4..2160534 100644 --- a/app.py +++ b/app.py @@ -116,6 +116,70 @@ def create_app(config_name='default'): site.view_count += 1 db.session.commit() + # 智能新闻更新:检查今天是否已更新过新闻 + from datetime import date + today = date.today() + + # 检查该网站最新一条新闻的创建时间 + latest_news = News.query.filter_by( + site_id=site.id + ).order_by(News.created_at.desc()).first() + + # 判断是否需要更新新闻 + need_update = False + if not latest_news: + # 没有任何新闻,需要获取 + need_update = True + elif latest_news.created_at.date() < today: + # 最新新闻不是今天创建的,需要更新 + need_update = True + + # 如果需要更新,自动获取最新新闻 + if need_update: + api_key = app.config.get('BOCHA_API_KEY') + if api_key: + try: + # 创建新闻搜索器 + searcher = NewsSearcher(api_key) + + # 获取新闻(限制3条,一周内的) + news_items = searcher.search_site_news( + site_name=site.name, + site_url=site.url, + count=3, + freshness='oneWeek' + ) + + # 保存新闻到数据库 + 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='Search Result', + is_active=True + ) + db.session.add(news) + + db.session.commit() + + except Exception as e: + # 获取新闻失败,不影响页面显示 + print(f"自动获取新闻失败:{str(e)}") + db.session.rollback() + # 获取该网站的相关新闻(最多显示5条) news_list = News.query.filter_by( site_id=site.id, diff --git a/templates/detail_new.html b/templates/detail_new.html index 18b43a1..b6ac54b 100644 --- a/templates/detail_new.html +++ b/templates/detail_new.html @@ -669,7 +669,10 @@ {% endfor %} {% endif %} + + + - - - {% endblock %} -- 2.50.1.windows.1