#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""复盘报告 md → 0813 手工定制版式 HTML (组件化渲染)
用法: python3 build_html.py [YYYYMMDD]   # 缺省取最新 report_*.md
版式基线: report_20260813.html (用户确认版) —— masthead / kpi / digest / sec-head /
          col-card 双栏 / ladder 梯队 / themes 主题条 / flow 资金卡 / cube 因果 /
          plan3 计划三列 / pull-quote / tbl-box 表格 / warn / ok
"""
import markdown, pathlib, re, sys
from datetime import datetime

def latest_date():
    d = pathlib.Path("/root/.hermes/skills/qianxiaodou-engine/data/reports")
    dates = [m.group(1) for f in d.glob("report_*.md")
             if (m := re.match(r"report_(\d{8})\.md$", f.name))]
    return max(dates) if dates else None

arg = sys.argv[1] if len(sys.argv) > 1 else latest_date()
if not arg:
    print("no report found", file=sys.stderr); sys.exit(1)

SRC = f"/root/.hermes/skills/qianxiaodou-engine/data/reports/report_{arg}.md"
OUT_DIR = pathlib.Path("/root/web_reports")
OUT_DIR.mkdir(exist_ok=True)
md_text = pathlib.Path(SRC).read_text(encoding="utf-8")

# ════════════════════════════ 基础解析 ════════════════════════════
lines = md_text.splitlines()
h1 = "市场复盘"; kpi_lines = []; summary_items = []
for i, ln in enumerate(lines):
    m = re.match(r"^#\s+(.*)$", ln)
    if m and i < 3:
        h1 = m.group(1).strip()
    if ln.strip().startswith(">"):
        kpi_lines.append(ln.strip().lstrip(">").strip())
    if ln.strip() == "复盘摘要:":
        j = i + 1
        while j < len(lines) and not lines[j].strip().startswith("---"):
            mm = re.match(r"^\d+\.\s+(.*)$", lines[j].strip())
            if mm:
                summary_items.append(mm.group(1).strip())
            j += 1
        break

def find_kv(seg, key):
    m = re.search(key + r"\s*[:：]\s*(半仓|空仓|满仓|轻仓|清仓|[0-9.]+-[0-9.]+成|[0-9.]+成|[0-9.]+/[0-9.]+|[0-9.]+%?|[一二三四五六七八九十])", seg)
    return m.group(1).strip() if m else ""

season = composite = panic = pos = zuoting = dieting = bidui = amount = zhuxian = maxban = ""
for seg in kpi_lines:
    if "行情:" in seg:
        m = re.search(r"行情[:：]\s*([^\s|]+)", seg); season = m.group(1) if m else ""
        composite = find_kv(seg, "综合评分") or ""
        panic = find_kv(seg, "恐慌度") or ""
        pos = find_kv(seg, "仓位上限") or ""
    elif "涨停" in seg:
        m = re.search(r"涨停\s*(\d+)", seg); zuoting = m.group(1) if m else ""
        m = re.search(r"跌停\s*(\d+)", seg); dieting = m.group(1) if m else ""
        m = re.search(r"涨跌比\s*([\d:]+)", seg); bidui = m.group(1) if m else ""
        m = re.search(r"成交\s*([\d.]+)\s*亿", seg); amount = m.group(1) if m else ""
    elif "主线" in seg:
        m = re.search(r"主线[:：]\s*([^|]+)", seg); zhuxian = m.group(1).strip() if m else ""
        m = re.search(r"最高\s*(\d+)板", seg); maxban = m.group(1) if m else ""
date_cn = f"{arg[:4]}年{int(arg[4:6])}月{int(arg[6:])}日"
WD = {"Monday": "星期一", "Tuesday": "星期二", "Wednesday": "星期三",
      "Thursday": "星期四", "Friday": "星期五", "Saturday": "星期六", "Sunday": "星期日"}
weekday_cn = WD.get(datetime.strptime(arg, "%Y%m%d").strftime("%A"), "")

# ════════════════════════════ 模块拆解 ════════════════════════════
# 模块: {num, title, blocks: [(kind, ...), ...]}  kind ∈ h3小节 / 段落 / 表格
sections = []
cur = None
for ln in lines:
    m = re.match(r"^##\s+模块\s*(\d+)[:：]?\s*(.*)$", ln.strip())
    if m:
        if cur: sections.append(cur)
        cur = {"num": int(m.group(1)), "title": m.group(2).strip() or f"模块{m.group(1)}",
               "blocks": []}
        continue
    if cur is None or ln.strip().startswith("---") or not ln.strip():
        continue
    cur["blocks"].append(ln.strip())
if cur:
    sections.append(cur)

_SEP_ROW_RE = re.compile(r"\|?[\s:|-]*\|[\s:|-]*")

def is_sep_row(ln):
    """表格分隔行 `|---|---|`；兼容省略行首/行尾竖线的写法(含 em dash `|——|`)。"""
    s = (ln or "").strip()
    return (bool(s) and ("-" in s or "—" in s) and "|" in s
            and _SEP_ROW_RE.fullmatch(s) is not None)

def is_tbl_row(ln, ncol=None):
    """表格数据行判定：标准 `| a | b |` 与 LLM 常写的松散行 `a|b|c`（无首尾竖线）都算行。

    2026-09-14 缺陷：8 行「方向操作建议」写成 `闽东电力(000993)|空间板4板|...`（无行首竖线），
    旧 parse_table 只认 `ln.startswith("|")` → 整表数据行被静默丢弃，HTML 只剩表头，
    而 --strict 仍 PASS（校验只匹配 `^\\|.*\\|$`）。同一写法 9/10 已在 build_premarket
    _table_rows 容忍，渲染端当时漏改。

    ncol 给定时，松散行必须切出与表头相同的列数才算数据行（防吞掉后续段落）。
    """
    s = (ln or "").strip()
    if not s or s.startswith((">", "#")):
        return False
    if s.startswith("|"):
        return True
    if "|" not in s or re.match(r"^([-*+]\s|\d+\.\s)", s):
        return False
    if ncol is None:
        return True
    return len(s.strip("|").split("|")) == ncol

def parse_table(block_lines):
    """markdown 表格 → {header:[...], rows:[[...]]}（rows 兼容省略首尾竖线的松散行）"""
    i = 0
    while i < len(block_lines) and not block_lines[i].strip().startswith("|"):
        i += 1
    if i >= len(block_lines):
        return [], []
    hdr = [c.strip() for c in block_lines[i].strip().strip("|").split("|")]
    ncol = len(hdr)
    rows = []
    for ln in block_lines[i+2:]:
        if is_sep_row(ln):
            continue
        if not is_tbl_row(ln, ncol):
            break
        rows.append([c.strip() for c in ln.strip().strip("|").split("|")])
    return hdr, rows

# ════════════════════════════ 行内渲染 ════════════════════════════
_BARE_NUM = re.compile(r"(?<![\d\-−])([+\-−＋]?\d+(?:[.,]\d+)?(?:万亿|%|亿|万|成|只|家)?)")
_CODE = re.compile(r"^\d{6}$")  # 股票代码(6位)不自动金色

def _shade_num(t):
    """对单个 token 上色: ±%/±亿/±万 → up红/down绿; 正数%或带单位 → num金"""
    if not t:
        return t
    if _CODE.match(t.strip()):
        return t
    neg = t[0] in "-−"
    has_sign = t[0] in "+-−＋"
    body = t[1:] if has_sign else t
    if has_sign:
        return f'<b class="{"down" if neg else "up"}">{t}</b>'
    if "%" in body or body.endswith(("亿", "万", "成", "只", "家")):
        return f'<b class="num">{t}</b>'
    return t

def shade_bare(text_html):
    """对已转义 html 中的裸数字 token 上色(不碰标签内文本)"""
    def _rep(m):
        return _shade_num(m.group(1))
    # 只处理标签外文本: 分组匹配 <...> 块与裸数字
    parts = re.split(r"(<[^>]+>)", text_html)
    for i in range(0, len(parts), 2):
        if i < len(parts):
            parts[i] = _BARE_NUM.sub(_rep, parts[i])
    return "".join(parts)

def inline(text):
    """md 行内 → html; **数字** → num 金; **+x%/-x%** → up/down; 裸数字自动着色"""
    h = markdown.markdown(text).strip()
    h = re.sub(r"<p>(.*)</p>", r"\1", h, flags=re.S)
    def _num(m):
        t = m.group(1)
        if re.match(r"^[+\-−][\d.]+%?$", t):
            cls = "up" if t.startswith(("+", "＋")) else "down"
            return f'<b class="{cls}">{t}</b>'
        if re.match(r"^[\d][\d.,]*%?$", t):
            return f'<b class="num">{t}</b>'
        return f"<b>{t}</b>"
    h = re.sub(r"<strong>(.*?)</strong>", lambda m: _num(re.match(r"(.*)", m.group(1))), h)
    h = re.sub(r"<strong>([^<]*?)</strong>", lambda m: _num(m), h)
    return shade_bare(h)

def para_inline(text):
    """完整段落 → html; 开头 加粗标签 → <b>:text</b>"""
    m = re.match(r"^\*\*(.+?)\*\*[:：]?(.*)$", text)
    h = markdown.markdown(text).strip()
    h = re.sub(r"<p>(.*)</p>", r"\1", h, flags=re.S)
    return h

def num_or_updown(t):
    if re.match(r"^[+\-−][\d.]+%?$", t):
        return f'<b class="{"up" if t[0] in "+＋" else "down"}">{t}</b>'
    if re.match(r"^[\d][\d.,]*$", t):
        return f'<b class="num">{t}</b>'
    return f"<b>{t}</b>"

def html_lead_bold(h):
    """把 md 转换后 <strong>..</strong> 开头的段落, 拆成 <b>:lead</b>"""
    return h

# ════════════════════════════ 组件 ════════════════════════════
def comp_tbl(hdr, rows, cap=None, hl_last=False, min_width=None):
    ths = "".join(f"<th>{inline(c)}</th>" for c in hdr)
    trs = []
    for r_i, r in enumerate(rows):
        cls = ' style="background:#fbf7ee"' if (hl_last and r_i == len(rows) - 1) else ""
        tds = "".join(f"<td>{inline(c)}</td>" for c in r)
        trs.append(f"<tr{cls}>{tds}</tr>")
    cap_html = f'<div class="tbl-cap">{cap}</div>' if cap else ""
    style = f' style="min-width:{min_width}px"' if min_width else ""
    wrap_cls = "tbl-wrap" if not min_width else "scroll-x"
    return (f'<div class="{wrap_cls}">{cap_html}<table{style}>'
            f"<tr>{ths}</tr>{''.join(trs)}</table></div>")

def comp_cube(head_html, body_html, head_extra=""):
    if head_html:
        return (f'<div class="cube"><div class="cb-head">{head_html}'
                f'{head_extra}</div><div class="cb-body">{body_html}</div></div>')
    return f'<div class="cube"><div class="cb-body">{body_html}</div></div>'

def comp_col_card(h4_html, items_html):
    return f'<div class="col-card">{h4_html}<ol>{items_html}</ol></div>'

def comp_ladder(table_ok):
    """连板梯队: (板位, 标的, 代码, 形式, 封成比) → ladder; 兼容3列汇总(板位|数量|代表个股)"""
    hdr, rows = table_ok
    groups = {}
    for r in rows:
        if len(r) < 4:
            continue
        ban = r[0].replace("板", "").strip()
        try:
            key = int(ban)
        except ValueError:
            continue
        groups.setdefault(key, []).append(r)
    if not groups:
        # 降级: 3列 (板位|数量|代表个股) —— 防止汇总格式导致空梯队
        groups3 = {}
        for r in rows:
            if len(r) < 3:
                continue
            ban = r[0].replace("板", "").strip()
            if ban == "首板":
                key = 1
            else:
                try:
                    key = int(ban)
                except ValueError:
                    continue
            groups3.setdefault(key, []).append(r)
        out3 = []
        for bi, ban in enumerate(sorted(groups3, reverse=True)):
            chips = []
            for r in groups3[ban]:
                cnt, rep = r[1], (r[2] if len(r) > 2 else "")
                chips.append(f'<span class="chip dim"><b>{inline(cnt)}只</b></span>')
                if rep:
                    chips.append(f'<span class="chip">{inline(rep)}</span>')
            lvl_cls = "lvl top" if bi == 0 else "lvl"
            out3.append(f'<div class="{lvl_cls}"><div class="badge"><span class="b">{ban}</span>'
                        f'<span class="t">板</span></div><div class="names">{"".join(chips)}</div></div>')
        if out3:
            return f'<div class="ladder">{"".join(out3)}</div>'
    out = []
    for bi, ban in enumerate(sorted(groups, reverse=True)):
        chips = []
        for r in groups[ban]:
            nm, code, form, ratio = r[1], (r[2] if len(r) > 2 else "-"), r[3], (r[4] if len(r) > 4 else "")
            if nm in ("-", "53只") or code == "-":
                chips.append(f'<span class="chip dim"><b>{inline(nm)}</b></span>')
                continue
            tag_cls = "hseal" if "一" in form else ""
            tag = "一字" if "一" in form else ("换手" if "换" in form else form.replace("板", ""))
            ratio_txt = f'<span class="rto">{ratio}</span>' if ratio and ratio not in ("-",) else ""
            chip_cls = "chip" + (" yz" if "一" in form else "")
            chips.append(f'<span class="{chip_cls}">{inline(nm)} '
                         f'<span class="cd">{code}</span>'
                         f'<span class="tag {tag_cls}">{tag}</span>{ratio_txt}</span>')
        lvl_cls = "lvl top" if bi == 0 else "lvl"
        out.append(f'<div class="{lvl_cls}"><div class="badge"><span class="b">{ban}</span>'
                   f'<span class="t">板</span></div><div class="names">{"".join(chips)}</div></div>')
    return f'<div class="ladder">{"".join(out)}</div>'

def comp_themes(table_ok):
    """主线识别: (主线, 涨停数, 代表标的) → themes 条形"""
    hdr, rows = table_ok
    maxv = 1
    clean = []
    for r in rows:
        if len(r) < 2: continue
        try:
            v = int(re.search(r"\d+", r[1]).group())
        except (AttributeError, ValueError):
            continue
        clean.append((r[0], v, r[2] if len(r) > 2 else ""))
        maxv = max(maxv, v)
    out = []
    for i0, (nm, v, rep) in enumerate(sorted(clean, key=lambda x: -x[1])):
        w = round(v / maxv * 100)
        rep_html = f'<span class="st">{inline(rep)}</span>' if rep else ""
        lead_cls = " theme lead" if i0 == 0 else ""
        out.append(f'<div class="theme{lead_cls}"><div class="tn">{inline(nm)}</div>'
                   f'<div class="cnt">{v}</div><div class="trk"><div class="fill" style="width:{w}%"></div></div>{rep_html}</div>')
    return f'<div class="themes">{"".join(out)}</div>'

def split_news(text):
    """新闻条目拆分: '事件 ... → 传导' 或 '标题: 内容'"""
    rng = re.search(r"→", text)
    arrow_i = rng.start() if rng else -1
    colon = re.search(r"[:：]", text)
    colon_i = colon.start() if colon else -1
    if arrow_i > 0:
        lead, convey = text[:arrow_i].strip(), text[arrow_i+1:].strip()
    else:
        lead, convey = text, ""
    # lead 拆标题(≤26字冒号前)与实体
    if colon_i > 0 and colon_i <= 26:
        mt, ml = lead[:colon_i].strip(), lead[colon_i+1:].strip()
    else:
        if len(lead) <= 20:
            mt, ml = lead, ""
        else:
            # 从第2个逗号后拆
            parts = re.split(r"[,，]", lead, maxsplit=2)
            if len(parts) >= 3 and sum(len(p) for p in parts[:2]) < 30:
                mt, ml = parts[0] + ("，" + parts[1] if parts[1] else ""), ",".join(parts[2:]).strip()
            elif len(parts) >= 1 and len(parts[0]) >= 6:
                mt, ml = parts[0] + ("，" + parts[1] if len(parts) > 1 else ""), ",".join(parts[2:]).strip() if len(parts) > 2 else ""
            else:
                mt, ml = lead[:18], lead[18:]
    return mt, ml, convey

def render_news_card(h4_text, items, en_tag="", extra_cls=""):
    """外围/内因 新闻卡: li = mt + ml (+ ml.sent 传导)"""
    en = {"外围": "Overseas", "外盘": "Overseas", "内因": "Domestic", "内盘": "Domestic",
          "消息面": "Flash News",
          "A股": "A-Shares", "国内": "Domestic"}.get(h4_text.strip().lstrip("✧❖· ").strip(), en_tag)
    h4_html = f"{h4_text} <b>{en}</b>"
    lis = []
    for it in items:
        mt, ml, conv = split_news(it)
        em = f'<em>{en_tag}</em>' if en_tag else ""
        parts = []
        if mt: parts.append(f'<div class="mt">{inline(mt)}{em}</div>')
        if ml: parts.append(f'<div class="ml">{inline(ml)}</div>')
        if conv: parts.append(f'<div class="ml sent">{inline(conv)}</div>')
        if not parts:
            parts.append(f'<div class="ml">{inline(it)}</div>')
        lis.append(f"<li>{''.join(parts)}</li>")
    return f'<div class="col-card{(" " + extra_cls) if extra_cls else ""}"><h4>{inline(h4_html)}</h4><ol>{"".join(lis)}</ol></div>'

def render_flow(rows):
    """资金净流入: (类型, 方向, 金额, 备注) → flow 卡"""
    cards = []
    for r in rows:
        if not r or not r[0]: continue
        rk = r[0].replace("资金净流入", "净流入").replace("#", "#")
        nm, amt = r[1], r[2]
        chg = ""
        m = re.search(r"当日([+\-−]?\d+\.\d+%)", r[3] if len(r) > 3 else "")
        if m:
            v = m.group(1)
            chg = f'<div class="chg {"up" if v[0] in "+＋" else "down"}">{v}</div>'
        cards.append(f'<div class="card"><div class="rk">{rk}</div>'
                     f'<div class="nm">{inline(nm)}</div>'
                     f'<div class="amt">{amt}</div>{chg}</div>')
    return f'<div class="flow">{"".join(cards)}</div>'

def render_cube_list(items, numbered=True, sep=":："):
    """列表 → cube li: 首冒号前加粗"""
    lis = []
    for i, it in enumerate(items, 1):
        m = re.search(rf"^(.{{2,24}}?)[{sep}](.*)$", it)
        if m and len(m.group(1)) <= 24:
            lead_html = f'<b>{inline(m.group(1))}</b>'
            rest_html = inline(m.group(2))
        else:
            lead_html, rest_html = "", inline(it)
        if numbered:
            lis.append(f'<li><span class="num gold" style="font-weight:700">{i}.</span> {lead_html}{rest_html}</li>')
        else:
            lis.append(f"<li>{lead_html}{rest_html}</li>")
    return f'<div class="cube"><div class="cb-body"><ul style="list-style:none;padding-left:0">{"".join(lis)}</ul></div></div>'

# ════════════════════════════ 模块渲染 ════════════════════════════
SEC_EN = {
    "消息面与驱动归因": "Catalysts & Attribution",
    "市场状态与近10日脉络": "Market State & 10-Day",
    "涨停结构与主线真假": "Limit-Up & Themes",
    "资金行为与主线因果链": "Capital Flow & Causality",
    "双轨判断": "Dual-Track Verdict",
    "正确率验证与自我迭代": "Accuracy & Iteration",
    "明日交易计划": "Next-Day Plan",
    "指数与广度复盘": "Indices & Breadth",
    "情绪与连板梯队": "Sentiment & Ladder",
    "资金面": "Capital Flow",
    "当日操作复盘": "Trade Review",
    "待验证项": "To Be Verified",
}

def render_section(sec):
    """把模块的 blocks 序列渲染成 0813 组件版式"""
    bs = sec["blocks"]
    htmls = []
    i = 0
    news_cards = []      # (h4, items, en)
    news_buffer = []     # 当前新闻小节数据
    news_title = None
    pending_para = []    # 小节间的说明段落

    def flush_news():
        nonlocal news_cards, news_buffer, news_title
        if news_title is not None and news_buffer:
            news_cards.append((news_title, news_buffer))
        news_title, news_buffer = None, []

    def flush_paras():
        nonlocal pending_para
        if pending_para:
            for t in pending_para:
                htmls.append(f'<p class="note">{para_html(t)}</p>')
            pending_para = []

    def para_html(t):
        h = markdown.markdown(t).strip()
        h = re.sub(r"<p>(.*)</p>", r"\1", h, flags=re.S)
        # 2026-09-11: 行内引用块在 .note 里会渲染成突兀的灰底块, 拆成纯文本
        h = re.sub(r"<blockquote>\s*(.*?)\s*</blockquote>", r"\1", h, flags=re.S)
        h = re.sub(r"<strong>([^<]*?)</strong>", lambda m: num_or_updown(m.group(1)), h)
        return h

    def parse_h3_items(items):
        """小节内容 → blocks: ('table',(hdr,rows)) / ('list',[..]) / ('para',txt)"""
        blocks = []
        k = 0
        cur_list = []
        def push_list():
            if cur_list:
                blocks.append(("list", cur_list[:])); cur_list.clear()
        while k < len(items):
            it = items[k]
            if it.startswith("|"):
                push_list()
                if k + 1 < len(items) and is_sep_row(items[k+1]):
                    hdr, rows = parse_table(items[k:])
                    _ncol = len(hdr)
                    blocks.append(("table", (hdr, rows)))
                    k += 2
                    while k < len(items) and is_tbl_row(items[k], _ncol):
                        k += 1
                    continue
                k += 1; continue
            if re.match(r"^\d+\.\s+", it):
                cur_list.append(re.sub(r"^\d+\.\s*", "", it)); k += 1; continue
            if it.startswith("- "):
                cur_list.append(re.sub(r"^- ", "", it)); k += 1; continue
            if it.startswith("归因"):
                push_list()
                blocks.append(("quote", it)); k += 1; continue
            push_list()
            blocks.append(("para", it)); k += 1; continue
        push_list()
        return blocks

    while i < len(bs):
        ln = bs[i]
        # ---- 归因判断(3层) h4 小节 → pull-quote (2026-09-11: 原先落到 pending_para,
        #      被渲染成无样式 <p class="note"> 平铺, 且 h4 标题本身被丢弃) ----
        if re.match(r"^####\s*归因判断", ln):
            flush_paras(); flush_news()
            j = i + 1
            bullets = []
            while j < len(bs) and not bs[j].startswith(("####", "###")):
                s = re.sub(r"^[-*]\s*", "", bs[j].strip())
                if s:
                    mb = re.match(r"^\*\*(.+?)\*\*\s*[:：]?\s*(.*)$", s)
                    if mb:
                        bullets.append(f'<div style="margin-top:6px"><b>{inline(mb.group(1))}</b>：{inline(mb.group(2))}</div>')
                    else:
                        bullets.append(f'<div style="margin-top:6px">{inline(s)}</div>')
                j += 1
            i = j
            if bullets:
                htmls.append('<div class="pull-quote"><div class="lab">归因判断 · ATTRIBUTION</div>'
                             + "".join(bullets) + '</div>')
            continue
        # ---- 新闻小节 (#### 外围/内因/消息面) ----
        m4 = re.match(r"^####\s*(外围|内因|外盘|内盘|消息面)(?:[（(][^)）]*[)）])?\s*$", ln)
        if m4:
            flush_paras()
            flush_news()
            news_title = m4.group(1)
            j = i + 1
            while j < len(bs) and not bs[j].startswith("####") and not bs[j].startswith("###") and not bs[j].startswith("归因"):
                if re.match(r"^\d+\.", bs[j]):
                    news_buffer.append(re.sub(r"^\d+\.\s*", "", bs[j]))
                j += 1
            i = j
            continue
        # ---- h3 小节 ----
        m3 = re.match(r"^###\s+(.*)$", ln)
        if m3:
            flush_paras(); flush_news()
            title = m3.group(1).strip()
            j = i + 1
            items = []
            while j < len(bs) and not bs[j].startswith(("###", "####")):
                items.append(bs[j]); j += 1
            i = j
            blocks = parse_h3_items(items)
            # ---- 模块6 plan3: 已兑现/正在兑现中/预期差 连续三节 ----
            if title.startswith("已兑现"):
                plan3 = {"已兑现": [], "正在兑现中": [], "预期差": []}
                def collect_plan(title, blocks):
                    for b in blocks:
                        if b[0] in ("list", "para"):
                            items_ = b[1] if b[0] == "list" else [b[1]]
                            plan3[title].extend(items_)
                collect_plan("已兑现", blocks)
                nxt_title = title
                nxt_bs = bs
                nxt_i = i
                for key in ("正在兑现中", "预期差"):
                    # 寻找连续小节
                    while nxt_i < len(nxt_bs) and not re.match(r"^###\s+", nxt_bs[nxt_i]):
                        nxt_i += 1
                    if nxt_i >= len(nxt_bs):
                        break
                    m3n = re.match(r"^###\s+(.*)$", nxt_bs[nxt_i])
                    nxt_t = m3n.group(1).strip()
                    if nxt_t.startswith(key):
                        jj = nxt_i + 1
                        its = []
                        while jj < len(nxt_bs) and not nxt_bs[jj].startswith(("###", "####")):
                            its.append(nxt_bs[jj]); jj += 1
                        collect_plan(key, parse_h3_items(its))
                        nxt_i = jj
                    else:
                        break
                i = nxt_i
                badges = {"已兑现": '<span class="badge-g yes">✓ 已兑现</span>',
                          "正在兑现中": '<span class="badge-g ing">◐ 正在兑现中</span>',
                          "预期差": '<span class="badge-g gap">⚖ 预期差</span>'}
                cubes = []
                for key in plan3:
                    lis = "".join(f'<li>{inline(k)}</li>' for k in plan3[key]) or "<li>—</li>"
                    cubes.append(f'<div class="cube"><div class="cb-head">{badges[key]}</div>'
                                 f'<div class="cb-body"><ul style="list-style:none;padding-left:0">{lis}</ul></div></div>')
                htmls.append(f'<div class="plan3">{"".join(cubes)}</div>')
                flush_paras()
                continue
            # ---- 方向操作建议/观察点/信号 (表格) 须在"方向"cube 之前判断 ----
            if "方向操作" in title or "观察点" in title or "信号" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_tbl(*t, min_width=0))
                    flush_paras(); continue
            # ---- 双轨判断: 方向A/方向B 连续小节合并为并排 cols 双 cube ----
            if re.match(r"^方向\s*[AB]\s*[:：]", title):
                def build_dir_cube(t, blks):
                    m = re.match(r"^方向\s*([AB])\s*[:：]\s*(.*?)(?:\s*[（(]([^（）()]*)[）)])?$", t)
                    g1, g2, g3 = (m.group(1), m.group(2), m.group(3)) if m else (t[:1], t, None)
                    head = f"方向 {g1} · {g2}"
                    head_html = f'<b>{inline(head)}</b>'
                    body = []
                    is_dim = False
                    for b in blks:
                        if b[0] == "table":
                            hdr, rows = b[1]
                            if hdr and rows and hdr[0].strip() in ("维度", "判断"):
                                is_dim = True
                                lis = []
                                for r in rows:
                                    if r[0].strip().startswith("双轨结论"):
                                        lis.append(f'<li style="list-style:none;margin-top:8px;border-top:1px dashed var(--line);padding-top:8px">'
                                                   f'<b class="gold">双轨结论:</b>{inline(r[1])}</li>')
                                    else:
                                        lis.append(f'<li><b>{inline(r[0])}:</b> {inline(r[1])}</li>')
                                body.append(f'<ul style="list-style:none;padding-left:0;margin:0">{"".join(lis)}</ul>')
                            else:
                                body.append(comp_tbl(*b[1], min_width=0))
                        elif b[0] == "list":
                            body.append(f'<ul style="margin-top:8px;list-style:none;padding-left:0">' + "".join(render_list_items(b[1])) + '</ul>')
                        elif b[0] == "para":
                            body.append(f'<p class="note" style="margin-top:8px">{para_html(b[1])}</p>')
                    extra = f'<span class="dim" style="font-weight:400">({g3})</span>' if g3 else ""
                    return f'<div class="cube"><div class="cb-head">{head_html} {extra}</div><div class="cb-body">{"".join(body)}</div></div>'
                cubes = [build_dir_cube(title, blocks)]
                j2 = i
                while j2 < len(bs):
                    m3x = re.match(r"^###\s+(方向\s*[AB]\s*[:：].*)$", bs[j2])
                    if not m3x:
                        break
                    t2 = m3x.group(1).strip()
                    j3 = j2 + 1
                    its2 = []
                    while j3 < len(bs) and not bs[j3].startswith(("###", "####")):
                        its2.append(bs[j3]); j3 += 1
                    cubes.append(build_dir_cube(t2, parse_h3_items(its2)))
                    j2 = j3
                i = j2
                # 2026-08-21 修复: 方向A/方向A2/方向B 被中间小节打断时各自成 cols 单元素,
                # grid 双列下只占左列、右列全白 → 单元素直接整宽输出。
                if len(cubes) == 1:
                    htmls.append(cubes[0])
                else:
                    htmls.append(f'<div class="cols">{"".join(cubes)}</div>')
                flush_paras(); continue
            # ---- 双轨结论 as 蓝色 pull-quote ----
            if title.startswith("双轨结论") or "双轨结论" == title:
                txt = " ".join(b[1] if b[0] == "para" else " ".join(b[1]) for b in blocks)
                if txt:
                    htmls.append(f'<div class="pull-quote" style="border-left-color:var(--blue)">'
                                 f'<div class="lab">双轨结论 · DUAL-TRACK</div>{para_html(txt)}</div>')
                flush_paras(); continue
            # ---- 规则验证/错误归因(6因子)/指标对比 表格: 须在"归因"cube 之前 ---- 
            if "规则验证" in title or "错误归因" in title or "因子" in title or "指标对比" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    hdr, rows = t
                    if "扣分" in "".join(hdr):
                        # 0813 基准: 扣分负值用红(up), -0/0 用绿(down)
                        def _pen(c):
                            s = c.strip()
                            m = re.fullmatch(r"([+\-−]?)(\d+(?:\.\d+)?)", s)
                            if not m:
                                return c
                            sign, v = m.group(1), m.group(2)
                            if sign in "-−":
                                return f'<b class="{"up" if v not in ("0", "0.0") else "down"}">{s}</b>'
                            return f'<b class="down">{s}</b>'
                        trs = ""
                        for r in rows:
                            tds = ""
                            for ci, c in enumerate(r):
                                if ci == 1:
                                    tds += f'<td class="c">{_pen(c)}</td>'
                                else:
                                    tds += f"<td>{inline(c)}</td>"
                            trs += f"<tr>{tds}</tr>"
                        htmls.append(f'<div class="tbl-wrap"><table><tr>{"".join(f"<th>{inline(h)}</th>" for h in hdr)}</tr>{trs}</table></div>')
                    else:
                        htmls.append(comp_tbl(hdr, rows))
                    flush_paras(); continue
            # ---- 归因/因果/矛盾 cube ----
            if "归因" in title or title == "因果判断" or "矛盾" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                if t:
                    htmls.append(comp_tbl(*t))
                else:
                    items_ = []
                    for b in blocks:
                        if b[0] == "list": items_.extend(b[1])
                        elif b[0] == "para":
                            m2 = re.match(r"^(.{2,24}?)[:：](.*)$", b[1])
                            if m2 and len(m2.group(1)) <= 16:
                                items_.append(m2.group(0) if False else (m2.group(1) + "：" + m2.group(2)))
                            else:
                                items_.append(b[1])
                    htmls.append(f'<div class="cube"><div class="cb-body"><ul>{"".join(render_list_items([it for it in items_]))}</ul></div></div>')
                    flush_paras(); continue
            if "连板" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_ladder(t))
                    flush_paras(); continue
            if "主线识别" in title or title == "主线":
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_themes(t))
                    flush_paras(); continue
            if "退潮" in title or "风险" in title:
                # 2026-08-21 修复: ①空小节不得输出硬编码文案; ②标题含"退潮/风险"
                # 但实际是表格小节(如"方向A2:创新药(情绪惯性,板块退潮中)")时不得吞表,
                # 未命中 list/para 内容的(空或纯表格)一律落默认渲染。
                if any(b[0] in ("list", "para") for b in blocks):
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    lis_html = ""
                    for b in blocks:
                        if b[0] == "list":
                            for it in b[1]:
                                m = re.match(r"^(.{2,20}?)[:：]", it)
                                if m:
                                    lis_html += f'<b>{inline(m.group(1))}：</b>{inline(it[len(m.group(1))+1:])}<br>'
                                else:
                                    lis_html += f"{inline(it)}<br>"
                        elif b[0] == "para":
                            lis_html += f"{inline(b[1])}<br>"
                    htmls.append(f'<div class="warn">{lis_html}</div>')
                    flush_paras(); continue
            if "封单" in title or "TOP" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_tbl(*t))
                    flush_paras(); continue
            if "板块资金" in title:
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    hdr, rows = t
                    flow_rows = [r for r in rows if r[0].startswith("资金净流入")][:5]
                    hangye = [r for r in rows if r[0].startswith("行业涨幅")]
                    gainian = [r for r in rows if r[0].startswith("概念涨幅")]
                    if flow_rows:
                        htmls.append(render_flow(flow_rows))
                    cols = []
                    if hangye:
                        lis = "".join(f'<li>{inline(r[1])} {num_or_updown(r[2])}<span class="dim"> {inline(r[3])}</span></li>' for r in hangye)
                        cols.append(comp_col_card("行业涨幅", lis))
                    if gainian:
                        lis = "".join(f'<li>{inline(r[1])} {num_or_updown(r[2])}<span class="dim"> {inline(r[3])}</span></li>' for r in gainian)
                        cols.append(comp_col_card("概念涨幅", lis))
                    if len(cols) == 2:
                        htmls.append(f'<div class="cols" style="margin-top:12px">{"".join(cols)}</div>')
                    elif len(cols) == 1:
                        htmls.append(cols[0])
                    flush_paras(); continue
            if "近10日" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_tbl(*t, hl_last=True, min_width=860))
                    flush_paras(); continue
            if "事实" in title or "七指数" in title or "全景" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_tbl(*t))
                    flush_paras(); continue
            if "结论" in title:
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                for b in blocks:
                    if b[0] in ("para", "list"):
                        txt = b[1] if b[0] == "para" else " ".join(b[1])
                        htmls.append(f'<p class="note">{para_html(txt)}</p>')
                flush_paras(); continue
            if "昨日方向验证" in title or "指标对比" in title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(comp_tbl(*t))
                for b in blocks:
                    if b[0] == "para":
                        htmls.append(f'<div class="ok"><b>方向准确率</b><br><span class="dim">{para_html(b[1])}</span></div>')
                    flush_paras(); continue
            if "今日教训" in title or "待验证" in title or "新规则" in title:
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                items_ = []
                for b in blocks:
                    if b[0] == "list": items_.extend(b[1])
                    elif b[0] == "para": items_.append(b[1])
                if "新规则" in title:
                    cubes = []
                    for it in items_:
                        body_txt = re.sub(r"^观察中\s*[:：]", "", it, count=1).strip()
                        m2 = re.match(r"^(.{2,20}?)[,，:：]|^(.{2,20}?)[（(]", body_txt)
                        if m2 and len(m2.group(1) or m2.group(2) or "") >= 2:
                            hd = (m2.group(1) or m2.group(2)).strip()
                        else:
                            hd = body_txt[:16]
                        head = f'<span class="badge-g ing">观察中</span> {inline(hd)}'
                        cubes.append(comp_cube(head, f'<span class="dim">{inline(body_txt)}</span>'))
                    if cubes:
                        # 2026-08-21 修复: 2 条规则应并排一行(同 cols), 不能各自成 cols
                        # 单元素(双列 grid 只占左列右列全白); 单卡行直接整宽输出。
                        if len(cubes) == 1:
                            htmls.append(cubes[0])
                        else:
                            for _k in range(0, len(cubes), 2):
                                _row = cubes[_k:_k + 2]
                                if len(_row) == 1:
                                    htmls.append(_row[0])
                                else:
                                    htmls.append(f'<div class="cols">{"".join(_row)}</div>')
                else:
                    htmls.append(render_cube_list(items_))
                flush_paras(); continue
            # ---- 仓位大数字 cube ----
            if "仓位" == title:
                t = next((b[1] for b in blocks if b[0] == "table"), None)
                if t:
                    hdr, rows = t
                    big = ""
                    extra = []
                    for r in rows:
                        if r[0].startswith("建议仓位"):
                            m_b = re.match(r"^([^（(]*)[（(](.*)[）)]$", r[1])
                            if m_b:
                                big = f'<span style="font-family:\'Helvetica Neue\',Arial,sans-serif;font-size:34px;font-weight:800;color:var(--gold)">{inline(m_b.group(1))}</span>'
                                extra.append(f'<span style="font-size:13px;color:var(--sub)">{inline(m_b.group(2))}</span>')
                            else:
                                big = f'<span style="font-family:\'Helvetica Neue\',Arial,sans-serif;font-size:34px;font-weight:800;color:var(--gold)">{inline(r[1])}</span>'
                        else:
                            extra.append(f'<span style="font-size:13px;color:var(--sub)">{inline(r[0])}: <b>{inline(r[1])}</b></span>')
                    htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                    htmls.append(f'<div class="cube"><div class="cb-body" style="display:flex;gap:18px;flex-wrap:wrap;align-items:baseline">{big}'
                                 f'<span style="flex:1;min-width:260px;font-size:13px;padding-left:14px;border-left:2px solid var(--line);display:flex;flex-direction:column;gap:6px">{"".join(extra)}</span></div></div>')
                    flush_paras(); continue
            # 其他小节: 表格或列表常规渲染
            if any(b[0] == "table" for b in blocks):
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                # 2026-09-11: 按源顺序渲染 —— 同小节内的说明段落/列表不再被表格分支吞掉
                # (原先只取表格, 其后的"标的分级: ..."等说明行静默丢失)
                for b in blocks:
                    if b[0] == "table":
                        htmls.append(comp_tbl(*b[1]))
                    elif b[0] in ("para", "list"):
                        _txt = b[1] if b[0] == "para" else " ".join(b[1])
                        htmls.append(f'<p class="note">{para_html(_txt)}</p>')
            else:
                items_ = []
                for b in blocks:
                    if b[0] == "list": items_.extend(b[1])
                    elif b[0] == "para": items_.append(b[1])
                htmls.append(f'<h3 class="block">{inline(title)}</h3>')
                lis = "".join(f"<li>{inline(it)}</li>" for it in items_)
                htmls.append(f'<div class="cube"><div class="cb-body"><ul>{"".join(lis)}</ul></div></div>')
            flush_paras()
            continue
        # ---- 模块级段落（归因判断/双轨结论/模块总结）----
        if ln.startswith("归因"):
            flush_paras(); flush_news()
            txt = re.sub(r"^归因判断[:：]?\s*", "", ln)
            htmls.append(f'<div class="pull-quote"><div class="lab">归因判断 · ATTRIBUTION</div>{para_html(txt)}</div>')
            i += 1; continue
        if ln.startswith("双轨结论"):
            flush_paras()
            txt = re.sub(r"^双轨结论[:：]?\s*", "", ln)
            htmls.append(f'<div class="pull-quote" style="border-left-color:var(--blue)">'
                         f'<div class="lab">双轨结论 · DUAL-TRACK</div>{para_html(txt)}</div>')
            i += 1; continue
        if len(ln) > 30 or ln.startswith("**"):
            pending_para.append(ln); i += 1; continue
        i += 1

    # 收尾: 新闻卡放最前(对应 md 中外围/内因在模块开头)
    flush_paras(); flush_news()
    if news_cards:
        # 2026-09-11: 消息面整宽独占一行, 外围/内因并排两栏 (三卡同入 2 列网格会错位)
        wide = [(t, it) for t, it in news_cards if t.strip().startswith("消息面")]
        pair = [(t, it) for t, it in news_cards if not t.strip().startswith("消息面")]
        parts = [render_news_card("✧ " + t, it, extra_cls="news-flash") for t, it in wide]
        if pair:
            pair_html = "".join(
                render_news_card(("✧ " if t.startswith("外") else "❖ ") + t, it) for t, it in pair)
            parts.append(f'<div class="cols">{pair_html}</div>' if len(pair) >= 2 else pair_html)
        return "\n".join(parts) + "\n" + "\n".join(htmls), True
    return "\n".join(htmls), bool(news_cards)

def render_list_items(items):
    out = []
    for it in items:
        m = re.match(r"^(.{2,24}?)[:：](.*)$", it)
        if m and len(m.group(1)) <= 24:
            out.append(f'<li><b>{inline(m.group(1))}：</b>{inline(m.group(2))}</li>')
        else:
            out.append(f'<li>{inline(it)}</li>')
    return out

# ════════════════════════════ 组装页面 ════════════════════════════
kpis = ""
kpis += f'<div class="kpi hero"><div class="v">{composite}<small>/100</small></div><div class="l">综合评分</div><div class="s">{season} 过渡</div></div>'
kpis += f'<div class="kpi"><div class="v green">{panic}</div><div class="l">恐慌度</div><div class="s">{season}档</div></div>'
kpis += f'<div class="kpi"><div class="v gold">{pos}</div><div class="l">仓位上限</div><div class="s">执行参考</div></div>'
kpis += f'<div class="kpi"><div class="v red">{zuoting}</div><div class="l">涨停</div><div class="s">首板占高</div></div>'
kpis += f'<div class="kpi"><div class="v green">{dieting}</div><div class="l">跌停</div><div class="s">高位出清</div></div>'
amount_v = amount if amount else "-"
kpis += f'<div class="kpi"><div class="v">{amount_v}</div><div class="l">两市成交</div><div class="s">亿元</div></div>'
kpis += f'<div class="kpi"><div class="v red">{bidui}</div><div class="l">涨跌比</div><div class="s">跌多涨少</div></div>'
kpis += f'<div class="kpi"><div class="v gold">{maxban}板</div><div class="l">最高板</div><div class="s">主线 {zhuxian}</div></div>'

digest_html = ""
if summary_items:
    dps = []
    for n, s in enumerate(summary_items, 1):
        m = re.match(r"^(.{2,30}?)[:：](.*)$", s)
        if m and len(m.group(2)) > 4:
            k, body = m.group(1), m.group(2)
        else:
            k, body = f"要点 {n:02d}", s
        ps = body.split("，")
        # 拆成两条 ln 使摘要更像 0813 (前4条各2行)
        if len(ps) >= 2:
            body1, body2 = "，".join(ps[:1]), "，".join(ps[1:])
        else:
            body1, body2 = body, ""
        ln1 = inline(body1)
        ln2 = f'<div class="ln">{inline(body2)}</div>' if body2 else ""
        dp_cls = " key" if "核心矛盾" in s and n == 4 else ""
        dps.append(f'<div class="dp{dp_cls}"><span class="no">{n:02d}</span><div>'
                   f'<div class="k">{inline(k)}</div>'
                   f'<div class="ln">{ln1}</div>{ln2}</div></div>')
    digest_html = (f'<section><div class="sec-head"><div class="sec-no">00</div>'
                   f'<div class="sec-title">复盘摘要</div><div class="sec-en">Executive Summary</div></div>'
                   f'<div class="digest">{"".join(dps)}</div></section>')

mods = []
for sec in sections:
    en = next((v for k, v in SEC_EN.items() if sec["title"].startswith(k)), "Module")
    body_html, _ = render_section(sec)
    no = f"{sec['num'] + 1:02d}"
    mods.append(f'<section><div class="sec-head"><div class="sec-no">{no}</div>'
                f'<div class="sec-title">模块 {sec["num"]} · {sec["title"]}</div>'
                f'<div class="sec-en">{en}</div></div>{body_html}</section>')

CSS_P = "/root/web_reports/build_assets/light_style.css"
CSS = pathlib.Path(CSS_P).read_text(encoding="utf-8") if pathlib.Path(CSS_P).exists() else ""

HTML_TMPL = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title} 市场复盘 · 钱小兜引擎</title>
<style>{css}</style>
</head>
<body>
<div class="wrap">
  <header class="masthead">
    <div class="tag">钱小兜分析引擎 V10 · DAILY MARKET REVIEW</div>
    <h1>{h1}</h1>
    <div class="sub">行情 · <b>{season}</b> · 主线 {zhuxian}</div>
    <div class="meta">
      <span>{date_cn} · {weekday_cn}</span>
      <span class="season">行情状态 · {season}</span>
      <span>数据来源 · WeStock · 通达信MCP · 腾讯行情</span>
    </div>
  </header>

  <div class="kpis">
    {kpis}
  </div>

  {digest}

  {mods}

  <div class="footer">
    <span>钱小兜分析引擎 V10 · 市场复盘</span>
    <span>数据日期 {date_cn} · 仅供参考不构成投资建议</span>
  </div>
</div>
</body>
</html>"""

title_d = f"{arg[:4]}.{arg[4:6]}.{arg[6:]}"
html = HTML_TMPL.format(
    title=title_d, css=CSS, h1="市场复盘",
    season=season, zhuxian=zhuxian, date_cn=date_cn, weekday_cn=weekday_cn,
    kpis=kpis, digest=digest_html, mods="\n\n  ".join(mods),
)
out = OUT_DIR / f"report_{arg}.html"
out.write_text(html, encoding="utf-8")
# 2026-08-21: 表格数量守恒自检——防"标题关键字分支"(封单/退潮/风险…)吞表格静默丢内容。
# md 表格分隔行(|----|)计数 = 源表格数; HTML 侧 = <table> + 被专属组件合法转换的
# 表格(连板→.lvl / 板块资金→.flow / 主线→.themes / 方向A/B→cube), 不等即 WARN。
_md_tbls = sum(1 for _l in md_text.splitlines() if is_sep_row(_l))
_html_consumed = (html.count("<table") + html.count('class="ladder')
                  + html.count('class="flow') + html.count('class="themes')
                  + len(re.findall(r"<b>方向 [AB] ·", html)))
if _md_tbls != _html_consumed:
    print(f"[WARN] 表格数不守恒: md={_md_tbls} html消费={_html_consumed} —— 有小节被分支吞掉, 检查标题关键字匹配", file=sys.stderr)

# 2026-09-14: 表格「行」守恒自检——md 里每个表格数据行的首格文本必须出现在 HTML 里。
# 背景: 8 行「方向操作建议」用松散写法(无行首竖线), 旧 parse_table 整表丢行, HTML 只剩表头,
# 表格数守恒查不出来, --strict 也 PASS。这里按内容兜底, 任何原因导致的整表/整行丢失都会告警。
_COMPONENT_TITLE_HINTS = ("连板", "主线", "板块资金", "资金与涨幅", "资金与主线",
                          "方向A", "方向B", "仓位", "封单", "退潮")

def _md_row_keys(md: str):
    """按行扫描 md, 返回所有「通用表格」数据行(含松散行)的首格文本。

    专门组件渲染的表格(连板→ladder / 板块资金→flow / 主线→themes / 方向A·B与仓位→cube)
    首格文本会被组件改写(如 `资金净流入#1` → 徽标 `净流入 #1`), 无法按文本比对, 故跳过;
    它们由「表格数守恒」+ 各自组件测试覆盖。
    """
    keys = []
    title = ""
    for _l in md.splitlines():
        _s = _l.strip()
        if _s.startswith("#"):
            title = _s
            continue
        if not _s or _s.startswith(">") or is_sep_row(_s):
            continue
        if "|" not in _s or re.match(r"^([-*+]\s|\d+\.\s)", _s):
            continue
        if any(h in title for h in _COMPONENT_TITLE_HINTS):
            continue
        _cells = [c.strip() for c in _s.strip("|").split("|") if c.strip()]
        if len(_cells) < 2:
            continue
        _k = re.sub(r"[*`]", "", _cells[0])
        if len(_k) >= 2:
            keys.append(_k)
    return keys

_plain = re.sub(r"<[^>]+>", "", html)
_missing = [k for k in dict.fromkeys(_md_row_keys(md_text)) if k not in _plain]
if _missing:
    print(f"[WARN] 表格行丢失 {len(_missing)} 行(首格未出现在 HTML): {_missing[:6]}"
          f" —— 渲染分支吞行或行写法不被识别, 需修渲染器", file=sys.stderr)
print(f"OK: {out} ({out.stat().st_size} bytes) · season={season} · mods={len(mods)} · kpis=8 · tbl {_md_tbls}/{_html_consumed}"
      f" · rows_lost={len(_missing)}")