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