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

244 lines
9.4 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.
"""
测量 board-finance H5 原型「经营一览」板块内所有关键元素的
实际渲染位置getBoundingClientRect计算相邻元素间的真实垂直间距。
配置与 screenshot_h5_pages.py 一致iPhone 15 Pro Max (430×932, DPR:3)
"""
import asyncio
import json
from pathlib import Path
from playwright.async_api import async_playwright
BASE_URL = "http://127.0.0.1:5500/docs/h5_ui/pages"
OUT_DIR = Path(__file__).resolve().parents[2] / "export" / "h5_style_extract"
OUT_DIR.mkdir(parents=True, exist_ok=True)
# 用 getBoundingClientRect 测量每个关键元素的精确位置
MEASURE_JS = """
() => {
const section = document.getElementById('section-overview');
if (!section) return { error: 'not found' };
const sRect = section.getBoundingClientRect();
function measure(el, label) {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
label,
top: Math.round(r.top * 100) / 100,
bottom: Math.round(r.bottom * 100) / 100,
height: Math.round(r.height * 100) / 100,
left: Math.round(r.left * 100) / 100,
right: Math.round(r.right * 100) / 100,
width: Math.round(r.width * 100) / 100,
// 相对于板块顶部的偏移
relTop: Math.round((r.top - sRect.top) * 100) / 100,
relBottom: Math.round((r.bottom - sRect.top) * 100) / 100,
fontSize: cs.fontSize,
lineHeight: cs.lineHeight,
paddingTop: cs.paddingTop,
paddingBottom: cs.paddingBottom,
text: el.textContent?.trim()?.substring(0, 30) || '',
};
}
const items = [];
// 板块根
items.push(measure(section, 'SECTION_ROOT'));
// 板块头
const header = section.querySelector('.summary-header');
if (header) {
items.push(measure(header, 'HEADER_BAR'));
const h3 = header.querySelector('h3');
if (h3) items.push(measure(h3, 'HEADER_TITLE'));
const p = header.querySelector('p');
if (p) items.push(measure(p, 'HEADER_DESC'));
const emoji = header.querySelector('span');
if (emoji) items.push(measure(emoji, 'HEADER_EMOJI'));
}
// 板块正文
const content = section.querySelector('.summary-content');
if (content) {
items.push(measure(content, 'CONTENT_BODY'));
}
// 收入概览子标题行
const subRows = content?.querySelectorAll(':scope > div > .flex.items-center.gap-2');
if (subRows) {
subRows.forEach((el, i) => {
items.push(measure(el, `SUB_LABEL_${i}`));
el.querySelectorAll('span').forEach((s, j) => {
items.push(measure(s, `SUB_LABEL_${i}_SPAN_${j}`));
});
});
}
// 3列网格
const grid3 = section.querySelector('.grid.grid-cols-3');
if (grid3) {
items.push(measure(grid3, 'GRID_3COL'));
grid3.querySelectorAll(':scope > div').forEach((cell, i) => {
items.push(measure(cell, `GRID3_CELL_${i}`));
// 标签文字
const labelP = cell.querySelector('p');
if (labelP) items.push(measure(labelP, `GRID3_CELL_${i}_LABEL`));
// 数值文字(第二个 p
const allP = cell.querySelectorAll('p');
if (allP[1]) items.push(measure(allP[1], `GRID3_CELL_${i}_VALUE`));
// 环比
const compare = cell.querySelector('.compare-data');
if (compare) items.push(measure(compare, `GRID3_CELL_${i}_COMPARE`));
});
}
// 确认收入行bg-white/10 rounded-xl 内的第一个)
const confirmedRow = content?.querySelector('.rounded-xl');
if (confirmedRow) {
items.push(measure(confirmedRow, 'CONFIRMED_ROW'));
confirmedRow.querySelectorAll('p').forEach((p, i) => {
items.push(measure(p, `CONFIRMED_P_${i}`));
});
}
// 分割线
const divider = content?.querySelector('.border-t');
if (divider) {
items.push(measure(divider, 'DIVIDER'));
}
// 现金流水子标题行
// 它是 divider 后面的 div 内的 flex 行
const cashSection = divider?.nextElementSibling;
if (cashSection) {
const cashSubLabel = cashSection.querySelector('.flex.items-center.gap-2');
if (cashSubLabel) {
items.push(measure(cashSubLabel, 'CASH_SUB_LABEL'));
}
}
// 2列网格
const grid2 = section.querySelector('.grid.grid-cols-2');
if (grid2) {
items.push(measure(grid2, 'GRID_2COL'));
grid2.querySelectorAll(':scope > div').forEach((cell, i) => {
items.push(measure(cell, `GRID2_CELL_${i}`));
const allP = cell.querySelectorAll('p');
if (allP[0]) items.push(measure(allP[0], `GRID2_CELL_${i}_LABEL`));
if (allP[1]) items.push(measure(allP[1], `GRID2_CELL_${i}_VALUE`));
const compare = cell.querySelector('.compare-data');
if (compare) items.push(measure(compare, `GRID2_CELL_${i}_COMPARE`));
});
}
// AI 洞察
const aiSection = section.querySelector('.ai-insight-section');
if (aiSection) {
items.push(measure(aiSection, 'AI_SECTION'));
const aiHeader = aiSection.querySelector('.ai-insight-header');
if (aiHeader) items.push(measure(aiHeader, 'AI_HEADER'));
const aiTitle = aiSection.querySelector('.ai-insight-title');
if (aiTitle) items.push(measure(aiTitle, 'AI_TITLE'));
aiSection.querySelectorAll('p').forEach((p, i) => {
items.push(measure(p, `AI_LINE_${i}`));
});
}
return items;
}
"""
async def main():
print("=" * 70)
print("H5 垂直间距精确测量 — getBoundingClientRect")
print("iPhone 15 Pro Max (430×932, DPR:3)")
print("=" * 70)
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=False,
args=["--hide-scrollbars"],
)
context = await browser.new_context(
viewport={"width": 430, "height": 932},
device_scale_factor=3,
)
page = await context.new_page()
dpr = await page.evaluate("() => window.devicePixelRatio")
assert dpr == 3
url = f"{BASE_URL}/board-finance.html"
await page.goto(url, wait_until="load", timeout=15000)
await page.wait_for_timeout(2500)
items = await page.evaluate(MEASURE_JS)
# 保存原始数据
out_path = OUT_DIR / "board-finance-vertical-gaps.json"
with open(out_path, "w", encoding="utf-8") as f:
json.dump(items, f, ensure_ascii=False, indent=2)
# 计算相邻元素间的真实垂直间距
print(f"\n共测量 {len(items)} 个元素\n")
# 按 relTop 排序,打印每个元素的位置
sorted_items = sorted(items, key=lambda x: x['relTop'])
print(f"{'元素':<30} {'relTop':>8} {'relBot':>8} {'height':>8} {'fontSize':>10} {'lineH':>10}")
print("-" * 100)
for it in sorted_items:
print(f"{it['label']:<30} {it['relTop']:>8.1f} {it['relBottom']:>8.1f} {it['height']:>8.1f} {it['fontSize']:>10} {it['lineHeight']:>10}")
# 计算关键间距对
print("\n" + "=" * 70)
print("关键垂直间距(相邻元素 bottom→top 的实际 px 距离)")
print("公式gap_rpx = gap_px × 2 × 0.875")
print("=" * 70)
gap_pairs = [
("HEADER_BAR", "CONTENT_BODY", "头部栏底 → 正文顶"),
("HEADER_TITLE", "HEADER_DESC", "标题底 → 副标题顶"),
("SUB_LABEL_0", "GRID_3COL", "收入概览标签底 → 3列网格顶"),
("GRID3_CELL_0_LABEL", "GRID3_CELL_0_VALUE", "标签底 → 数值顶cell0"),
("GRID3_CELL_0_VALUE", "GRID3_CELL_0_COMPARE", "数值底 → 环比顶cell0"),
("GRID_3COL", "CONFIRMED_ROW", "3列网格底 → 确认收入行顶"),
("CONFIRMED_ROW", "DIVIDER", "确认收入行底 → 分割线顶"),
("DIVIDER", "CASH_SUB_LABEL", "分割线底 → 现金流水标签顶"),
("CASH_SUB_LABEL", "GRID_2COL", "现金流水标签底 → 2列网格顶"),
("GRID2_CELL_0_LABEL", "GRID2_CELL_0_VALUE", "标签底 → 数值顶2col cell0"),
("GRID2_CELL_0_VALUE", "GRID2_CELL_0_COMPARE", "数值底 → 环比顶2col cell0"),
("GRID_2COL", "AI_SECTION", "2列网格底 → AI洞察顶"),
("AI_HEADER", "AI_LINE_0", "AI标题底 → 第一行文字顶"),
("AI_LINE_0", "AI_LINE_1", "AI第1行底 → 第2行顶"),
]
item_map = {it['label']: it for it in items}
for from_label, to_label, desc in gap_pairs:
fr = item_map.get(from_label)
to = item_map.get(to_label)
if fr and to:
gap_px = to['relTop'] - fr['relBottom']
gap_rpx = round(gap_px * 2 * 0.875, 1)
print(f" {desc}")
print(f" {from_label}.bottom={fr['relBottom']:.1f}{to_label}.top={to['relTop']:.1f}")
print(f" 间距: {gap_px:.1f}px → {gap_rpx:.1f}rpx")
print()
else:
missing = from_label if not fr else to_label
print(f" {desc}: ⚠️ 未找到 {missing}")
print()
await browser.close()
print(f"原始数据: {out_path}")
if __name__ == "__main__":
asyncio.run(main())