#!/usr/bin/env python3
"""存量清洗：剥掉 gov_raw.content 里的 <style>/<script>/<noscript>/<template> 块。

背景（2026-09-11）
  政府站正文常整段内联 CSS —— 绍兴上虞区政府-环评公示某条 content 共 9853 字，
  其中约 9300 字是 3 个 <style> 块，真文本（一张公示表）只有 548 字。
  后果：① search_app 列表摘要整行变成 `.ewb-header { box-sizing: ... }`；
        ② 详情页把 <style> **原样注入** → 浏览器当全页样式表应用，污染整页 CSS。

修复分层
  显示端：search_app.strip_body_noise()（5 处调用点）—— 已上线
  入库端：crawler_lib.strip_noise_blocks()（push_to_searchdb/clean_html，覆盖 561 脚本）—— 已上线
  兜底：  fix_md_after_crawl.py 的 fix_noise()（每日扫最近 N 天，覆盖直写 sqlite 的脚本）—— 已上线
  存量：  **本脚本**（8,513 行）

⚠️ 安全策略：开/闭标签数量不等的行**跳过不洗**。
   full content 上 `<style>` 未闭合 = HTML 本身畸形，截断兜底会把后面的真文本一起吃掉。
   （显示端用 SUBSTR 造成的「假不对齐」不受影响，那边兜底是对的。）

用法
  python3 clean_content_noise_20260911.py --dry-run    # 只体检：目标数 / 跳过数 / 前后样本
  python3 clean_content_noise_20260911.py              # 建回滚表 → 分批清洗 → 校验
"""
import re
import sqlite3
import sys
import time

DB = '/mnt/data/search.db'
BAK = 'gov_raw_content_bak_20260911'
BATCH = 200

BLOCK = re.compile(r'<(script|style|noscript|template)\b[^>]*>.*?</\1\s*>', re.I | re.S)
OPEN_ONLY = re.compile(r'<(?:script|style|noscript|template)\b[^>]*>.*\Z', re.I | re.S)
OPEN_TAG = re.compile(r'<(script|style|noscript|template)\b[^>]*>', re.I)
TAGS = ('script', 'style', 'noscript', 'template')
# 存量筛选条件（⚠️ <link 也要收：<link rel=stylesheet> 在 .ct-html 里会让浏览器加载外部样式表）
# ⚠️ CMS 标记（ZJEG/TRS）也要收：注释形态浏览器看不见，但有的站爬虫把注释符去掉了 → 裸文本直接显示
CAND_SQL = ("content LIKE '%<style%' OR content LIKE '%<script%' OR content LIKE '%<noscript%' "
            "OR content LIKE '%<template%' OR content LIKE '%<link%' "
            "OR content LIKE '%ZJEG_RSS%' OR content LIKE '%<$[%'")
# CMS 模板标记（与 search_app/crawler_lib 里同名正则同一套）
CMS_MARKER = re.compile(
    r'(?:<!--\s*)?(?:<\$\[[^\]]*\]>|ZJEG_RSS\.[\w.]*)\s*(?:begin|end)?\s*(?:-->)?', re.I)


def strip_noise_paired(html):
    """配对删除：每个开标签只删到它**后面第一个**匹配的闭合标签为止。

    ⚠️ 与 search_app.strip_body_noise() 的关键区别：**不做「未闭合就截到末尾」的兜底**。
    那个兜底是给 `SUBSTR(content,1,1200)` 这种**被截断的前缀**用的；
    在**全文**上用会把后面的真文本一起吞掉（2026-09-11 实测踩过）。
    全文里找不到闭合标签时 → 只删开标签本身，后面的正文一律保留（保守，绝不吞正文）。
    """
    if not html:
        return html
    out, i, n = [], 0, len(html)
    while i < n:
        m = OPEN_TAG.search(html, i)
        if not m:
            out.append(html[i:])
            break
        out.append(html[i:m.start()])
        cm = re.search(r'</%s\s*>' % m.group(1), html[m.end():], re.I)
        if cm:                                  # 有闭合 → 整块删掉（含块内 CSS/JS 文本）
            i = m.end() + cm.end()
        else:                                   # 无闭合 → 只删开标签，正文保留
            i = m.end()
        out.append(' ')
    s = ''.join(out)
    s = re.sub(r'<!--.*?-->', ' ', s, flags=re.S)
    s = re.sub(r'<link\b[^>]*>', ' ', s, flags=re.I)
    s = CMS_MARKER.sub(' ', s)
    return s


def audit(html):
    """逐次检查每个开标签有没有闭合。返回 (可安全配对删除数, 无闭合的标签列表)。"""
    ok, unclosed = 0, []
    for m in OPEN_TAG.finditer(html or ''):
        if re.search(r'</%s\s*>' % m.group(1), html[m.end():], re.I):
            ok += 1
        else:
            unclosed.append((m.group(1).lower(), m.start()))
    return ok, unclosed


# 兼容旧调用名
def strip_noise(html):
    return strip_noise_paired(html)


def unbalance(html):
    """已废弃：计数式判据会把「两个 <script> 共用一个 </script>」误判为畸形
    （绍兴上虞那批就是这样，298 条被误跳过）。保留仅为兼容，勿再用于判定。"""
    for t in TAGS:
        o = len(re.findall(r'<%s\b' % t, html, re.I))
        c = len(re.findall(r'</%s\s*>' % t, html, re.I))
        if o != c:
            return (t, o, c)
    return None


def text_of(html, n=160):
    t = re.sub(r'<[^>]+>', ' ', html or '')
    t = re.sub(r'&nbsp;|&amp;|&lt;|&gt;|&#\d+;|&[a-z]+;', ' ', t)
    return re.sub(r'\s+', ' ', t).strip()[:n]


def main():
    dry = '--dry-run' in sys.argv
    global BAK
    for a in sys.argv:
        if a.startswith('--bak='):
            BAK = a.split('=', 1)[1]
    db = sqlite3.connect(DB, timeout=300)
    db.execute('PRAGMA busy_timeout=300000')
    db.execute('PRAGMA synchronous=NORMAL')

    t0 = time.time()
    rows = db.execute('SELECT id, length(content), content FROM gov_raw WHERE ' + CAND_SQL).fetchall()
    print('[体检] 候选 %d 条，查询耗时 %.1fs' % (len(rows), time.time() - t0))

    todo, unc_rows, n_unc = [], [], 0
    for rid, clen, content in rows:
        ok, unc = audit(content)
        if unc:
            unc_rows.append(rid)
            n_unc += len(unc)
        if strip_noise(content) != content:
            todo.append(rid)
    print('[体检] 可清洗 %d 条' % len(todo))
    print('[体检] 含无闭合标签的行 %d 条（%d 个标签无闭合 → 只删开标签本身，后面的正文一律保留）'
          % (len(unc_rows), n_unc))

    # 样本预览
    for rid, clen, content in rows[:3]:
        ok, unc = audit(content)
        print('\n--- id=%s (原 %d 字 → 洗后 %d 字) 配对标签 %d 个, 无闭合 %d 个'
              % (rid, clen, len(strip_noise(content)), ok, len(unc)))
        print('  原文本:', repr(text_of(content)))
        print('  新文本:', repr(text_of(strip_noise(content))))

    if dry:
        print('\n[DRY-RUN] 未写库。')
        db.close()
        return

    # ── 1) 回滚表（候选全量备份，含跳过的）──
    db.execute('DROP TABLE IF EXISTS %s' % BAK)
    db.execute('CREATE TABLE %s (id INTEGER PRIMARY KEY, content TEXT, bak_at TEXT)' % BAK)
    for i in range(0, len(rows), BATCH):
        db.executemany('INSERT INTO %s (id, content, bak_at) VALUES (?,?,datetime(\'now\',\'localtime\'))' % BAK,
                       [(r[0], r[2]) for r in rows[i:i + BATCH]])
        db.commit()
    n_bak = db.execute('SELECT COUNT(*) FROM %s' % BAK).fetchone()[0]
    print('[备份] %s 写入 %d 行' % (BAK, n_bak))

    # ── 2) 分批清洗 ──
    ids = set(todo)
    done = 0
    for rid, clen, content in rows:
        if rid not in ids:
            continue
        db.execute('UPDATE gov_raw SET content=? WHERE id=?', (strip_noise(content), rid))
        done += 1
        if done % BATCH == 0:
            db.commit()
            print('   ... 已清洗 %d / %d  (%.0fs)' % (done, len(todo), time.time() - t0))
    db.commit()

    # ── 3) 校验 ──
    left = db.execute('SELECT COUNT(*) FROM gov_raw WHERE ' + CAND_SQL).fetchone()[0]
    print('[完成] 清洗 %d 条 | 残留候选 %d 条（应=无闭合行数 %d）| 回滚表 %s %d 行 | 耗时 %.0fs'
          % (done, left, len(unc_rows), BAK, n_bak, time.time() - t0))
    db.close()


if __name__ == '__main__':
    main()
