"""从 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("= 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