"""HTML / MHTML 技术文档导入。

管线：解析（MIME 多部分或单文件 HTML）
      → 抽取正文容器（main / article / #content / body）
      → 白名单清洗（去脚本、事件属性、危险 URL，防 XSS）
      → 图片解码落盘到站点媒体目录、重写为站内 /media/ 链接

应用模式（整页是带 JS 的网页工具）不走本模块解析，文件原样保存，
由前端 sandbox iframe 加载（见 main.py 的导入接口）。
"""
from __future__ import annotations

import base64
import email
import email.policy
import html as _html_mod
import re
import time
import urllib.request
import uuid
from datetime import datetime
from email.message import Message
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit, parse_qsl

import config

MEDIA_ROOT = config.UPLOAD_DIR / "media"

IMG_EXT = {
    "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
    "image/webp": ".webp", "image/bmp": ".bmp", "image/x-icon": ".ico",
    "image/svg+xml": ".svg",
}

# 文档模式保留的标签（白名单之外的标签丢弃，文本内容默认保留）
ALLOWED_TAGS = {
    "a", "abbr", "address", "article", "aside", "b", "blockquote", "br",
    "caption", "cite", "code", "col", "colgroup", "dd", "del", "details",
    "div", "dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2",
    "h3", "h4", "h5", "h6", "header", "hr", "i", "img", "ins", "kbd", "li",
    "main", "mark", "nav", "ol", "p", "picture", "pre", "q", "s", "samp",
    "section", "small", "source", "span", "strong", "sub", "summary", "sup",
    "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul", "var",
    "video", "audio", "track", "font", "center",
}
VOID_TAGS = {"br", "hr", "img", "col", "source", "track", "input", "meta", "link"}
# 整个子树一起丢弃
DROP_SUBTREE = {"script", "style", "link", "meta", "head", "title", "template"}

# 各标签允许的属性（除通用属性外）
TAG_ATTRS = {
    "a": {"href", "title", "target", "rel", "name"},
    "img": {"src", "alt", "title", "width", "height", "srcset", "loading"},
    "source": {"src", "srcset", "type", "media", "sizes"},
    "video": {"src", "controls", "poster", "width", "height", "preload"},
    "audio": {"src", "controls", "preload"},
    "track": {"src", "kind", "srclang", "label"},
    "td": {"colspan", "rowspan", "align", "valign", "width"},
    "th": {"colspan", "rowspan", "scope", "align", "valign", "width"},
    "col": {"span", "width"},
    "ol": {"start", "type", "reversed"},
    "table": {"border", "cellpadding", "cellspacing", "width", "align"},
    "q": {"cite"},
}
COMMON_ATTRS = {"id", "class", "style", "title", "dir", "lang"}
URL_ATTRS = {"href", "src", "poster"}
DANGEROUS_URL = re.compile(r"^\s*(javascript|vbscript|data:text/html|file):", re.I)
SAFE_URL = re.compile(r"^(https?:/|/|#|[^:/?#]+(?:[?#]|$))", re.I)
_STYLE_BAD = re.compile(r"expression\s*\(|@import|javascript:", re.I)


def _month_dir(sub: str = "") -> Path:
    d = MEDIA_ROOT / sub / datetime.now().strftime("%Y%m")
    d.mkdir(parents=True, exist_ok=True)
    return d


def save_blob(data: bytes, ext: str, subdir: str = "") -> str:
    """落盘一个媒体文件，返回 /media/... 相对 URL"""
    d = _month_dir(subdir)
    name = uuid.uuid4().hex + ext
    (d / name).write_bytes(data)
    rel = f"{subdir + '/' if subdir else ''}{datetime.now():%Y%m}/{name}"
    return f"/media/{rel}"


# ---------------- MHTML / HTML 解析 ----------------

def _decode_html_bytes(data: bytes) -> str:
    if data.startswith((b"\xff\xfe", b"\xfe\xff")):
        return data.decode("utf-16", errors="replace")
    head = data[:4096].decode("ascii", errors="ignore")
    m = re.search(r'charset=["\']?\s*([\w\-]+)', head, re.I)
    enc = (m.group(1) if m else "utf-8").lower()
    for cand in (enc, "utf-8-sig", "gb18030", "latin-1"):
        try:
            return data.decode(cand)
        except (LookupError, UnicodeDecodeError):
            continue
    return data.decode("utf-8", errors="replace")


def parse_mhtml(data: bytes):
    """返回 (html 文本, 资源映射)。资源 key：绝对 URL 或 cid:xxx"""
    msg: Message = email.message_from_bytes(data, policy=email.policy.default)
    html = None
    base = ""
    resources: dict[str, tuple[str, bytes]] = {}
    for part in msg.walk():
        if part.is_multipart():
            continue
        ctype = part.get_content_type()
        if ctype == "text/html" and html is None:
            # Blink 导出的 MHTML 里 HTML part 常常不带 charset 参数，默认策略会按
            # us-ascii 解码导致中文乱码；取原始字节后按 <meta charset> 自行解码
            html = _decode_html_bytes(part.get_payload(decode=True) or b"")
            loc = part.get("Content-Location") or ""
            if loc:
                base = loc
        elif ctype in IMG_EXT or ctype.startswith("image/"):
            payload = part.get_payload(decode=True)
            if not payload:
                continue
            loc = (part.get("Content-Location") or "").strip()
            cid = (part.get("Content-ID") or "").strip().strip("<>")
            if loc:
                resources[loc] = (ctype, payload)
            if cid:
                resources[f"cid:{cid.lower()}"] = (ctype, payload)
    if html is None:
        raise ValueError("MHTML 中没有找到 HTML 正文")
    return html, base, resources


# MHTML 资源键与页面 URL 常有无关尾缀差异（&tp=webp / &wxfrom=5 等 CDN 转换参数），
# 归一化时忽略它们
_RES_QUERY_IGNORE = {"tp", "wxfrom", "wx_lazy", "wx_co", "scene", "from",
                     "tp=webp"}
# 路径中长度 >=20 的不透明段视为图片唯一 ID（微信 mmbiz 的图片 ID 长度约 86）
_OPAQUE_RE = re.compile(r"[A-Za-z0-9_-]{20,}$")


def _norm_resource_url(u: str):
    """归一化 (host, path, 查询参数)，容忍查询顺序与 CDN 尾缀差异"""
    u = (u or "").strip().rstrip("?&")
    sp = urlsplit(u)
    host = sp.netloc.lower()
    query = tuple(sorted(
        (k.lower(), v) for k, v in parse_qsl(sp.query, keep_blank_values=True)
        if k.lower() not in _RES_QUERY_IGNORE
    ))
    return host, sp.path.rstrip("/"), query


# 外链图片兜底下载：Blink 保存 MHTML 时只存已加载的图片，懒加载未滚到的图
# （微信常见）不在存档里，只能在导入时由服务端按 URL 补抓；离线/超时则保留外链
FETCH_TIMEOUT = 6          # 单张图：连接/读取各阶段超时
FETCH_BUDGET = 25.0        # 每篇文档补抓总耗时上限（秒），防止无外网时大量外链逐个超时拖垮导入
MAX_FETCH_BYTES = 20 * 1024 * 1024
MAX_FETCH_IMAGES = 60
_FETCH_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
             "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")


def fetch_remote_image(url: str, referer: str = "", timeout: float = FETCH_TIMEOUT):
    """best-effort 下载外链图片，返回 (mime, bytes)；任何失败返回 None"""
    try:
        headers = {"User-Agent": _FETCH_UA, "Accept": "image/avif,image/webp,image/*,*/*;q=0.8"}
        if referer:
            headers["Referer"] = referer
        req = urllib.request.Request(url, headers=headers)
        with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as resp:
            ctype = (resp.headers.get_content_type() or "").lower()
            if not ctype.startswith("image/"):
                return None
            if IMG_EXT.get(ctype) is None:
                return None
            data = resp.read(MAX_FETCH_BYTES + 1)
            if not data or len(data) > MAX_FETCH_BYTES:
                return None
            return ctype, data
    except Exception:
        return None


def build_resource_index(resources: dict):
    """从 MHTML 资源构造两级回退索引：
    1) 归一化完整 URL（host+path+核心查询参数）
    2) (host, 路径中的图片唯一ID)，用于同图不同格式/尺寸变体
       （如页面引用 sz_mmbiz_png/<id>/640，存档里是 sz_mmbiz_jpg/<id>/0）"""
    norm_index, token_index = {}, {}
    for key in resources:
        if key.startswith("cid:"):
            continue
        host, path, query = _norm_resource_url(key)
        norm_index.setdefault((host, path, query), key)
        for seg in path.split("/"):
            if _OPAQUE_RE.match(seg):
                token_index.setdefault((host, seg), key)
    return norm_index, token_index


# ---------------- 正文容器抽取 ----------------

class _ElementFinder(HTMLParser):
    """按 (标签, id, class 词) 条件找元素在原文中的起止位置"""

    def __init__(self, html):
        super().__init__(convert_charrefs=True)
        # HTMLParser 只按 \n 计行号/列号；不能用 splitlines()，否则 CRLF（MHTML 常见）
        # 会让每行起点累计少算一个 \r，导致切片错位
        offs, pos = [], 0
        for line in html.split("\n"):
            offs.append(pos)
            pos += len(line) + 1
        self._offs = offs
        self.stack: list[tuple[str, int]] = []
        self.match_start = None
        self.match_end = None

    def _pos(self):
        line, col = self.getpos()
        return self._offs[line - 1] + col

    def handle_starttag(self, tag, attrs):
        start = self._pos()  # getpos() 指向本标签的 '<'
        self.stack.append((tag, start))

    def handle_startendtag(self, tag, attrs):
        pass

    def handle_endtag(self, tag):
        pos = self._pos()
        for i in range(len(self.stack) - 1, -1, -1):
            if self.stack[i][0] == tag:
                start = self.stack[i][1]
                del self.stack[i:]
                if self.match_start is not None and self.match_start == start:
                    self.match_end = pos + len(tag) + 3  # 越过 '</tag>'
                return


def _slice_element(html: str, tag: str = None, elem_id: str = None,
                   cls_contains: str = None) -> str | None:
    """粗粒度抽取：用 parser 记录匹配开始位置，再按标签深度找闭合点"""

    def matches(t, attrs):
        ad = dict(attrs)
        cls_tokens = (ad.get("class") or "").lower().split()
        return ((tag and t == tag) or
                (elem_id and (ad.get("id") or "").lower() == elem_id.lower()) or
                (cls_contains and cls_contains.lower() in cls_tokens))

    class _F(_ElementFinder):
        def handle_starttag(self, t, attrs):
            super().handle_starttag(t, attrs)
            if self.match_start is None and matches(t, attrs) and self.stack:
                self.match_start = self.stack[-1][1]

        def handle_startendtag(self, t, attrs):
            if self.match_start is None and matches(t, attrs):
                s = self._pos()
                self.match_start, self.match_end = s, self._pos()

    f = _F(html)
    try:
        f.feed(html)
    except Exception:
        return None
    if f.match_start is None:
        return None
    start = f.match_start
    end = f.match_end or len(html)
    raw = html[start:end]
    m = re.match(r"<[^>]+>", raw, re.S)
    inner = raw[m.end():] if m else raw
    m2 = re.search(r"</[a-zA-Z][\w-]*>\s*$", inner, re.S)
    if m2:
        inner = inner[:m2.start()]
    return inner


def _fragment_quality(frag: str):
    """评估片段的正文质量：(可见文字数, 图片数)。先去掉 script/style/注释，
    避免被内联脚本/data-URI 撑大的空容器误判为正文"""
    t = re.sub(r"<!--.*?-->", "", frag, flags=re.S)
    t = re.sub(r"<(script|style)\b[^>]*>.*?</\1>", "", t, flags=re.S | re.I)
    imgs = len(re.findall(r"<img\b", t, re.I))
    text = re.sub(r"<[^>]+>", "", t)
    text = re.sub(r"\s+", "", text)
    return len(text), imgs


def extract_body(html: str):
    """返回 (标题, 正文 HTML 片段)。
    按语义优先级找容器，但用「可见文字/图片数量」而不是原始字节数判断，
    防止 <article> 里塞满内联脚本/data-URI 却没有正文时挤掉真正的 #js_content。"""
    mt = re.search(r"<title[^>]*>(.*?)</title>", html, re.S | re.I)
    title = re.sub(r"\s+", " ", mt.group(1)).strip() if mt else ""

    candidates = (dict(tag="main"), dict(tag="article"),
                  dict(elem_id="content"), dict(elem_id="main-content"),
                  dict(elem_id="article-content"), dict(elem_id="post-content"),
                  dict(elem_id="js_content"), dict(elem_id="page-content"),
                  dict(cls_contains="rich_media_content"),
                  dict(cls_contains="markdown-body"), dict(cls_contains="article-content"))
    body_kw = dict(tag="body")

    def good_enough(text_n, img_n):
        return text_n >= 200 or (text_n >= 80 and img_n >= 1) or img_n >= 3

    fragment = None
    sem_title = None  # 有标题但正文极少的语义容器（如微信「分享图片」消息）
    for kw in candidates:
        cand = _slice_element(html, **kw)
        if not cand:
            continue
        q = _fragment_quality(cand)
        if good_enough(*q):
            fragment = cand
            break
        if sem_title is None and q[0] >= 5:
            sem_title = cand
    if fragment is None:
        body = _slice_element(html, **body_kw)
        if sem_title is not None:
            fragment = sem_title     # 有语义容器和标题：宁可内容少也不要 body 的导航/评论噪声
        elif body and _fragment_quality(body)[0] >= 500:
            fragment = body          # 非标准容器的正文页
        elif body:
            fragment = body
        else:
            fragment = html
    return title, fragment


def _extract_title(doc: str, fragment: str, filename: str) -> str:
    """标题回退链：<title> → og:title 等 meta → 微信正文 H1 → fragment 首个 H1 → 文件名"""
    def clean(s):
        return re.sub(r"\s+", " ", _html_mod.unescape(s or "")).strip()

    m = re.search(r"<title[^>]*>(.*?)</title>", doc, re.S | re.I)
    t = clean(m.group(1)) if m else ""
    if t:
        return t
    # og:title / twitter:title（微信单文件保存的 <title> 常为空）；
    # content 值不含引号，用 [^"']* 防止跨标签误匹配
    m = re.search(r'<meta[^>]+(?:property|name)=["\'](?:og:title|twitter:title)["\']'
                  r'[^>]+content=["\']([^"\']*)["\']', doc, re.S | re.I)
    if not m:
        m = re.search(r'<meta[^>]+content=["\']([^"\']*)["\'][^>]+(?:property|name)='
                      r'["\'](?:og:title|twitter:title)["\']', doc, re.S | re.I)
    t = clean(m.group(1)) if m else ""
    if t:
        return t
    # 微信图文/图片消息正文里的 H1
    for src in (fragment, doc):
        m = re.search(r"<h1[^>]*>(.*?)</h1>", src, re.S | re.I)
        t = clean(re.sub(r"<[^>]+>", "", m.group(1))) if m else ""
        if t:
            return t
    return Path(filename).stem


# ---------------- 清洗 + 图片重写 ----------------

class _Cleaner(HTMLParser):
    def __init__(self, resolve_image):
        super().__init__(convert_charrefs=True)
        self.resolve_image = resolve_image
        self.out: list[str] = []
        self.drop_depth = 0
        self.missing = 0
        self.saved = 0

    def _safe_url(self, url):
        if DANGEROUS_URL.match(url or ""):
            return None
        return url

    def _map_url(self, tag, attr, url):
        if not url:
            return None
        url = url.strip()
        if attr in URL_ATTRS or attr == "srcset":
            # 只有图片类属性（src/poster）才允许网络补抓；href 等绝不能去请求，
            # 否则会把文章里的普通链接挨个当图片下载
            image_ctx = attr in ("src", "poster")
            if attr == "srcset":
                parts = []
                for item in re.split(r",\s*", url):
                    bits = item.split(maxsplit=1)
                    new = self._map_url(tag, "src", bits[0])
                    if new:
                        parts.append(" ".join([new] + bits[1:]))
                return ", ".join(parts) if parts else None
            if url.startswith("data:image/"):
                new = self.resolve_image("data:" + url[5:].split(",", 1)[0].lower(),
                                         url, True)
                if new:
                    self.saved += 1
                    return new
                return None
            if DANGEROUS_URL.match(url):
                return None
            new = self.resolve_image(None, url, image_ctx)
            if new is not None:
                if new and new != url:
                    self.saved += 1
                return new
            if SAFE_URL.match(url):
                return url
            self.missing += 1
            return url
        return url

    def _attrs(self, tag, attrs):
        allowed = COMMON_ATTRS | TAG_ATTRS.get(tag, set())
        out = []
        for k, v in attrs:
            k = k.lower()
            if k.startswith("on") or k not in allowed:
                continue
            if v is None:
                continue
            if k == "style":
                if _STYLE_BAD.search(v):
                    v = _STYLE_BAD.sub("", v)
                if not v.strip():
                    continue
            if k in URL_ATTRS or k == "srcset":
                v = self._map_url(tag, k, v)
                if not v:
                    continue
            if k == "href" and tag == "a" and re.match(r"https?://", v):
                out.append(('target', '_blank'))
                out.append(('rel', 'noopener noreferrer'))
            out.append((k, v))
        return out

    def handle_starttag(self, tag, attrs):
        if tag in DROP_SUBTREE:
            # link/meta 是空标签，没有闭合标签，绝不能计入 drop_depth，
            # 否则深度只增不减，后面的正文会被整段误删（微信 MHTML 即如此）
            if tag not in VOID_TAGS:
                self.drop_depth += 1
            return
        if self.drop_depth:
            return
        if tag not in ALLOWED_TAGS:
            return
        # 各站点图片懒加载属性（真实地址），按常见程度排序
        LAZY_SRC_ATTRS = ("data-src", "data-original-src", "data-original",
                          "data-actualsrc", "data-echo", "data-lazy-src",
                          "data-defer-src", "data-ks-lazyload", "data-cover",
                          "data-url", "data-actual-src", "data-ftpsrc")
        # 各站点图片懒加载：真实地址在 data-src / data-original / data-echo 等属性里，
        # src 常是 1px 占位图或为空。逐个探测本地存档/补抓，取第一个能拿到的
        if tag == "img":
            ad = {k.lower(): (v or "").strip() for k, v in attrs}
            src = ad.get("src", "")
            probes = []
            if src and not src.startswith("data:image/"):
                probes.append(src)
            for la in LAZY_SRC_ATTRS:
                v = ad.get(la)
                if v and v not in probes:
                    probes.append(v)
            if src.startswith("data:image/"):
                probes.append(src)
            chosen = ""
            for u in probes:
                if self.resolve_image(None, u, True) is not None:
                    chosen = u
                    break
            if not chosen and probes:
                chosen = probes[0]
            drop_lazy = set(LAZY_SRC_ATTRS)
            attrs = [(k, v) for k, v in attrs
                     if not (k and k.lower() in drop_lazy)]
            if chosen:
                attrs = [(k, chosen) if (k and k.lower() == "src") else (k, v)
                         for k, v in attrs]
                if not any(k and k.lower() == "src" for k, _ in attrs):
                    attrs.append(("src", chosen))
            else:
                # 没有任何可用地址的 img（广告位/懒加载空占位）：直接丢弃，
                # 否则前台显示裂图
                return
        a = self._attrs(tag, attrs)
        # src 在属性清洗阶段被移除（危险 URL / data: 无法解析）的 img 丢弃
        if tag == "img" and not any(k == "src" for k, _ in a):
            return
        s = "<" + tag
        for k, v in a:
            s += f' {k}="{_attr_escape(v)}"'
        s += ">"
        self.out.append(s)

    handle_startendtag = handle_starttag

    def handle_endtag(self, tag):
        if tag in DROP_SUBTREE:
            if self.drop_depth:
                self.drop_depth -= 1
            return
        if self.drop_depth or tag not in ALLOWED_TAGS or tag in VOID_TAGS:
            return
        self.out.append(f"</{tag}>")

    def handle_data(self, data):
        if not self.drop_depth:
            self.out.append(_html_escape(data))

    def handle_comment(self, data):
        pass


def _html_escape(s: str) -> str:
    return (s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))


def _attr_escape(s: str) -> str:
    return _html_escape(s).replace('"', "&quot;")


def _data_uri_to_blob(uri: str):
    m = re.match(r"data:(image/[\w.+-]+)?(;charset=[\w-]+)?(;base64)?,(.*)$",
                 uri, re.S)
    if not m:
        return None, None
    mime = (m.group(1) or "image/png").lower()
    raw = m.group(4)
    try:
        if m.group(3):
            data = base64.b64decode(raw + "=" * (-len(raw) % 4))
        else:
            data = unquote(raw).encode("latin-1", errors="replace")
    except Exception:
        return None, None
    return mime, data


def import_document(filename: str, data: bytes) -> dict:
    """文档模式导入入口，返回 {title, html, saved, missing, script_bytes}"""
    lower = filename.lower()
    if lower.endswith((".mhtml", ".mht")):
        html, base, resources = parse_mhtml(data)
    else:
        html, base, resources = _decode_html_bytes(data), "", {}

    # 统计被清除的脚本体量，提示用户是否误把交互式网页工具当文档导入
    script_bytes = sum(len(m.group(1)) for m in
                       re.finditer(r"<script[^>]*>(.*?)</script>", html, re.S | re.I))

    title, fragment = extract_body(html)
    title = _extract_title(html, fragment, filename)
    saved_cache: dict[str, str | None] = {}
    norm_index, token_index = build_resource_index(resources)
    fetch_tried = 0
    fetch_failed = 0
    fetch_deadline = time.monotonic() + FETCH_BUDGET

    def _lookup_resource(url: str) -> str | None:
        """在 MHTML 资源里找 URL：精确 → 归一化 → 图片 ID 变体"""
        candidates = [url]
        joined = urljoin(base, url)
        if joined != url:
            candidates.append(joined)
        # 1) 精确
        for cand in candidates:
            if cand in resources:
                return cand
        # 2) 归一化（忽略查询顺序、结尾 ?&、CDN 转换参数）
        for cand in candidates:
            host, path, query = _norm_resource_url(cand)
            k = norm_index.get((host, path, query))
            if k:
                return k
        # 3) 同图不同格式/尺寸变体：按路径中的图片唯一 ID
        for cand in candidates:
            sp = urlsplit(cand)
            host = sp.netloc.lower()
            for seg in sp.path.split("/"):
                if _OPAQUE_RE.match(seg):
                    k = token_index.get((host, seg))
                    if k:
                        return k
        return None

    def resolve(mime_hint, url, allow_fetch=False):
        nonlocal fetch_tried, fetch_failed
        if url.startswith("data:image/"):
            mime, blob = _data_uri_to_blob(url)
            if not blob:
                return None
            ext = IMG_EXT.get(mime, ".bin")
            if ext == ".bin":
                return None
            return save_blob(blob, ext, "doc")
        key = None
        if url.startswith("cid:"):
            key = url.lower() if url.lower() in resources else None
        else:
            u = url.strip()
            key = _lookup_resource(u)
            if key is None:
                # MHTML 未收录（懒加载、导入时未滚到的图最常见）：服务端兜底补抓。
                # 只对图片属性开放（allow_fetch），href 等普通链接绝不请求；
                # 离线/超时/非图片则保留原外链
                if not allow_fetch or not re.match(r"^https?://", u, re.I):
                    return None
                if u in saved_cache:
                    return saved_cache[u]
                if fetch_tried >= MAX_FETCH_IMAGES or time.monotonic() > fetch_deadline:
                    return None
                fetch_tried += 1
                remaining = max(1.0, fetch_deadline - time.monotonic())
                got = fetch_remote_image(u, referer=base,
                                         timeout=min(FETCH_TIMEOUT, remaining))
                if got:
                    mime, blob = got
                    ext = IMG_EXT.get(mime)
                    if not ext:
                        fetch_failed += 1
                        saved_cache[u] = None
                        return None
                    ret = save_blob(blob, ext, "doc")
                    saved_cache[u] = ret
                    return ret
                fetch_failed += 1
                saved_cache[u] = None
                return None
        if key in saved_cache:
            return saved_cache[key]
        ctype, blob = resources[key]
        ext = IMG_EXT.get(ctype.lower(), ".bin")
        if ext == ".bin":
            saved_cache[key] = None
            return None
        ret = save_blob(blob, ext, "doc")
        saved_cache[key] = ret
        return ret

    cleaner = _Cleaner(resolve)
    try:
        cleaner.feed(fragment)
        cleaner.close()
    except Exception as e:
        raise ValueError(f"HTML 解析失败：{e}")
    body = '<div class="imported-doc">\n' + "".join(cleaner.out) + "\n</div>"
    return {
        "title": title[:200],
        "html": body,
        "saved": cleaner.saved,
        "missing": cleaner.missing + fetch_failed,
        "script_bytes": script_bytes,
    }


def store_app_file(filename: str, data: bytes) -> str:
    """应用模式：HTML 原样保存（仅允许 .html/.htm），返回相对 URL"""
    ext = ".html" if filename.lower().endswith(".htm") else ".html"
    return save_blob(data, ext, "apps")


def html_title(data: bytes) -> str:
    """从 HTML 字节里取 <title>，用于应用模式自动填标题"""
    try:
        text = _decode_html_bytes(data[:65536])
    except Exception:
        return ""
    m = re.search(r"<title[^>]*>(.*?)</title>", text, re.S | re.I)
    return re.sub(r"\s+", " ", m.group(1)).strip()[:200] if m else ""
