Files
Neo-ZQYY/scripts/ops/anchor_compare.py

756 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
分区锚点对齐的长页面像素级对比脚本v2 — 逐段截图方案)。
核心思路:
H5 和 MP 两端都按 section 锚点逐段滚动 + 单屏截图,不再使用长截图裁剪。
两端视口高度统一为 430×752MP 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×2256DPR=3MP 截图 645×1128 → ×2 缩放到 1290×2256。
两端截图从顶部自然对齐,直接输出配对图片供 pixelmatch 对比。
输出文件:
seg-h5-<page>-<i>.png — H5 区域截图1290px 宽,直接复制)
seg-mp-<page>-<i>.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 <command> <page_name>
命令:
extract-h5 <page> 提取 H5 锚点坐标 + 逐段截图(需 Go Live 5500
mp-inst <page> 生成 MP 截图指令(基于已有锚点数据)
compare <page> 逐段对比,输出配对图片
full <page> 一键执行 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()