#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""make_qc_doc.py —— 建立 QC 结果文档（本地 md 全量 + Notion 摘要页）"""
import json
import os
import subprocess
import sys

OUT = "/Users/chniir0000outlook.com/Crawler/gov_crawler/qc_out/"
DATE = "2026-09-25"
TOK = "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"
LOCAL_MD = OUT + "QC结果文档.md"
D = json.load(open(OUT + "qc_tier1_%s.json" % DATE.replace("-", ""), encoding="utf-8"))
iss = D["issues"]


def cnt(k):
    return len(iss.get(k, []))


# ── 本地 md（活文档，每批追加）──
md = """# 爬虫数据 QC 结果文档

> 活文档：Tier1 自动体检 + Tier2 人工裁定逐批追加。全量明细见同目录 `qc_tier1_<date>.md/.json`。

## 一、口径（2026-09-25 确认）

- **主键 = `Script_name`**。配置编号**已弃用**：历史遗留（当时调度=50 个/批分批跑，编号=当时数组快照序），现行调度不看它 → **QC 不校验编号**。
- **停滞 > 30 天** 算异常。
- **先列清单，再定批量策略**；Tier1 不改任何数据。
- 三层：Tier1 全自动出清单 → Tier2 人工（10 个/批）只处理异常 → Tier3 抽样反验判据。

## 二、Tier 1 首跑结果（2026-09-25）

config 条目 %d | md5 %s | gov_raw %d 行 | script_name 空 %d（%.2f%%）

| 维度 | 条数 | 涉及脚本 | 性质 |
|---|---|---|---|
| D1 config 条目自身问题 | %d | %d | 已校准：真问题=未知参数 121 + 同脚本同参数重复 1 |
| D2 孤儿脚本（磁盘有、config 无） | %d | %d | 真问题：永不更新 |
| D3 无 --pages 且有全站翻页风险 | %d | %d | 判据待收紧 |
| D4 库内脚本名磁盘查无此文件 | %d | %d | 真问题（含 `luan`/`jz`/`kmyl` 等非文件名垃圾） |
| D5 config 注册但库内 0 行 | %d | %d | 真问题：空跑 |
| D6 库有数据但不在 config | %d | %d | 待判：改名/退役 |
| D7 停滞 >30 天 | %d | %d | 真问题（分档见下） |
| D8 行数≥30 却全无附件 | %d | %d | **高危候选池**，需页面级核查 |
| D9 存在空正文行 | %d | %d | 真问题 |
| D10 FTS 计数差（gov_search 634,551 vs gov_raw 580,796） | — | — | 需先确认 gov_search 服务范围 |
| D11b 空名行站点→候选脚本 | — | — | 25 个站点有候选脚本，可填充 |

### D1 假阳性说明（重要，自我纠正）

Tier1 v1 把 `args 为空`(718) 与 `sync_mode 非 direct_server`(432) 判为异常 —— **错**：调度器对二者都有默认值（日增量默认 1 页是**正常设计**）。因此「有问题的脚本 1917/2034」这个数字是虚的。
→ 加判据前必须先用 `crawl_scheduler_v2.py` 的真实解析行为校准。

### D7 停滞分档

    30~90 天   254 个
    90~180 天  124 个
    180~365 天  45 个
    >365 天     64 个（含 3894 天等古老站点，多为源站已死）

⚠️ 停滞 ≠ 爬虫坏了：**先证源站，再判爬虫**。

## 三、待裁定清单（先出清单，不批量动）

| # | 事项 | 规模 | 状态 |
|---|---|---|---|
| 1 | D1 判据收紧（剔除 args/sync_mode 假阳性） | 1150 条 | 待做 |
| 2 | D3 判据收紧（522 个"不支持 --pages"需逐个确认） | 522 | 待做 |
| 3 | (script,args) 完全重复 → 会重复跑 | 1 | 待定 |
| 4 | 孤儿脚本 43 个：注册 or 归档 | 43 | 待定 |
| 5 | 库内脚本名查无文件 62 个 / 13,876 行 | 62 | 待定 |
| 6 | 注册但 0 行 190 个：修 or 注销 | 190 | 待定 |
| 7 | 停滞 495 个：逐站证源站 | 495 | 待定 |
| 8 | 疑似无附件 1033 个：页面级抽样核查 | 1033 | 待定 |
| 9 | 空正文 302 个脚本 | 302 | 待定 |
| 10 | 空 script_name 3,669 行是否可填充 | 3669 行 | 待定 |
| 11 | FTS 计数差 +53,755：确认 gov_search 服务范围 | 1 | 待定 |

## 四、批次记录（Tier 2，10 个/批）

（尚无 —— 等 Tier1 判据收紧后开始）
""" % (D["cfg_entries"], D["cfg_md5"], D["gov_raw"], D["script_name_empty"],
       100.0 * D["script_name_empty"] / D["gov_raw"],
       cnt("D1 config 条目自身问题"), len(set(x.get("script") or "" for x in iss.get("D1 config 条目自身问题", []))), cnt("D2 孤儿脚本（磁盘有、config 无 → 永不更新）"), 43,
       cnt("D3 无 --pages 且有全站翻页风险（日跑撞 600s 超时）"), cnt("D3 无 --pages 且有全站翻页风险（日跑撞 600s 超时）"),
       cnt("D4 库内脚本名磁盘查无此文件"), 62,
       cnt("D5 config 注册但库内 0 行"), 190,
       cnt("D6 库有数据但不在 config（脚本可能已改名/退役）"), 22,
       cnt("D7 停滞 >30 天"), 495,
       cnt("D8 行数≥30 却全部无附件（高危候选，需页面级核查）"), 1033,
       cnt("D9 存在空正文行"), 302)

# ── 追加：日跑失败/超时（证据口径，取代 D3 静态判据）──
try:
    _TO = json.load(open(OUT + "qc_timeout_%s.json" % DATE.replace("-", ""), encoding="utf-8"))
except Exception:
    _TO = []
if _TO:
    from collections import Counter as _C
    _cc = _C(x["分类"] for x in _TO)
    _tt = _C(x["类型"] for x in _TO)
    md += "\n### 补充：日跑失败/超时（证据口径，取代 D3 静态判据）\n\n"
    md += "- 数据源 `run_logs`（95,568 次运行 / 2026-07-30 ~ 2026-09-25）→ 全量见 `qc_timeout_20260925.md/.json`\n"
    md += "- 有失败记录的脚本 **%d** 个：持续型 **%d**（优先）/ 反复型 %d / 偶发型 %d\n" % (
        len(_TO), _cc.get("持续型", 0), _cc.get("反复型", 0), _cc.get("偶发型", 0))
    md += "- 类型：%s\n" % " / ".join("%s %d" % (k, v) for k, v in _tt.most_common())
    md += "- ⚠️ D3 静态判据（无 --pages + 全站翻页风险）实测精确率仅 8%（19/231）且漏 217 个，**已废弃**，本表取代之\n\n"
    md += "**持续型清单（失败率≥30% 且最近一次仍失败，优先处理）**\n\n"
    for _x in [y for y in _TO if y["分类"] == "持续型"]:
        md += "- `%s` ｜ %s ｜ 运行%d 失败%d(%s) ｜ 末次 %s %s\n" % (
            _x["script"], _x["类型"], _x["运行"], _x["失败"], _x["失败率"], _x["末次日期"], _x["末次状态"])
    md += "\n"

# ── 追加：持续型 17 个的取证结论 ──
try:
    _PD = json.load(open(OUT + "persistent_diag.json", encoding="utf-8"))
except Exception:
    _PD = []
if _PD:
    md += "\n### 持续型 %d 个 · 报错取证结论（2026-09-25）\n\n" % len(_PD)
    md += "| 脚本 | 站点 | 性质 | 真实报错/结论 |\n|---|---|---|---|\n"
    _kind = {
        "jd_kfq": "非真失败", "jd_xnz": "非真失败", "crawl_yanshou100.py": "非真失败",
        "crawl_luanxian_854.py": "真bug·FTS抢锁", "crawl_quanjiao_tzgg.py": "真bug·FTS抢锁",
        "crawl_wenshang_gggs.py": "真bug·Playwright", "crawl_lufeng.py": "真bug·javascriptURL",
        "crawl_ahgc.py": "待实跑", "crawl_longtan_zcfg.py": "疑误判", "crawl_nc_hjbha.py": "待实跑",
        "crawl_yjgx.py": "待实跑", "crawl_hbqj_eia.py": "待实跑", "crawl_ahgc.py ": "待实跑",
    }
    _note = {
        "jd_kfq": "脚本文件不存在（本地降级站，本机跑）→ 应从服务器 QC 排除",
        "jd_xnz": "脚本文件不存在（本地降级站，本机跑）→ 应从服务器 QC 排除",
        "crawl_yanshou100.py": "脚本文件不存在 → config 挂着但磁盘没有",
        "crawl_luanxian_854.py": "line156 `DELETE FROM gov_search WHERE site_name=?` → sqlite3.OperationalError: database is locked",
        "crawl_quanjiao_tzgg.py": "line219 `DELETE FROM gov_search WHERE rowid IN (...)` → 同因（手写 DELETE FTS + 长事务抢锁）",
        "crawl_wenshang_gggs.py": "`goto exc: TimeoutError / 列表未渲染`（Playwright 页未渲染，间歇 50%）",
        "crawl_lufeng.py": "`[FETCH ERROR] javascript:void(0): unknown url type: javascript` → 把 javascript: 链接当 URL 抓",
        "crawl_ahgc.py": "traceback 显示跑的是 /mnt/data/crawler/ 旧副本（非 /root/gov_crawler）→ 需实跑取证",
        "crawl_longtan_zcfg.py": "error_detail 是正常日志（共9页/本次爬取5页），疑被调度器误判失败 → 需实跑",
        "crawl_nc_hjbha.py": "日志正常（19 条已列出），真错在后段被截断 → 需实跑",
        "crawl_yjgx.py": "日志正常（列表页1-4/5 已抓到）→ 需实跑",
        "crawl_hbqj_eia.py": "`[TIMEOUT]`（仅跑过 1 次）→ 需实跑",
    }
    for _x in _PD:
        md += "| `%s` | %s | %s | %s |\n" % (
            _x["script"], (_x.get("站点") or "?")[:20],
            _kind.get(_x["script"], "待实跑"), _note.get(_x["script"], ""))
    md += "\n> **QC 口径修正**：本地降级站（`*_local.py` / 无 .py 后缀的 jd_kfq、jd_xnz 等）在服务器跑必然失败，**必须从服务器侧 QC 排除**，否则会持续误报。\n\n"

md += """
## 六、修复记录（Tier 2 执行）

### 2026-09-25 · 批 1：去掉 2 个脚本的「整站清空再重灌」DELETE

- **对象**：`crawl_luanxian_854.py`（滦州市-服务事项）、`crawl_quanjiao_tzgg.py`（全椒县-通知公告）
- **症状**：`sqlite3.OperationalError: database is locked`，失败率 60% / 53%（持续型）
- **根因**：两脚本开头执行 `DELETE FROM gov_search ...` + `DELETE FROM gov_raw WHERE site_name=?`（整站清空再重灌）
  → ① 大事务 + 日跑并发 → 抢锁崩溃；② 更危险：中途超时/异常会把**整站数据清空**
- **为什么可以安全去掉**：`page_url` 有 UNIQUE 索引 `idx_gov_raw_page_url`，且两脚本分别用
  `INSERT OR IGNORE` / `INSERT OR REPLACE` → 本来就幂等；DELETE 纯属多余
- **改法**：删掉两条 DELETE（+ 保留 commit 语义），连接后加 `PRAGMA busy_timeout=60000`
- **验证**：两脚本实跑 **EXIT=0**（原 60%/53% 失败）；行数 **50→50 / 20→20 未丢数据** ✅
- **备份**：`Archive/crawl_luanxian_854.py.bak_20260925_delwholesite`、`Archive/crawl_quanjiao_tzgg.py.bak_20260925_delwholesite`
- **脚本**：`fix_delete_wholesite.py`（含 ast 校验，锚点不唯一则整体不落盘）

### 2026-09-25 · 批 2：全库 49 个「整站 DELETE」脚本批量清理

- **扫描**：全库 `DELETE FROM gov_raw WHERE site_name=?` → **49 个**，全部注册日跑；全部为幂等插入（`INSERT OR IGNORE/REPLACE`）→ 判定**全部可安全去掉**
- **工具**：`fix_delete_wholesite_batch.py`（dry-run 默认；安全闸 = 幂等插入校验 + 锚点唯一 + ast 校验 + 备份 `Archive/*.bak_20260925_delwholesite` + 残留复查）
- **结果**：注释 DELETE 行 —— 24 个 2 行 / 15 个 1 行 / 4 个 3 行；24 个顺带加 `PRAGMA busy_timeout=60000`
- **正确保留 1 处**：某脚本 `DELETE ... WHERE site_name=? AND publish_date < ?`（只删 CUTOFF 前老数据）→ 属正常保留策略，**未动**
- **校验**：`py_compile` **49/49 通过**；残留未注释 DELETE 行 = 1（即上述正常逻辑）；备份 51 份
- **实跑抽验**：`crawl_lufeng.py` **EXIT=0 且新增 38 条**（原失败率 46%+、数据停在 2025-10-20 → 顺带治好）、`crawl_lufeng_tzgg.py` EXIT=0、`crawl_fjql.py` EXIT=0（75 条）、批 1 的 5 个（平罗县/泉港区/米易/石大胜华/大冶）全部 EXIT=0 且行数 560/450/180/146/120 未丢
- **回归核对**：49 站行数 vs 基线 → 核对 45 站，**下降 0 个 ✅**
- **同步**：49 个脚本 + 工具 三处一致（服务器/本地/坚果云）**49/49 ✅**

### 2026-09-26 · 批 3：协议头 / 双写 FTS / off-by-one 三类修复 + FTS 对账

#### 3.1 HTTP→HTTPS（8 脚本 / 7 条 URL）
- **判据**：扫全库 1317 条写死的 `http://` URL，逐条实测 http vs https → 只有 **7 条**属「http 死、https 活」（另 548 条 http 正常，**绝不能盲切**，已避开）
- **修法**：按域名精确替换 http→https（共 21 处）+ 备份 `Archive/*.bak_20260925_https` + `py_compile` 8/8
- **实跑**：6/8 见效 —— `anlu` +15 ｜ `jxln` +12 ｜ `xunwu` +38 ｜ `yantai` new8/pushed75 ｜ `xunwu_tzgg` 正常 ｜ `zhcqhj` EXIT=0；`shaowu` 超时（慢非空）；`jxdy` 暴露第二个 bug（见 3.2）
- 兄弟脚本 `crawl_zhcqhj_gsl.py` 一并切片，但实跑 Found 0 articles → 归入「列表抓空」另计

#### 3.2 jxdy_hjypj 双写 FTS（错误 20 → 0）
- **症状**：切 https 后能访问站点，但 20 条全部 `constraint failed`
- **根因**：脚本手写 `INSERT INTO gov_search(rowid,...)`，而 gov_raw 上已有触发器 `trg_gov_raw_fts_ins AFTER INSERT → INSERT OR REPLACE INTO gov_search(rowid,...) VALUES(new.id,...)` → **同一行写两次** → rowid 冲突
- **修法（最小改动）**：`INSERT INTO gov_search` → `INSERT OR REPLACE`；`INSERT INTO gov_raw` → `INSERT OR IGNORE`
- **验证**：EXIT=0 ｜ 列表 20 条 ｜ 新增 0 / 重复 20 / **错误 0**（原来每次 20 个错误）

#### 3.3 shaowu off-by-one（0 秒假成功）
- **症状**：生产 `run_logs` 近 5 次全是 0 秒完成、0 新增、状态"成功"
- **根因**：config `args="1"` → `_MAX_PG=1` → `for pg in range(1, _MAX_PG or 53)` = **`range(1,1)` 空循环** → 连第 1 页都没抓
- **修法**：`range(1, (_MAX_PG or 52) + 1)`（args=1 → 扫第1页；无参 → 仍 53 = 全 52 页，行为不变）
- **验证**：11s 完成、`[PAGE] 第1页` 正常解析、1150 条正确识别为重复

#### 3.4 健康确认（不需动）
- `chiping`：最长 455s / 失败 0/52 → 慢但健康
- `ccs`：config `args=--pages 1` → 只抓第1页 30 条全部已存在 → 0 新增属**正确行为**（此前"597 条 / 300s"是我未带参数全量跑的假象）

#### 3.5 FTS 对账（验证今日修复未留坑）
- 对「批2 涉及的 45 站」+「89 个手写删 FTS 脚本的 52 站」逐站比对 `gov_raw` vs `gov_search`
- **结果**：97 站 / 18,992 行 → **FTS 查不到 0 条** ✅ 无"数据在但搜不到"
- **遗留**：89 脚本的手写 `DELETE FROM gov_search` 无数据损害，但**仍是抢锁源**（`luanxian`/`quanjiao` 60%/53% 失败的同一机制）→ 列为**低优先**防抢锁优化

### 待办（批 3 起）

- `crawl_lufeng.py`：跳过 `javascript:void(0)` 链接（`unknown url type: javascript`）
- `crawl_wenshang_gggs.py`：Playwright 列表未渲染（间歇 50%）
- 4 个「疑似调度器误判」实跑取证：`crawl_longtan_zcfg` / `crawl_nc_hjbha` / `crawl_yjgx` / `crawl_hbqj_eia`
- 3 个非真失败清理（进 D5 定策）：`jd_kfq` / `jd_xnz` / `crawl_yanshou100.py`
- `crawl_ahgc.py`：traceback 显示跑的是 `/mnt/data/crawler/` 旧副本 → 需实跑取证

"""

os.makedirs(OUT, exist_ok=True)
open(LOCAL_MD, "w", encoding="utf-8").write(md)
print("✅ 本地文档:", LOCAL_MD, len(md), "字符")

# ── Notion：找 Crawler 父页 ──
def api(url, method="GET", body=None):
    cmd = ["curl", "-sS", "-X", method, "-H", "Authorization: Bearer " + TOK,
           "-H", "Notion-Version: 2022-06-28", "-H", "Content-Type: application/json",
           "--max-time", "60", url]
    if body is not None:
        cmd += ["-d", json.dumps(body, ensure_ascii=False)]
    p = subprocess.run(cmd, capture_output=True, timeout=90)
    try:
        return json.loads(p.stdout.decode("utf-8", "ignore"))
    except Exception:
        return {"_raw": p.stdout.decode("utf-8", "ignore")[:300]}


print("\n=== 搜索父页 ===")
seen = []
for q in ["Crawler", "爬虫"]:
    r = api("https://api.notion.com/v1/search", "POST",
            {"query": q, "filter": {"property": "object", "value": "page"}, "page_size": 20})
    for pg in r.get("results", []):
        t = "".join(x.get("plain_text", "") for x in (pg.get("properties", {}).get("title", {}).get("title") or []))
        par = pg.get("parent", {})
        if t and t not in [s[0] for s in seen]:
            seen.append((t, pg["id"], par.get("type")))
for t, pid, pt in seen[:15]:
    print("  %-40s %s parent=%s" % (t[:40], pid, pt))

if "--create" in sys.argv:
    parent = None
    for t, pid, pt in seen:
        if t.strip() in ("Crawler", "爬虫", "爬虫系统") and pt == "page_id":
            parent = pid
            break
    if not parent:
        # 从 Dis-db 的 parent 反查 Crawler 父页
        print("\n改用 Dis-db parent 反查…")
        d = api("https://api.notion.com/v1/databases/22e399b8-3c5b-80f5-b6dc-c6d96cafa1bd")
        if d.get("parent"):
            pr = d["parent"]
            print("  Dis-db parent:", pr)
            if pr.get("type") == "page_id":
                parent = pr["page_id"]
            elif pr.get("type") == "database_id":
                dp = api("https://api.notion.com/v1/databases/%s" % pr["database_id"])
                if (dp.get("parent") or {}).get("type") == "page_id":
                    parent = dp["parent"]["page_id"]
    if not parent:
        print("\n⚠️ 未找到合适的父页，未创建（需人工指定）")
        sys.exit(0)
    print("\n父页:", parent)
    blocks = [{"object": "block", "type": "heading_2",
               "heading_2": {"rich_text": [{"type": "text", "text": {"content": "一、口径（2026-09-25 确认）"}}]}},
              {"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [
                  {"type": "text", "text": {"content": "主键 = Script_name。配置编号已弃用（历史遗留：当时调度=50 个/批分批跑，编号=当时数组快照序；现行调度不看它）→ QC 不校验编号。"}}]}},
              {"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [
                  {"type": "text", "text": {"content": "停滞 > 30 天 算异常。"}}]}},
              {"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [
                  {"type": "text", "text": {"content": "先列清单，再定批量策略；Tier1 不改任何数据。"}}]}},
              {"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [
                  {"type": "text", "text": {"content": "三层：Tier1 全自动清单 → Tier2 人工（10 个/批）只处理异常 → Tier3 抽样反验判据。"}}]}},
              {"object": "block", "type": "heading_2",
               "heading_2": {"rich_text": [{"type": "text", "text": {"content": "二、Tier 1 首跑（2026-09-25）"}}]}},
              {"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content":
                  "config %d 条 | gov_raw %d 行 | script_name 空 %d（%.2f%%）" % (
                      D["cfg_entries"], D["gov_raw"], D["script_name_empty"],
                      100.0 * D["script_name_empty"] / D["gov_raw"])}}]}}]
    for label, n, note in [
        ("D2 孤儿脚本（磁盘有、config 无 → 永不更新）", cnt("D2 孤儿脚本（磁盘有、config 无 → 永不更新）"), "真问题"),
        ("D3 不支持 --pages", cnt("D3 不支持 --pages（增量会失效）"), "判据待收紧"),
        ("D4 库内脚本名磁盘查无此文件", cnt("D4 库内脚本名磁盘查无此文件"), "真问题"),
        ("D5 config 注册但库内 0 行", cnt("D5 config 注册但库内 0 行"), "真问题：空跑"),
        ("D6 库有数据但不在 config", cnt("D6 库有数据但不在 config（脚本可能已改名/退役）"), "待判"),
        ("D7 停滞 >30 天", cnt("D7 停滞 >30 天"), "真问题；停滞≠爬虫坏，先证源站"),
        ("D8 行数≥30 却全无附件", cnt("D8 行数≥30 却全部无附件（高危候选，需页面级核查）"), "高危候选池"),
        ("D9 存在空正文行", cnt("D9 存在空正文行"), "真问题"),
    ]:
        blocks.append({"object": "block", "type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [
            {"type": "text", "text": {"content": "%s：%d（%s）" % (label, n, note)}}]}})
    blocks += [
        {"object": "block", "type": "heading_3", "heading_3": {"rich_text": [{"type": "text", "text": {"content": "D1 假阳性说明（自我纠正）"}}]}},
        {"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content":
            "Tier1 v1 把「args 为空」(718) 与「sync_mode 非 direct_server」(432) 判为异常 —— 错：调度器对二者都有默认值（日增量默认 1 页是正常设计）。故「有问题的脚本 1917/2034」是虚数。加判据前须先用 crawl_scheduler_v2.py 的真实解析行为校准。"}}]}},
        {"object": "block", "type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": "三、待裁定清单"}}]}},
    ]
    for i, (t, s) in enumerate([
        ("D1 判据收紧", "1150 条"), ("D3 判据收紧", "522"), ("(script,args) 重复", "1"),
        ("孤儿脚本：注册 or 归档", "43"), ("库内脚本名查无文件", "62 个/13,876 行"),
        ("注册但 0 行：修 or 注销", "190"), ("停滞：逐站证源站", "495"),
        ("疑似无附件：页面级抽样", "1033"), ("空正文", "302"),
        ("空 script_name 是否可填", "3,669 行"), ("FTS 计数差确认服务范围", "1"),
    ], 1):
        blocks.append({"object": "block", "type": "numbered_list_item", "numbered_list_item": {"rich_text": [
            {"type": "text", "text": {"content": "%s（%s）— 待定" % (t, s)}}]}})
    blocks.append({"object": "block", "type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": "四、批次记录（Tier 2，10 个/批）"}}]}})
    blocks.append({"object": "block", "type": "paragraph", "paragraph": {"rich_text": [{"type": "text", "text": {"content": "（等 Tier1 判据收紧后开始）"}}]}})

    r = api("https://api.notion.com/v1/pages", "POST", {
        "parent": {"page_id": parent},
        "properties": {"title": {"title": [{"type": "text", "text": {"content": "爬虫数据 QC 结果（活文档）"}}]}},
        "children": blocks})
    if r.get("id"):
        print("✅ Notion 页已建:", r.get("url"))
    else:
        print("❌ 创建失败:", json.dumps(r, ensure_ascii=False)[:400])
else:
    print("\n(dry-run：加 --create 才建 Notion 页)")
