2026-05-08 08:50:12 +00:00
|
|
|
|
# --- routers/leaf_options.py ---
|
2026-05-18 07:10:38 +00:00
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Body
|
2026-05-13 10:31:34 +00:00
|
|
|
|
from fastapi.responses import StreamingResponse
|
2026-05-08 08:50:12 +00:00
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
from typing import List
|
2026-05-18 07:10:38 +00:00
|
|
|
|
import json, os, asyncio, re
|
2026-05-08 08:50:12 +00:00
|
|
|
|
|
|
|
|
|
|
from cmts_scraper import sync_cmts_leaves_async
|
|
|
|
|
|
from shared import CMTS_DEVICE
|
|
|
|
|
|
|
2026-05-13 10:31:34 +00:00
|
|
|
|
router = APIRouter(tags=["Options"])
|
2026-05-08 08:50:12 +00:00
|
|
|
|
CACHE_FILE = "cmts_leaf_options_cache.json"
|
2026-05-13 10:31:34 +00:00
|
|
|
|
|
2026-05-15 07:17:28 +00:00
|
|
|
|
# ==========================================
|
2026-05-18 07:10:38 +00:00
|
|
|
|
# 🌟 全域廣播機制 (SSE)
|
2026-05-15 07:17:28 +00:00
|
|
|
|
# ==========================================
|
2026-05-18 07:10:38 +00:00
|
|
|
|
IS_SCANNING = False
|
|
|
|
|
|
active_clients = set()
|
|
|
|
|
|
|
|
|
|
|
|
async def broadcast_message(message_dict: dict):
|
|
|
|
|
|
"""將訊息推播給所有連線中的前端"""
|
|
|
|
|
|
dead_clients = set()
|
|
|
|
|
|
|
|
|
|
|
|
# 🌟 關鍵修復 1:SSE 協定嚴格要求必須以 "data: " 開頭,並以 "\n\n" 結尾
|
|
|
|
|
|
message_str = f"data: {json.dumps(message_dict)}\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
for client_queue in active_clients:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await client_queue.put(message_str)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
dead_clients.add(client_queue)
|
|
|
|
|
|
for dead in dead_clients:
|
|
|
|
|
|
active_clients.discard(dead)
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/cmts-leaf-options/stream")
|
|
|
|
|
|
async def sse_stream(request: Request):
|
|
|
|
|
|
"""前端一載入就會連上這個路由,持續監聽廣播"""
|
|
|
|
|
|
client_queue = asyncio.Queue()
|
|
|
|
|
|
active_clients.add(client_queue)
|
|
|
|
|
|
|
|
|
|
|
|
async def event_generator():
|
|
|
|
|
|
try:
|
|
|
|
|
|
while True:
|
2026-05-18 07:40:19 +00:00
|
|
|
|
# 🌟 每次迴圈先檢查前端是否已經斷線 (按了 F5)
|
|
|
|
|
|
if await request.is_disconnected():
|
|
|
|
|
|
break
|
2026-05-18 07:10:38 +00:00
|
|
|
|
try:
|
2026-05-18 07:40:19 +00:00
|
|
|
|
# 縮短 timeout 為 1 秒,讓檢查更靈敏
|
|
|
|
|
|
message = await asyncio.wait_for(client_queue.get(), timeout=1.0)
|
2026-05-18 07:10:38 +00:00
|
|
|
|
yield message
|
|
|
|
|
|
except asyncio.TimeoutError:
|
|
|
|
|
|
yield ": keepalive\n\n"
|
2026-05-18 07:40:19 +00:00
|
|
|
|
|
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
|
# 🌟 關鍵修復:當 Uvicorn 觸發 reload 關閉伺服器時,會拋出此例外
|
|
|
|
|
|
# 我們捕捉它並默默退出,不讓伺服器卡住
|
|
|
|
|
|
pass
|
2026-05-18 07:10:38 +00:00
|
|
|
|
finally:
|
|
|
|
|
|
active_clients.discard(client_queue)
|
|
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
|
|
|
2026-05-15 07:17:28 +00:00
|
|
|
|
@router.get("/scan-status")
|
|
|
|
|
|
async def get_scan_status():
|
|
|
|
|
|
global IS_SCANNING
|
|
|
|
|
|
return {"is_scanning": IS_SCANNING}
|
|
|
|
|
|
|
2026-05-18 07:10:38 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 🌟 快取讀取與背景掃描任務
|
|
|
|
|
|
# ==========================================
|
2026-05-08 08:50:12 +00:00
|
|
|
|
class SyncOptionsRequest(BaseModel):
|
|
|
|
|
|
leaf_paths: List[str]
|
|
|
|
|
|
|
2026-05-13 10:31:34 +00:00
|
|
|
|
@router.get("/cmts-leaf-options")
|
|
|
|
|
|
async def get_leaf_options():
|
|
|
|
|
|
if not os.path.exists(CACHE_FILE): return {}
|
|
|
|
|
|
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
|
return json.load(f)
|
|
|
|
|
|
|
2026-05-18 07:10:38 +00:00
|
|
|
|
async def run_scan_task(paths: List[str]):
|
|
|
|
|
|
"""在背景執行的爬蟲任務,並將進度廣播出去"""
|
|
|
|
|
|
global IS_SCANNING
|
|
|
|
|
|
try:
|
|
|
|
|
|
await broadcast_message({"event": "scan_start"})
|
|
|
|
|
|
|
|
|
|
|
|
# 呼叫爬蟲 (帶入設備連線資訊)
|
|
|
|
|
|
async for chunk in sync_cmts_leaves_async(
|
|
|
|
|
|
host=CMTS_DEVICE.get("host"),
|
|
|
|
|
|
username=CMTS_DEVICE.get("username"),
|
|
|
|
|
|
password=CMTS_DEVICE.get("password"),
|
|
|
|
|
|
leaf_paths=paths
|
|
|
|
|
|
):
|
|
|
|
|
|
if isinstance(chunk, str):
|
|
|
|
|
|
await broadcast_message(json.loads(chunk))
|
|
|
|
|
|
else:
|
|
|
|
|
|
await broadcast_message(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
await broadcast_message({"event": "done"})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
await broadcast_message({"event": "error", "message": str(e)})
|
|
|
|
|
|
finally:
|
|
|
|
|
|
IS_SCANNING = False
|
|
|
|
|
|
|
2026-05-13 10:31:34 +00:00
|
|
|
|
@router.post("/cmts-leaf-options/sync")
|
2026-05-18 07:10:38 +00:00
|
|
|
|
async def sync_leaf_options(request: SyncOptionsRequest, background_tasks: BackgroundTasks):
|
|
|
|
|
|
"""前端點擊掃描時,只負責觸發背景任務並立即回傳 JSON"""
|
|
|
|
|
|
global IS_SCANNING
|
2026-05-13 10:31:34 +00:00
|
|
|
|
|
|
|
|
|
|
if not request.leaf_paths:
|
|
|
|
|
|
raise HTTPException(status_code=400)
|
|
|
|
|
|
|
|
|
|
|
|
if IS_SCANNING:
|
2026-05-18 07:10:38 +00:00
|
|
|
|
return {"status": "busy", "message": "⚠️ 掃描任務正在執行中,請勿重複點擊!"}
|
2026-05-08 08:50:12 +00:00
|
|
|
|
|
2026-05-13 10:31:34 +00:00
|
|
|
|
IS_SCANNING = True
|
2026-05-18 07:10:38 +00:00
|
|
|
|
|
|
|
|
|
|
cmts_query_paths = []
|
|
|
|
|
|
for p in request.leaf_paths:
|
|
|
|
|
|
clean_p = p.replace("::", " ")
|
|
|
|
|
|
clean_p = re.sub(r"\s*\[\d+\]", "", clean_p)
|
|
|
|
|
|
cmts_query_paths.append(clean_p)
|
|
|
|
|
|
|
|
|
|
|
|
cmts_query_paths = list(dict.fromkeys(cmts_query_paths))
|
|
|
|
|
|
|
|
|
|
|
|
# 將任務丟入背景執行,並立刻回傳 JSON 給前端
|
|
|
|
|
|
background_tasks.add_task(run_scan_task, cmts_query_paths)
|
|
|
|
|
|
return {"status": "started"}
|
2026-05-08 08:50:12 +00:00
|
|
|
|
|
2026-05-18 07:10:38 +00:00
|
|
|
|
# ==========================================
|
|
|
|
|
|
# 🌟 清除快取 API (對應 api.js 的 POST 請求)
|
|
|
|
|
|
# ==========================================
|
2026-05-08 08:50:12 +00:00
|
|
|
|
@router.post("/clear_cache")
|
|
|
|
|
|
async def clear_specific_cache(paths_to_clear: list = Body(...)):
|
|
|
|
|
|
cleared_count = 0
|
|
|
|
|
|
if not os.path.exists(CACHE_FILE):
|
|
|
|
|
|
return {"status": "success", "cleared_count": 0, "message": "快取檔案不存在"}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(CACHE_FILE, "r", encoding="utf-8") as f:
|
|
|
|
|
|
cache_data = json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
for path in paths_to_clear:
|
|
|
|
|
|
if path in cache_data:
|
|
|
|
|
|
del cache_data[path]
|
|
|
|
|
|
cleared_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
if cleared_count > 0:
|
|
|
|
|
|
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(cache_data, f, ensure_ascii=False, indent=4)
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "cleared_count": cleared_count}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-05-13 10:31:34 +00:00
|
|
|
|
return {"status": "error", "message": f"清除快取時發生錯誤: {str(e)}"}
|