page_fingerprint.py 6.2 KB

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