chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
370
ai-service/html_layout.py
Normal file
370
ai-service/html_layout.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""从 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
|
||||
Reference in New Issue
Block a user