#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把各教材源文件物理拆分成「一专题一个 PDF」，统一落到 源题文件/_专题拆分/。

## 为什么要做
组卷台的「下载本专题源文件」是**现切**（qpdf 实时切页）。老师要的是**能直接拿走的文件夹**，
所以把同一套页范围一次性全落盘。全程 qpdf/pdftotext，**零 API、零额度**。

## 五套教材的粒度差异，各有各的解法
  动脑筋 / 举一反三   已有官方「拆分版」          → 硬链接（零额外磁盘）
  初中培优讲义        1 专题 = 1 个 docx          → 硬链接（原始格式，老师能直接改）
  胡小群 1-2年级课后  1 讲 = 1 个 PDF             → 硬链接
  胡小群 L7-L9        已按讲/讲段分好文件         → 硬链接
  一课一练            整本 PDF，按题库 lesson+page 聚合  → qpdf 切
  胡小群讲义          整本 PDF，md 里有「PDF第X-Y页」锚点 → qpdf 切
  胡小群 L3-L6 课后   整本 PDF，正文有「第 N 节」标题     → pdftotext 定位后 qpdf 切

## 幂等
已存在的输出直接跳过；只新建、不删除、不改动任何源文件。
"""
import os, re, sys, json, glob, collections, subprocess

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)                                  # 课程设计机器人/
SRC = os.path.join(ROOT, '源题文件')
OUT = os.path.join(SRC, '_专题拆分')

STAT = collections.Counter()
MANIFEST = []


def safe(name, maxlen=80):
    """文件名净化：去掉路径分隔符和广告尾巴，压掉连续空白。"""
    name = re.sub(r'【[^】]*微信[^】]*】', '', name or '')
    name = re.sub(r'[/\\:*?"<>|\n\r\t]', '_', name).strip().strip('.')
    name = re.sub(r'\s+', ' ', name)
    return (name[:maxlen] or '未命名')


def link(src, dst, relink=False):
    """硬链接（同盘零拷贝）；跨盘或不支持时退回复制。

    relink=True 时，若已有文件不是指向这个源，就**原地换成正确的**（os.replace 原子替换）。
    只用于本目录这种可重建产物 —— 源文件一个不动。
    （必须有：初中原先链的是解析版，改成学生版时若只"存在就跳过"，错的那份会一直留着。）
    """
    if os.path.exists(dst):
        if not (relink and os.path.samefile(src, dst) is False):
            STAT['skip'] += 1
            return True
        tmp = dst + '.tmp_relink'
        try:
            if os.path.exists(tmp):
                os.remove(tmp)
            os.link(src, tmp)
        except OSError:
            import shutil; shutil.copy2(src, tmp)
        os.replace(tmp, dst)
        STAT['relink'] += 1
        return True
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    try:
        os.link(src, dst)
    except OSError:
        import shutil
        shutil.copy2(src, dst)
    STAT['link'] += 1
    return True


def cut(src, lo, hi, dst):
    """qpdf 按页范围切（0.05 秒级）。"""
    if os.path.exists(dst):
        STAT['skip'] += 1
        return True
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    r = subprocess.run(['qpdf', src, '--pages', '.', f'{lo}-{hi}', '--', dst],
                       capture_output=True, timeout=180)
    # qpdf 对轻微损坏的 PDF 返回 3（warning）但输出可用，只认文件是否生成
    if os.path.exists(dst):
        STAT['cut'] += 1
        return True
    STAT['fail'] += 1
    print('  ✗ 切页失败', os.path.basename(dst), r.stderr.decode()[:160])
    return False


def rec(tb, book, idx, title, out, src, pages=None, role='学生版'):
    MANIFEST.append({'tb': tb, 'book': book, 'no': idx, 'title': title, 'role': role,
                     'out': os.path.relpath(out, SRC), 'src': os.path.relpath(src, ROOT),
                     'pages': pages})


# ── 学生版 / 教师版 ────────────────────────────────────────────
# 实扫 381 个专题目录得到的全部命名（不是猜的）：
#   学生版 原卷版205 试题版126 考试版A4·32 考试版17
#   教师版 解析版330 全解全析51 参考答案41 答案版4
# 老师要发给学生的是**没有答案**那一版；带解析那版是自己备课用。
ROLE_STU = re.compile(r'原卷版|试题版|考试版')
ROLE_TEA = re.compile(r'解析版|全解全析|参考答案|答案版')
ROLE_SUF = {'学生版': '', '教师版': '·教师版'}          # 学生版不加后缀 = 默认给学生的那份


def role_of(name):
    if ROLE_TEA.search(name):
        return '教师版'
    if ROLE_STU.search(name):
        return '学生版'
    return None


def variants_of(d):
    """一个专题目录 → {'学生版': 路径, '教师版': 路径}（各取第一份）"""
    out = {}
    for p in sorted(glob.glob(os.path.join(d, '*.doc*'))):
        r = role_of(os.path.basename(p))
        if r and r not in out:
            out[r] = p
    return out


# ════════════════════════ ① 一课一练：按课次切 ════════════════════════
YKL_BOOKS = [
    ('一年级上普通2024秋', '一课一练_结构化题目.json'), ('一年级上增强2024秋', '一课一练_结构化题目.json'),
    ('二年级上普通', '一课一练_结构化题目.json'), ('二年级上增强', '一课一练_结构化题目.json'),
    ('三年级上', '一课一练_结构化题目_三年级上_v2.json'), ('三年级上增强2022秋', '一课一练_结构化题目.json'),
    ('三年级下普通2022秋', '一课一练_结构化题目.json'), ('三年级下增强2022秋', '一课一练_结构化题目.json'),
    ('四年级上普通2026秋', '一课一练_结构化题目.json'), ('四年级上增强2026秋', '一课一练_结构化题目.json'),
    ('四年级下普通2023春', '一课一练_结构化题目.json'), ('四年级下增强2023春', '一课一练_结构化题目.json'),
    ('五年级上普通2026秋', '一课一练_结构化题目.json'), ('五年级上增强2026秋', '一课一练_结构化题目.json'),
    ('五年级下普通2023春', '一课一练_结构化题目.json'), ('五年级下增强2023春', '一课一练_结构化题目.json'),
    ('六年级上普通2024秋', '一课一练_结构化题目.json'),
    ('八年级上普通2025秋', '一课一练_结构化题目.json'),      # 2026-09-09 补（数学资料批）
]


def do_ykl():
    print('\n① 一课一练（按课次切）')
    for book, bank in YKL_BOOKS:
        bd = os.path.join(ROOT, '成果', '一课一练题库', book)
        bp = os.path.join(bd, bank)
        # ⚠ 元数据文件名不统一：多数册是 book.json，三年级上(v2 管线)是 meta.json
        bkj = next((os.path.join(bd, f) for f in ('book.json', 'meta.json')
                    if os.path.exists(os.path.join(bd, f))), None)
        if not (os.path.exists(bp) and bkj):
            print('  ! 缺题库', book); continue
        meta = json.load(open(bkj, encoding='utf-8'))
        src = os.path.join(ROOT, meta['source'])              # 元数据里记的就是源 PDF 相对路径
        if not os.path.exists(src):
            print('  ! 缺源PDF', book); continue
        npg = int(subprocess.run(['qpdf', '--show-npages', src],
                                 capture_output=True, text=True).stdout.strip() or 0)
        agg = collections.OrderedDict()                       # lesson_key → [课名, 首现序, minP, maxP]
        for q in json.load(open(bp, encoding='utf-8')):
            lk = q.get('lesson_key') or q.get('lesson') or '?'
            pg = int(str(q.get('page', 'p000'))[1:])
            a = agg.setdefault(lk, [(q.get('lesson') or lk).strip(), len(agg) + 1, 10 ** 9, 0])
            a[2], a[3] = min(a[2], pg), max(a[3], pg)
        # 2026-09-09 ⚠ 别学 do_xsl 去补「两课之间的空隙页」：一课一练看着像空隙的 11 处，录入窗口逐页核过
        #   全是**别的课次键**的页（「概念部分」「7 复习」「4 10以内数的加减法」这些键页序夹在中间），题库没漏；
        #   补给上一课会让老师下载期中练习时多带 5 页概念部分。曾改过一版「空隙归上一课」，已撤回。
        n = 0
        for lk, (title, no, lo, hi) in agg.items():
            hi = min(hi, npg) if npg else hi
            dst = os.path.join(OUT, '一课一练', book, f'{no:02d}.{safe(title)}.pdf')
            if cut(src, lo, hi, dst):
                rec('一课一练', book, no, title, dst, src, [lo, hi], '学生版'); n += 1
        # 答案区：这本书的答案是**整本连排在末尾**（book.json 的 answer_pages 记着范围），
        # 不按课分，所以整段切一份当全册教师版。
        m = re.search(r'p(\d+)\s*[-~－]\s*(\d+)', str(meta.get('answer_pages') or ''))
        if m:
            alo, ahi = int(m.group(1)), min(int(m.group(2)), npg or int(m.group(2)))
            dst = os.path.join(OUT, '一课一练', book, f'00.全册参考答案·教师版.pdf')
            if cut(src, alo, ahi, dst):
                rec('一课一练', book, 0, '全册参考答案', dst, src, [alo, ahi], '教师版')
        print(f'  {book}: {n} 课 (源 {npg} 页){" +答案" if m else ""}')


# ════════════════════════ ② 胡小群讲义：按讲切 ════════════════════════
def do_hxq_lecture():
    print('\n② 胡小群讲义（按讲切，页范围取自 md 锚点）')
    MD = os.path.join(ROOT, '成果', '胡小群讲义', 'md')
    for bdir in sorted(glob.glob(os.path.join(MD, '*'))):
        book = os.path.basename(bdir)                          # 三上 / 六下 …
        # ⚠ 源文件名是「四年级下胡小群.pdf」「六年级上册胡小群.pdf」，不是「四下」
        g, term = book[0], book[1]
        src = None
        for p in glob.glob(os.path.join(SRC, '胡小群数学L0-L6', '讲义习题', '*.pdf')):
            b = os.path.basename(p)
            if b.startswith(f'{g}年级') and term in b[:6]:
                src = p; break
        if not src:
            print('  ! 缺源PDF', book); continue
        npg = int(subprocess.run(['qpdf', '--show-npages', src],
                                 capture_output=True, text=True).stdout.strip() or 0)
        n = 0
        for f in sorted(glob.glob(os.path.join(bdir, 'S*.md'))):
            head = open(f, encoding='utf-8').read(600)
            mt = re.search(r'^#\s*第(\d+)节\s*(.+)$', head, re.M)
            mp = re.search(r'PDF第(\d+)[-–~](\d+)页|PDF第(\d+)页', head)
            if not (mt and mp):
                print('  ! 无锚点', f); continue
            no, title = int(mt.group(1)), mt.group(2).strip()
            lo = int(mp.group(1) or mp.group(3)); hi = int(mp.group(2) or mp.group(3))
            hi = min(hi, npg) if npg else hi
            dst = os.path.join(OUT, '胡小群讲义', book, f'{no:02d}.{safe(title)}.pdf')
            if cut(src, lo, hi, dst):
                rec('胡小群讲义', book, no, title, dst, src, [lo, hi], '学生版'); n += 1
        print(f'  {book}: {n} 讲 (源 {npg} 页)')


# ═══════════ ②b 胡小群讲义册尾「参考答案」：整册 2~3 页，切成一份 ═══════════
# 讲义每讲的练习题答案不在讲里，统一印在册子最后的「参考答案」大页上，按讲名分组、
# 只给最终答案不给解析。8 册的讲覆盖都是 p2~p(尾-2/3) **零断层**，所以最后那几页
# 就是答案页 —— 8 册已逐册看图确认页首都是「参考答案」大标题（2026-07-28）。
#
# ⚠ 为什么不按讲再切细：一页上排着好几讲、还跨分栏和跨页（如四上「相遇问题」的答案
#   从左栏末尾续到右栏），按页/按框切都会切坏。整段给老师，与动脑筋/举一反三的
#   教师版（整册参考答案）是同一种做法。
def do_hxq_lecture_key():
    print('\n②b 胡小群讲义册尾参考答案')
    pool = [m for m in MANIFEST if m['tb'] == '胡小群讲义']
    if not pool:                       # 单独跑 `key` 时 MANIFEST 是空的，读已有索引
        mp = os.path.join(OUT, '_索引.json')
        if os.path.exists(mp):
            pool = [m for m in json.load(open(mp, encoding='utf-8'))['items']
                    if m['tb'] == '胡小群讲义']
    covered = {}
    for m in pool:
        if m['pages']:
            covered[m['book']] = max(covered.get(m['book'], 0), m['pages'][1])
    for book, last in sorted(covered.items()):
        src = None
        for m in pool:
            if m['book'] == book:
                src = os.path.join(ROOT, m['src']); break
        npg = int(subprocess.run(['qpdf', '--show-npages', src],
                                 capture_output=True, text=True).stdout.strip() or 0)
        lo, hi = last + 1, npg
        if lo > hi:
            print(f'  ! {book} 册尾没有多余页，跳过'); continue
        dst = os.path.join(OUT, '胡小群讲义答案', book, '参考答案.pdf')
        if cut(src, lo, hi, dst):
            rec('胡小群讲义答案', book, 0, '参考答案', dst, src, [lo, hi], '教师版')
            print(f'  {book}: p{lo}-{hi}（{hi - lo + 1} 页）')


# ══════════════ ③ 胡小群 L3~L6 课后习题集：按「第 N 节」文本锚点切 ══════════════
# ⚠ 不能只靠正则认「第 N 节」：L6 第4节源书漏印了「节」字（页首是「第 4 量率问题（一）」）。
#   所以**以题库的 sec/sec_title 为权威**（每级 30 节，节号连续），PDF 文本只负责定位起页；
#   节号写死后文件名也直接用题库标题 —— 与组卷台里的专题名天然一致。
SEC_LOOSE = re.compile(r'第\s*(\d{1,2})\s*节?')


def _hw_bank():
    """{级别: {节号: 节标题}} —— 取自 成果/胡小群题库/胡小群题库_结构化题目.json"""
    if 'hwbank' not in _CACHE:
        idx = collections.defaultdict(dict)
        p = os.path.join(ROOT, '成果', '胡小群题库', '胡小群题库_结构化题目.json')
        d = json.load(open(p, encoding='utf-8'))
        for q in (d.get('items') if isinstance(d, dict) else d):
            g, s, t = q.get('grade'), q.get('sec'), (q.get('sec_title') or '').strip()
            if g in ('L3', 'L4', 'L5', 'L6') and s and t:
                idx[g].setdefault(int(s), t)
        _CACHE['hwbank'] = idx
    return _CACHE['hwbank']


_CACHE = {}


def sec_anchors(pdf, npg, want):
    """逐页抽文本定位每节起页 → [(节号, 题库标题, 起页)…]

    判定：页首几行出现「第 N [节]」，且**同页文本含该节标题的前几个字**（防正文里
    随口提到的「第3节」被当成新节起点，也兜住源书漏印「节」字的情况）。
    """
    hits, nxt = [], min(want) if want else 1
    for p in range(1, npg + 1):
        t = subprocess.run(['pdftotext', '-f', str(p), '-l', str(p), pdf, '-'],
                           capture_output=True, text=True).stdout
        head = '\n'.join(t.splitlines()[:6])                   # 节标题一定在页首
        for m in SEC_LOOSE.finditer(head):
            no = int(m.group(1))
            if no not in want or any(h[0] == no for h in hits):
                continue
            tt = want[no]
            key = re.sub(r'[（(].*', '', tt)[:3]                # 去掉「（一）」这类序号后取前3字
            if key and key not in re.sub(r'\s', '', head):
                continue
            hits.append((no, tt, p))
            break
    return sorted(hits, key=lambda x: x[2])


def do_hxq_hw():
    print('\n③ 胡小群 L3~L6 课后习题集（题库节表 + PDF 文本定位起页）')
    HW = os.path.join(SRC, '胡小群数学L0-L6', '课后习题', '习题+解析')
    bank = _hw_bank()
    for lv in ('L3', 'L4', 'L5', 'L6'):
        src = os.path.join(HW, f'{lv}-习题集.pdf')
        if not os.path.exists(src):
            print('  ! 缺', lv); continue
        want = bank.get(lv) or {}
        npg = int(subprocess.run(['qpdf', '--show-npages', src],
                                 capture_output=True, text=True).stdout.strip() or 0)
        hits = sec_anchors(src, npg, want)
        miss = sorted(set(want) - {h[0] for h in hits})
        if miss:
            print(f'  ⚠ {lv} 未定位到节: {miss}（这些节不出 PDF，别静默漏掉）')
        n = 0
        for i, (no, title, lo) in enumerate(hits):
            hi = (hits[i + 1][2] - 1) if i + 1 < len(hits) else npg
            hi = max(hi, lo)                                   # 一页两节时保底 1 页
            dst = os.path.join(OUT, '胡小群课后', lv, f'{no:02d}.{safe(title)}.pdf')
            if cut(src, lo, hi, dst):
                rec('胡小群课后', lv, no, title, dst, src, [lo, hi], '学生版'); n += 1
        print(f'  {lv}: {n}/{len(want)} 节 (源 {npg} 页)')

        # 解析版同样按节切一份（页码与习题集不同，要单独定位）——老师备课要的就是这一版
        jx = os.path.join(HW, f'{lv}解析版.pdf')
        if not os.path.exists(jx):
            continue
        jn = int(subprocess.run(['qpdf', '--show-npages', jx],
                                capture_output=True, text=True).stdout.strip() or 0)
        jh = sec_anchors(jx, jn, want)
        m = 0
        for i, (no, title, lo) in enumerate(jh):
            hi = max((jh[i + 1][2] - 1) if i + 1 < len(jh) else jn, lo)
            dst = os.path.join(OUT, '胡小群课后', lv, f'{no:02d}.{safe(title)}·教师版.pdf')
            if cut(jx, lo, hi, dst):
                rec('胡小群课后', lv, no, title, dst, jx, [lo, hi], '教师版'); m += 1
        print(f'      解析版: {m}/{len(want)} 节 (源 {jn} 页)')


# ════════════ ④ 已是专题级的：硬链接进统一目录 ════════════
def do_links():
    print('\n④ 已是专题级的源文件（硬链接，零额外磁盘）')

    # 动脑筋 / 举一反三 官方拆分版
    for tb, d in (('动脑筋', '动脑筋/动脑筋拆分版'), ('举一反三', '举一反三/举一反三拆分版')):
        n = 0
        for p in sorted(glob.glob(os.path.join(SRC, d, '**', '*.pdf'), recursive=True)):
            rel = os.path.relpath(p, os.path.join(SRC, d))     # <册>/[上下/]<NN.名>.pdf
            dst = os.path.join(OUT, tb, rel)
            if link(p, dst):
                base = os.path.splitext(os.path.basename(p))[0]
                mo = re.match(r'(\d+)\.\s*(.*)$', base)
                # ⚠ title 里**不能留序号前缀**：组卷台的专题名是「专题7·时段问题」，
                #   存成「7.时段问题」会让名字匹配永远落空、悄悄退化成按序号硬对。
                rec(tb, os.path.dirname(rel), int(mo.group(1)) if mo else 0,
                    mo.group(2) if mo else base, dst, p, None,
                    '教师版' if '答案' in base else '学生版'); n += 1
        print(f'  {tb}: {n} 个')

    # 胡小群 1-2 年级课后：一讲一个 PDF
    n = 0
    for sub in sorted(glob.glob(os.path.join(SRC, '胡小群数学L0-L6', '课后习题', '习题+解析', '*讲'))):
        for p in sorted(glob.glob(os.path.join(sub, '习题*.pdf'))):
            b = os.path.basename(p)
            mo = re.match(r'^习题(\d+)(答案)?[：:](.+?)(?:【|\.pdf)', b)
            if not mo:
                continue
            no, isans, title = int(mo.group(1)), bool(mo.group(2)), mo.group(3)
            dst = os.path.join(OUT, '胡小群课后', 'L1-2',
                               f'{no:02d}.{safe(title)}{"·答案" if isans else ""}.pdf')
            if link(p, dst):
                rec('胡小群课后', 'L1-2', no, title, dst, p, None,
                    '教师版' if isans else '学生版'); n += 1
    print(f'  胡小群课后 L1-2: {n} 个')

    # 胡小群 L7~L9、综合：本就按讲/讲段分好文件
    n = 0
    for p in sorted(glob.glob(os.path.join(SRC, '胡小群数学L7-L9、综合', '**', '*.pdf'), recursive=True)):
        rel = os.path.relpath(p, os.path.join(SRC, '胡小群数学L7-L9、综合'))
        dst = os.path.join(OUT, '胡小群L7-L9', rel)
        if link(p, dst):
            bn = os.path.splitext(os.path.basename(p))[0]
            rec('胡小群L7-L9', os.path.dirname(rel), 0, bn, dst, p, None,
                '教师版' if ('解析' in bn or '答案' in bn) else '学生版'); n += 1
    print(f'  胡小群 L7-L9、综合: {n} 个')

    # 初中培优讲义：1 专题 = 1 个 docx（保留原始格式，老师能直接改）
    # ⚠ 每个专题目录里**同时有学生版和教师版**，units.json 指向的多数是解析版（=教师版）。
    #   只链那一份的话，老师下载发给学生，答案就印在卷子上了。两份都出。
    uj = os.path.join(ROOT, '数学学习系统', 'data', 'v2', 'units.json')
    n = collections.Counter()
    if os.path.exists(uj):
        for u in json.load(open(uj, encoding='utf-8')):
            dx = u.get('docx')
            if not (dx and os.path.exists(dx)):
                continue
            book = u.get('book') or '未分册'
            # 2026-09-09：新册（九上·新教材/八上·期末汇编/八下·期中汇编/中考·分类汇编）的 units 记了
            #   docx_student / docx_teacher，且**同一目录混放多个专题**，variants_of(目录) 会拿到别的专题的文件。
            #   有这两个字段就只认字段；docx 本身按文件名定角色（（原卷版）/（学生版）→学生版，（解析版）/（教师版）/（解析）→教师版）。
            vs = {}
            if u.get('docx_student') or u.get('docx_teacher'):
                for role, k in (('学生版', 'docx_student'), ('教师版', 'docx_teacher')):
                    if u.get(k) and os.path.exists(u[k]):
                        vs[role] = u[k]
                bn = re.sub(r'^精品解析[：:]\s*', '', os.path.basename(dx))
                r0 = ('教师版' if re.search(r'解析|教师版|答案', bn) else
                      '学生版' if re.search(r'原卷|学生版|试题版|考试版', bn) else None)
                if r0 and r0 not in vs:
                    vs[r0] = dx
            else:
                vs = variants_of(os.path.dirname(dx))
            for role, src_p in vs.items():
                dst = os.path.join(OUT, '初中培优讲义', book,
                                   f"{safe(u['title'])}{ROLE_SUF[role]}.docx")
                if link(src_p, dst, relink=True):
                    rec('初中培优讲义', book, 0, u['title'], dst, src_p, None, role); n[role] += 1
            if not vs:                                    # 认不出角色 → 照原样链一份，别漏
                dst = os.path.join(OUT, '初中培优讲义', book, f"{safe(u['title'])}.docx")
                if link(dx, dst):
                    rec('初中培优讲义', book, 0, u['title'], dst, dx, None, '学生版'); n['未分类'] += 1
    print(f'  初中培优讲义: 学生版 {n["学生版"]} · 教师版 {n["教师版"]} · 未分类 {n["未分类"]}')


# ════════════════════════ ⑤ 新思路：按课次切（2026-09-09） ════════════════════════
def do_xsl():
    """《新思路辅导与训练》六册：结构同一课一练（整本 PDF，按题库 lesson_key+page 聚合切页）。
    book.json 的 answer_pages 写成「PDF p139-152(源头排除)」，有的册本来就没答案。"""
    print('\n⑤ 新思路（按课次切）')
    for bd in sorted(glob.glob(os.path.join(ROOT, '成果', '新思路题库', '*第三版'))):
        book = os.path.basename(bd)
        bp = os.path.join(bd, '一课一练_结构化题目.json')        # 建库工具沿用的文件名
        bkj = os.path.join(bd, 'book.json')
        if not (os.path.exists(bp) and os.path.exists(bkj)):
            print('  ! 缺题库', book); continue
        meta = json.load(open(bkj, encoding='utf-8'))
        src = os.path.join(ROOT, meta['source'])
        if not os.path.exists(src):
            print('  ! 缺源PDF', book); continue
        npg = int(subprocess.run(['qpdf', '--show-npages', src],
                                 capture_output=True, text=True).stdout.strip() or 0)
        agg = collections.OrderedDict()
        for q in json.load(open(bp, encoding='utf-8')):
            lk = q.get('lesson_key') or q.get('lesson') or '?'
            pg = int(str(q.get('page', 'p000'))[1:])
            a = agg.setdefault(lk, [(q.get('lesson') or lk).strip(), len(agg) + 1, 10 ** 9, 0])
            a[2], a[3] = min(a[2], pg), max(a[3], pg)
        # 2026-09-09 陆老师发现：只按「题目所在页」切会把每讲开头的讲义页（要点归纳/疑难分析/例题）切掉——
        # 新思路每讲讲义占 1-2 整页且没有题，六册 259 讲里 162 讲第一页就是这样丢的（11.2(三) 从例3 半截开始）。
        # 规矩：每讲起点 = 上一讲最后一题页的下一页（两讲之间没人认领的页全归后一讲）；首讲用 book.json 的正文起始页。
        # 各讲之间没有共页（实测 0），所以不会把上一讲的题带进来。
        order = sorted(agg.items(), key=lambda kv: kv[1][2])
        qp = meta.get('question_pages') or []
        prev_hi = (int(qp[0]) - 1) if qp else None
        for lk, a in order:
            if prev_hi is not None and a[2] > prev_hi + 1:
                a[2] = prev_hi + 1
            prev_hi = a[3]
        n = 0
        for lk, (title, no, lo, hi) in agg.items():
            hi = min(hi, npg) if npg else hi
            dst = os.path.join(OUT, '新思路', book, f'{no:02d}.{safe(title)}.pdf')
            if cut(src, lo, hi, dst):
                rec('新思路', book, no, title, dst, src, [lo, hi], '学生版'); n += 1
        m = re.search(r'p(\d+)\s*[-~－]\s*(\d+)', str(meta.get('answer_pages') or ''))
        dst = os.path.join(OUT, '新思路', book, f'00.全册参考答案·教师版.pdf')
        ans = ''
        if m:
            alo, ahi = int(m.group(1)), min(int(m.group(2)), npg or int(m.group(2)))
            if cut(src, alo, ahi, dst):
                rec('新思路', book, 0, '全册参考答案', dst, src, [alo, ahi], '教师版'); ans = ' +答案'
        else:
            # 2026-09-09 陆老师问「新思路没有答案吗」：七下/八上/八下的答案不在正文 PDF 里，
            # 是源目录里**单独一份**「…答案….pdf」（20/31/36 页）。正文没答案页时到源目录找同级答案 PDF 硬链进来。
            cands = [p for p in glob.glob(os.path.join(os.path.dirname(src), '*.pdf'))
                     if '答案' in os.path.basename(p) and os.path.abspath(p) != os.path.abspath(src)]
            if cands:
                asrc = sorted(cands)[0]
                if link(asrc, dst, relink=True):
                    rec('新思路', book, 0, '全册参考答案', dst, asrc, None, '教师版'); ans = ' +答案(独立PDF)'
        print(f'  {book}: {n} 课 (源 {npg} 页){ans}')


# ════════════════════════ ⑥ AMC8：一年一卷（2026-09-09） ════════════════════════
AMC8_DIR = os.path.join(os.path.dirname(ROOT), 'AMC8考情分析', '试卷')

def do_amc8():
    """AMC8 真题 PDF 一年一份（2000-2025，2021 停考）。多数卷末印着 Answer Key/Solutions
    → 按 pdftotext 是否含答案页定角色：有 → 教师版「原卷+答案」，无 → 学生版「原卷」。"""
    print('\n⑥ AMC8 真题（硬链接）')
    n = collections.Counter()
    for p in sorted(glob.glob(os.path.join(AMC8_DIR, '*.pdf'))):
        mo = re.search(r'(20\d\d)', os.path.basename(p))
        if not mo:
            continue
        year = mo.group(1)
        txt = subprocess.run(['pdftotext', p, '-'], capture_output=True, text=True).stdout
        has_key = bool(re.search(r'Answer Key|Solutions?\b|答案[:：]|参考答案', txt, re.I))
        role = '教师版' if has_key else '学生版'
        dst = os.path.join(OUT, 'AMC8真题', f'{year}年 AMC8{"·含答案" if has_key else ""}.pdf')
        if link(p, dst):
            rec('AMC8真题', f'{year}年', int(year), f'{year}年 AMC8', dst, p, None, role); n[role] += 1
    print(f'  AMC8: 教师版(含答案) {n["教师版"]} · 学生版 {n["学生版"]}')


if __name__ == '__main__':
    only = set(sys.argv[1:])
    os.makedirs(OUT, exist_ok=True)
    if not only or 'ykl' in only:   do_ykl()
    if not only or 'hxq' in only:   do_hxq_lecture()
    if not only or 'key' in only:   do_hxq_lecture_key()
    if not only or 'hw' in only:    do_hxq_hw()
    if not only or 'link' in only:  do_links()
    if not only or 'xsl' in only:   do_xsl()
    if not only or 'amc8' in only:  do_amc8()
    mp = os.path.join(OUT, '_索引.json')
    old = []
    if os.path.exists(mp):
        try: old = json.load(open(mp, encoding='utf-8')).get('items', [])
        except Exception: old = []
    seen, items = set(), []
    for it in MANIFEST + old:                                  # 新的优先，去重按输出路径
        if it['out'] in seen: continue
        seen.add(it['out']); items.append(it)
    json.dump({'n': len(items), 'items': items}, open(mp, 'w', encoding='utf-8'),
              ensure_ascii=False, indent=1)
    print(f'\n═══ 切页 {STAT["cut"]} · 硬链 {STAT["link"]} · 跳过(已存在) {STAT["skip"]} · 失败 {STAT["fail"]}')
    print(f'    索引 {len(items)} 条 → {os.path.relpath(mp, ROOT)}')
