guanlan_portal_fix_anchors.py 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """门户内嵌文档 (iframe srcdoc) 的章节锚点修复 — 幂等注入器 (xzy 测试报告 2026-09-08 P1-3).
  4. 病灶: 报告经 `ovf.srcdoc = 模板` 挂进 iframe。srcdoc 文档的**基地址继承父页**, 所以报告里的
  5. `<a href="#section-3">` 解析成 `http://127.0.0.1:28084/#section-3` —— 与 iframe 当前文档 (about:srcdoc)
  6. 不同源同址, 浏览器判为**导航**, 于是 iframe 整个跳去门户首页 (门户路由 route() 认不出 section-3 → 回落 index)。
  7. 修法: iframe 载入后 (srcdoc 同源, 父页可访问其 document) 拦截 `a[href^="#"]` 点击, 改为在报告文档内 scrollIntoView。
  8. 不改 srcdoc 挂载方式 (换 blob: 会让相对资源失效; 加 <base> 又会把锚点解析回父页)。
  9. 用法: python scripts/guanlan_portal_fix_anchors.py [--portal <path>] [--check]
  10. --check 只报是否已注入 (exit 0 已注入 / 1 未注入); 不带则注入 (已注入时不重复写)。"""
  11. from __future__ import annotations
  12. import argparse, hashlib, os, shutil, sys, time
  13. from pathlib import Path
  14. ROOT = Path(__file__).resolve().parents[1]
  15. MARK = "/*guanlan-anchor-fix-v2*/"
  16. MARK_ANY = "/*guanlan-anchor-fix-v" # 旧版本先剥再注 (升级用)
  17. ANCHOR = "document.getElementById('ovf').srcdoc=t.content.textContent;"
  18. ANCHOR_ALT = "document.getElementById('ovf').srcdoc = t.content.textContent;"
  19. # v2: 用**即时**跳 (原生锚点行为), 不用 behavior:'smooth' —— 平滑滚动在隐藏/零尺寸视口里不执行
  20. # (2026-09-08 浏览器实测: smooth 后 scrollTop 仍 0, 即时跳到位 14173), 且长文档平滑滚动本身也不是文档阅读的习惯。
  21. PATCH = (MARK + "(function(){var f=document.getElementById('ovf');if(!f||f._afix)return;f._afix=1;"
  22. "f.addEventListener('load',function(){try{var d=f.contentDocument;if(!d||d._afix)return;d._afix=1;"
  23. "d.addEventListener('click',function(ev){var a=ev.target&&ev.target.closest?ev.target.closest('a[href^=\"#\"]'):null;if(!a)return;"
  24. "ev.preventDefault();var id=decodeURIComponent((a.getAttribute('href')||'#').slice(1));"
  25. "if(!id){d.defaultView.scrollTo(0,0);return}"
  26. "var el=d.getElementById(id)||(d.getElementsByName(id)||[])[0];"
  27. "if(el&&el.scrollIntoView)el.scrollIntoView({block:'start'})},true)}catch(e){}})})();")
  28. def portal_path(a=None) -> Path:
  29. if a: return Path(a)
  30. return Path(os.environ.get("GUANLAN_PORTAL") or (ROOT / "release/portal.html"))
  31. def _strip_old(s: str) -> tuple[str, int]:
  32. """剥掉旧版本注入块 (从旧标记到该块结尾 `})();`), 用于升级; 返回 (新串, 剥掉几块)."""
  33. n = 0
  34. while True:
  35. i = s.find(MARK_ANY)
  36. if i < 0 or s[i:i + len(MARK)] == MARK: break
  37. j = s.find("})();", i)
  38. if j < 0: break
  39. s = s[:i] + s[j + len("})();"):]; n += 1
  40. return s, n
  41. def inject(p: Path) -> tuple[bool, str]:
  42. s = p.read_text(encoding="utf-8", errors="strict")
  43. if MARK in s: return False, "已注入 (幂等, 不重复写)"
  44. s, n_old = _strip_old(s)
  45. for a in (ANCHOR, ANCHOR_ALT):
  46. if a in s:
  47. bak = p.with_suffix(p.suffix + ".bak_anchor_" + time.strftime("%Y%m%d%H%M%S")); shutil.copy2(p, bak)
  48. s2 = s.replace(a, a + PATCH, 1)
  49. assert MARK in s2 and len(s2) > len(s), "注入后自检失败"
  50. p.write_text(s2, encoding="utf-8")
  51. return True, f"已注入 ({p.name}, 备份 {bak.name}, 剥旧版 {n_old} 块, sha16 {hashlib.sha256(s2.encode()).hexdigest()[:16]})"
  52. return False, "未找到挂载点 (门户结构变了: 找 ovf.srcdoc 赋值处) → 未改动"
  53. def main():
  54. ap = argparse.ArgumentParser(); ap.add_argument("--portal"); ap.add_argument("--check", action="store_true"); a = ap.parse_args()
  55. p = portal_path(a.portal)
  56. if not p.exists(): print(f"门户不存在: {p}"); return 2
  57. if a.check:
  58. ok = MARK in p.read_text(encoding="utf-8", errors="replace"); print(("已注入" if ok else "未注入") + f": {p}"); return 0 if ok else 1
  59. changed, msg = inject(p); print(msg); return 0 if (changed or "已注入" in msg) else 2
  60. if __name__ == "__main__":
  61. sys.exit(main())