snapshot.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. # -*- coding: utf-8 -*-
  2. """新前端单文件快照 —— 把页面会取的接口响应烤进 HTML, 静态站 (云上 nginx) 双击即开。
  3. 与经典 /api/snapshot 的分工: 经典页烤 2874 个 key (它的页面会取很多带参接口); 新版取数面小得多,
  4. 只烤下面这几类。拦截器策略与经典一致 —— **实时优先、快照兜底**:
  5. · ask / ask_status / ask_models / rpt_compose / rpt_export → 永远走网络 (云上 nginx /api/ 后面有真服务)
  6. · ont_chain / ont_obj / ont_query → 先网络, 取不到再回落快照 (离线可用)
  7. · 其余 (fleet / curves / maint_survey / vibcms) → 只走快照; 未烤的窗回落到默认窗
  8. 英文版: 与在线 API 同一套投影 (_redact → _proj)。公网默认 slim: 不携带 _original 原文副本, 只烤默认窗;
  9. 需要内部追溯时可用 slim=0 生成全量包。上云前仍过 scripts/windscada_cloud_pack.py 的脱敏闸。
  10. """
  11. from __future__ import annotations
  12. import json
  13. import re
  14. import time
  15. from typing import Optional
  16. from . import build
  17. WINS = ['2026年', '2026H1', '2025H2', '全程', '近30日', '近90日']
  18. SYS9 = ['叶片与叶根', '变桨', '偏航', '齿轮箱', '发电机', '主轴承', '变流器', '塔架与基础', '主控与传感网']
  19. _PUBLIC_LITERAL_CJK = re.compile(r'[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]')
  20. def _strip_public_comments(page: str) -> str:
  21. """Remove build-time comments from the public English artifact.
  22. The public snapshot is a product demo, so design notes, user-decision notes,
  23. and internal audit language should not ship inside CSS/JS comments.
  24. """
  25. page = re.sub(r'/\*.*?\*/', '', page, flags=re.S)
  26. page = re.sub(r'(?m)^[ \t]*//.*(?:\n|$)', '', page)
  27. return page
  28. def _escape_public_cjk_source(page: str) -> str:
  29. """Unicode-escape CJK and full-width characters in public English source.
  30. Some Chinese values are still legitimate machine keys in the browser logic
  31. and snapshot lookup tables. Escaping keeps JavaScript semantics intact while
  32. preventing literal Chinese from appearing in the public HTML source.
  33. Runtime display is still audited separately.
  34. """
  35. return _PUBLIC_LITERAL_CJK.sub(lambda m: '\\u%04x' % ord(m.group(0)), page)
  36. def _scrub_public_ascii_source(page: str) -> str:
  37. """Remove public-facing ASCII placeholders and internal handoff labels."""
  38. replacements = [
  39. (r'\[translation pending\]', 'Review required'),
  40. (r'Vibration-line handoff', 'Vibration evidence package'),
  41. (r'python(?:3)?\s+-c\s+"[^"]+"', 'managed knowledge-graph refresh'),
  42. (r'python(?:3)?\s+-c\s+\\"[^"]+\\"', 'managed knowledge-graph refresh'),
  43. (r'handoff[_ -]?vibration[_ -]?v2\.json', 'Vibration evidence package'),
  44. (r'handoff[_ -]?vibration[_ -]?v2', 'vibration evidence package'),
  45. (r'handoff\\u65e5\\u671f', 'evidence_package_date'),
  46. (r'handoff日期', 'evidence_package_date'),
  47. (r'only_handoff', 'only_vibration_package'),
  48. (r'transcribed from the vibration line', 'reported by the vibration evidence line'),
  49. (r'verbatim transcription', 'source-grounded evidence'),
  50. (r'fusion does not assign grades', 'fusion preserves the source rating'),
  51. (r'hardest evidence', 'strongest evidence'),
  52. (r'currently rising', 'active trend'),
  53. (r'outputs/rudong', 'outputs/<site>'),
  54. (r'Rudong', 'offshore site'),
  55. (r'Huanneng', 'site operator'),
  56. (r'BPFI|BPFO|FTF|BSF', 'bearing marker'),
  57. (r'characteristic frequenc(?:y|ies)', 'bearing signature marker'),
  58. (r'spectra', 'condition traces'),
  59. (r'spectrum', 'condition trace'),
  60. (r'waveforms?', 'condition traces'),
  61. (r'\bAddress\b', 'Resolve'),
  62. (r'\baddress\b', 'resolve'),
  63. (r'addressing', 'resolving'),
  64. (r'Addressing', 'Resolving'),
  65. (r'coordinate(?:d)?', 'aligned'),
  66. (r'\bOwner\b', 'Client'),
  67. (r'\bowner\b', 'client'),
  68. (r'\bAddress\b', 'Resolve'),
  69. (r'\baddress\b', 'resolve'),
  70. ]
  71. for pat, repl in replacements:
  72. page = re.sub(pat, repl, page, flags=re.I)
  73. return page
  74. def public_literal_cjk_report(page: str) -> list[str]:
  75. """Return short literal-CJK snippets for the public source gate."""
  76. hits = []
  77. for m in _PUBLIC_LITERAL_CJK.finditer(page):
  78. a = max(0, m.start() - 24)
  79. b = min(len(page), m.end() + 24)
  80. hits.append(page[a:b].replace('\n', '\\n'))
  81. if len(hits) >= 20:
  82. break
  83. return hits
  84. def _nk(path: str, **kw) -> str:
  85. """与页内 __NK 同语义: 参数解码后按字母排序, 空值丢弃。"""
  86. a = sorted('%s=%s' % (k, v) for k, v in kw.items() if v not in (None, ''))
  87. return path + ('?' + '&'.join(a) if a else '')
  88. def _strip_original(o):
  89. """Drop internal source-text mirrors from public English snapshots."""
  90. if isinstance(o, dict):
  91. return {k: _strip_original(v) for k, v in o.items() if k != '_original'}
  92. if isinstance(o, list):
  93. return [_strip_original(v) for v in o]
  94. return o
  95. def bake(lang: str, ctx: dict, peer: str = '', noindex: bool = False, slim: Optional[bool] = None) -> tuple[str, dict]:
  96. """返回 (html, report)。ctx 由服务端注入 (fleet_view/curve_view/…/_redact/_jclean/_proj/_REQ/ont)。
  97. peer 对方语言快照的相对 URL。**静态文件下 `?lang=` 不起作用** —— 点了只会重载同一个文件,
  98. 所以双语互切必须指向对方的真实文件。空 = 该版不提供切换 (公开英文版不链内部中文版)。
  99. noindex 写入 robots meta。站点已有 robots.txt Disallow, 这是第二道 (含真名的中文内部件用)。
  100. slim 英文公网瘦身模式: 只烤默认窗, 且删除 _original 原文副本; None 表示英文默认开启。
  101. """
  102. en = lang == 'en'
  103. if slim is None:
  104. slim = en
  105. t0 = time.time()
  106. snap, skip = {}, []
  107. R = ctx['_REQ']
  108. R.lang = 'en' if en else ''
  109. R.keep_orig = bool(en and not slim) # full 英文包才留原文; slim 未译出则显式 pending
  110. def lp(**kw): # 英文取数带 lang/keep (与 app.js 的 URL 一致), 中文不带
  111. if en:
  112. kw.update(lang='en', keep='1')
  113. return kw
  114. def _bake(key, fn):
  115. try:
  116. v = ctx['_redact'](ctx['_jclean'](fn()))
  117. if en:
  118. v = ctx['_proj'](v, 'en')
  119. snap[key] = _strip_original(v) if (en and slim) else v
  120. return True
  121. except Exception as e: # 失败记名不静默 —— 守护步骤失败必须响亮
  122. skip.append('%s: %s' % (key, str(e)[:80]))
  123. return False
  124. active_wins = WINS[:1] if (en and slim) else WINS
  125. for w in active_wins:
  126. _bake(_nk('/api/fleet', **lp(win=w)), lambda w=w: ctx['fleet_view'](w))
  127. # 曲线按窗重算(2026-09-21 用户令「发电性能随时间窗变化」):离线快照只烤**首个窗**
  128. # (每窗 10~45 s,全烤 6 窗会让打包多等几分钟;页面选其它窗时按"未烤 ⇒ 走实时接口"回落)。
  129. _bake(_nk('/api/curves', **lp(win=WINS[0])), lambda: ctx['curve_view'](WINS[0]))
  130. _bake(_nk('/api/maint_survey', **lp()), lambda: dict(rows=ctx['survey']()))
  131. _bake(_nk('/api/vibcms', **lp()), ctx['vibcms_results'])
  132. om = ctx['ont']
  133. for s in SYS9:
  134. _bake(_nk('/api/ont_chain', **lp(kind='prev', system=s)), lambda s=s: om.ont_chain_preventive(system=s))
  135. _bake(_nk('/api/ont_chain', **lp(kind='tree', system=s)), lambda s=s: om.ont_fault_tree(system=s))
  136. codes = ctx['alarm_codes']()
  137. for c in codes:
  138. _bake(_nk('/api/ont_chain', **lp(kind='fault', code=c)), lambda c=c: om.ont_chain_fault(code=c))
  139. # ── 逐台 / 单问题 (浮层用) ────────────────────────────────────────
  140. # /turbine/<t> 与 /problem/<t>/<sys> 原是**服务端独立页**, 静态站上一律 404 (2026-09-03 实逮:
  141. # 部件问题页点机组号或系统名 → 404)。前端已改为页内浮层, 这里把它要的数据烤进来。
  142. # **只烤默认窗**: 每台每系统再乘 6 个窗会让快照翻几倍, 而拦截器对未烤的窗本就会回落默认窗。
  143. w0 = WINS[0]
  144. fl0 = snap.get(_nk('/api/fleet', **lp(win=w0))) or {}
  145. pairs = set()
  146. for sysn, rows in (fl0.get('systems') or {}).items():
  147. base = sysn.split('·')[0] # 分组键可能带等级后缀 (塔架与基础·良好)
  148. for r in (rows or []):
  149. if isinstance(r, dict) and r.get('t'):
  150. pairs.add((r['t'], base))
  151. turbs = sorted({t for t, _ in pairs})
  152. for tb in turbs:
  153. _bake(_nk('/api/turbine', **lp(t=tb, win=w0)), lambda tb=tb: dict(problems=ctx['turbine_problems'](tb, w0)))
  154. for tb, sy in sorted(pairs):
  155. _bake(_nk('/api/problem', **lp(t=tb, sys=sy, win=w0)),
  156. lambda tb=tb, sy=sy: dict(problems=ctx['single_problem'](tb, sy, w0)))
  157. page = build.render(lang, include_records=('terms' if (en and slim) else True))
  158. if en and slim:
  159. page = _strip_public_comments(page)
  160. wins_baked = [w for w in active_wins if _nk('/api/fleet', **lp(win=w)) in snap]
  161. inject = ('<script>window.__SNAP__=' + json.dumps(snap, ensure_ascii=False, allow_nan=False).replace('<', '\\u003c') + ';\n'
  162. 'window.__WINS=' + json.dumps(wins_baked, ensure_ascii=False) + ';window.__DEFWIN=window.__WINS[0]||"";\n'
  163. # key 归一化: 参数解码后排序 —— 编码差异与参数顺序都会让原样 URL 比对静默落空
  164. 'window.__NK=function(u){var s=String(u),i=s.indexOf("?");if(i<0)return s;'
  165. 'var p=s.slice(0,i),a=s.slice(i+1).split("&").filter(Boolean).map(function(kv){'
  166. 'var j=kv.indexOf("=");if(j<0)return kv;var k=kv.slice(0,j),v=kv.slice(j+1);'
  167. 'try{v=decodeURIComponent(v.replace(/\\+/g," "))}catch(e){}'
  168. 'return v===""?null:k+"="+v}).filter(function(x){return x!==null}).sort();'
  169. 'return p+(a.length?"?"+a.join("&"):"")};\n'
  170. 'window.__PEER=' + json.dumps(peer) + ';\n'
  171. 'window.__HIT={ok:0,miss:[]};window.__RF=window.fetch;\n'
  172. # ★win 候选键不能 encodeURIComponent —— __NK 已把 k 里所有值 decode 回原文
  173. # (含 win=近30日 这种中文), __SNAP__ 的键也是 _nk() 直接拼的原文, 两边都不编码;
  174. # 这里若再 encodeURIComponent 就会拼出 win=2026%E5%B9%B4 这种谁都不匹配的键,
  175. # 导致非默认窗下的兜底**从未生效** (2026-09-03 实逮: 换窗点浮层报"本窗无记录",
  176. # 而对应默认窗的快照其实有数据 —— 兜底静默失效, 不是真没数据)。
  177. 'function __fromSnap(k){var c=[k,k.replace(/win=[^&]*/,"win="+(window.__DEFWIN||""))];'
  178. 'for(var i=0;i<c.length;i++){var d=window.__SNAP__[c[i]];if(d!==undefined){window.__HIT.ok++;'
  179. 'return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(d)}})}}'
  180. 'if(window.__HIT.miss.indexOf(k)<0)window.__HIT.miss.push(k);'
  181. 'return Promise.resolve({ok:false,status:404,json:function(){return Promise.resolve({err:"snapshot miss: "+k})}})}\n'
  182. 'window.fetch=function(u,o){var s=String(u),k=window.__NK(s);'
  183. 'if(/^\\/api\\/(ask|ask_status|ask_models|rpt_compose|rpt_export)\\b/.test(s))return window.__RF(u,o);'
  184. 'return __fromSnap(k)};\n'
  185. '</script>\n')
  186. # 注入在语言包之后、图元之前 (拦截器必须先于 app.js 装上)
  187. marker = '<script>window.__L='
  188. assert marker in page, '骨架缺语言包注入点'
  189. i = page.index(marker)
  190. j = page.index('</script>', i) + len('</script>')
  191. page = page[:j] + '\n' + inject + page[j:]
  192. if noindex:
  193. page = page.replace('<meta charset="utf-8">',
  194. '<meta charset="utf-8">\n<meta name="robots" content="noindex,nofollow,noarchive">', 1)
  195. if en and slim:
  196. page = _scrub_public_ascii_source(page)
  197. page = _escape_public_cjk_source(page)
  198. leaks = public_literal_cjk_report(page)
  199. if leaks:
  200. raise RuntimeError('public English snapshot still contains literal CJK: ' + repr(leaks[:5]))
  201. R.lang = ''
  202. R.keep_orig = False
  203. rep = dict(lang=lang, peer=peer, noindex=noindex, slim=bool(en and slim), keys=len(snap), turbines=len(turbs), pairs=len(pairs), wins=wins_baked, codes=len(codes), skipped=skip,
  204. bytes=len(page.encode('utf-8')), secs=round(time.time() - t0, 1))
  205. return page, rep