756 lines
30 KiB
Python
756 lines
30 KiB
Python
"""
|
||
分区锚点对齐的长页面像素级对比脚本(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-<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()
|