diff --git a/docker-compose.yml b/docker-compose.yml
index f547882..982ea99 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -79,14 +79,6 @@ services:
- ./auto_import_index.html:/app/auto_import_index.html:ro
- ./settings.html:/app/settings.html:ro
- ./system_settings.html:/app/system_settings.html:ro
- # 廠商缺貨系統
- - ./vendor_routes.py:/app/vendor_routes.py:ro
- - ./vendor_stockout_index.html:/app/vendor_stockout_index.html:ro
- - ./vendor_stockout_import.html:/app/vendor_stockout_import.html:ro
- - ./vendor_stockout_list.html:/app/vendor_stockout_list.html:ro
- - ./vendor_stockout_send_email.html:/app/vendor_stockout_send_email.html:ro
- - ./vendor_stockout_history.html:/app/vendor_stockout_history.html:ro
- - ./vendor_management.html:/app/vendor_management.html:ro
# 其他根目錄路由
- ./auto_import_routes.py:/app/auto_import_routes.py:ro
- ./crawler_management_routes.py:/app/crawler_management_routes.py:ro
diff --git a/routes/vendor_routes.py b/routes/vendor_routes.py
index c741c5a..0c107f5 100644
--- a/routes/vendor_routes.py
+++ b/routes/vendor_routes.py
@@ -23,9 +23,8 @@ from services.logger_manager import SystemLogger
sys_log = SystemLogger("VendorRoutes").get_logger()
# 建立 Blueprint
-# V-Fix (2026-01-23): 指定正確的 template_folder,解決 TemplateNotFound 錯誤
-import os
-_base_dir = os.path.dirname(os.path.abspath(__file__))
+# V-Fix: 指定正確的 template_folder,解決 TemplateNotFound 錯誤
+_base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_template_folder = os.path.join(_base_dir, 'web', 'templates')
vendor_bp = Blueprint('vendor', __name__, url_prefix='/vendor-stockout', template_folder=_template_folder)
diff --git a/scheduler.py b/scheduler.py
index 10da552..62d9390 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -240,7 +240,8 @@ def run_momo_task():
logging.warning(f"[Crawler] [MOMO] ⚠️ 頁面載入超時 (15s),強制停止載入並嘗試解析內容...")
try:
driver.execute_script("window.stop();")
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [MOMO] window.stop() 失敗但繼續 | Category: {cat_name} | Error: {e}")
# V-Opt: eager 模式下,get 會很快返回,這裡稍微等待一下 DOM 穩定
time.sleep(1)
@@ -251,7 +252,8 @@ def run_momo_task():
for j in range(1, 3): # V-Opt: 再減少一次滾動 (4 -> 3),通常前兩屏即包含大部分熱銷商品
driver.execute_script(f"window.scrollTo(0, {j * 1500});") # 加大滾動距離
time.sleep(0.5)
- except Exception: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [MOMO] 滾動頁面失敗但繼續 | Category: {cat_name} | Error: {e}")
# === V9.95: 改為容器優先的爬取策略,提高穩定性 ===
# 1. 找出所有可能的商品容器
@@ -467,18 +469,16 @@ def run_momo_task():
_save_stats('momo_task', stats)
# ADR-012 Phase 2: 走 EventRouter(Hermes L1 翻譯 + 三層式訊息)
try:
- from services.event_router import dispatch as _dispatch
- _dispatch({
- "source": "Scheduler.MOMOCrawler",
- "event_type": "crawler_timeout",
- "severity": "alert",
- "title": "MOMO 爬蟲任務中斷",
- "status": "任務失敗",
- "impact": "P1 - 熱銷商品監控中斷",
- "summary": str(e)[:200],
- "trace": _tb.format_exc(),
- "payload": {"task_name": "run_momo_task"},
- })
+ from services.event_router import notify_failure
+ notify_failure(
+ task_name="run_momo_task",
+ error=e,
+ source="Scheduler.MOMOCrawler",
+ event_type="crawler_timeout",
+ priority="P1",
+ title="MOMO 爬蟲任務中斷",
+ trace=_tb.format_exc(),
+ )
except Exception as _router_e:
logging.error(f"[Crawler] [MOMO] event_router 失敗: {_router_e}")
finally:
@@ -579,12 +579,14 @@ def run_edm_task(lpn_code="O1K5FBOqsvN"):
brand_text = ""
try:
brand_text = item.find_element(By.CSS_SELECTOR, ".brand").text.strip()
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [EDM] 解析品牌失敗但繼續 | Block: {i+1} | Error: {e}")
name_text = ""
try:
name_text = item.find_element(By.CSS_SELECTOR, ".brand2").text.strip()
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [EDM] 解析品名失敗但繼續 | Block: {i+1} | Error: {e}")
name = f"{brand_text} {name_text}".strip()
@@ -650,7 +652,8 @@ def run_edm_task(lpn_code="O1K5FBOqsvN"):
disc_el = item.find_element(By.CSS_SELECTOR, ".discount span")
if disc_el:
discount_text = disc_el.text.strip() + "折"
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [EDM] 解析折扣失敗但繼續 | i_code: {i_code} | Error: {e}")
# V9.66: 解析倒數組數
# HTML:
倒數:92組
@@ -887,7 +890,8 @@ def run_festival_task(lpn_code="O7ylWfihYUM"):
logging.warning("[Crawler] [Festival] ⚠️ 頁面載入超時 (120s),嘗試停止載入並繼續解析...")
try:
driver.execute_script("window.stop();")
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [Festival] window.stop() 失敗但繼續 | Error: {e}")
# V-Fix: 增加初始等待時間,確保頁面上的 Vue.js 框架有足夠時間初始化並掛載懶加載事件
time.sleep(10)
@@ -1219,7 +1223,8 @@ def run_promo_event_task(lpn_code, page_type, activity_name):
logging.warning(f"[Crawler] [{page_type.upper()}] ⚠️ 頁面載入超時 (120s),嘗試停止載入並繼續解析...")
try:
driver.execute_script("window.stop();")
- except: pass
+ except Exception as e:
+ logging.exception(f"[Crawler] [{page_type.upper()}] window.stop() 失敗但繼續 | Error: {e}")
time.sleep(10)
logging.info(f"[Crawler] [{page_type.upper()}] 📄 頁面標題: {driver.title}")
@@ -1876,18 +1881,16 @@ def run_auto_import_task():
# ADR-012 Phase 2: 改走 EventRouter(Hermes L1 翻譯 + 降級鏈)
# LINE 通道保留(event_router 只處理 Telegram)
try:
- from services.event_router import dispatch as _dispatch
- _dispatch({
- "source": "Scheduler.AutoImport",
- "event_type": "db_connection_error" if "translate host" in str(e).lower() or "operational" in str(e).lower() else "import_failure",
- "severity": "alert",
- "title": "當日業績自動匯入異常",
- "status": "匯入失敗",
- "impact": "P1 - 當日業績未更新",
- "summary": str(e)[:200],
- "trace": _tb.format_exc(),
- "payload": {"task_name": "run_auto_import_task"},
- })
+ from services.event_router import notify_failure
+ notify_failure(
+ task_name="run_auto_import_task",
+ error=e,
+ source="Scheduler.AutoImport",
+ event_type="db_connection_error" if "translate host" in str(e).lower() or "operational" in str(e).lower() else "import_failure",
+ priority="P1",
+ title="當日業績自動匯入異常",
+ trace=_tb.format_exc(),
+ )
except Exception as _router_e:
logging.error(f"[Scheduler] [AutoImport] event_router 失敗: {_router_e}")
@@ -2331,4 +2334,4 @@ def run_monthly_report_task():
if __name__ == "__main__":
# 此檔案現在由 app.py 導入並由其主執行緒管理排程。
# 若需獨立測試,可在此處臨時加入調用程式碼。
- logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.py 執行。")
\ No newline at end of file
+ logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.py 執行。")
diff --git a/services/event_router.py b/services/event_router.py
index 7b9c7d3..794d151 100644
--- a/services/event_router.py
+++ b/services/event_router.py
@@ -5,6 +5,8 @@
#
import asyncio
import logging
+import threading
+import traceback
from typing import Any, Dict, Optional
from services.ai_orchestrator import AIOrchestrator
@@ -103,6 +105,71 @@ async def dispatch(event: Dict[str, Any], admin_chat_ids: Optional[list] = None)
}
+def _run_coroutine_in_thread(coro) -> Dict[str, Any]:
+ result = {}
+
+ def runner():
+ try:
+ result["value"] = asyncio.run(coro)
+ except Exception as e:
+ result["value"] = {
+ "tier": "unknown",
+ "sent": 0,
+ "errors": [str(e)],
+ "latency_ms": 0,
+ "payload": None,
+ }
+
+ thread = threading.Thread(target=runner, daemon=True)
+ thread.start()
+ thread.join(timeout=15)
+ if thread.is_alive():
+ return {
+ "tier": "unknown",
+ "sent": 0,
+ "errors": ["dispatch_sync timed out"],
+ "latency_ms": 15000,
+ "payload": None,
+ }
+ return result["value"]
+
+
+def dispatch_sync(event: Dict[str, Any], admin_chat_ids: Optional[list] = None) -> Dict[str, Any]:
+ """同步環境使用的 EventRouter 入口。"""
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(dispatch(event, admin_chat_ids=admin_chat_ids))
+ return _run_coroutine_in_thread(dispatch(event, admin_chat_ids=admin_chat_ids))
+
+
+def notify_failure(
+ task_name: str,
+ error: Exception,
+ *,
+ source: Optional[str] = None,
+ event_type: str = "scheduler_task_failure",
+ priority: str = "P2",
+ title: Optional[str] = None,
+ trace: Optional[str] = None,
+ payload: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """排程/背景任務失敗的同步通知 helper。"""
+ severity = "alert" if priority in {"P1", "P2"} else "warning"
+ event = {
+ "source": source or f"Scheduler.{task_name}",
+ "event_type": event_type,
+ "severity": severity,
+ "title": title or f"{task_name} 任務異常",
+ "status": "任務失敗",
+ "impact": f"{priority} - 背景任務需要檢查",
+ "summary": str(error)[:200],
+ "trace": trace or "".join(traceback.format_exception(type(error), error, error.__traceback__)),
+ "payload": {"task_name": task_name, **(payload or {})},
+ }
+ return dispatch_sync(event)
+
+
def _classify(event: Dict[str, Any]) -> str:
sev = event.get("severity", "info")
has_trace = bool(event.get("trace"))