微信小程序页面迁移校验之前 P5任务处理之前

This commit is contained in:
Neo
2026-03-09 01:19:21 +08:00
parent 263bf96035
commit 6e20987d2f
1112 changed files with 153824 additions and 219694 deletions

View File

@@ -0,0 +1,755 @@
"""
分区锚点对齐的长页面像素级对比脚本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()