Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
16 KiB
Python
401 lines
16 KiB
Python
#!/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/<int:product_id>')
|
||
@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()
|