371 lines
13 KiB
Python
371 lines
13 KiB
Python
"""从 Ctrl+S 保存的 HTML / MHTML 提取布局真源,配合截图做更精确还原(行业无关)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import email
|
||
import re
|
||
from email import policy
|
||
from html.parser import HTMLParser
|
||
from typing import Any
|
||
|
||
|
||
_SKIP_TAGS = {"script", "style", "noscript", "svg", "path"}
|
||
_MAX_TEXT = 48
|
||
_NAV_NOISE = {
|
||
"欢迎您",
|
||
"退出",
|
||
"帮助",
|
||
"登录",
|
||
"注册",
|
||
"返回",
|
||
"首页",
|
||
"客服",
|
||
"电话",
|
||
"反馈",
|
||
"消息",
|
||
"系统消息",
|
||
}
|
||
# 泛化业务词:用于区分「姓名」与「页签」
|
||
_BUSINESS_HINTS = (
|
||
"列表",
|
||
"新增",
|
||
"创建",
|
||
"看板",
|
||
"概览",
|
||
"统计",
|
||
"报表",
|
||
"管理",
|
||
"设置",
|
||
"详情",
|
||
"汇总",
|
||
"筛选",
|
||
"查询",
|
||
"导入",
|
||
"导出",
|
||
"设备",
|
||
"人员",
|
||
"信息",
|
||
"数据",
|
||
"记录",
|
||
"测点",
|
||
"断面",
|
||
"工点",
|
||
"观测",
|
||
"订单",
|
||
"商品",
|
||
"库存",
|
||
"客户",
|
||
)
|
||
|
||
|
||
def _is_nav_noise(text: str) -> bool:
|
||
t = (text or "").strip()
|
||
if not t or t in _NAV_NOISE:
|
||
return True
|
||
if len(t) <= 3 and re.fullmatch(r"[\u4e00-\u9fff]{2,3}", t):
|
||
if not any(b in t for b in _BUSINESS_HINTS):
|
||
return True
|
||
return False
|
||
|
||
|
||
class _LayoutHTMLParser(HTMLParser):
|
||
def __init__(self) -> None:
|
||
super().__init__(convert_charrefs=True)
|
||
self.title = ""
|
||
self._in_title = False
|
||
self._skip = 0
|
||
self.headings: list[str] = []
|
||
self.nav_items: list[str] = []
|
||
self.buttons: list[str] = []
|
||
self.labels: list[str] = []
|
||
self.table_headers: list[list[str]] = []
|
||
self.radio_options: list[str] = []
|
||
self._cur_th: list[str] = []
|
||
self._in_th = False
|
||
self._in_nav = 0
|
||
self._in_button = False
|
||
self._in_label = False
|
||
self._pending_radio = False
|
||
self._buf = ""
|
||
self.meta_desc = ""
|
||
|
||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||
t = tag.lower()
|
||
ad = {k.lower(): (v or "") for k, v in attrs}
|
||
if t in _SKIP_TAGS:
|
||
self._skip += 1
|
||
return
|
||
if self._skip:
|
||
return
|
||
if t == "title":
|
||
self._in_title = True
|
||
self._buf = ""
|
||
elif t == "meta" and ad.get("name", "").lower() == "description":
|
||
self.meta_desc = (ad.get("content") or "")[:200]
|
||
elif t in {"nav", "header"} or "nav" in (ad.get("class") or "").lower() or ad.get("role") == "navigation":
|
||
self._in_nav += 1
|
||
elif t in {"button"} or ad.get("role") == "button" or (
|
||
t == "input" and ad.get("type") in {"button", "submit", "reset"}
|
||
):
|
||
self._in_button = True
|
||
self._buf = ad.get("value") or ad.get("aria-label") or ad.get("title") or ""
|
||
elif t == "input" and ad.get("type") == "radio":
|
||
# 值若是纯数字/无意义,等后续文本当选项;有中文 value 则直接采用
|
||
self._pending_radio = True
|
||
val = (ad.get("value") or "").strip()
|
||
if val and val not in {"", "on"} and not re.fullmatch(r"\d+", val) and len(val) <= 16:
|
||
self.radio_options.append(val)
|
||
elif t == "a" and self._in_nav:
|
||
self._buf = ""
|
||
elif t in {"h1", "h2", "h3", "h4"}:
|
||
self._buf = ""
|
||
elif t == "label":
|
||
self._in_label = True
|
||
self._buf = ""
|
||
elif t == "th":
|
||
self._in_th = True
|
||
self._buf = ""
|
||
elif t == "tr":
|
||
self._cur_th = []
|
||
|
||
def handle_endtag(self, tag: str) -> None:
|
||
t = tag.lower()
|
||
if t in _SKIP_TAGS:
|
||
if self._skip:
|
||
self._skip -= 1
|
||
return
|
||
if self._skip:
|
||
return
|
||
text = re.sub(r"\s+", " ", self._buf or "").strip()
|
||
if t == "title":
|
||
self._in_title = False
|
||
if text:
|
||
self.title = text[:80]
|
||
elif t in {"nav", "header"}:
|
||
if self._in_nav:
|
||
self._in_nav -= 1
|
||
elif t in {"button"} or self._in_button and t in {"input", "a", "span", "div"}:
|
||
if text and len(text) <= _MAX_TEXT:
|
||
self.buttons.append(text)
|
||
self._in_button = False
|
||
elif t == "a" and self._in_nav and text and len(text) <= _MAX_TEXT:
|
||
if not _is_nav_noise(text):
|
||
self.nav_items.append(text)
|
||
elif t in {"h1", "h2", "h3", "h4"} and text and len(text) <= 64:
|
||
if not _is_nav_noise(text):
|
||
self.headings.append(text)
|
||
elif t == "label":
|
||
self._in_label = False
|
||
cleaned = text.rstrip("::")
|
||
if cleaned and len(cleaned) <= _MAX_TEXT:
|
||
# 单选旁的短文案不要当导航噪声丢掉(如「全部」「类型A」)
|
||
if self._pending_radio and 1 <= len(cleaned) <= 12:
|
||
self.radio_options.append(cleaned)
|
||
elif not _is_nav_noise(cleaned):
|
||
self.labels.append(cleaned)
|
||
self._pending_radio = False
|
||
elif t == "th":
|
||
self._in_th = False
|
||
if text:
|
||
self._cur_th.append(text[:32])
|
||
elif t == "tr" and self._cur_th:
|
||
self.table_headers.append(self._cur_th[:12])
|
||
self._cur_th = []
|
||
self._buf = ""
|
||
|
||
def handle_data(self, data: str) -> None:
|
||
if self._skip:
|
||
return
|
||
if self._in_title or self._in_button or self._in_nav or self._in_th or self._in_label:
|
||
self._buf += data
|
||
return
|
||
self._buf += data
|
||
|
||
|
||
def _decode_bytes(raw: bytes) -> str:
|
||
for enc in ("utf-8", "gb18030", "gbk", "latin-1"):
|
||
try:
|
||
return raw.decode(enc)
|
||
except UnicodeDecodeError:
|
||
continue
|
||
return raw.decode("utf-8", errors="ignore")
|
||
|
||
|
||
def extract_html_from_mhtml(raw: bytes) -> str:
|
||
msg = email.message_from_bytes(raw, policy=policy.default)
|
||
if msg.is_multipart():
|
||
for part in msg.walk():
|
||
ctype = (part.get_content_type() or "").lower()
|
||
if ctype in {"text/html", "application/xhtml+xml"}:
|
||
try:
|
||
return part.get_content()
|
||
except Exception: # noqa: BLE001
|
||
payload = part.get_payload(decode=True) or b""
|
||
return _decode_bytes(payload)
|
||
text = _decode_bytes(raw)
|
||
i = text.lower().find("<html")
|
||
if i >= 0:
|
||
return text[i:]
|
||
return text
|
||
|
||
|
||
def parse_layout_file(filename: str, content: bytes) -> dict[str, Any]:
|
||
name = (filename or "").lower()
|
||
if name.endswith((".mhtml", ".mht")):
|
||
html = extract_html_from_mhtml(content)
|
||
source = "mhtml"
|
||
elif name.endswith((".html", ".htm", ".xhtml")):
|
||
html = _decode_bytes(content)
|
||
source = "html"
|
||
else:
|
||
html = _decode_bytes(content)
|
||
source = "text"
|
||
|
||
if len(html) > 2_000_000:
|
||
html = html[:2_000_000]
|
||
|
||
parser = _LayoutHTMLParser()
|
||
try:
|
||
parser.feed(html)
|
||
parser.close()
|
||
except Exception as e: # noqa: BLE001
|
||
return {"ok": False, "error": str(e), "filename": filename, "source": source}
|
||
|
||
# 兜底:radio 后紧跟的短文案(常见 value=数字 + 中文标签)
|
||
for m in re.finditer(
|
||
r'type=["\']radio["\'][^>]*>\s*(?:<[^>]+>\s*)*([\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9]{0,11})',
|
||
html,
|
||
flags=re.I,
|
||
):
|
||
lab = m.group(1).strip()
|
||
if lab and not re.fullmatch(r"\d+", lab):
|
||
parser.radio_options.append(lab)
|
||
|
||
def uniq(items: list[str], limit: int = 40) -> list[str]:
|
||
seen: set[str] = set()
|
||
out: list[str] = []
|
||
for x in items:
|
||
x = x.strip()
|
||
if not x or x in seen:
|
||
continue
|
||
seen.add(x)
|
||
out.append(x)
|
||
if len(out) >= limit:
|
||
break
|
||
return out
|
||
|
||
ths = parser.table_headers[0] if parser.table_headers else []
|
||
return {
|
||
"ok": True,
|
||
"filename": filename,
|
||
"source": source,
|
||
"title": parser.title,
|
||
"meta_description": parser.meta_desc,
|
||
"headings": uniq(parser.headings, 20),
|
||
"nav_items": uniq(parser.nav_items, 24),
|
||
"buttons": uniq(parser.buttons, 30),
|
||
"labels": uniq(parser.labels, 30),
|
||
"radio_options": uniq(parser.radio_options, 12),
|
||
"table_headers": uniq(ths, 16),
|
||
"table_header_rows": parser.table_headers[:3],
|
||
}
|
||
|
||
|
||
def layout_summary_text(parsed: dict[str, Any]) -> str:
|
||
if not parsed or not parsed.get("ok"):
|
||
err = (parsed or {}).get("error") or "parse failed"
|
||
return f"[页面 HTML 解析失败] {err}"
|
||
lines = [
|
||
"[页面 HTML 布局真源 · 优先于猜测 · 禁止套用其它行业模板]",
|
||
f"文件: {parsed.get('filename')} ({parsed.get('source')})",
|
||
]
|
||
if parsed.get("title"):
|
||
lines.append(f"文档标题: {parsed['title']}")
|
||
if parsed.get("headings"):
|
||
lines.append("标题层级: " + " / ".join(parsed["headings"][:12]))
|
||
if parsed.get("nav_items"):
|
||
lines.append("导航页签: " + "、".join(parsed["nav_items"][:16]))
|
||
if parsed.get("buttons"):
|
||
lines.append("操作按键: " + "、".join(parsed["buttons"][:20]))
|
||
if parsed.get("labels"):
|
||
lines.append("分区/筛选项标签: " + "、".join(parsed["labels"][:20]))
|
||
if parsed.get("radio_options"):
|
||
lines.append("单选选项: " + "、".join(parsed["radio_options"][:12]))
|
||
if parsed.get("table_headers"):
|
||
lines.append("表格列: " + "、".join(parsed["table_headers"][:16]))
|
||
lines.append(
|
||
"要求:pages 标题、导航、action_labels、表格列、筛选文案必须优先采用上述 HTML 原文;"
|
||
"meta.ui_preset=screenshot_faithful;文案以 HTML 为准、分区以截图为准;"
|
||
"禁止擅自换成其它业务的固定文案。"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def merge_layout_into_ui_hints(hints: dict[str, Any], parsed: dict[str, Any]) -> dict[str, Any]:
|
||
"""把 HTML 解析结果并入 ui_hints。只使用文件里的原文,不写死行业词。"""
|
||
if not parsed or not parsed.get("ok"):
|
||
return hints
|
||
ui = hints.setdefault("ui", {})
|
||
|
||
if not hints.get("app_name") and parsed.get("title"):
|
||
title = str(parsed["title"])
|
||
parts = re.split(r"\s*[-_||]\s*", title)
|
||
if parts:
|
||
cand = parts[0][:64]
|
||
if cand and not _is_nav_noise(cand):
|
||
hints["app_name"] = cand
|
||
if len(parts) > 1 and "platform_title" not in ui:
|
||
ui["platform_title"] = parts[-1][:64]
|
||
|
||
labels = [x for x in (parsed.get("labels") or []) if x and not _is_nav_noise(x)]
|
||
# 按出现顺序把较长分区标签分给 图 / 条 / 表(通用,不认行业)
|
||
region_labels = [x for x in labels if len(x) >= 4 and not x.startswith("(")]
|
||
if region_labels:
|
||
if len(region_labels) >= 1:
|
||
ui.setdefault("chart_side_label", region_labels[0])
|
||
hints.setdefault("chart_title", region_labels[0])
|
||
if len(region_labels) >= 2:
|
||
ui.setdefault("strip_side_label", region_labels[1])
|
||
hints.setdefault("strip_title", region_labels[1])
|
||
if len(region_labels) >= 3:
|
||
ui.setdefault("table_side_label", region_labels[2])
|
||
# 表格横标题:优先含「表」或最长的后续标签
|
||
for lab in reversed(region_labels):
|
||
if "表" in lab or len(lab) >= 8:
|
||
hints.setdefault("table_title", lab)
|
||
ui.setdefault("table_title", lab)
|
||
break
|
||
|
||
nav = [x for x in (parsed.get("nav_items") or []) if not _is_nav_noise(x)]
|
||
if nav:
|
||
ui["nav_items"] = nav[:16]
|
||
for n in nav:
|
||
if any(k in n for k in ("看板", "概览", "统计", "dashboard")):
|
||
hints.setdefault("dash_title", n)
|
||
elif any(k in n for k in ("新增", "创建", "新建")):
|
||
hints.setdefault("create_title", n)
|
||
elif any(k in n for k in ("列表", "明细", "记录", "数据", "测点", "订单", "商品")):
|
||
hints.setdefault("list_title", n)
|
||
|
||
if parsed.get("buttons"):
|
||
hints["_html_buttons_line"] = "操作:" + "、".join(parsed["buttons"][:20])
|
||
if parsed.get("table_headers"):
|
||
ui["table_headers"] = parsed["table_headers"]
|
||
if labels:
|
||
ui.setdefault("filter_labels", labels[:12])
|
||
|
||
radios = [
|
||
x
|
||
for x in (parsed.get("radio_options") or [])
|
||
if x
|
||
and x not in {"全部", "all", "All"}
|
||
and not re.fullmatch(r"\d+", x)
|
||
and len(x) <= 12
|
||
]
|
||
if len(radios) >= 2:
|
||
ui.setdefault("section_options", radios[:8])
|
||
ui.setdefault("filter_radios", radios[:8])
|
||
ui.setdefault("filter_style", "section_radios")
|
||
|
||
return hints
|