「国家中小学智慧教育平台」上的电子课本质量非常高——官方排版、可缩放、目录清晰、配套音频齐全,但官方并没有提供「一键导出 PDF」的入口,老师和家长想存到本地就只能靠网页打印或手动截图。

GitHub 上有一个叫 tchMaterial-parser(仓库:happycola233/tchMaterial-parser)的开源工具把这件事做得相当彻底:粘一个 URL,自动解析、自动命名、自动按学段/学科/版本建子目录、自动写章节书签、还能批量下。2025 年 5 月它曾登顶 GitHub Trending 总榜第 3 名,单日新增约 400 Stars,今天这篇文章就带大家把这个工具从功能到实现原理完整拆开。

项目地址:https://github.com/happycola233/tchMaterial-parser
作者:肥宅水水呀(happycola233)+ 晨叶梦春
许可证:MIT(同时使用了 Microsoft Fluent Emoji 中的部分图片资源)
当前版本:v4.4
技术栈:Python 3.10+ · Tkinter · sv-ttk · requests · Pillow · pypdf · pywin32 · PyInstaller
代码规模:约 20 个 .py 文件、215 次提交,核心模块 3000 余行业务代码


一、它到底解决了什么问题

basic.smartedu.cn/tchMaterial/ 是教育部「国家中小学智慧教育平台」电子课本的入口。点开任意一本教材,网页可以流畅翻页,但:

  1. 无法直接保存为 PDF:网页用 pdf.js 渲染,没有提供下载入口;
  2. 音频资源被拆得很碎:英语教材的听力、课件朗读的 MP3 都分散在 relation_audios.json 之类的接口里;
  3. 章节没有书签:下载下来的 PDF 想跳到「第一单元·识字」必须手动滚;
  4. 私有 CDN 需要鉴权签名:核心资源放在 r1-ndr-private.ykt.cbern.com.cn 这种私有 CDN 上,每次请求都要附 X-ND-AUTH,签名错误立刻 400;
  5. 跨平台体验不一致:网页在 Linux 上的字体、对 macOS 触控板的适配都不如 Windows 顺手。

tchMaterial-parser 的思路非常直接——完全模拟官方 web 的请求链路,但把所有解析、下载、命名、书签、UI 都收到一个本地桌面应用里,并补上网页没有的「下载到本地」能力。

主界面


二、三分钟跑起来

1. 选一个适合自己的安装方式

工具在四个分发渠道都做了适配,按自己平台挑一个:

平台 推荐方式 一句话命令
Windows 10/11/Server 2025 WinGet winget install happycola233.tchMaterial-parser
Arch Linux AUR yay -S tchmaterial-parser
Windows / macOS / Linux GitHub Releases 前往 Releases 选 windows-x64.exe / mac-arm64.zip / linux-x64
任意平台(开发用) pip + 源码 pip install tchmaterial-parser 或 python ./src/main.py

macOS 小坑:由于没签名,第一次启动会报「文件已损坏」,需要先运行
xattr -cr /Applications/tchMaterial-parser.app 移除隔离属性。

2. 输入资源页面链接

把电子课本的预览页面 URL(长这样:https://basic.smartedu.cn/tchMaterial/detail?contentType=assets_document&contentId=…)粘到右边的文本框里,每行一个。工具一次能处理几百条链接。

你也可以直接用左侧的资源树——点开「高中 → 语文 → 统编版」,工具会自动把对应 URL 写进文本框。

3. (可选但强烈建议)设置 Access Token

工具默认会用一种「匿名方式」尝试下载,但私有 CDN 上的资源会 400。想要稳定下载,先登录官网获取 Access Token:

  1. 打开 auth.smartedu.cn/uias/login 并登录账号;

  2. 按 F12 打开 DevTools,切到 Console;

  3. 粘贴下面这段 JS 回车,复制输出的整段 JSON:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    (function () {
    const authKey = Object.keys(localStorage).find(
    key => /^ND_UC_AUTH-[^&]+&[^&]+&token$/.test(key),
    );
    if (!authKey) {
    console.error("未找到登录凭据,请确保已登录!");
    return;
    }
    const tokenData = JSON.parse(localStorage.getItem(authKey));
    const { access_token, mac_key, diff } = JSON.parse(tokenData.value);
    const credentials = JSON.stringify({ access_token, mac_key, diff });
    console.log(
    "%c请复制下面整段 JSON 并粘贴到下载工具:",
    "color: green; font-weight: bold",
    );
    console.log(credentials);
    })();
  4. 回到工具,点「设置 Token」,粘进去保存。Token 只存本机(Windows 注册表 / Linux & macOS 配置文件),不会上传。

4. 点下载

点主按钮「下载」之后,工具会先在后台把每条 URL 解析成 ResourceInfo(直链 + 标题 + 章节树),再开 3 个工作线程(_download_slots)拉文件,同名同路径已下载完成的会自动跳过。v4.4 新增的「下载管理」窗口会实时显示每个任务的状态、速度、错误详情。

下载完成的 PDF 还会被自动加上章节书签:

自动生成的 PDF 书签


三、功能一览

工具的 README 里把功能列得很清楚,这里挑几个特别值得一提的:

1. 智能批量下载

  • 逐行解析:每行一个 URL,去重、跳过空行;
  • 后台解析:批量 200 条链接也不会卡 UI(parse_urls_in_background 把解析放进 ThreadPoolExecutor);
  • 跳过已下载:批量模式下 os.path.isfile(save_path) 命中就标 skipped,再下一轮时不会再拉;
  • 随时停止:_stop_requested 这个 threading.Event 会中止所有进行中的任务,正在写的临时文件会被清理,已经下完的文件保留;
  • 路径自动归类:通过 tag_list 里的 tag_dimension_id(学段 / 学科 / 版别)自动建立 高中/语文/人教版/ 子目录。

2. 私有 CDN 与签名重算

这是工具的核心难点之一:r1-ndr-private.ykt.cbern.com.cn 收到每个请求都要校验 X-ND-AUTH 头,而 X-ND-AUTH 是基于完整 URL + 方法 + 时间戳现算的 HMAC-SHA256。也就是说:

  • 每条 URL 都必须单独签名一次,不能用「抓一个头到处贴」;
  • Authorization: Bearer <token> 是同一个 token,但 X-ND-AUTH 的 MAC 是按当前 URL 现算的。

工具在 auth.py 里完整复刻了官方 UC SDK 的签名算法:

1
2
3
4
5
6
7
8
9
10
11
12
def signature_string(url: str, method: str, nonce: str) -> str:
"""构造官网 ze() 的 HMAC 原文。path 先解码,query 原样保留,host 不含端口。"""
parts = urlsplit(url)
relative = unquote(parts.path) + (f"?{parts.query}" if parts.query else "")
return f"{nonce}\n{method.upper()}\n{relative}\n{parts.hostname or ''}\n"

def build_nd_auth(url, method, access_token, mac_key, diff, nonce=None) -> str:
if not mac_key:
return f'MAC id="{token_id}",nonce="0",mac="0"' # 旧版占位头
nonce = nonce or generate_nonce(diff)
mac = sign_mac(signature_string(url, method, nonce), mac_key)
return f'MAC id="{token_id}",nonce="{nonce}",mac="{mac}"'

network.request_headers(url) 会拿到 URL 后现算 X-ND-AUTH,覆盖全局占位头,再用这个新头去请求。每条私有 CDN URL 都吃这个流程。注意:中文路径会先 unquote 再签名,HAR 抓包里看到的中文编码路径直接拿来对 MAC 永远对不上,这是作者踩过的一个大坑(见源码注释)。

3. 自动 PDF 书签

工具通过 ebook_mapping.txt + trees/<ebook_id>.json 两个接口组合拿到「目录标题 + 页码」的映射,再交给 bookmarks.py 写到 PDF 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pypdf import PdfReader, PdfWriter

def add_bookmarks(pdf_path: str, chapters: list[dict]) -> None:
reader = PdfReader(pdf_path)
writer = PdfWriter()
writer.append_pages_from_reader(reader)

def add_chapter(chapter_list, parent=None):
for chapter in chapter_list:
title = chapter["title"]
page_num = int(chapter["page_index"]) - 1 # pypdf 页码从 0 开始
bookmark = writer.add_outline_item(title, page_num, parent=parent)
if chapter.get("children"):
add_chapter(chapter["children"], parent=bookmark)

add_chapter(chapters)
with open(pdf_path, "wb") as f:
writer.write(f)

写入流程是:先下载到 xxx.pdf.tmp,书签写好后才 os.replace 成正式文件——这一步保证任何中途失败都不会留下损坏的 PDF。

4. 资源树 + 搜索

左侧的 ttk.Treeview 是工具的「门面」,体验比官网更顺手:

  • 三级层级:学段 → 学科 → 版别 → 教材,展开后是按封面缩略图 + 标题显示的;
  • 跨层级搜索:filter_resource_items 会把分类路径拼成 初中/英语/人教版/...,再用空格分词做 casefold 包含匹配;
  • 悬停看大封面:resource_tree.py 里的 <Enter> 事件会异步拉封面图,悬停时显示 240×320 的大图;
  • 复选框三态:父分类会根据子树的全选/部分选状态显示空白/打勾/横线三种图标(自己用 PIL.ImageDraw 画的,跟主题色联动)。

5. 下载管理(v4.4 新增)

旧版本只有底部一条进度条,下载多了根本看不清谁是谁。新版把进度条扩成了一个独立的「下载管理」窗口:

  • 表格列:文件名 / 状态 / 进度 / 大小 / 速度 / 错误;
  • 筛选器:全部 / 进行中 / 需处理 / 已完成,方便在批量任务里挑出失败的;
  • 复制错误详情:失败任务的错误堆栈可以直接复制,调试时不用再去翻终端;
  • 重试:「重试失败项」会重新调度所有 failed_reason 不为空的任务,已存在的目标文件不会自动覆盖(必须重新走保存对话框)。

6. 主题与高 DPI

  • 主题三档:浅色 / 深色 / 跟随系统(Windows 读注册表、macOS 读 defaults、Linux 读 gsettings);
  • 深色标题栏:Windows 上调 DwmSetWindowAttribute(属性 20 / 19,对应 Win10 20H1 与更早版本)让标题栏也变深;
  • 高 DPI:启动时通过 win32print.GetDeviceCaps 算缩放因子,再 tk scaling 设置,UI 字体全部走 scaled(size) 按像素算字号;
  • 跨平台中文字体:pick_ui_font_family 依次尝试 Microsoft YaHei UI → 微软雅黑 → PingFang SC → Noto Sans CJK SC → WenQuanYi Zen Hei → Arial Unicode MS,选第一个可用的。

四、代码架构:一张图看清楚

工具源码组织得很干净,模块之间单向依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
tchmaterial_parser/
├── app.py # 入口:装配 Tkinter 窗口,进入主循环
├── api.py # 资源 URL → ResourceInfo(标题 + 直链 + 章节树)
├── auth.py # UC SDK 签名算法复刻
├── network.py # 全局 requests.Session + request_headers(url)
├── config.py # Token / 主题本地持久化(注册表 / JSON)
├── catalog.py # 资源树接口(tch_material_tag.json 等)
├── bookmarks.py # PDF 书签写入
├── images.py # 系统彩色 Emoji 渲染、封面适配
├── platform_utils.py # print_error / resource_path / Windows 库导入
└── ui/
├── download_panel.py # 解析 + 下载 + 进度
├── download_manager.py # 任务表格 UI(v4.4)
├── resource_tree.py # 左侧资源树 + 搜索 + 封面
├── token_window.py # 设置 Token 弹窗 + 获取步骤
├── theme.py # 配色 / 字体 / 系统主题探测
├── about_window.py # 关于窗口
├── widgets.py # 右键菜单 / 滚动条 / Tab 导航 / 居中
└── runtime.py # 主线程与后台线程互操作的桥

最值得说一下的是 runtime 这个共享上下文:Tkinter 的所有 UI 操作只能在主线程,但下载/解析需要后台线程,作者用 runtime.bind_root(root) + ui_call(fn, *args) + thread_it(worker) 三件套把跨线程调用收口到一个文件里——其它模块通过 runtime.root、runtime.ui_scale 这些模块级全局变量访问主线程对象,避免了在十几个文件里到处塞 global root。

关键流程:从一次下载看完整调用链

  1. 用户点「下载」 → download_panel.download()
  2. parse_urls_in_background 把每条 URL 放进后台线程调 api.parse(url, bookmarks);
  3. api.parse 根据 URL 里的 contentType 与域名分支:
    • assets_document(电子课本)→ zxx/ndrv2/resources/tch_material/details/{id}.json
    • national_lesson → .../national_lesson/resources/details/{id}.json
    • thematic_course → .../special_edu/thematic_course/{id}/resources/list.json
    • ……
  4. 解析完成拿到 ResourceInfo 后,回到主线程让用户选择保存目录(批量)或文件对话框(单条);
  5. allocate_download_paths 在线程启动前统一预留路径,避免同名任务抢同一个 .tmp;
  6. run_download_tasks 用 ThreadPoolExecutor(max_workers=3) 跑 download_file;
  7. download_file → request_download(带签名头 + 镜像兜底)→ 流式写 .tmp → add_bookmarks → os.replace 成正式文件;
  8. monitor_downloads 每 200 ms 读一次 download_states,刷新主窗口进度条与管理窗口。

五、私有 CDN 的限流避坑

这套请求链路里有一个非常细节、但作者花了大量精力打磨的地方——私有 CDN 的限流应对。

r1/r2/r3-ndr-private.ykt.cbern.com.cn 在「短时间连打」时会回 400(有时带 InvalidArgument,有时几乎是空包)。作者做了三件事:

1. 同地址用新 nonce 退避重试,而不是切镜像

1
2
3
4
5
6
7
8
_400_RETRY_DELAYS = (1.0, 3.0)

if response.status_code == 400:
if retry < len(_400_RETRY_DELAYS):
response.close()
(cancel or _stop_requested).wait(_400_RETRY_DELAYS[retry])
retry += 1
continue

**直觉是切到 r2/r3 就好,但实测发现切换会打得更快。**因为 400 主要是「签发过快」,不是「这台 CDN 挂了」,换地址只会把限流打得更死。同地址等 1 秒、3 秒,重新算一个 nonce 再签一次,往往就过了。

2. 镜像只在 401/403 时启用

401/403 是「这台 CDN 拒绝我」,那确实该切:

1
2
if response.status_code in (401, 403):
return last_response, attempted_urls

其它情况都按「同地址重试」处理。

3. 同一进程限速到 200ms/请求

1
2
3
4
5
6
7
8
9
_MIN_REQUEST_INTERVAL = 0.2

def _pace_request() -> None:
global _last_request_at
with _rate_lock:
wait = interval - (time.monotonic() - _last_request_at)
if wait > 0:
time.sleep(wait)
_last_request_at = time.monotonic()

加上 ThreadPoolExecutor(max_workers=3) + _download_slots = threading.BoundedSemaphore(3),一个批次里同时并发最多 3 条,间隔 200 ms,等于稳定的 5 QPS——既能跑满小水管,又不会触发 CDN 限流。

这套细节藏在 download_panel.py 的注释里:「# 立刻换 r2/r3 只会把限流打得更死」是作者用日志堆出来的结论。


六、跨平台分发与打包

工具是纯 Python,依赖只有:requests、Pillow、psutil、pypdf、sv-ttk、pywin32(仅 Windows)。打包用 PyInstaller,仓库根目录有现成的 tchMaterial-parser.spec:

  • macOS:产出 .app bundle,禁用控制台窗口;
  • Windows / Linux:单文件 + UPX 压缩,附加 version_info.txt 让 .exe 的文件属性里能显示版本号与公司名;
  • 运行时资源:sv_ttk 的 .tcl/.png 和工具自带的 logo 通过 collect_data_files + runtime_assets 一并打进 bundle;
  • PIL 子模块:PIL._tkinter_finder 显式加进 hiddenimports,否则 Pillow 在非 Windows 平台会因为 C 层动态导入而缺符号。

CI(.github/workflows/*.yml)在 6 个 runner(Windows x64/ARM64、Linux x64/ARM64、macOS x64/ARM64)上跑构建,fail-fast: false,保证单个平台失败不会拖累其它。

Releases 之外还做了:


七、安全与合规

工具本身不存储、不分发任何资源,下载下来的所有 PDF/MP3 都来自原平台,作者在 README 里把这一点写得很清楚:

本工具仅提供下载上的便利,不存储、不托管、不分发任何资源内容。所下载资源的版权归原平台及相关权利人所有,请仅用于个人学习与教学参考,请勿用于商业用途或二次分发。

代码层面也对凭据做了几层保护:

  • redact_access_token 会把所有错误日志里的 accessToken=…、access_token、mac_key 替换成 <已隐藏>,防止用户复制错误详情时把凭据贴出去;
  • Token 只存本地(Windows 注册表 / Linux & macOS 配置文件),没有任何上传逻辑;
  • parse_token_input 对粘贴内容做严格 JSON 校验(access_token、mac_key、diff 三项必填且类型正确),防止用户误把账号资料缓存(ND_UC_AUTH-…&sdk_cache)当成 Token 贴进来——#89 就是这个坑。

八、值得关注的小细节

  • 文件名清洗:sanitize_filename 把 Windows 禁用的 < > : " / \ | ? * 替换成全角(< > : " / \ | ? *),既保留可读性又避开 #86 报错的 Windows 报错;同时会避开 CON / PRN / COM1..9 / LPT1..9 等保留设备名。
  • Emoji 渲染:images.color_emoji_font_paths() 会按平台找系统 Emoji 字体(Windows seguiemj.ttf、macOS Apple Color Emoji.ttc、Linux Noto Color Emoji.ttf),用 Pillow 的 embedded_color=True 直接画出系统原始彩色字形,而不是被 Tk 文本控件糊成黑白框。
  • 404 处理:私有 CDN 4xx 时解析响应 XML 拿 Code,InvalidArgument 单独提示 Token 过期,普通 4xx 提示网络问题,避免给用户弹一个看不懂的堆栈。
  • 临时文件清理:每个 download_file 用 xxx.pdf.tmp 写,失败时 os.remove;allocate_download_paths 会主动避开正在被其它实例写的 .tmp,防止两台工具同时跑互踩。
  • 测试覆盖:仓库里 16 个 tests/ 文件,覆盖鉴权、签名、解析、并发下载、文件名分配、Token 脚本生成等所有关键路径,CI 里跑 pytest + flake8。

九、怎么参与贡献

作者在 CONTRIBUTING.md 里把规矩写得很细:

  • 提交前先搜索已有 Issue / PR,避免重复讨论;
  • 凭据(Access Token)永远不要贴到 Issue / PR / commit / 代码里;
  • 代码风格:4 空格缩进、snake_case 命名、字符串默认用双引号、不出现尾随空格;
  • UI 改动必须同时检查浅色与深色模式;
  • 修改打包配置 / 资源文件 / 程序入口时额外跑一次 PyInstaller 打包验证。

最近的几个有亮点的提交:

Commit 内容
47899f7 fix(auth): 仅从 ND_UC_AUTH 的 &token 项读取登录凭据(#89)
cd4b6a0 fix: 按官网 MAC 签名私有下载,并避免 400 时连打镜像(#81、#76)
78938b1 fix: 用全角标点替换文件名中的非法字符(#86)
e9a1604 feat(download): 批量下载时跳过已下载的文件并支持手动停止(#94)
d09686d fix: 教材听力和听力课件按播放器顺序选择音频
14c4e45 chore: 版本号从 4.3 升到 4.4

v4.x 三个版本(4.2 / 4.3 / 4.4)几乎是同一个主线在「下载管理 / 鉴权修正 / 限流应对」这条轴上滚动,每次都附带一份诚实的 Issue 引用。


十、写在最后

tchMaterial-parser 不是一个「爬虫工具」,它本质上是官方 web 阅读器的能力补全:

  • 补了「下载入口」;
  • 补了「批量」;
  • 补了「书签」;
  • 补了「跨平台桌面体验」。

而它实现这一切靠的不是「逆向破解」,而是:

  1. 老老实实复刻官方签名算法——auth.py 的注释里写满了 Fe(diff)、ze(url)、He(...) 这些函数名,与官网 UC SDK 一一对应;
  2. 老老实实抓官方公开接口——tch_material_tag.json、details/{id}.json、trees/{ebook_id}.json 都是公开可访问的资源;
  3. 老老实实做工程——错误日志 Token 自动脱敏、临时文件原子重命名、并发限速到 5 QPS、跨平台高 DPI 适配。

这种「把一件小事做到极致」的开源项目,正是中文开源社区里最值得收藏的那一类。如果你身边有老师、家长或学生需要批量保存电子课本,强烈建议把这个工具放进收藏夹。

相关阅读:如果你对「爬虫 + 桌面应用」的其它玩法感兴趣,可以看
《微信视频号下载神器 wx_channels_download 全解析:MIT 开源、8MB 小体积、跨平台、支持 MCP》——同样的「桌面 GUI + 私有协议逆向 + 跨平台打包」的另一种实现。

想要批量获取整套教材 PDF / 不想自己写脚本的话,也可以直接看整理好的网盘资源:电子课本合集归档,按学段学科分类下载即可。