scm-harmonic-cmts-admin/cmts_scraper.py

268 lines
11 KiB
Python
Raw Normal View History

# --- cmts_scraper.py ---
import asyncio
import asyncssh
import re
import json
import os
import time
def parse_question_mark_output(output: str) -> dict:
"""解析 '?' 回傳內容,支援無 Description、子命令判定與 dhcp-relay 混合欄位"""
hint_lines = []
options = []
current_value = None
format_desc = None
is_format_only = False
state = "INIT"
has_description = False
has_bracket_value = False
# 🌟 Case-5 特殊處理dhcp-relay 混合型欄位 (選項 + IP輸入)
# 直接在開頭掃描完整輸出,若包含 dhcp-relay強制轉為純文字輸入框
if "dhcp-relay" in output.lower():
is_format_only = True
format_desc = "IP address or options"
for line in output.splitlines():
line = line.strip()
# 過濾雜訊與終端機提示字元
if not line or line.startswith('admin@') or line.startswith('cable') or line.startswith('%') or line.startswith('^'):
continue
if line.startswith('Description:'):
state = "DESC"
has_description = True
hint_lines.append(line)
continue
if line.startswith('Possible completions:'):
state = "COMPLETIONS"
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
continue
if state == "DESC":
hint_lines.append(line)
elif state == "COMPLETIONS":
# Case 1~4: 無 Description 時,將後續選項說明也納入提示
# 🌟 解除限制:無論有沒有 Description都把這行加進 hint
hint_lines.append(line)
# 規則 1: <格式說明>[當前值]
match_format = re.search(r"<([^>]+)>\s*(?:\[([^\]]+)\])?", line)
if match_format:
is_format_only = True
format_desc = match_format.group(1).strip()
if match_format.group(2):
current_value = match_format.group(2).strip()
continue
# 規則 1.5: 型態, 最小值 .. 最大值
match_range = re.search(r"^\s*([a-zA-Z0-9_]+),\s*(\d+)\s*\.\.\s*(\d+)\s*$", line)
if match_range:
is_format_only = True
format_desc = f"{match_range.group(1)} ({match_range.group(2)} .. {match_range.group(3)})"
continue
# 規則 1.6: IP address 等純文字關鍵字
match_keyword = re.search(r"^(IP address|IPv4 address|IPv6 address|MAC address)$", line, re.IGNORECASE)
if match_keyword:
is_format_only = True
format_desc = match_keyword.group(1)
continue
# 規則 2: 選項列表處理
match_current = re.search(r"^\[([^\]]+)\]", line)
if match_current:
has_bracket_value = True # 標記:這是一個帶有現值的標準選項清單
if not current_value:
current_value = match_current.group(1).strip()
clean_line = re.sub(r"^\[[^\]]+\]", "", line).strip()
else:
clean_line = line.strip()
# 🌟 Case-6 防呆:過濾垂直列表的說明文字
# 利用「3 個以上的連續空白」作為選項與說明文字的分界線
if re.search(r"\s{3,}", clean_line):
clean_line = re.split(r"\s{3,}", clean_line)[0]
parts = clean_line.split()
for p in parts:
if p and p not in ["|", ".."]:
options.append(p)
# 🌟 Case 2, 3, 4: 子命令防呆機制
# 若在選項區塊從未發現 [現值],且非已知格式,判定為子命令,強制轉純文字
if state == "COMPLETIONS" and not has_bracket_value and not is_format_only and options:
is_format_only = True
options = []
# 確保現值一定包含在選項中(如果它是一般的下拉選單)
if current_value and options and current_value not in options:
options.append(current_value)
hint_text = "\n".join(hint_lines).strip()
return {
"hint": hint_text,
# 若為純文字模式,強制回傳空陣列,確保前端正確渲染為 input
"options": list(dict.fromkeys(options)) if not is_format_only else [],
"current_value": current_value,
"format_desc": format_desc,
"is_format_only": is_format_only
}
def parse_device_response(output: str) -> dict:
"""支援多種編輯狀態格式解析"""
match = re.search(r"(?:\[(.*?)\]|\(<(.*?)>\))\s*\((.*?)\):", output)
if match:
enum_content = match.group(1)
desc_content = match.group(2)
current_value = match.group(3).strip()
if enum_content:
if enum_content.strip().lower() == "list":
return {"type": "list", "options": [], "current_value": current_value}
else:
return {
"type": "enum",
"options": [opt.strip() for opt in enum_content.split(",") if opt.strip()],
"current_value": current_value
}
elif desc_content:
return {
"type": "string_or_number",
"options": [],
"current_value": current_value,
"format_desc": desc_content.strip()
}
return {"type": "unknown", "options": [], "current_value": None, "raw_output": output.strip()}
async def sync_cmts_leaves_async(host, username, password, leaf_paths: list) -> dict:
try:
result_data = {}
BATCH_SIZE = 30
batches = [leaf_paths[i:i + BATCH_SIZE] for i in range(0, len(leaf_paths), BATCH_SIZE)]
for batch_idx, batch in enumerate(batches):
print(f"🔄 正在處理第 {batch_idx + 1}/{len(batches)} 批次 (共 {len(batch)} 個路徑)...")
conn = None
try:
conn = await asyncssh.connect(host, username=username, password=password, known_hosts=None)
process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8')
async def read_until_quiet(timeout=1.0):
output = ""
while True:
try:
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk: break
output += chunk
if "--More--" in chunk or "More" in chunk:
process.stdin.write(" ")
await process.stdin.drain()
except asyncio.TimeoutError:
break
return output
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
for path in batch:
# --- 步驟 1發送 '?' 查詢 ---
command_to_send = f"{path} ?"
process.stdin.write(command_to_send)
await process.stdin.drain()
output_question = await read_until_quiet(timeout=1.0)
q_data = parse_question_mark_output(output_question)
backspaces = "\x08" * (len(command_to_send) + 5)
process.stdin.write(backspaces)
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
parsed_data = {
"hint": q_data["hint"],
"options": q_data["options"],
"type": "list" if q_data["options"] else "unknown",
"current_value": q_data.get("current_value")
}
# --- 步驟 2判斷是否需要發送 Enter ---
# 🌟 配合新規則:如果 '?' 已經明確告訴我們這是 <格式> 欄位,直接跳過 Enter
if q_data.get("is_format_only"):
parsed_data["type"] = "string_or_number"
# if q_data.get("format_desc"):
# parsed_data["hint"] += f"\nFormat: {q_data['format_desc']}"
elif not q_data["options"]:
# 原本的 Enter 邏輯
process.stdin.write(f"{path}\n")
await process.stdin.drain()
output_enter = await read_until_quiet(timeout=1.0)
enter_data = parse_device_response(output_enter)
parsed_data["type"] = enter_data["type"]
parsed_data["options"] = enter_data["options"]
parsed_data["current_value"] = enter_data["current_value"]
# if "format_desc" in enter_data:
# parsed_data["hint"] += f"\nFormat: {enter_data['format_desc']}"
if enter_data["type"] != "unknown":
process.stdin.write("\x03")
await process.stdin.drain()
else:
print(f"⚠️ [Debug] 未知格式 ({path}):\n{enter_data['raw_output']}")
process.stdin.write("\n")
await process.stdin.drain()
await read_until_quiet(timeout=0.5)
result_data[path] = parsed_data
process.stdin.write("exit\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.0)
except Exception as e:
print(f"❌ 第 {batch_idx + 1} 批次發生錯誤: {str(e)}")
continue
finally:
if conn: conn.close()
try:
cache_file = "cmts_leaf_options_cache.json"
cache_data = {}
if os.path.exists(cache_file):
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
current_ts = int(time.time())
for p in batch:
if p in result_data:
result_data[p]["updated_at"] = current_ts
cache_data[p] = result_data[p]
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, indent=4, ensure_ascii=False)
print(f"💾 [Debug] 第 {batch_idx + 1}/{len(batches)} 批次已提早寫入快取檔!")
except Exception as e:
print(f"⚠️ 提早寫入快取失敗: {e}")
if batch_idx < len(batches) - 1:
await asyncio.sleep(2)
return {"status": "success", "data": result_data}
except Exception as e:
return {"status": "error", "message": f"AsyncSSH 爬蟲錯誤: {str(e)}"}