windscada_cloud_pack.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """云版打包 — 源码级脱敏 + 泄露核查闸。
  4. 为什么单独一条路径, 不改 /api/snapshot:
  5. 内部快照要保留真实路径与场名 (工程师要照着去取数)。云版是另一个用途,
  6. 两者混在一个开关里迟早互相污染。故: 内部快照照旧生成, 云版从它派生。
  7. 为什么是源码级不是显示层:
  8. __SNAP__ 把所有 API 响应完整烤进 HTML, 显示层只译/只抽象**渲染出来的**那部分。
  9. 上云后客户 View Source 能读到全部 JSON —— 显示层脱敏在这个威胁模型下无效。
  10. 退役判据: 若快照生成端改为按 profile 只烤客户可见字段 (源头就没有敏感值),
  11. 本脚本降级为核查闸 (只跑 audit 不做替换)。
  12. """
  13. import argparse, json, pathlib, re, sys
  14. # ── 文档名整体替换 (必须排在最前) ─────────────────────────────────
  15. # 2026-08-30 实体扫描逮到: 本体 Doc 对象带进来 73 个真实文档名, 里面同时含
  16. # 业主名 中广核如东10400分析及建议.pdf / 华能如东中段塔架支撑示意图.pdf
  17. # 供应商 上海电气 故障处理手册.xlsx / ABB ACS880-87LC 十余本手册
  18. # 图纸号 V304002 / D1194234-111124 / 物料号 1SDH000460R0002CN
  19. # 部件型号 7-Y013.60.3035K2 偏航回转支承
  20. # 等于把业主的技术资料目录整份交出去。逐项列举挡不住(下次换一批文档又漏),
  21. # 故按**扩展名整体替换**: 不管里面写了什么, 一律换成类型化通用名。
  22. # ★用户早先点名删过的 SB540-A06 液压盘式制动器用户手册.pdf 这次又回来了 ——
  23. # 扩展预烤把 Doc 对象全烤进产物。证明闸必须每次跑, "上次删过了"不作数。
  24. DOCNAME = [
  25. # 路径形式的文档引用: 前面的目录段可能已被别的规则部分替换 (site data / OEM ...),
  26. # 剩下的仍暴露资料目录结构。整条换掉, 不做长度限制。
  27. (r'"[^"]*/[^"]*\.(pdf|xlsx|xls|docx|doc|pptx|7z|zip)"', '"[technical document]"'),
  28. (r'"([^"]{2,90}?(?:手册|指南|说明书)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"', '"[OEM manual]"'),
  29. (r'"([^"]{2,90}?(?:图纸|原理图|接线图|示意图|drawing|schematic)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"',
  30. '"[OEM drawing]"'),
  31. (r'"([^"]{2,90}?(?:接线表|参数表|清单|台账)[^"]{0,40}?)\.(pdf|xlsx|xls|docx)"', '"[reference table]"'),
  32. (r'"([^"]{2,}?)\.(pdf|xlsx|xls|docx|doc|pptx|7z|zip)"', '"[technical document]"'),
  33. ]
  34. # ── 业主与供应商实名 ───────────────────────────────────────────────
  35. # 文档名在正文里常被截断成不带扩展名的形式 (如 "2-2 上海风电变压器操作维护手册-中文版"
  36. # 去掉 .pdf 后仍是文档名), DOCNAME 那几条靠扩展名匹配, 抓不到这种。补按**特征词**收尾。
  37. DOCFRAG = [
  38. (r'[0-9\-\s]*上海风电变压器操作维护[^,,。;;\s"]*', '[OEM manual]'),
  39. (r'[^,,。;;\s"]{0,12}项目产品[^,,。;;\s"]{0,12}', '[project document]'),
  40. (r'新增设备电气接口图[^,,。;;\s"]*', '[interface drawing]'),
  41. (r'[0-9.]*\s*原理图_[0-9\-]+[^,,。;;\s"]*', '[schematic]'),
  42. (r'-\s*中文版', ''),
  43. ]
  44. PARTY = [
  45. # 业主 (集团名出现在文档名与分析件名里)
  46. ('中广核', '[owner]'), ('华能', '[owner]'), ('国家能源', '[owner]'),
  47. ('国电投', '[owner]'), ('大唐', '[owner]'), ('华电', '[owner]'), ('三峡', '[owner]'),
  48. # 整机与部件供应商
  49. ('上海电气', 'OEM'), ('Envision', 'supplier'), ('远景', 'supplier'),
  50. ('ABB ACS880-87LC', 'converter'), ('ACS880-87LC', 'converter'),
  51. ('ABB ACS880', 'converter'), ('ACS880', 'converter'), ('ABB', 'supplier'),
  52. # 内部系统与产物名 (暴露我方架构)
  53. ('m5_cms_tcm', 'CMS dataset'), ('windcms', 'CMS module'), ('findings.json', 'findings store'),
  54. ('analysis_kit', 'analysis library'), ('sop_check', 'SOP checker'),
  55. ]
  56. # ── 无争议脱敏: 本机路径 / 场名 / OEM ───────────────────────────────
  57. # 顺序敏感: 长串先替, 否则 '如东' 先被替掉会让后面的长路径匹配不上
  58. SUBS = [
  59. # 本机绝对路径 (含用户名) → 抽象位置
  60. (r'/Users/[^/"]+/rudong/如东风场数据', '[site data]'),
  61. (r'/Users/[^/"]+/rudong', '[site data]'),
  62. (r'/Users/[^/"]+', '[local path]'),
  63. (r'/Volumes/[A-Za-z0-9_]+', '[data volume]'),
  64. # 场名
  65. ('如东海上', 'the offshore site'),
  66. ('如东', 'the site'),
  67. ('rudong', 'site'),
  68. ('RUDONG', 'SITE'),
  69. ('Rudong', 'Site'),
  70. # OEM / 机型
  71. ('西门子4.0技术资料', '[OEM technical library]'),
  72. ('西门子4.0', 'OEM 4.0 MW'),
  73. ('西门子', 'OEM'),
  74. # 英文形态 (2026-09-03 v2 快照实逮): 厂商英文码表里的报警名自带厂名 —— "Remote stop-Siemens" /
  75. # "DG:Local power limit, Siemens"。替换表只有中文形, 回扫规则却认英文 ⇒ 闸 FAIL 7 处。与中文同一落点。
  76. ('Siemens Gamesa', 'OEM'), ('Siemens', 'OEM'), ('SIEMENS', 'OEM'),
  77. ('SWT-4.0-130', '4.0 MW class'),
  78. ('SWT130-4000', '4.0 MW class'),
  79. ]
  80. # ── 经济数字: 默认脱, --keep-money 放开 ────────────────────────────
  81. # 这些是业主真实损失与上网电价。技术脱敏解决不了授权问题, 故默认最保守。
  82. MONEY = [
  83. # 电价基准 (中国上网电价区间) → 参考基准
  84. (r'电价区间\s*[\d.]+\s*~\s*[\d.]+\s*元/kWh', '按参考电价基准'),
  85. (r'[\d.]+\s*~\s*[\d.]+\s*元/kWh', '参考电价基准'),
  86. (r'[\d.]+\s*元/kWh', '参考电价基准'),
  87. ]
  88. # ── 机组编号代号化 (2026-08-30 用户令: 用代号一次替换最简单) ────────
  89. # 我先前主张保留原编号 (8323 处, 是全系统主键), 用户裁决用代号 —— 执行。
  90. # 两个实现约束:
  91. # 1) 代号必须**纯字母数字**: 前端有 8 处 t.replace("WTG","") 把 WTG16 剥成 16 再显示成
  92. # "16#"。代号不含 WTG 时这些 replace 自然失效, 显示成 "A7#", 与原形态同构不破版。
  93. # 2) 映射必须**确定性**: 同一台在十个页面、本体 id、下钻链接里必须是同一个代号,
  94. # 两次出包也必须一致 (否则新旧链接对不上)。故用 md5 定序而非随机。
  95. # 代号刻意不连续 (A7 / K23 / C4), 数不出总台数 —— 这正是"隐藏台数"要的效果。
  96. def _unit_map(s):
  97. import hashlib
  98. ids = sorted(set(re.findall(r'WTG(\d{2})', s)))
  99. m, used = {}, set()
  100. for i in ids:
  101. h = hashlib.md5(('guanlan-unit-' + i).encode()).hexdigest()
  102. a, n = chr(65 + int(h[:2], 16) % 26), int(h[2:6], 16) % 90 + 10
  103. # 生日悖论: 26×90 的空间里 38 台仍会撞 (首版实测 38 台只出 36 个代号)。
  104. # 撞了就沿数字轴确定性平移 —— 仍然可复现, 且绝不把两台并成一个代号。
  105. while a + str(n) in used:
  106. n = (n - 9) % 90 + 10
  107. used.add(a + str(n))
  108. m[i] = a + str(n)
  109. if len(set(m.values())) != len(m):
  110. raise ValueError(f'代号冲突: {len(m)} 台只生成 {len(set(m.values()))} 个代号')
  111. return m
  112. def unitize(s):
  113. m = _unit_map(s)
  114. for i, c in m.items():
  115. s = s.replace('WTG' + i, c) # 本体 id / t 字段 / href 一并覆盖
  116. s = re.sub(r'(?<!\d)' + i + r'#', c + '#', s) # 数据里硬编码的 "16#" 显示形式
  117. return s
  118. # ── 机组台数 (2026-08-30 用户令: 机组台数和地区都隐藏) ──────────────
  119. # 只脱**显式台数**, 不动机组编号 WTGnn:
  120. # 编号是全系统的主键 (8323 处, 图表标签/排序/下钻链接/本体对象 id 都用它),
  121. # 映射成代号要保证跨页一致 + 不破坏数值排序, 风险远大于收益;
  122. # 而客户就算逐个数编号也得先把十个页面翻遍。显式的 "38 台" 才是一眼可见的。
  123. # 地区已由场名规则覆盖 (如东 → the site); "海上/offshore" 保留 —— 那是机型工况特征,
  124. # 不指向任何具体地区, 脱了反而让技术描述失真。
  125. FLEETSIZE = [
  126. (r'(?<![\d.])38\s*台', 'the fleet'),
  127. (r'全场\s*38\b', '全场'),
  128. (r'/\s*38(?![\d.])', '/ N'),
  129. (r'(?<![\d.])38(?=\s*(?:台|units|turbines))', 'N'),
  130. ]
  131. # ── 判据门槛: 客户版不给数值阈 (memory client-deliverable-hide-core-parameters) ──
  132. # 服务端 _redact 漏了 fig.note 这一路 —— 浮层里 "报警门 ±0.6°" 是直接印出来的判据门槛。
  133. THRESH = [
  134. (r'(报警门|alarm gate)\s*±?\s*[\d.]+\s*[°℃%]?', r'\1 ▪'),
  135. (r'(门槛|阈值|判据门)\s*[≥≤>=<]*\s*[\d.]+\s*[°℃%]?', r'\1 ▪'),
  136. ]
  137. def scrub(s, keep_money=False):
  138. for pat, rep in DOCNAME: # 文档名先整体换掉, 免得后续逐词替换在里面留半截
  139. s = re.sub(pat, rep, s)
  140. for pat, rep in DOCFRAG:
  141. s = re.sub(pat, rep, s)
  142. for k, v in PARTY:
  143. s = s.replace(k, v)
  144. for pat, rep in SUBS:
  145. s = re.sub(pat, rep, s) if pat.startswith(('/', '[', '(')) or '\\' in pat or '[' in pat else s.replace(pat, rep)
  146. if not keep_money:
  147. for pat, rep in MONEY:
  148. s = re.sub(pat, rep, s)
  149. for pat, rep in THRESH:
  150. s = re.sub(pat, rep, s)
  151. for pat, rep in FLEETSIZE:
  152. s = re.sub(pat, rep, s)
  153. s = unitize(s)
  154. return s
  155. # ── 泄露核查闸: 必须零命中, 否则 exit 2 ─────────────────────────────
  156. GATES = {
  157. '本机路径': r'/Users/[A-Za-z0-9_.-]+/',
  158. '数据盘路径': r'/Volumes/[A-Za-z0-9_]+/',
  159. '场名(中)': '如东',
  160. '场名(英)': r'[Rr]udong|RUDONG',
  161. 'OEM名': '西门子',
  162. '机型': r'SWT[-\d]',
  163. '判据门槛': r'(?:报警门|alarm gate|门槛|阈值)\s*[±≥≤>=<]*\s*[\d.]+',
  164. '业主实名': r'中广核|华能|国家能源|国电投|大唐|华电|三峡',
  165. '供应商实名': r'上海电气|Envision|远景|\bABB\b|ACS880|Siemens|Gamesa|SKF|FAG|Flender|Winergy',
  166. # ★上限 120 曾让长路径逃过检查 (2026-08-31 实逮: 部分替换后路径变长, 反而不匹配)。
  167. # 判据的长度上限本身就是盲区 —— 越长的路径泄露越多, 却越不会被抓到。改为无上限。
  168. '文档名': r'"[^"]{2,}\.(?:pdf|xlsx|xls|docx|doc|pptx|7z|zip)"',
  169. '内部系统名': r'm5_cms_tcm|windcms|findings\.json|analysis_kit|sop_check',
  170. '显式台数': r'(?<![\d.])38\s*(?:台|units|turbines)|/\s*38(?![\d.])',
  171. '原始机组号': r'WTG\d{2}',
  172. }
  173. GATES_MONEY = {
  174. '上网电价': r'元/kWh',
  175. }
  176. def audit(s, keep_money=False):
  177. gates = dict(GATES)
  178. if not keep_money:
  179. gates.update(GATES_MONEY)
  180. hits = {}
  181. for name, pat in gates.items():
  182. m = re.findall(pat, s)
  183. if m:
  184. hits[name] = len(m)
  185. return hits
  186. def main():
  187. ap = argparse.ArgumentParser()
  188. ap.add_argument('src', help='内部快照 HTML')
  189. ap.add_argument('-o', '--out', required=True, help='云版输出路径')
  190. ap.add_argument('--keep-money', action='store_true',
  191. help='保留经济数字与电价 (需业主授权; 默认脱敏)')
  192. a = ap.parse_args()
  193. src = pathlib.Path(a.src)
  194. raw = src.read_text(encoding='utf-8')
  195. before = audit(raw, a.keep_money)
  196. out = scrub(raw, a.keep_money)
  197. after = audit(out, a.keep_money)
  198. dst = pathlib.Path(a.out)
  199. dst.parent.mkdir(parents=True, exist_ok=True)
  200. dst.write_text(out, encoding='utf-8')
  201. print('=' * 66)
  202. print(f'云版打包 — {src.name} → {dst.name}')
  203. print(f' 经济数字: {"保留 (须业主授权)" if a.keep_money else "已脱敏"}')
  204. print('=' * 66)
  205. print(f'{"项":14} {"脱敏前":>8} {"脱敏后":>8}')
  206. for k in sorted(set(before) | set(after)):
  207. print(f'{k:14} {before.get(k, 0):>8} {after.get(k, 0):>8}')
  208. if not before:
  209. print('(脱敏前即零命中)')
  210. print('-' * 66)
  211. print(f'输出: {dst} ({dst.stat().st_size/1024:.0f} KB)')
  212. if after:
  213. print(f'\n✗ 闸 FAIL — 残留 {sum(after.values())} 处: {after}')
  214. return 2
  215. n_gate = len(GATES) + (0 if a.keep_money else len(GATES_MONEY))
  216. print(f'\n✓ 闸 PASS — {n_gate} 项零命中, 可上云')
  217. return 0
  218. if __name__ == '__main__':
  219. # 控制台可能是 GBK(中文 Windows 代码页 936): 正文里的 ✔ ✗ ✅ ⚠ 这类字符编不出来会抛
  220. # UnicodeEncodeError, 脚本干成了事却以退出码 1 结束(同类坑见 src/console.py)。降级为 '?' 而不是崩;
  221. # 不用 import 是为了兼顾 python -m 与直接当脚本跑两种启动方式。
  222. import sys as _sys
  223. for _s in (_sys.stdout, _sys.stderr):
  224. try: _s.reconfigure(errors='replace')
  225. except Exception: pass
  226. sys.exit(main())