""" 分区锚点对齐的长页面像素级对比脚本(v2 — 逐段截图方案)。 核心思路: H5 和 MP 两端都按 section 锚点逐段滚动 + 单屏截图,不再使用长截图裁剪。 两端视口高度统一为 430×752(MP windowHeight),截图自然 1:1 对齐。 H5 DPR=3 → 1290×2256 物理像素;MP DPR=1.5 → 645×1128 → ×2 缩放到 1290×2256。 用法: # 第一步:提取 H5 锚点坐标 + 逐段截图(需要 Go Live 运行在 5500 端口) python scripts/ops/anchor_compare.py extract-h5 board-finance # 第二步:生成 MP 截图指令(AI 通过 MCP 工具手动执行) python scripts/ops/anchor_compare.py mp-inst board-finance # 第三步:逐区域对比 python scripts/ops/anchor_compare.py compare board-finance # 一键执行(仅 H5 端 + 对比,MP 截图需手动) python scripts/ops/anchor_compare.py full board-finance 依赖: pip install playwright Pillow playwright install chromium DPR 换算关系: H5: viewport 430×752, DPR=3 → 截图 1290×2256 MP: viewport 430×752, DPR=1.5 → 截图 645×1128 → ×2 缩放到 1290×2256 """ import asyncio import io import json import sys import time from pathlib import Path from typing import Optional from PIL import Image def _ensure_utf8_stdio(): """Windows 终端 GBK 编码兼容:强制 stdout/stderr 为 UTF-8,避免 emoji 输出报错。 仅在 CLI 入口调用,不在模块导入时执行——否则与 Playwright asyncio 冲突导致空输出。 """ if sys.platform == "win32": sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") # ─── 路径常量 ─── ROOT = Path(__file__).resolve().parents[2] SCREENSHOTS_DIR = ROOT / "docs" / "h5_ui" / "screenshots" ANCHORS_DIR = ROOT / "docs" / "h5_ui" / "anchors" H5_PAGES_DIR = ROOT / "docs" / "h5_ui" / "pages" # 确保目录存在 SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True) ANCHORS_DIR.mkdir(parents=True, exist_ok=True) # ─── DPR / 尺寸常量 ─── H5_DPR = 3 MP_DPR = 1.5 VIEWPORT_W = 430 VIEWPORT_H = 752 # MP windowHeight,两端统一 TARGET_W = 1290 # 统一对比宽度 = 430 × 3 TARGET_H = 2256 # 统一对比高度 = 752 × 3 # H5 截图参数(与 screenshot_h5_pages.py 一致) H5_BASE_URL = "http://127.0.0.1:5500/docs/h5_ui/pages" TAILWIND_WAIT_MS = 2500 # Tailwind CDN JIT 渲染等待 SCROLLBAR_HIDE_JS = """ () => { document.documentElement.style.overflow = 'auto'; document.documentElement.style.scrollbarWidth = 'none'; const s = document.createElement('style'); s.textContent = '::-webkit-scrollbar { display: none !important; }'; document.head.appendChild(s); } """ # ═══════════════════════════════════════════════════════════ # 锚点配置:每个长页面的语义区域定义 # ═══════════════════════════════════════════════════════════ # 锚点选择器规则: # - id 优先(如 #section-overview) # - 无 id 时用 CSS 选择器(如 .section-title.pink 的父容器) # - 每个锚点指向区域的「顶部边界元素」 # # sticky_selectors: 页面中 sticky/fixed 定位的元素选择器 # fixed_bottom_selectors: 底部 fixed 元素,截图时需要隐藏 # mp_scroll_mode: "scroll_into_view" 用于 scroll-view 页面 PAGE_ANCHORS: dict = { "board-finance": { "sticky_selectors": [".safe-area-top", "#filterBar"], "fixed_bottom_selectors": [".ai-float-btn-container"], "mp_scroll_mode": "scroll_into_view", "anchors": [ {"selector": "#section-overview", "name": "经营一览", "scroll_into_view_id": "section-overview"}, {"selector": "#section-recharge", "name": "预收资产", "scroll_into_view_id": "section-recharge"}, {"selector": "#section-revenue", "name": "应计收入确认", "scroll_into_view_id": "section-revenue"}, {"selector": "#section-cashflow", "name": "现金流入", "scroll_into_view_id": "section-cashflow"}, {"selector": "#section-expense", "name": "现金流出", "scroll_into_view_id": "section-expense"}, {"selector": "#section-coach", "name": "助教分析", "scroll_into_view_id": "section-coach"}, ], }, "task-detail": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section", "name": "客户信息Banner"}, {"selector": ".section-title.pink", "name": "与我的关系", "use_parent": True}, {"selector": ".section-title.orange", "name": "任务建议", "use_parent": True}, {"selector": ".section-title.green", "name": "维客线索", "use_parent": True}, ], }, "task-detail-callback": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section", "name": "客户信息Banner"}, {"selector": ".section-title.pink", "name": "与我的关系", "use_parent": True}, {"selector": ".section-title.orange", "name": "任务建议", "use_parent": True}, {"selector": ".section-title.green", "name": "维客线索", "use_parent": True}, ], }, "task-detail-priority": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section", "name": "客户信息Banner"}, {"selector": ".section-title.pink", "name": "与我的关系", "use_parent": True}, {"selector": ".section-title.orange", "name": "任务建议", "use_parent": True}, {"selector": ".section-title.green", "name": "维客线索", "use_parent": True}, ], }, "task-detail-relationship": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section", "name": "客户信息Banner"}, {"selector": ".section-title.pink", "name": "与我的关系", "use_parent": True}, {"selector": ".section-title.orange", "name": "任务建议", "use_parent": True}, {"selector": ".section-title.green", "name": "维客线索", "use_parent": True}, ], }, "coach-detail": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section, .coach-banner, header", "name": "助教信息Banner"}, {"selector": ".st.blue, [class*='perf']", "name": "绩效概览", "use_parent": True}, {"selector": ".st.green, [class*='income']", "name": "收入明细", "use_parent": True}, {"selector": ".st.purple, [class*='customer']", "name": "前10客户", "use_parent": True}, ], }, "customer-detail": { "sticky_selectors": [], "fixed_bottom_selectors": ["div[class*='fixed'][class*='bottom-0']", ".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section, header", "name": "客户信息Banner"}, {"selector": ".section-title, .card-section", "name": "消费习惯", "use_parent": True}, ], }, "performance": { "sticky_selectors": [], "fixed_bottom_selectors": [".ai-float-btn-container"], "anchors": [ {"selector": ".banner-section, header, .perf-banner", "name": "业绩总览Banner"}, {"selector": ".perf-card, .card-section", "name": "本月业绩进度"}, ], }, "board-coach": { "sticky_selectors": [".safe-area-top", "#filterBar"], "fixed_bottom_selectors": ["#bottomNav", ".ai-float-btn-container"], "anchors": [ {"selector": ".coach-card, .card-list, .dim-container.active", "name": "助教卡片列表"}, ], }, "board-customer": { "sticky_selectors": [".safe-area-top", "#filterBar"], "fixed_bottom_selectors": ["#bottomNav", ".ai-float-btn-container"], "anchors": [ {"selector": ".customer-card, .card-list, .dim-container.active", "name": "客户卡片列表"}, ], }, } # ═══════════════════════════════════════════════════════════ # 工具函数 # ═══════════════════════════════════════════════════════════ def load_anchor_data(page_name: str) -> Optional[dict]: """加载已保存的锚点坐标数据""" path = ANCHORS_DIR / f"{page_name}.json" if not path.exists(): return None with open(path, "r", encoding="utf-8") as f: return json.load(f) def save_anchor_data(page_name: str, data: dict) -> Path: """保存锚点坐标数据""" path = ANCHORS_DIR / f"{page_name}.json" with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) print(f" 💾 锚点数据已保存: {path.relative_to(ROOT)}") return path def resize_to_width(img: Image.Image, target_w: int) -> Image.Image: """缩放图片到指定宽度,保持宽高比""" if img.width == target_w: return img.copy() ratio = target_w / img.width new_h = int(img.height * ratio) return img.resize((target_w, new_h), Image.LANCZOS) # ═══════════════════════════════════════════════════════════ # H5 端:提取锚点坐标 + 逐段截图 # ═══════════════════════════════════════════════════════════ def build_h5_extract_js(anchors: list, sticky_selectors: list, fixed_bottom_selectors: list) -> str: """构建提取锚点坐标的 JS 代码(仅获取坐标,不截图)""" anchor_configs = json.dumps(anchors, ensure_ascii=False) sticky_json = json.dumps(sticky_selectors) fixed_json = json.dumps(fixed_bottom_selectors) return f""" () => {{ const anchors = {anchor_configs}; const stickySelectors = {sticky_json}; const fixedSelectors = {fixed_json}; const pageHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight ); const anchorPositions = []; for (const anchor of anchors) {{ const selectors = anchor.selector.split(',').map(s => s.trim()); let el = null; for (const sel of selectors) {{ el = document.querySelector(sel); if (el) break; }} if (!el) {{ anchorPositions.push({{ name: anchor.name, selector: anchor.selector, found: false, top: 0, bottom: 0, height: 0, }}); continue; }} const target = anchor.use_parent ? el.parentElement : el; const rect = target.getBoundingClientRect(); const scrollY = window.scrollY || window.pageYOffset; anchorPositions.push({{ name: anchor.name, selector: anchor.selector, found: true, top: rect.top + scrollY, bottom: rect.bottom + scrollY, height: rect.height, }}); }} let stickyTotalHeight = 0; const stickyDetails = []; for (const sel of stickySelectors) {{ const el = document.querySelector(sel); if (el) {{ const rect = el.getBoundingClientRect(); stickyDetails.push({{ selector: sel, height: rect.height }}); stickyTotalHeight += rect.height; }} }} let fixedBottomHeight = 0; const fixedDetails = []; for (const sel of fixedSelectors) {{ const el = document.querySelector(sel); if (el) {{ const rect = el.getBoundingClientRect(); fixedDetails.push({{ selector: sel, height: rect.height }}); fixedBottomHeight += rect.height; }} }} return {{ pageHeight, viewportHeight: window.innerHeight, anchorPositions, stickyTotalHeight, stickyDetails, fixedBottomHeight, fixedDetails, }}; }} """ def build_scroll_to_anchor_js(anchor_top: float, sticky_height: float) -> str: """构建滚动到锚点位置的 JS 代码。 滚动目标:让 section 顶部紧贴 sticky 区域下方。 scrollTo = anchor_top - sticky_height """ scroll_y = max(0, anchor_top - sticky_height) return f""" () => {{ window.scrollTo({{ top: {scroll_y}, behavior: 'instant' }}); return {{ scrollY: window.scrollY, target: {scroll_y} }}; }} """ async def extract_h5_anchors(page_name: str) -> dict: """ 用 Playwright 提取 H5 页面的锚点坐标,并逐段截图。 视口 430×752(与 MP windowHeight 一致),DPR=3 → 每张截图 1290×2256。 每个 section 滚动到锚点位置后截一屏(非 full_page)。 """ from playwright.async_api import async_playwright config = PAGE_ANCHORS.get(page_name) if not config: print(f"❌ 页面 '{page_name}' 未配置锚点,请在 PAGE_ANCHORS 中添加") return {} url = f"{H5_BASE_URL}/{page_name}.html" html_path = H5_PAGES_DIR / f"{page_name}.html" if not html_path.exists(): print(f"❌ H5 源文件不存在: {html_path.relative_to(ROOT)}") return {} print(f"\n{'='*60}") print(f"📐 提取 H5 锚点坐标 + 逐段截图: {page_name}") print(f" URL: {url}") print(f" 视口: {VIEWPORT_W}×{VIEWPORT_H}, DPR={H5_DPR}") print(f" 每张截图: {TARGET_W}×{TARGET_H}") print(f"{'='*60}") max_retries = 3 for attempt in range(1, max_retries + 1): try: async with async_playwright() as p: browser = await p.chromium.launch( headless=True, args=["--hide-scrollbars"], ) context = await browser.new_context( viewport={"width": VIEWPORT_W, "height": VIEWPORT_H}, device_scale_factor=H5_DPR, ) page = await context.new_page() # 验证 DPR dpr = await page.evaluate("() => window.devicePixelRatio") assert dpr == H5_DPR, f"DPR 应为 {H5_DPR},实际为 {dpr}" # 导航 + 等待渲染 await page.goto(url, wait_until="load", timeout=15000) await page.wait_for_timeout(TAILWIND_WAIT_MS) await page.evaluate(SCROLLBAR_HIDE_JS) await page.wait_for_timeout(300) # 提取锚点坐标 js_code = build_h5_extract_js( config["anchors"], config.get("sticky_selectors", []), config.get("fixed_bottom_selectors", []), ) result = await page.evaluate(js_code) # 检查锚点是否全部找到 not_found = [a for a in result["anchorPositions"] if not a["found"]] if not_found: print(f"\n⚠️ 以下锚点未找到:") for a in not_found: print(f" - {a['name']}: {a['selector']}") # 打印坐标信息 print(f"\n📊 页面总高度: {result['pageHeight']:.0f}px (逻辑)") print(f" 视口高度: {result['viewportHeight']}px") print(f" Sticky 总高度: {result['stickyTotalHeight']:.0f}px") for a in result["anchorPositions"]: status = "✅" if a["found"] else "❌" print(f" {status} {a['name']}: top={a['top']:.0f}px") # 逐段截图:滚动到每个锚点位置,截一屏 sticky_h = result["stickyTotalHeight"] segments = [] for i, anchor in enumerate(result["anchorPositions"]): if not anchor["found"]: print(f"\n ⏭️ 跳过未找到的锚点: {anchor['name']}") continue # 滚动到锚点位置(section 顶部紧贴 sticky 下方) scroll_js = build_scroll_to_anchor_js(anchor["top"], sticky_h) scroll_result = await page.evaluate(scroll_js) await page.wait_for_timeout(500) # 等待滚动完成 + 渲染 # 截一屏(非 full_page) seg_path = SCREENSHOTS_DIR / f"h5-{page_name}--seg-{i}.png" await page.screenshot(path=str(seg_path), full_page=False) # 验证截图尺寸 img = Image.open(seg_path) assert img.width == TARGET_W, ( f"截图宽度应为 {TARGET_W},实际为 {img.width}" ) assert img.height == TARGET_H, ( f"截图高度应为 {TARGET_H},实际为 {img.height}" ) segments.append({ "index": i, "name": anchor["name"], "scroll_y": scroll_result.get("scrollY", 0), "screenshot": str(seg_path.relative_to(ROOT)), "width": img.width, "height": img.height, }) print(f"\n 📸 段 {i} [{anchor['name']}]: " f"scrollY={scroll_result.get('scrollY', 0):.0f} → " f"{seg_path.name} ({img.width}×{img.height})") await browser.close() # 组装结果 result["segments"] = segments result["page_name"] = page_name result["viewport"] = {"width": VIEWPORT_W, "height": VIEWPORT_H} result["dpr"] = H5_DPR result["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S") save_anchor_data(page_name, result) print(f"\n✅ 完成: {len(segments)} 段截图") return result except Exception as e: print(f"\n⚠️ 第 {attempt}/{max_retries} 次尝试失败: {e}") if attempt < max_retries: wait_sec = attempt * 3 print(f" 等待 {wait_sec}s 后重试...") await asyncio.sleep(wait_sec) else: print(f"❌ 提取失败,已重试 {max_retries} 次") raise return {} # ═══════════════════════════════════════════════════════════ # MP 端辅助:生成 MP 截图指令 # ═══════════════════════════════════════════════════════════ def generate_mp_capture_instructions(page_name: str) -> list[dict]: """ 根据已保存的锚点数据,生成 MP 端的截图指令列表。 AI 通过 MCP 工具按指令逐步执行。 v2 方案:不再裁剪,MP 截图即为一屏完整内容(430×752 逻辑 = 645×1128 物理)。 两端截图从顶部自然对齐: H5: safe-area-top(44.67) + filterBar(70) ≈ 114.67px MP: board-tabs(45) + filter-bar(70) = 115px 差异 ~0.33px 可忽略。 返回格式: [ { "region_name": "经营一览", "scroll_mode": "scroll_into_view", "scroll_into_view_id": "section-overview", "wait_ms": 800, "screenshot_name": "mp-board-finance--seg-0.png", "notes": "scroll-into-view → section-overview" }, ... ] """ data = load_anchor_data(page_name) if not data: print(f"❌ 未找到 {page_name} 的锚点数据,请先运行 extract-h5") return [] anchors = data.get("anchorPositions", []) found_anchors = [a for a in anchors if a.get("found", False)] # 从 PAGE_ANCHORS 获取滚动模式和 scroll_into_view_id page_config = PAGE_ANCHORS.get(page_name, {}) scroll_mode = page_config.get("mp_scroll_mode", "page_scroll") anchors_config = page_config.get("anchors", []) sticky_height = data.get("stickyTotalHeight", 0) instructions = [] for i, anchor in enumerate(found_anchors): inst = { "region_index": i, "region_name": anchor["name"], "scroll_mode": scroll_mode, "wait_ms": 800 if i == 0 else 1000, "screenshot_name": f"mp-{page_name}--seg-{i}.png", } if scroll_mode == "scroll_into_view" and i < len(anchors_config): view_id = anchors_config[i].get("scroll_into_view_id", "") inst["scroll_into_view_id"] = view_id inst["notes"] = f"scroll-into-view → {view_id}" else: # page_scroll 模式:滚动到 section 顶部 - sticky 高度 scroll_top = max(0, anchor["top"] - sticky_height) inst["scroll_top"] = int(scroll_top) inst["notes"] = f"scrollTop → {int(scroll_top)}px" instructions.append(inst) # 打印指令 print(f"\n{'='*60}") print(f"📋 MP 截图指令: {page_name} ({len(instructions)} 段)") print(f" 滚动模式: {scroll_mode}") print(f" 每张截图: 645×1128 物理 (430×752 逻辑)") print(f"{'='*60}") for inst in instructions: print(f"\n 段 {inst['region_index']}: {inst['region_name']}") if "scroll_into_view_id" in inst: print(f" → scrollIntoView: {inst['scroll_into_view_id']}") elif "scroll_top" in inst: print(f" → scrollTop: {inst['scroll_top']}px") print(f" → 等待: {inst['wait_ms']}ms") print(f" → 截图: {inst['screenshot_name']}") print(f" → {inst['notes']}") # 保存指令 inst_path = ANCHORS_DIR / f"{page_name}-mp-instructions.json" with open(inst_path, "w", encoding="utf-8") as f: json.dump(instructions, f, ensure_ascii=False, indent=2) print(f"\n💾 指令已保存: {inst_path.relative_to(ROOT)}") return instructions # ═══════════════════════════════════════════════════════════ # 对比:逐段截图 1:1 对比 # ═══════════════════════════════════════════════════════════ def compare_regions(page_name: str) -> list[dict]: """ 逐段对比 H5 和 MP 截图。 H5 截图已是 1290×2256(DPR=3);MP 截图 645×1128 → ×2 缩放到 1290×2256。 两端截图从顶部自然对齐,直接输出配对图片供 pixelmatch 对比。 输出文件: seg-h5--.png — H5 区域截图(1290px 宽,直接复制) seg-mp--.png — MP 区域截图(缩放到 1290px 宽) """ data = load_anchor_data(page_name) if not data: print(f"❌ 未找到 {page_name} 的锚点数据") return [] segments = data.get("segments", []) if not segments: print(f"❌ {page_name} 无有效段(可能需要重新运行 extract-h5)") return [] results = [] print(f"\n{'='*60}") print(f"📊 逐段对比: {page_name} ({len(segments)} 段)") print(f"{'='*60}") for seg in segments: i = seg["index"] name = seg["name"] # H5 段截图 h5_seg_path = SCREENSHOTS_DIR / f"h5-{page_name}--seg-{i}.png" if not h5_seg_path.exists(): print(f"\n ❌ H5 段截图不存在: {h5_seg_path.name}") results.append({"region": name, "index": i, "error": "H5 截图缺失"}) continue # MP 段截图 mp_seg_path = SCREENSHOTS_DIR / f"mp-{page_name}--seg-{i}.png" if not mp_seg_path.exists(): print(f"\n ❌ MP 段截图不存在: {mp_seg_path.name}") print(f" 请通过 MCP 工具执行截图指令后重试") results.append({"region": name, "index": i, "error": "MP 截图缺失"}) continue h5_img = Image.open(h5_seg_path) mp_img = Image.open(mp_seg_path) print(f"\n{'─'*40}") print(f"📦 段 {i}: {name}") print(f" H5: {h5_img.width}×{h5_img.height}") print(f" MP: {mp_img.width}×{mp_img.height}") # H5 应该已经是 TARGET_W 宽 if h5_img.width != TARGET_W: print(f" ⚠️ H5 宽度异常 {h5_img.width},期望 {TARGET_W}") # MP 缩放到 TARGET_W(×2) mp_scaled = resize_to_width(mp_img, TARGET_W) print(f" MP 缩放后: {mp_scaled.width}×{mp_scaled.height}") # 取两者较小高度作为对比高度(正常情况下应该相等) compare_h = min(h5_img.height, mp_scaled.height) if h5_img.height != mp_scaled.height: print(f" ⚠️ 高度不一致: H5={h5_img.height}, MP={mp_scaled.height}") print(f" 取较小值 {compare_h} 进行对比") # 裁剪到统一高度 h5_final = h5_img.crop((0, 0, TARGET_W, compare_h)) mp_final = mp_scaled.crop((0, 0, TARGET_W, compare_h)) # 输出对比图对 h5_out = SCREENSHOTS_DIR / f"seg-h5-{page_name}-{i}.png" mp_out = SCREENSHOTS_DIR / f"seg-mp-{page_name}-{i}.png" h5_final.save(h5_out) mp_final.save(mp_out) print(f" ✅ 对比尺寸: {TARGET_W}×{compare_h}") print(f" → {h5_out.name}") print(f" → {mp_out.name}") results.append({ "region": name, "index": i, "h5_path": str(h5_out.relative_to(ROOT)), "mp_path": str(mp_out.relative_to(ROOT)), "width": TARGET_W, "height": compare_h, "h5_original": f"{h5_img.width}×{h5_img.height}", "mp_original": f"{mp_img.width}×{mp_img.height}", "mp_scaled": f"{mp_scaled.width}×{mp_scaled.height}", }) # 汇总 ok_count = sum(1 for r in results if "error" not in r) err_count = sum(1 for r in results if "error" in r) print(f"\n{'='*60}") print(f"📊 对比准备完成: {page_name}") print(f" ✅ 成功: {ok_count} 段") if err_count: print(f" ❌ 失败: {err_count} 段") print(f"\n 使用 mcp_image_compare_compare_images 逐段对比:") for r in results: if "error" not in r: print(f" → seg-h5-{page_name}-{r['index']}.png vs " f"seg-mp-{page_name}-{r['index']}.png") return results # ═══════════════════════════════════════════════════════════ # CLI 入口 # ═══════════════════════════════════════════════════════════ def print_usage(): print(""" 用法: python scripts/ops/anchor_compare.py 命令: extract-h5 提取 H5 锚点坐标 + 逐段截图(需 Go Live 5500) mp-inst 生成 MP 截图指令(基于已有锚点数据) compare 逐段对比,输出配对图片 full 一键执行 extract-h5 + mp-inst + compare list 列出所有已配置锚点的页面 v2 方案:两端都按 section 逐段截图(视口 430×752),不再使用长截图裁剪。 示例: python scripts/ops/anchor_compare.py extract-h5 board-finance python scripts/ops/anchor_compare.py mp-inst board-finance python scripts/ops/anchor_compare.py compare board-finance python scripts/ops/anchor_compare.py list """) def cmd_list(): """列出所有已配置锚点的页面""" print("\n已配置锚点的页面:") for name, config in PAGE_ANCHORS.items(): n_anchors = len(config["anchors"]) n_sticky = len(config.get("sticky_selectors", [])) n_fixed = len(config.get("fixed_bottom_selectors", [])) scroll_mode = config.get("mp_scroll_mode", "page_scroll") has_data = "✅" if (ANCHORS_DIR / f"{name}.json").exists() else " " print(f" {has_data} {name}: {n_anchors} 锚点, " f"{n_sticky} sticky, {n_fixed} fixed, {scroll_mode}") async def async_main(): if len(sys.argv) < 2: print_usage() sys.exit(1) command = sys.argv[1] if command == "list": cmd_list() return if len(sys.argv) < 3: print(f"❌ 缺少 page_name 参数") print_usage() sys.exit(1) page_name = sys.argv[2] if page_name not in PAGE_ANCHORS: print(f"❌ 页面 '{page_name}' 未配置锚点") print(f" 已配置的页面: {', '.join(PAGE_ANCHORS.keys())}") sys.exit(1) if command == "extract-h5": await extract_h5_anchors(page_name) elif command == "mp-inst": generate_mp_capture_instructions(page_name) elif command == "compare": compare_regions(page_name) elif command == "full": print(f"\n🚀 一键执行: {page_name}") print(f" Step 1/3: 提取 H5 锚点 + 逐段截图...") await extract_h5_anchors(page_name) print(f"\n Step 2/3: 生成 MP 截图指令...") generate_mp_capture_instructions(page_name) print(f"\n Step 3/3: 逐段对比...") compare_regions(page_name) else: print(f"❌ 未知命令: {command}") print_usage() sys.exit(1) def main(): _ensure_utf8_stdio() asyncio.run(async_main()) if __name__ == "__main__": main()