"""文档截图流水线:CDP 驱动无头 Chrome 抓 docs/images,并强制过一遍隐私门禁。 设计要点:**扫描不可跳过**。每张图在按下截图键之前,先把渲染后的页面文本 (innerText + 所有 title/placeholder/value + 原始 HTML)过一遍泄露正则, 命中任何一条就直接拒绝出图并打印上下文。要绕过必须显式 --force, 而 --force 会在输出里留下「已绕过门禁」的警告,不会静默。 用法: # 演示数据(隔离库),出中英两套 python scripts/shoot_docs_screenshots.py --base http://127.0.0.1:8848 --lang both # 真环境截图:门禁会拦下真实路径/会话内容,先看清再决定 python scripts/shoot_docs_screenshots.py --base http://127.0.0.1:8848 --out docs/images 依赖:本机 Chrome 或 Edge + httpx + websockets(均在 requirements 内)。 """ from __future__ import annotations import argparse import base64 import asyncio import json import os import re import shutil import subprocess import sys import tempfile import time from pathlib import Path import httpx import websockets REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_OUT = REPO_ROOT / "docs" / "images" # 路由 -> (输出文件名基名, 加载后要点击的选择器) PAGES: dict[str, tuple[str, str | None]] = { "/monitor": ("monitor-system", None), "/usage": ("monitor-usage", None), "/automation": ("automation", None), "/observe": ("observe", ".session-item"), "/events": ("events", None), "/notify": ("notify", None), "/plugin-builder": ("plugin-builder", None), } # ── 隐私门禁 ──────────────────────────────────────────────────────────── # 只读扫描 D:\.scout 时发现:101 个会话的 title 字段全为空,界面回退成 # 「首条消息截断」,所以真环境截图必然把真实对话内容印进左栏;另有 7 条 # 消息含 C:\Users\ 真实路径与真实文件名。下面这些模式就是拦它们的。 LEAK_PATTERNS: dict[str, re.Pattern[str]] = { "本机用户目录": re.compile(r"[A-Za-z]:[\\/]{1,2}[Uu]sers[\\/]{1,2}[^\\/\s\"'<]+"), "家目录": re.compile(r"(?:/home/|/Users/)[a-z0-9_.-]{2,}", re.I), "邮箱": re.compile(r"[\w.+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "手机号": re.compile(r"(? int: global _rpc_id _rpc_id += 1 return _rpc_id async def _rpc(ws, method: str, params: dict | None = None): mine = _next_id() await ws.send(json.dumps({"id": mine, "method": method, "params": params or {}})) while True: msg = json.loads(await ws.recv()) if msg.get("id") == mine: if "error" in msg: raise RuntimeError(f"{method}: {msg['error']}") return msg.get("result", {}) # 一次求值把页面所有可能承载文本的位置都取回来,避免多次往返 _TEXT_PROBE = r""" (() => { const parts = []; parts.push(document.title || ''); parts.push(document.body ? document.body.innerText : ''); document.querySelectorAll('[title],[placeholder],[value],a[href]').forEach(el => { ['title','placeholder','value','href'].forEach(a => { const v = el.getAttribute(a); if (v) parts.push(v); }); }); parts.push(document.documentElement.innerHTML); return parts.join('\n'); })() """ def scan_for_leaks(text: str) -> list[tuple[str, str]]: """返回 [(类别, 命中上下文)],空列表表示干净。""" findings: list[tuple[str, str]] = [] for label, pattern in LEAK_PATTERNS.items(): for match in pattern.finditer(text): snippet = match.group(0) if LEAK_ALLOW.search(snippet): continue findings.append((label, snippet[:120])) return findings def _find_browser() -> str | None: candidates = [ r"C:\Program Files\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", ] for path in candidates: if os.path.exists(path): return path return None async def _capture(lang: str, base: str, out_dir: Path, force: bool) -> int: browser = _find_browser() if not browser: print("找不到 Chrome / Edge,无法截图", file=sys.stderr) return 2 profile = Path(tempfile.mkdtemp(prefix="scout_shot_")) port = 9333 proc = subprocess.Popen([ browser, "--headless=new", "--disable-gpu", "--no-first-run", "--hide-scrollbars", "--disable-background-networking", "--no-default-browser-check", f"--remote-debugging-port={port}", f"--user-data-dir={profile}", "--window-size=1280,800", "about:blank", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) written, blocked = 0, 0 try: target = None for _ in range(60): time.sleep(0.5) try: tabs = httpx.get(f"http://127.0.0.1:{port}/json/list", timeout=3).json() pages = [t for t in tabs if t.get("type") == "page"] if pages: target = pages[0] break except Exception: # noqa: BLE001 continue if target is None: print("浏览器调试端口未就绪", file=sys.stderr) return 2 async with websockets.connect( target["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024) as ws: await _rpc(ws, "Page.enable") await _rpc(ws, "Runtime.enable") await _rpc(ws, "Emulation.setDeviceMetricsOverride", { "width": 1280, "height": 800, "deviceScaleFactor": 1, "mobile": False}) for route, (name, click_sel) in PAGES.items(): url = base + route await _rpc(ws, "Page.navigate", {"url": url}) await asyncio.sleep(4.0) cur = await _rpc(ws, "Runtime.evaluate", { "expression": "localStorage.getItem('scout_ui_lang') || 'zh'", "returnByValue": True}) if cur.get("result", {}).get("value") != lang: await _rpc(ws, "Runtime.evaluate", { "expression": f"localStorage.setItem('scout_ui_lang','{lang}')"}) await _rpc(ws, "Page.navigate", {"url": url}) await asyncio.sleep(4.0) if click_sel: expr = ("(() => { const el = document.querySelector('" + click_sel + "'); if (!el) return 'MISS'; el.click(); " "return 'HIT'; })()") await _rpc(ws, "Runtime.evaluate", {"expression": expr}) await asyncio.sleep(2.5) probe = await _rpc(ws, "Runtime.evaluate", { "expression": _TEXT_PROBE, "returnByValue": True}) page_text = probe.get("result", {}).get("value") or "" suffix = "-en" if lang == "en" else "" dst = out_dir / f"{name}{suffix}.png" findings = scan_for_leaks(page_text) if findings and not force: blocked += 1 print(f" [BLOCKED] {route} -> {dst.name}") seen: set[str] = set() for label, snippet in findings: if snippet in seen: continue seen.add(snippet) print(f" {label}: {snippet}") if len(seen) >= 8: print(" …(其余省略)") break print(" 加 --force 可强制出图(会在控制台留警告)") continue if findings and force: print(f" [FORCE] {route}: 门禁命中 {len(findings)} 处,已按 --force 放行") shot = await _rpc(ws, "Page.captureScreenshot", {"format": "png"}) dst.write_bytes(base64.b64decode(shot["data"])) written += 1 print(f" [OK] {route} -> {dst.name} ({dst.stat().st_size} bytes)") finally: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() shutil.rmtree(profile, ignore_errors=True) print(f"\n完成:出图 {written} 张,拦截 {blocked} 张") return 0 if blocked == 0 else 1 def main() -> int: parser = argparse.ArgumentParser(description="抓取 README 文档截图(含隐私门禁)") parser.add_argument("--base", default="http://127.0.0.1:8848", help="Scout Web 地址") parser.add_argument("--lang", default="zh", choices=["zh", "en", "both"]) parser.add_argument("--out", default=str(DEFAULT_OUT), help="输出目录") parser.add_argument("--force", action="store_true", help="即使门禁命中也出图(会在控制台留警告)") args = parser.parse_args() out_dir = Path(args.out) out_dir.mkdir(parents=True, exist_ok=True) langs = ["zh", "en"] if args.lang == "both" else [args.lang] rc = 0 for lang in langs: print(f"=== lang={lang} base={args.base} out={out_dir} ===") rc |= asyncio.run(_capture(lang, args.base, out_dir, args.force)) return rc if __name__ == "__main__": sys.exit(main())