# -*- coding: utf-8 -*- """sync_pages.py —— 网页指引同步工具(Intent F 的核心实现) 功能: 1. 调平台权限接口拉取全量菜单树(权威路由清单,无需遍历爬取) 2. 与 pages.yaml 比对:报告新增页面(菜单有、注册表无)/ 疑似下线页面(注册表可见、菜单无) 3. 为新增页面生成 pages.yaml 草稿片段(desc/usage 留 TODO,由人工或智能体补写) 4. 输出到 stdout 与 sync_draft.yaml(只读比对 + 草稿,不改动 pages.yaml) 用法: python sync_pages.py # token 也可用环境变量 TF_TOKEN python sync_pages.py --base-url http://x.x.x.x:8092 # 覆盖 config.yaml 说明: - 只比对叶子菜单(有 children 的容器节点跳过) - 注册表的 path 与 menu_key 都参与比对(设备详情页 path 带 ?id=,按 menu_key 认领) - HTTP 状态无法识别 SPA 的客户端 404,页面可用性验证需浏览器渲染(见 SKILL.md 流程 F 第3步) """ import io import json import os import sys import urllib.request from pathlib import Path import yaml sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") HERE = Path(__file__).parent ROOT = HERE.parent # ---------- 参数 ---------- token = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("TF_TOKEN", "") base_url = None if "--base-url" in sys.argv: base_url = sys.argv[sys.argv.index("--base-url") + 1] if not token: print("用法: python sync_pages.py (或设环境变量 TF_TOKEN)") sys.exit(1) cfg = yaml.safe_load((ROOT / "config.yaml").read_text(encoding="utf-8")) base_url = (base_url or cfg["base_url"]).rstrip("/") # ---------- 拉取菜单树 ---------- url = f"{base_url}/modelreq/sys/permissionNew/getUserPermissionByToken?token={token}" req = urllib.request.Request(url, headers={"X-Access-Token": token}) with urllib.request.urlopen(req, timeout=15) as resp: payload = json.loads(resp.read().decode("utf-8")) if not payload.get("success"): print(f"权限接口调用失败: {payload.get('message')}(token 是否过期?重新登录平台后从请求头 X-Access-Token 复制)") sys.exit(1) leaves = [] # 叶子菜单(新增比对用) all_paths = set() # 全部节点路径含容器(下线比对用,避免父节点误报) def walk(nodes, parent): for n in nodes or []: path = n.get("path") or "" if path: all_paths.add(path.split("?")[0]) if n.get("children"): walk(n["children"], n.get("meta", {}).get("title") or parent) else: leaves.append((n.get("meta", {}).get("title") or "?", path, bool(n.get("hidden")), parent)) walk(payload["result"]["menu"], "") # ---------- 读取注册表 ---------- data = yaml.safe_load((HERE / "pages.yaml").read_text(encoding="utf-8")) reg_paths, reg_keys = set(), set() for cat in data["categories"]: items = list(cat.get("pages") or []) for g in cat.get("groups") or []: items += g.get("pages") or [] for p in items: reg_paths.add(p.get("path", "").split("?")[0]) if p.get("menu_key"): reg_keys.add(p["menu_key"].split("?")[0]) # ---------- 差集 ---------- new_pages, known = [], [] for title, path, hidden, parent in leaves: base = path.split("?")[0] if base in reg_paths or base in reg_keys: known.append(title) else: new_pages.append((title, path, hidden, parent)) reg_visible_keys = set() for cat in data["categories"]: items = list(cat.get("pages") or []) for g in cat.get("groups") or []: items += g.get("pages") or [] for p in items: if p.get("visible", True) and p.get("menu_key"): reg_visible_keys.add((p["name"], p["menu_key"].split("?")[0])) menu_bases = all_paths removed = [(n, k) for n, k in reg_visible_keys if k not in menu_bases] # ---------- 报告 ---------- print(f"菜单叶子页: {len(leaves)} | 注册表已认领: {len(known)} | 菜单新增: {len(new_pages)} | 疑似下线: {len(removed)}") print(f"\n== 菜单新增(候选待录入) ==") for t, p, h, par in new_pages: print(f" [{'hidden' if h else '可见 '}] {par} > {t} {p}") print(f"\n== 疑似下线/权限变化(注册表可见但当前账号菜单中无) ==") for n, k in removed: print(f" {n} {k}") # ---------- 草稿 ---------- if new_pages: lines = ["# ==== 同步草稿(生成于菜单比对,请确认后合并进 pages.yaml 对应分类) ===="] for t, p, h, par in new_pages: lines += [ f" - id: TODO-{abs(hash(t)) % 100000}", f" name: {t}", f" aliases: [] # TODO 补口语别名", f" keywords: [] # TODO 补关键词", f" menu_key: {p.split('?')[0]}", f" path: {p}", f" desc: TODO # TODO 浏览器打开页面阅读内容后拟写(≤60字)", f" usage: null # TODO 实测后补操作步骤", f" visible: false # 验证可用后删除此行启用", f" note: 同步自动发现({par}),{'隐藏菜单页' if h else '可见菜单页'}", ] (HERE / "sync_draft.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"\n草稿已生成: references/sync_draft.yaml(合并后删除该文件,并运行 build_pages.py)")