""" 趨勢資料 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') # ===== 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