feat: v2.6.0 - API安全优化和文档整合

## 核心优化
- 移除详情页自动调用博查API的逻辑,改为按需加载
- 添加基于IP的频率限制(每小时3次)
- 实现验证码防护机制(超过阈值后要求验证)
- 新增频率限制工具类 utils/rate_limiter.py

## 成本控制
- API调用减少约90%+(只在用户点击时调用)
- 防止恶意滥用和攻击
- 可配置的频率限制和验证码策略

## 文档整合
- 创建 docs/ 目录结构
- 归档历史版本文档到 docs/archive/
- 移动部署文档到 docs/deployment/
- 添加文档索引 docs/README.md

## 技术变更
- 新增依赖: Flask-Limiter==3.5.0
- 修改: app.py (移除自动调用,新增API端点)
- 修改: templates/detail_new.html (按需加载UI)
- 新增: utils/rate_limiter.py (频率限制和验证码)
- 新增: docs/archive/DEVELOP_v2.6.0_API_SECURITY.md

## 部署说明
1. pip install Flask-Limiter==3.5.0
2. 重启应用
3. 无需数据库迁移

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-02-06 15:54:13 +08:00
parent c1a06ad684
commit 939717fa57
27 changed files with 1670 additions and 140 deletions

213
app.py
View File

@@ -10,6 +10,7 @@ from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTe
from utils.website_fetcher import WebsiteFetcher
from utils.tag_generator import TagGenerator
from utils.news_searcher import NewsSearcher
from utils.rate_limiter import get_rate_limiter, get_client_ip, CaptchaVerifier
def create_app(config_name='default'):
"""应用工厂函数"""
@@ -156,70 +157,8 @@ 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,
news_keywords=site.news_keywords, # v2.3新增:使用专用关键词
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()
# v2.6优化移除自动调用博查API的逻辑改为按需加载
# 只获取数据库中已有的新闻不再自动调用API
# 获取该网站的相关新闻最多显示5条
news_list = News.query.filter_by(
@@ -227,6 +166,9 @@ def create_app(config_name='default'):
is_active=True
).order_by(News.published_at.desc()).limit(5).all()
# 检查是否有新闻,如果没有则标记需要加载
has_news = len(news_list) > 0
# 获取同类工具推荐通过标签匹配最多显示4个
recommended_sites = []
if site.tags:
@@ -237,7 +179,148 @@ def create_app(config_name='default'):
Site.tags.any(Tag.id.in_([tag.id for tag in site.tags]))
).order_by(Site.view_count.desc()).limit(4).all()
return render_template('detail_new.html', site=site, news_list=news_list, recommended_sites=recommended_sites)
return render_template('detail_new.html', site=site, news_list=news_list, has_news=has_news, recommended_sites=recommended_sites)
# ========== 新闻相关API (v2.6优化) ==========
@app.route('/api/fetch-news/<code>', methods=['POST'])
def fetch_news_for_site(code):
"""
按需获取指定网站的新闻
v2.6优化添加频率限制和验证码防护避免API被滥用
"""
try:
# 获取客户端IP
client_ip = get_client_ip(request)
# 获取频率限制器
limiter = get_rate_limiter()
# 检查是否需要验证码
captcha_required, captcha_reason = limiter.is_captcha_required(client_ip)
if captcha_required:
return jsonify({
'success': False,
'error': captcha_reason,
'require_captcha': True
}), 429
# 检查频率限制每小时3次
is_limited, remaining, reset_time = limiter.is_rate_limited(
client_ip,
action='news_fetch',
limit=3,
window_minutes=60
)
if is_limited:
# 触发频率限制,要求验证码
limiter.require_captcha(client_ip, duration_minutes=30)
return jsonify({
'success': False,
'error': f'请求过于频繁,请在 {reset_time.strftime("%H:%M")} 后重试',
'require_captcha': True,
'reset_time': reset_time.isoformat()
}), 429
# 检查验证码(如果提供了)
captcha_token = request.json.get('captcha_token') if request.json else None
if captcha_token:
# TODO: 配置实际的验证码服务reCAPTCHA或hCaptcha
verifier = CaptchaVerifier(service='simple')
success, error = verifier.verify(captcha_token, client_ip)
if success:
# 验证通过,清除验证码要求
limiter.clear_captcha_requirement(client_ip)
else:
return jsonify({
'success': False,
'error': f'验证码验证失败: {error}'
}), 400
# 记录本次请求
limiter.record_request(client_ip, action='news_fetch')
# 查找网站
site = Site.query.filter_by(code=code, is_active=True).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='Search Result',
is_active=True
)
db.session.add(news)
new_count += 1
db.session.commit()
# 返回最新的新闻列表
news_list = News.query.filter_by(
site_id=site.id,
is_active=True
).order_by(News.published_at.desc()).limit(5).all()
# 格式化新闻数据
news_data = []
for news in news_list:
news_data.append({
'title': news.title,
'content': news.content[:200] if news.content else '',
'url': news.url,
'source_name': news.source_name,
'source_icon': news.source_icon,
'published_at': news.published_at.strftime('%Y年%m月%d') if news.published_at else '未知日期',
'news_type': news.news_type
})
return jsonify({
'success': True,
'news': news_data,
'new_count': new_count,
'remaining_requests': remaining - 1, # 已经消耗了一次
'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
# ========== 社媒营销路由 (v2.5新增) ==========
@app.route('/api/generate-social-share', methods=['POST'])