fix(stability): 補強 scheduler 例外處理與 vendor 模板路徑
ADR-017 Phase 3f-3:移除 scheduler 裸 except,P1 任務失敗改走 EventRouter notify_failure 同步入口;清理 docker-compose vendor 死 mount;修正 vendor_bp template_folder 指向專案 web/templates。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
67
scheduler.py
67
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: <div class="last">倒數:<span id="gdsStock_1">92</span><small>組</small></div>
|
||||
@@ -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 執行。")
|
||||
logging.info("[Scheduler] [Main] 此為排程任務定義檔,請由主程式 app.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"))
|
||||
|
||||
Reference in New Issue
Block a user