#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 通用 API 路由模組 包含:任務觸發、通知、歷史查詢、價格變動等 API """ import os import threading import importlib from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify from sqlalchemy import func, desc from auth import login_required from config import BASE_DIR from database.manager import DatabaseManager from database.models import Product, PriceRecord from database.edm_models import PromoProduct from services.logger_manager import SystemLogger # 時區設定 TAIPEI_TZ = timezone(timedelta(hours=8)) # Logger sys_log = SystemLogger("APIRoutes").get_logger() # Blueprint 定義 api_bp = Blueprint('api', __name__) # ========================================== # 任務觸發 API # ========================================== @api_bp.route('/api/run_task', methods=['POST']) @login_required def trigger_task(): """API: 手動觸發 MOMO 爬蟲任務""" try: client_ip = request.remote_addr sys_log.info(f"[Web] [Task] 接收到手動執行請求 | IP: {client_ip}") # 使用獨立的 task_runner 服務,避免循環依賴 from services.task_runner import run_momo_task_with_notification run_momo_task_with_notification() return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"}) except Exception as e: sys_log.error(f"[Web] [Task] 手動觸發任務失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @api_bp.route('/api/run_edm_task', methods=['POST']) @login_required def trigger_edm_task(): """API: 手動觸發 EDM 爬蟲任務""" try: target_lpn = "O1K5FBOqsvN" # 預設活動代碼 sys_log.info(f"[Web] [Task] 接收到手動 EDM 執行請求 | LPN: {target_lpn}") # 強制重載 scheduler 模組 import scheduler importlib.reload(scheduler) # 使用執行緒啟動,避免卡住 Web Server task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,)) task_thread.daemon = True task_thread.start() return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) except Exception as e: sys_log.error(f"[Web] [Task] 手動觸發 EDM 任務失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @api_bp.route('/api/run_festival_task', methods=['POST']) @login_required def trigger_festival_task(): """API: 手動觸發 1.1 狂歡購物節爬蟲任務""" try: target_lpn = "O7ylWfihYUM" sys_log.info(f"[Web] [Task] 接收到手動 Festival 執行請求 | LPN: {target_lpn}") # 延遲導入 import scheduler importlib.reload(scheduler) # 使用執行緒啟動,避免卡住 Web Server task_thread = threading.Thread(target=scheduler.run_festival_task, args=(target_lpn,)) task_thread.daemon = True task_thread.start() return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) except Exception as e: sys_log.error(f"[Web] [Task] 手動觸發 Festival 任務失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 # ========================================== # 通知 API # ========================================== @api_bp.route('/api/trigger_momo_notification', methods=['POST']) @login_required def trigger_momo_notification(): """API: 手動觸發商品看板通知""" try: # 強制重載通知模組 import scheduler import services.notification_manager importlib.reload(scheduler) importlib.reload(services.notification_manager) from services.notification_manager import NotificationManager # 從 dashboard_routes 導入 get_dashboard_stats,避免循環依賴 from routes.dashboard_routes import get_dashboard_stats # 1. 取得統計數據 stats = get_dashboard_stats() # 2. 截取儀表板畫面 dashboard_url = "http://127.0.0.1/" screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard") # 3. 發送通知 notifier = NotificationManager() sys_log.info(f"[Web] [Notification] 手動觸發 MOMO 通知") notifier.send_momo_report(stats, screenshot_path) return jsonify({"status": "success", "message": "已發送商品看板通知"}) except Exception as e: sys_log.error(f"[Web] [Notification] 手動通知失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @api_bp.route('/api/trigger_edm_notification', methods=['POST']) @login_required def trigger_edm_notification(): """API: 手動觸發 EDM 比價通知 (不重爬,僅重發)""" try: # 強制重新載入設定與通知模組 import config import services.notification_manager import services.edm_notifier importlib.reload(config) importlib.reload(services.notification_manager) importlib.reload(services.edm_notifier) db = DatabaseManager() session = db.get_session() try: # 找出最新的 batch_id latest_batch_tuple = session.query(PromoProduct.batch_id).filter( PromoProduct.page_type == 'edm' ).order_by(desc(PromoProduct.crawled_at)).first() if not latest_batch_tuple: return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400 latest_batch_id = latest_batch_tuple[0] # 取得最新批次的所有異動商品 products = session.query(PromoProduct).filter( PromoProduct.batch_id == latest_batch_id ).all() if not products: return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200 # 嘗試尋找對應的截圖檔案 screenshot_path = None try: filename = f"edm_{latest_batch_id}.png" potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename) if os.path.exists(potential_path): screenshot_path = potential_path except Exception: pass from services.edm_notifier import EdmNotifier notifier = EdmNotifier() sys_log.info(f"[Web] [Notification] 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}") notifier.send_edm_report(products, screenshot_path) return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"}) finally: session.close() except Exception as e: sys_log.error(f"[Web] [Notification] 手動通知失敗 | Error: {e}") return jsonify({"status": "error", "message": str(e)}), 500 @api_bp.route('/api/test_notification', methods=['POST']) @login_required def test_notification(): """API: 測試訊息通知功能""" try: from services.notification_manager import NotificationManager import config import requests notifier = NotificationManager() sys_log.info("[Web] [Notification] 執行手動通知發送測試 (Line/Telegram/Email)...") token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None) target_id = getattr(config, 'LINE_GROUP_ID', None) if token and target_id: sys_log.info(f"[Web] [Notification] 偵測到 Channel Token: {token[:4]}...{token[-4:]}") sys_log.info(f"[Web] [Notification] 目標 ID: {target_id}") # 嘗試直接發送請求 try: headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } payload = { "to": target_id, "messages": [ { "type": "text", "text": "這是系統診斷測試訊息 (Messaging API)\n\n連線測試成功!" } ] } sys_log.info("[Web] [Notification] 正在嘗試連線至 Line Messaging API (push)...") resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10) sys_log.info(f"[Web] [Notification] Line API 回應 | Code: {resp.status_code}") sys_log.info(f"[Web] [Notification] Line API 內容 | Body: {resp.text}") if resp.status_code != 200: return jsonify({"status": "error", "message": f"Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400 except Exception as req_err: sys_log.error(f"[Web] [Notification] 直接連線測試發生異常 | Error: {req_err}") return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500 else: sys_log.warning("[Web] [Notification] 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)") return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400 # 呼叫真實的日報發送邏輯 notifier.send_daily_report() return jsonify({"status": "success", "message": "當日異動通知已發送 (Line/Telegram/Email)"}) except ImportError: return jsonify({"status": "error", "message": "找不到 NotificationManager 模組"}), 500 except Exception as e: sys_log.error(f"[Web] [Notification] 測試通知失敗 | Error: {e}") return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500 # ========================================== # 歷史查詢 API # ========================================== @api_bp.route('/api/history/') @login_required def get_price_history(product_id): """API: 取得商品過去 180 天的價格歷史""" db = DatabaseManager() session = db.get_session() try: # 計算 180 天前的日期 (保持台北時區) start_date = datetime.now(TAIPEI_TZ) - timedelta(days=180) records = session.query(PriceRecord).filter( PriceRecord.product_id == product_id, PriceRecord.timestamp >= start_date ).order_by(PriceRecord.timestamp).all() data = [{ 't': r.timestamp.strftime('%Y-%m-%d %H:%M'), 'p': r.price } for r in records] return jsonify(data) except Exception as e: sys_log.error(f"[Web] [History] 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}") return jsonify([]), 500 finally: session.close() @api_bp.route('/api/price_change_details') @login_required def get_price_change_details(): """API: 取得價格變動商品明細 (供彈窗使用)""" filter_type = request.args.get('type', '') # 以下參數保留以供未來擴展 # filter_category = request.args.get('category', '') # filter_product_id = request.args.get('product_id', '') db = DatabaseManager() session = db.get_session() try: # 取得今日起始時間 now_taipei = datetime.now(TAIPEI_TZ) today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) # 基礎查詢:取得所有商品的最新記錄 latest_records_subq = session.query( func.max(PriceRecord.id).label('max_id') ).group_by(PriceRecord.product_id).subquery() query = session.query(PriceRecord, Product).join( latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id ).join(Product, PriceRecord.product_id == Product.id) # 一次性查詢所有商品的「今日之前最後價格」 product_ids = [r[0] for r in session.query(PriceRecord.product_id).join( latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id ).all()] yesterday_prices_subq = session.query( PriceRecord.product_id, func.max(PriceRecord.id).label('max_id') ).filter( PriceRecord.product_id.in_(product_ids), PriceRecord.timestamp < today_start ).group_by(PriceRecord.product_id).subquery() yesterday_prices_q = session.query( PriceRecord.product_id, PriceRecord.price ).join( yesterday_prices_subq, PriceRecord.id == yesterday_prices_subq.c.max_id ) yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} # 根據 filter_type 進行篩選 products = [] if filter_type == 'increase': # 漲價商品 for record, product in query.all(): old_price = yesterday_prices_map.get(product.id) if old_price is not None and record.price > old_price: products.append({ 'product_id': product.i_code, 'name': product.name, 'category': product.category, 'url': product.url, 'image_url': product.image_url or '/static/placeholder.png', 'old_price': old_price, 'current_price': record.price, 'change': record.price - old_price, 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') }) elif filter_type == 'decrease': # 降價商品 for record, product in query.all(): old_price = yesterday_prices_map.get(product.id) if old_price is not None and record.price < old_price: products.append({ 'product_id': product.i_code, 'name': product.name, 'category': product.category, 'url': product.url, 'image_url': product.image_url or '/static/placeholder.png', 'old_price': old_price, 'current_price': record.price, 'change': record.price - old_price, 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') }) elif filter_type == 'delisted': # 下架商品 (今日狀態為 INACTIVE 且今天更新的) today_delisted = session.query(Product).filter( Product.status == 'INACTIVE', Product.updated_at >= today_start ).all() for product in today_delisted: last_record = session.query(PriceRecord).filter( PriceRecord.product_id == product.id ).order_by(PriceRecord.timestamp.desc()).first() if last_record: products.append({ 'product_id': product.i_code, 'name': product.name, 'category': product.category, 'url': product.url, 'image_url': product.image_url or '/static/placeholder.png', 'last_price': last_record.price, 'update_time': product.updated_at.strftime('%Y-%m-%d %H:%M') if product.updated_at else '' }) return jsonify({'products': products}) except Exception as e: sys_log.error(f"[Web] [PriceChange] 獲取價格變動明細失敗 | Error: {e}") return jsonify({'products': []}), 500 finally: session.close()