← Back to skills
extension
Category: Data & AnalyticsAPI key required

下载国家中小学教育平台教材

下载国家中小学智慧教育平台(basic.smartedu.cn)教师教材详情页。上半讲公共源(免 Token,批量抓预览图合成 PDF);下半讲私有源(带 access token 直拉原生分辨率 PDF)。当用户给出 basic.smartedu.cn/tchMaterial/detail 链接,或要求"下载教材 / 教材转 PDF / 批量抓教材图片"时使用。

personAuthor: user_a3ed06bchubcommunity

智慧教育平台教材下载

本 skill 分两半:

  • 上半:公共源下载方法 —— 免 Token,从公开 JSON 拉预览图合成 PDF。零依赖、可自动化,首选。
  • 下半:私有源获取方法 —— 需 access token,直拉平台原生 A4 矢量 PDF(体积小、文字可选)。想要原生分辨率时用。

两条路线的数据源都是同一个公开 JSON 接口(无需登录),区别只在最终资源落在「非私有 host 的图片」还是「私有 host 的 source PDF」。


上半:公共源下载方法(免 Token,图片合成 PDF)

适用场景

用户只想把整本教材抓成 PDF,不在乎它是否原生矢量。此路线不需要任何登录态,全程可自动化。

核心原理(关键坑已标出)

  1. 详情页是 SPA:直接抓页面只能拿到空壳。不要解析 HTML。
  2. 真实数据源是公开 JSON 接口,无需登录、无需 Token:
    https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{contentId}.json
    
    {contentId} 取自 URL 的 contentId 参数(UUID 格式)。
  3. preview 只是预览子集,≠ 全书页数(最易踩的坑)。 custom_properties.preview 是 dict:SlideN → 该页 jpg 的完整 URL。但它的条目数通常远少于真实页数(实测一本 167 页的书,preview 只有 49 条)。绝不能拿 len(preview) 当总页数。
  4. 真实页数在 ti_items 里:找到 ti_file_flag=="image" 的项,其 custom_properties.requirements 中有 {"name":"pagesize","value":"167"} —— 这才是全书真实页数。脚本用这个值驱动下载。
  5. 图片 URL 规律:非私有 host 上每页一张 jpg,按页码顺序可直接遍历:
    https://r1-ndr.ykt.cbern.com.cn/edu_product/esp/assets/{cid}.t/zh-CN/{ts}/transcode/image/{N}.jpg
    
    {ts} 与时间戳相关,从任意 preview URL 里原样抠出即可;{N} 从 1 顺序递增到 pagesize。第 pagesize+1 页会返回 403,可据此验证边界。
  6. 镜像容灾:主机 r1-ndr / r2-ndr / r3-ndr.ykt.cbern.com.cn 三个等价镜像,失败自动切换。
  7. Referer 必需:请求头带 Referer: https://basic.smartedu.cn/,否则 CDN 拒回。

工作流程

Step 1:提取 contentId

从用户链接的 contentId 查询参数取 UUID。只给 contentId 也行。

Step 2:拉取 JSON、确认真实页数(先确认,避免误下)

python download_textbook.py <contentId> --dry-run

打印书名、contentId、真实页数(来自 ti_items.pagesize)。确认无误再去掉 --dry-run。

Step 3:批量下载图片

  • 按 ti_items 的 pagesize 驱动,从 1 遍历到真实页数(而非 preview 条目数)。
  • r1 → r2 → r3 镜像轮换容灾 + 带 Referer。
  • 断点续传:已存在且大小正常的文件跳过(重跑自动补缺失页)。
  • 支持 --start/--end 分段、--workers 并发。
  • 保存到 textbook_{contentId}/page_{N:03d}.jpg。
  • 结尾打印 done: ok/total,失败页列出 MISSING=[...]。

网络注意:部分沙盒/服务器到 *.ykt.cbern.com.cn 的 TLS 会被封锁。若下载全失败是网络层限制,非代码问题——需在本机(能访问该域的环境)执行,本机只需 pip install Pillow。

Step 4:按页码升序合成 PDF

python make_pdf.py <contentId>

默认不缩放;图片极大导致 PDF 过大时加 --max 2000 把最长边限制在 2000px。 合成后用 pdfinfo 教材_<contentId>.pdf | grep Pages 核对页数 == 真实页数。

脚本 A:download_textbook.py(标准库 urllib,仅 Pillow 为外部依赖)

import os, re, sys, json, argparse, urllib.request
from concurrent.futures import ThreadPoolExecutor

UA = "Mozilla/5.0"
REFERER = "https://basic.smartedu.cn/"
MIRRORS = ["r1", "r2", "r3"]
JSON_TMPL = "https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{cid}.json"


def fetch_json(content_id):
    req = urllib.request.Request(
        JSON_TMPL.format(cid=content_id),
        headers={"User-Agent": UA, "Referer": REFERER},
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read().decode("utf-8"))


def build_url_template(d):
    """从 preview 任一 URL 抽取 image/{N}.jpg 之前的固定前缀。"""
    prev = d.get("custom_properties", {}).get("preview", {})
    sample = None
    if isinstance(prev, dict):
        sample = next(iter(prev.values()), None)
    elif isinstance(prev, list):
        sample = prev[0] if prev else None
    if isinstance(sample, dict):
        sample = sample.get("url")
    if not sample:
        raise RuntimeError("preview 中找不到可用 URL 样本")
    return re.sub(r"/image/\d+\.jpg$", "/image/{n}.jpg", sample)


def get_total_pages(d):
    """真实页数取自 ti_items 中 image 项的 requirements.pagesize(非 preview 子集)。"""
    for it in d.get("ti_items", []):
        if it.get("ti_file_flag") == "image":
            for req in it.get("custom_properties", {}).get("requirements", []):
                if req.get("name") == "pagesize":
                    return int(req["value"])
    prev = d.get("custom_properties", {}).get("preview", {})
    nums = []
    items = prev.items() if isinstance(prev, dict) else enumerate(prev)
    for key, _ in items:
        m = re.search(r"Slide(\d+)", str(key))
        if m:
            nums.append(int(m.group(1)))
    return max(nums) if nums else 0


def swap_mirror(url, idx):
    return re.sub(r"r[123]-ndr", f"{MIRRORS[idx % len(MIRRORS)]}-ndr", url, count=1)


def download_one(args):
    page, url, outdir = args
    path = os.path.join(outdir, f"page_{page:03d}.jpg")
    if os.path.exists(path) and os.path.getsize(path) > 1000:
        return page, True
    for i in range(len(MIRRORS) * 3):
        u = swap_mirror(url.format(n=page), i)
        try:
            req = urllib.request.Request(u, headers={"User-Agent": UA, "Referer": REFERER})
            data = urllib.request.urlopen(req, timeout=30).read()
            if len(data) > 1000:
                with open(path, "wb") as f:
                    f.write(data)
                return page, True
        except Exception:
            continue
    return page, False


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("content_id")
    ap.add_argument("--start", type=int, default=1)
    ap.add_argument("--end", type=int, default=9999)
    ap.add_argument("--workers", type=int, default=8)
    ap.add_argument("--dry-run", action="store_true", help="只打印元数据,不下载")
    args = ap.parse_args()

    d = fetch_json(args.content_id)
    total = get_total_pages(d)
    tpl = build_url_template(d)
    print(f"[{d.get('title','')}] content_id={args.content_id} 真实页数={total}")
    if args.dry_run:
        return
    outdir = f"textbook_{args.content_id}"
    os.makedirs(outdir, exist_ok=True)
    sel = list(range(args.start, min(args.end, total) + 1))
    print(f"downloading {len(sel)} pages ...")
    with ThreadPoolExecutor(max_workers=args.workers) as ex:
        results = list(ex.map(download_one, [(n, tpl, outdir) for n in sel]))
    ok = sum(1 for _, s in results if s)
    miss = [n for n, s in results if not s]
    print(f"done: {ok}/{len(sel)}" + (f"  MISSING={miss}" if miss else ""))


if __name__ == "__main__":
    main()

脚本 B:make_pdf.py(合成 PDF,支持可选缩放)

import glob, os, sys, argparse
from PIL import Image


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("content_id", nargs="?", default="*")
    ap.add_argument("--max", type=int, default=0, help="最长边上限(px),0=不缩放")
    args = ap.parse_args()
    cid = args.content_id
    pattern = f"textbook_{cid}/page_*.jpg" if cid != "*" else "textbook_*/page_*.jpg"
    files = sorted(glob.glob(pattern))
    imgs = []
    for f in files:
        im = Image.open(f).convert("RGB")
        if args.max and max(im.size) > args.max:
            im.thumbnail((args.max, args.max))
        imgs.append(im)
    out = f"教材_{cid}.pdf" if cid != "*" else "教材_合成.pdf"
    if imgs:
        imgs[0].save(out, save_all=True, append_images=imgs[1:])
        print(f"saved {out}: {len(imgs)} pages, {os.path.getsize(out)//1024}KB")
    else:
        print("no images found")


if __name__ == "__main__":
    main()

使用方式(公共源)

本机执行(仅需 pip install Pillow):

python download_textbook.py 9085151a-b698-4b28-8c00-2c4aaf0c91ad --dry-run   # 先看真实页数
python download_textbook.py 9085151a-b698-4b28-8c00-2c4aaf0c91ad               # 全量下载
python make_pdf.py 9085151a-b698-4b28-8c00-2c4aaf0c91ad                        # 合成 PDF
pdfinfo 教材_9085151a-b698-4b28-8c00-2c4aaf0c91ad.pdf | grep Pages             # 核对=167

分段 / 换教材:改 contentId,或加 --start 10 --end 30;PDF 过大加 --max 2000。


下半:私有源获取方法(需 access token,原生 PDF)

适用场景

用户要原生分辨率的教材 PDF:A4 页面、体积更小(约为图片合成版的一半)、文字可选中、更清晰。图片合成版(上半)是 1488×2104 像素图,非真矢量。此路线需用户提供一份有时效的 access token。

核心原理(HTTP MAC 鉴权)

  1. 源 PDF 在哪:ti_items 里 ti_file_flag=="source" 的项,其 ti_storages 含形如 https://r1-ndr-private.ykt.cbern.com.cn/edu_product/esp/assets/{cid}.pkg/pdf_xxx.pdf 的地址,落在 -private host。该 host 的 HEAD 常返回 200,但 GET 返回 401 Authorization Required——实体下载才强制鉴权。
  2. 鉴权是 HTTP MAC 方案,最简单可用的是 URL 拼 ?accessToken=<token>,已实测直达 200 / 完整体积。
  3. token 来自登录态:浏览器登录后,UC_TOKEN-<appId>-ncet-xedu cookie 是一段 base64 JSON,用 base64 解码后含:
    • access_token —— 即 MAC 的 id,直接当 ?accessToken= 的值;
    • mac_key + mac_algorithm:"hmac-sha-256" —— 用于构造 x-nd-auth 请求头(见下);
    • expires_at —— 约 7 天有效,过期需重新取。
  4. 取 token 的两种方式:
    • 方式一(推荐,最简单):浏览器打开教材页 → F12 → Application → Cookies → 找 UC_TOKEN-*-ncet-xedu → 复制整段值 → base64 解码 → 取 access_token。
    • 方式二(看网络包):F12 → Network → 找 viewer.html?file=<私有PDF> 或 .pdf 的 XHR 请求 → 复制其 x-nd-auth 头里的 MAC id="<token>",取引号内即为 access_token。
  5. 等效请求头写法(防 query 参数被拦时兜底):
    x-nd-auth: MAC id="<access_token>",nonce="<毫秒时间戳:随机串>",mac="<HMAC-SHA256(mac_key, 归一化串)>"
    
    归一化串格式:timestamp\nnonce\nMETHOD\n/path?query\nhost\nport\n\n。绝大多数情况用 ?accessToken= 即可,无需手搓 HMAC。
  6. 平台实际用法:前端用 basic.smartedu.cn/pdfjs/2.15/web/viewer.html?file=<私有PDF> 加载,子请求带上述鉴权。

工作流程

Step 1:提取 contentId(同上)

从用户链接的 contentId 参数取 UUID。

Step 2:让用户提供 access_token

明确要求用户从浏览器按上面的「方式一 / 方式二」拷一份新鲜的 access_token(base64 JSON 里的 access_token 字段值)。不要把明文 token 落盘到交付文件,用完即弃。

Step 3:拉取源 PDF

python fetch_source_pdf.py 9085151a-b698-4b28-8c00-2c4aaf0c91ad --token "<access_token>"
  • 脚本从 JSON 的 ti_items 定位 source PDF 的私有地址,拼 ?accessToken= 直下。
  • 输出 教材_源PDF_<contentId>.pdf。
  • 合成后 pdfinfo 教材_源PDF_*.pdf | grep -E "Pages|Page size" 核对页数与尺寸(应为 A4 / 595×842pt)。

若返回 401:token 已过期或取自非本资源会话,请用户重新从浏览器取一份新鲜 token。

脚本 C:fetch_source_pdf.py(标准库 urllib,零外部依赖)

import sys, json, argparse, urllib.request

UA = "Mozilla/5.0"
REFERER = "https://basic.smartedu.cn/"
JSON_TMPL = "https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{cid}.json"


def fetch_json(cid):
    req = urllib.request.Request(
        JSON_TMPL.format(cid=cid),
        headers={"User-Agent": UA, "Referer": REFERER},
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read().decode("utf-8"))


def find_source_pdf(d):
    """从 ti_items 找 ti_file_flag=="source" 的私有 PDF 存储地址(含 -private host)。"""
    for it in d.get("ti_items", []):
        if it.get("ti_file_flag") == "source":
            for s in it.get("ti_storages", []):
                if s.endswith(".pdf"):
                    return s
    return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("content_id")
    ap.add_argument("--token", required=True,
                    help="浏览器 cookie (UC_TOKEN) 解码后的 access_token 字段值")
    ap.add_argument("--out", default=None, help="输出 PDF 路径")
    args = ap.parse_args()

    d = fetch_json(args.content_id)
    url = find_source_pdf(d)
    if not url:
        print("未在 ti_items 中找到 source PDF 存储地址")
        sys.exit(1)

    url += f"?accessToken={args.token}"
    out = args.out or f"教材_源PDF_{args.content_id}.pdf"
    req = urllib.request.Request(url, headers={"User-Agent": UA, "Referer": REFERER})
    with urllib.request.urlopen(req, timeout=60) as r:
        data = r.read()
    with open(out, "wb") as f:
        f.write(data)
    print(f"saved {out}: {len(data)//1024}KB")


if __name__ == "__main__":
    main()

使用方式(私有源)

# 1) 从浏览器取新鲜 access_token(UA-TOKEN 解码后的 access_token 字段)
# 2) 拉取原生 PDF
python fetch_source_pdf.py 9085151a-b698-4b28-8c00-2c4aaf0c91ad --token "<access_token>"
# 3) 校验
pdfinfo 教材_源PDF_9085151a-b698-4b28-8c00-2c4aaf0c91ad.pdf | grep -E "Pages|Page size"

两半对照与公共边界

| 维度 | 上半:公共源 | 下半:私有源 | |---|---|---| | 是否需登录/Token | 否 | 是(access token,约 7 天有效) | | 资源位置 | 非私有 host transcode/image/N.jpg | 私有 host pkg/pdf_xxx.pdf | | 产物 | 图片合成 PDF(1488×2104 像素图) | 原生 A4 矢量 PDF(文字可选) | | 体积(167 页样例) | ~53MB | ~26MB | | 自动化程度 | 完全可自动化 | 需用户定期提供新鲜 token | | 脚本 | download_textbook.py + make_pdf.py | fetch_source_pdf.py |

公共边界与风险提示:

  • 页数陷阱:两半都要注意 preview 是预览子集,永远用 ti_items 的 pagesize 当总页数。
  • 缺页核对:合成/下载后务必 pdfinfo 确认页数 == 真实页数;缺失页回到对应 Step 补抓重跑。
  • 版权:仅用于个人学习、备课,请勿二次分发。
  • 网络可达性:运行环境到 *.ykt.cbern.com.cn 不通时脚本会全部失败,是网络层限制,需换能访问该域的环境执行。
  • token 安全:私有源路线拿到的 access token 等同登录凭据,不要写进脚本文件、不要提交到 git、不要落盘交付物;过期后让用户重新从浏览器取。
  • 接口变动:JSON 结构与图片路径规律为逆向所得,平台改版需重新核对 ti_items 与 preview 字段。