page_fingerprint.py 6.6 KB

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