Files
ai_site/ai-service/html_layout.py
2026-07-31 10:31:17 +08:00

371 lines
13 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.
"""从 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