Files
ewoooc/routes/trend_routes.py
OoO 869cf2da31
All checks were successful
CD Pipeline / deploy (push) Successful in 59s
統一工具頁新版殼層
2026-05-14 00:57:29 +08:00

426 lines
13 KiB
Python

"""
趨勢資料 API 路由
提供:
- 趨勢記錄查詢
- 熱門關鍵字查詢
- AI 分析報告
- Web Search API
- 手動觸發爬取
"""
import json
import logging
from datetime import date, timedelta
from flask import Blueprint, request, jsonify, render_template
from sqlalchemy import func, text
from database.trend_models import TrendRecord, TrendKeyword, TrendAnalysis, WebSearchCache
from database.manager import get_session
from services.trend_crawler_service import get_trend_crawler_service
from services.ollama_service import OllamaService
from auth import login_required
# 設定 logging
logger = logging.getLogger(__name__)
# 建立 Blueprint
trend_bp = Blueprint('trend', __name__)
# ===== 頁面路由 =====
@trend_bp.route('/trends')
@login_required
def trends_page():
"""趨勢資料頁面"""
return render_template('trends.html', active_page='trends')
# ===== API 路由 =====
@trend_bp.route('/api/trends/records', methods=['GET'])
@login_required
def get_trend_records():
"""
取得趨勢記錄
Query params:
source: 來源篩選 (google_news, ptt, dcard, youtube)
category: 分類篩選
date_from: 起始日期 (YYYY-MM-DD)
date_to: 結束日期 (YYYY-MM-DD)
days: 天數 (與 date_from/date_to 二擇一)
limit: 筆數限制 (預設 50)
offset: 分頁偏移
"""
try:
source = request.args.get('source')
category = request.args.get('category')
days = request.args.get('days', type=int)
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
limit = min(int(request.args.get('limit', 50)), 200)
offset = int(request.args.get('offset', 0))
# 處理日期
if days:
date_from = (date.today() - timedelta(days=days)).isoformat()
date_to = date.today().isoformat()
else:
date_from = date_from or (date.today() - timedelta(days=7)).isoformat()
date_to = date_to or date.today().isoformat()
session = get_session()
try:
query = session.query(TrendRecord).filter(
TrendRecord.trend_date >= date_from,
TrendRecord.trend_date <= date_to
)
if source:
query = query.filter(TrendRecord.source == source)
if category:
query = query.filter(TrendRecord.category == category)
total = query.count()
records = query.order_by(
TrendRecord.popularity_score.desc(),
TrendRecord.created_at.desc()
).offset(offset).limit(limit).all()
return jsonify({
'success': True,
'total': total,
'limit': limit,
'offset': offset,
'data': [r.to_dict() for r in records]
})
finally:
session.close()
except Exception as e:
logger.error(f"取得趨勢記錄失敗: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@trend_bp.route('/api/trends/keywords', methods=['GET'])
@login_required
def get_trend_keywords():
"""
取得熱門關鍵字
Query params:
source: 來源篩選
category: 分類篩選
days: 天數範圍 (預設 7)
limit: 筆數限制 (預設 30)
"""
try:
source = request.args.get('source')
category = request.args.get('category')
days = int(request.args.get('days', 7))
limit = min(int(request.args.get('limit', 30)), 100)
date_from = date.today() - timedelta(days=days)
session = get_session()
try:
query = session.query(
TrendKeyword.keyword,
func.sum(TrendKeyword.mention_count).label('total_mentions'),
func.count(TrendKeyword.source.distinct()).label('source_count')
).filter(
TrendKeyword.trend_date >= date_from
).group_by(TrendKeyword.keyword)
if source:
query = query.filter(TrendKeyword.source == source)
if category:
query = query.filter(TrendKeyword.category == category)
keywords = query.order_by(
text('total_mentions DESC')
).limit(limit).all()
return jsonify({
'success': True,
'date_range': {
'from': date_from.isoformat(),
'to': date.today().isoformat()
},
'data': [
{
'keyword': kw.keyword,
'total_mentions': kw.total_mentions,
'source_count': kw.source_count
}
for kw in keywords
]
})
finally:
session.close()
except Exception as e:
logger.error(f"取得趨勢關鍵字失敗: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@trend_bp.route('/api/trends/analysis', methods=['GET'])
@login_required
def get_trend_analysis():
"""
取得 AI 趨勢分析報告
Query params:
date: 分析日期 (預設今日)
category: 分類篩選
type: 分析類型 (daily_summary, weekly_trend, hot_topic)
"""
try:
analysis_date = request.args.get('date', date.today().isoformat())
category = request.args.get('category')
analysis_type = request.args.get('type', 'daily_summary')
session = get_session()
try:
query = session.query(TrendAnalysis).filter(
TrendAnalysis.analysis_date == analysis_date,
TrendAnalysis.analysis_type == analysis_type
)
if category:
query = query.filter(TrendAnalysis.category == category)
else:
query = query.filter(TrendAnalysis.category.is_(None))
analysis = query.first()
if not analysis:
return jsonify({
'success': False,
'message': '找不到分析報告'
}), 404
return jsonify({
'success': True,
'data': analysis.to_dict()
})
finally:
session.close()
except Exception as e:
logger.error(f"取得趨勢分析失敗: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@trend_bp.route('/api/trends/crawl', methods=['POST'])
@login_required
def trigger_trend_crawl():
"""手動觸發趨勢爬取"""
try:
data = request.get_json() or {}
sources = data.get('sources') # 可選,指定來源
categories = data.get('categories') # 可選,指定分類
analyze = data.get('analyze', True) # 是否進行 AI 分析
service = get_trend_crawler_service()
result = service.crawl_and_save_all(
sources=sources,
categories=categories,
analyze=analyze
)
return jsonify({
'success': True,
'message': f"爬取完成: 新增 {result['new_records']} 筆記錄",
'result': result
})
except Exception as e:
logger.error(f"觸發趨勢爬取失敗: {e}")
return jsonify({
'success': False,
'error': f"爬取失敗: {str(e)}"
}), 500
@trend_bp.route('/api/trends/stats', methods=['GET'])
@login_required
def get_trend_stats():
"""取得趨勢資料統計"""
try:
days = int(request.args.get('days', 7))
service = get_trend_crawler_service()
result = service.get_trend_stats(days=days)
return jsonify(result)
except Exception as e:
logger.error(f"取得趨勢統計失敗: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@trend_bp.route('/api/trends/web_search', methods=['POST'])
@login_required
def trend_web_search():
"""
使用 Ollama Web Search 搜尋趨勢
Request body:
query: 搜尋查詢
search_type: 搜尋類型 (general, news, shopping, trends)
use_cache: 是否使用快取 (預設 true)
cache_hours: 快取時間 (預設 24)
"""
try:
data = request.get_json() or {}
query = data.get('query', '').strip()
search_type = data.get('search_type', 'trends')
use_cache = data.get('use_cache', True)
cache_hours = data.get('cache_hours', 24)
if not query:
return jsonify({'success': False, 'error': '請輸入搜尋關鍵字'}), 400
service = get_trend_crawler_service()
if use_cache:
result = service.web_search_with_cache(query, search_type, cache_hours)
else:
# 直接呼叫 Ollama
ollama = OllamaService()
response = ollama.web_search(query, search_type=search_type)
if response.success:
try:
content = response.content
json_start = content.find('{')
json_end = content.rfind('}') + 1
if json_start >= 0 and json_end > json_start:
parsed = json.loads(content[json_start:json_end])
else:
parsed = {'raw': content}
except json.JSONDecodeError:
parsed = {'raw': response.content}
result = {
'success': True,
'cached': False,
'data': {
'query': query,
'search_type': search_type,
'result': parsed,
'model': response.model,
'duration': response.total_duration
}
}
else:
result = {
'success': False,
'error': response.error
}
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
except Exception as e:
logger.error(f"趨勢 Web Search 失敗: {e}")
return jsonify({
'success': False,
'error': f'搜尋失敗: {str(e)}'
}), 500
@trend_bp.route('/api/trends/category_insights', methods=['GET'])
@login_required
def get_category_insights():
"""
取得分類趨勢洞察 (結合 Web Search)
Query params:
category: 商品分類
"""
try:
category = request.args.get('category', '美妝')
service = get_trend_crawler_service()
insights = service.search_trends_for_category(category)
return jsonify({
'success': True,
'data': insights
})
except Exception as e:
logger.error(f"取得分類洞察失敗: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@trend_bp.route('/api/trends/sources', methods=['GET'])
@login_required
def get_trend_sources():
"""取得可用的趨勢來源列表"""
return jsonify({
'success': True,
'data': {
'sources': [
{'code': 'google_news', 'name': 'Google 新聞', 'icon': 'newspaper'},
{'code': 'ptt', 'name': 'PTT', 'icon': 'comments'},
{'code': 'dcard', 'name': 'Dcard', 'icon': 'mobile'},
{'code': 'youtube', 'name': 'YouTube', 'icon': 'youtube'},
{'code': 'ollama_web_search', 'name': 'AI 搜尋', 'icon': 'robot'},
],
'categories': [
{'code': '美妝', 'name': '美妝'},
{'code': '3C', 'name': '3C'},
{'code': '服飾', 'name': '服飾'},
{'code': '居家', 'name': '居家'},
{'code': '電商', 'name': '電商'},
{'code': '優惠', 'name': '優惠'},
{'code': '母嬰', 'name': '母嬰'},
{'code': '美食', 'name': '美食'},
{'code': '熱門', 'name': '熱門話題'},
]
}
})
@trend_bp.route('/api/trends/cache/clear', methods=['POST'])
@login_required
def clear_trend_cache():
"""清除過期的 Web Search 快取"""
try:
session = get_session()
try:
# 刪除過期快取
deleted = session.query(WebSearchCache).filter(
WebSearchCache.expires_at < datetime.now()
).delete()
session.commit()
return jsonify({
'success': True,
'message': f'已清除 {deleted} 筆過期快取'
})
finally:
session.close()
except Exception as e:
logger.error(f"清除快取失敗: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
# 匯入 datetime 用於清除快取
from datetime import datetime