sync_pages.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. # -*- coding: utf-8 -*-
  2. """sync_pages.py —— 网页指引同步工具(Intent F 的核心实现)
  3. 功能:
  4. 1. 调平台权限接口拉取全量菜单树(权威路由清单,无需遍历爬取)
  5. 2. 与 pages.yaml 比对:报告新增页面(菜单有、注册表无)/ 疑似下线页面(注册表可见、菜单无)
  6. 3. 为新增页面生成 pages.yaml 草稿片段(desc/usage 留 TODO,由人工或智能体补写)
  7. 4. 输出到 stdout 与 sync_draft.yaml(只读比对 + 草稿,不改动 pages.yaml)
  8. 用法:
  9. python sync_pages.py <JWT_TOKEN> # token 也可用环境变量 TF_TOKEN
  10. python sync_pages.py <JWT_TOKEN> --base-url http://x.x.x.x:8092 # 覆盖 config.yaml
  11. 说明:
  12. - 只比对叶子菜单(有 children 的容器节点跳过)
  13. - 注册表的 path 与 menu_key 都参与比对(设备详情页 path 带 ?id=,按 menu_key 认领)
  14. - HTTP 状态无法识别 SPA 的客户端 404,页面可用性验证需浏览器渲染(见 SKILL.md 流程 F 第3步)
  15. """
  16. import io
  17. import json
  18. import os
  19. import sys
  20. import urllib.request
  21. from pathlib import Path
  22. import yaml
  23. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  24. HERE = Path(__file__).parent
  25. ROOT = HERE.parent
  26. # ---------- 参数 ----------
  27. token = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("TF_TOKEN", "")
  28. base_url = None
  29. if "--base-url" in sys.argv:
  30. base_url = sys.argv[sys.argv.index("--base-url") + 1]
  31. if not token:
  32. print("用法: python sync_pages.py <JWT_TOKEN>(或设环境变量 TF_TOKEN)")
  33. sys.exit(1)
  34. cfg = yaml.safe_load((ROOT / "config.yaml").read_text(encoding="utf-8"))
  35. base_url = (base_url or cfg["base_url"]).rstrip("/")
  36. # ---------- 拉取菜单树 ----------
  37. url = f"{base_url}/modelreq/sys/permissionNew/getUserPermissionByToken?token={token}"
  38. req = urllib.request.Request(url, headers={"X-Access-Token": token})
  39. with urllib.request.urlopen(req, timeout=15) as resp:
  40. payload = json.loads(resp.read().decode("utf-8"))
  41. if not payload.get("success"):
  42. print(f"权限接口调用失败: {payload.get('message')}(token 是否过期?重新登录平台后从请求头 X-Access-Token 复制)")
  43. sys.exit(1)
  44. leaves = [] # 叶子菜单(新增比对用)
  45. all_paths = set() # 全部节点路径含容器(下线比对用,避免父节点误报)
  46. def walk(nodes, parent):
  47. for n in nodes or []:
  48. path = n.get("path") or ""
  49. if path:
  50. all_paths.add(path.split("?")[0])
  51. if n.get("children"):
  52. walk(n["children"], n.get("meta", {}).get("title") or parent)
  53. else:
  54. leaves.append((n.get("meta", {}).get("title") or "?",
  55. path, bool(n.get("hidden")), parent))
  56. walk(payload["result"]["menu"], "")
  57. # ---------- 读取注册表 ----------
  58. data = yaml.safe_load((HERE / "pages.yaml").read_text(encoding="utf-8"))
  59. reg_paths, reg_keys = set(), set()
  60. for cat in data["categories"]:
  61. items = list(cat.get("pages") or [])
  62. for g in cat.get("groups") or []:
  63. items += g.get("pages") or []
  64. for p in items:
  65. reg_paths.add(p.get("path", "").split("?")[0])
  66. if p.get("menu_key"):
  67. reg_keys.add(p["menu_key"].split("?")[0])
  68. # ---------- 差集 ----------
  69. new_pages, known = [], []
  70. for title, path, hidden, parent in leaves:
  71. base = path.split("?")[0]
  72. if base in reg_paths or base in reg_keys:
  73. known.append(title)
  74. else:
  75. new_pages.append((title, path, hidden, parent))
  76. reg_visible_keys = set()
  77. for cat in data["categories"]:
  78. items = list(cat.get("pages") or [])
  79. for g in cat.get("groups") or []:
  80. items += g.get("pages") or []
  81. for p in items:
  82. if p.get("visible", True) and p.get("menu_key"):
  83. reg_visible_keys.add((p["name"], p["menu_key"].split("?")[0]))
  84. menu_bases = all_paths
  85. removed = [(n, k) for n, k in reg_visible_keys if k not in menu_bases]
  86. # ---------- 报告 ----------
  87. print(f"菜单叶子页: {len(leaves)} | 注册表已认领: {len(known)} | 菜单新增: {len(new_pages)} | 疑似下线: {len(removed)}")
  88. print(f"\n== 菜单新增(候选待录入) ==")
  89. for t, p, h, par in new_pages:
  90. print(f" [{'hidden' if h else '可见 '}] {par} > {t} {p}")
  91. print(f"\n== 疑似下线/权限变化(注册表可见但当前账号菜单中无) ==")
  92. for n, k in removed:
  93. print(f" {n} {k}")
  94. # ---------- 草稿 ----------
  95. if new_pages:
  96. lines = ["# ==== 同步草稿(生成于菜单比对,请确认后合并进 pages.yaml 对应分类) ===="]
  97. for t, p, h, par in new_pages:
  98. lines += [
  99. f" - id: TODO-{abs(hash(t)) % 100000}",
  100. f" name: {t}",
  101. f" aliases: [] # TODO 补口语别名",
  102. f" keywords: [] # TODO 补关键词",
  103. f" menu_key: {p.split('?')[0]}",
  104. f" path: {p}",
  105. f" desc: TODO # TODO 浏览器打开页面阅读内容后拟写(≤60字)",
  106. f" usage: null # TODO 实测后补操作步骤",
  107. f" visible: false # 验证可用后删除此行启用",
  108. f" note: 同步自动发现({par}),{'隐藏菜单页' if h else '可见菜单页'}",
  109. ]
  110. (HERE / "sync_draft.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
  111. print(f"\n草稿已生成: references/sync_draft.yaml(合并后删除该文件,并运行 build_pages.py)")