page_fingerprint.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""页面/接口指纹 —— 改造前后与换机部署的回归判据 (2026-09-11)。
  4. ## 为什么要有
  5. 路径收敛、换机器部署这类改动,**危险在静默**: 某处路径漏改 → 页面某块空掉/取不到数, 但不报错。
  6. 所以需要一把"改前改后必须一模一样"的尺子。
  7. 指纹 = 对一组页面与接口取 `status + 字节数 + sha256`; 另存一份**归一化** sha256:
  8. 把安装根路径 (本机绝对路径, 正/反斜杠两种写法) 统一替换成 `<ROOT>` 后再算 —— 这样
  9. "换了机器所以绝对路径前缀不同"不会误报成内容变化, 而"内容真的变了"仍然抓得到。
  10. 用法:
  11. python scripts/page_fingerprint.py --save .fingerprint_before.json # 取基线 (相对安装根)
  12. python scripts/page_fingerprint.py --diff .fingerprint_before.json # 比对 (不一致则退出码 3)
  13. """
  14. from __future__ import annotations
  15. try:
  16. from app_common.app_common_guanlan.api import install_root as _install_root
  17. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  18. from pathlib import Path as _P
  19. def _install_root(_f): return _P(_f).resolve().parents[4]
  20. import argparse
  21. import hashlib
  22. import json
  23. import pathlib
  24. import sys
  25. import urllib.error
  26. import urllib.parse
  27. import urllib.request
  28. ROOT = _install_root(__file__)
  29. sys.path.insert(0, str(ROOT))
  30. from src import paths as P # 配置唯一取用口
  31. # 覆盖六个服务与门户; 页面类要能代表"路径是否解析对"(取数面), 接口类代表"数据是否读得到"
  32. ENDPOINTS = [
  33. ('gateway.healthz', 'http://127.0.0.1:{gateway}/healthz'),
  34. ('gateway.version', 'http://127.0.0.1:{gateway}/api/version'),
  35. ('portal.home', 'http://127.0.0.1:{gateway}/'),
  36. ('detail.v2', 'http://127.0.0.1:{detail}/v2'),
  37. ('detail.maint_survey', 'http://127.0.0.1:{detail}/api/maint_survey'),
  38. ('detail.query.actpower', 'http://127.0.0.1:{detail}/api/query?t=WTG01&ch=grd_wtc_ActPower_mean&win=2026%E5%B9%B4&agg=med'),
  39. ('detail.turbine_problems', 'http://127.0.0.1:{detail}/api/turbine?t=WTG01&win=2026%E5%B9%B4'),
  40. ('cms.home', 'http://127.0.0.1:{cms}/?theme=light'),
  41. ('sim_sys.page', 'http://127.0.0.1:{sim_sys}/' + urllib.parse.quote('0_四系统合页.html')),
  42. ('viewer.index', 'http://127.0.0.1:{viewer}/index.html'),
  43. ]
  44. def ports() -> dict:
  45. """端口只从 configs/serve.json 读 (相对路径按本文件位置解析, 不依赖 cwd); 缺项用启动器同款默认值。"""
  46. default = dict(gateway=28084, detail=18033, cms=18020, sim=18791, sim_sys=18792, viewer=64292, ollama=11434)
  47. p = P.SERVE_JSON # 配置唯一取用口
  48. cfg = json.loads(p.read_text(encoding='utf-8-sig')) if p.exists() else {}
  49. return {**default, **{k: v for k, v in cfg.items() if isinstance(v, int)}}
  50. # 设计上就会变的字段 (实测: healthz 的 checked/ms 每次不同; version 的 repo_head 随提交变) ——
  51. # 归一化时剔除, 否则每次比对都是噪音; 真实数据/页面内容不在其列。
  52. VOLATILE = {'checked', 'ms', 'repo_head', 'elapsed', 'uptime', 'uptime_s', 'pid', 'generated_at', 'asof'}
  53. def _strip(node):
  54. if isinstance(node, dict):
  55. return {k: _strip(v) for k, v in node.items() if k not in VOLATILE}
  56. if isinstance(node, list):
  57. return [_strip(v) for v in node]
  58. return node
  59. def normalize(body: bytes) -> bytes:
  60. """→ 用于指纹的字节: JSON 先剔易变字段再按键排序; 非 JSON 原样 (页面类实测稳定)。
  61. 另外把安装根 (绝对路径, 两种斜杠写法) 归一成 <ROOT>, 消除"换机器"的前缀差异。"""
  62. t = body.decode('utf-8', 'replace')
  63. try:
  64. j = json.loads(t)
  65. t = json.dumps(_strip(j), ensure_ascii=False, sort_keys=True)
  66. except Exception:
  67. pass
  68. for form in (str(ROOT), str(ROOT).replace('\\', '/'), str(ROOT).replace('/', '\\')):
  69. t = t.replace(form, '<ROOT>')
  70. return t.encode('utf-8')
  71. def fetch(url: str):
  72. try:
  73. with urllib.request.urlopen(url, timeout=120) as r:
  74. return r.status, r.read()
  75. except urllib.error.HTTPError as e:
  76. return e.code, e.read()
  77. except Exception as e:
  78. return None, f'{type(e).__name__}: {e}'.encode()
  79. def take() -> dict:
  80. P = ports()
  81. out = {}
  82. for name, tpl in ENDPOINTS:
  83. url = tpl.format(**P)
  84. status, body = fetch(url)
  85. out[name] = dict(url=url, status=status, bytes=len(body),
  86. sha256=hashlib.sha256(body).hexdigest()[:16],
  87. norm_sha256=hashlib.sha256(normalize(body)).hexdigest()[:16],
  88. norm_bytes=len(normalize(body)))
  89. return out
  90. def main() -> int:
  91. ap = argparse.ArgumentParser()
  92. ap.add_argument('--save', default=None, help='把指纹写到这个 JSON')
  93. ap.add_argument('--diff', default=None, help='与这个基线 JSON 比对')
  94. a = ap.parse_args()
  95. now = take()
  96. if a.save:
  97. p = pathlib.Path(a.save)
  98. p.parent.mkdir(parents=True, exist_ok=True)
  99. p.write_text(json.dumps(now, ensure_ascii=False, indent=1), encoding='utf-8')
  100. print(f'已保存 {len(now)} 个端点指纹 → {p}')
  101. for k, v in now.items():
  102. print(f' {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B {v["sha256"]} norm {v["norm_sha256"]}')
  103. return 0
  104. if a.diff:
  105. base = json.loads(pathlib.Path(a.diff).read_text(encoding='utf-8'))
  106. bad = 0
  107. print(f'{"端点":26s} {"状态":>10s} {"归一化字节":>24s} norm_sha (基线→现在)')
  108. for k, v in now.items():
  109. b = base.get(k)
  110. if b is None:
  111. print(f'{k:26s} {"(基线无)":>10s}'); bad += 1; continue
  112. same = v['norm_sha256'] == b.get('norm_sha256')
  113. flag = '一致' if same else '★变化'
  114. if not same:
  115. bad += 1
  116. print(f'{k:26s} {str(b["status"])+"→"+str(v["status"]):>10s} '
  117. f'{b.get("norm_bytes", b["bytes"]):>11,}→{v["norm_bytes"]:<12,} '
  118. f'{b.get("norm_sha256")}→{v["norm_sha256"]} {flag}')
  119. print(f'\n结论: {"全部一致 ✔ (页面与取数面零变化)" if not bad else f"{bad} 个端点有变化"}')
  120. return 0 if not bad else 3
  121. for k, v in now.items():
  122. print(f' {k:26s} HTTP {str(v["status"]):>4} {v["bytes"]:>10,} B {v["sha256"]} norm {v["norm_sha256"]}')
  123. return 0
  124. if __name__ == '__main__':
  125. # 控制台 GBK 编不出 ✔ 时降级为 '?' (见 src/console.py): 否则"全部一致"这一行 print 会抛
  126. # UnicodeEncodeError、退出码变 1 —— 闸门报成失败而实际是通过的 (2026-09-11 踩过)。
  127. from src import console
  128. console.soft()
  129. sys.exit(main())