426 lines
13 KiB
Python
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
|