智慧教育平台教材下载
本 skill 分两半:
- 上半:公共源下载方法 —— 免 Token,从公开 JSON 拉预览图合成 PDF。零依赖、可自动化,首选。
- 下半:私有源获取方法 —— 需 access token,直拉平台原生 A4 矢量 PDF(体积小、文字可选)。想要原生分辨率时用。
两条路线的数据源都是同一个公开 JSON 接口(无需登录),区别只在最终资源落在「非私有 host 的图片」还是「私有 host 的 source PDF」。
上半:公共源下载方法(免 Token,图片合成 PDF)
适用场景
用户只想把整本教材抓成 PDF,不在乎它是否原生矢量。此路线不需要任何登录态,全程可自动化。
核心原理(关键坑已标出)
- 详情页是 SPA:直接抓页面只能拿到空壳。不要解析 HTML。
- 真实数据源是公开 JSON 接口,无需登录、无需 Token:
https://s-file-2.ykt.cbern.com.cn/zxx/ndrv2/resources/tch_material/details/{contentId}.json{contentId}取自 URL 的contentId参数(UUID 格式)。 preview只是预览子集,≠ 全书页数(最易踩的坑)。custom_properties.preview是 dict:SlideN→ 该页 jpg 的完整 URL。但它的条目数通常远少于真实页数(实测一本 167 页的书,preview只有 49 条)。绝不能拿len(preview)当总页数。- 真实页数在
ti_items里:找到ti_file_flag=="image"的项,其custom_properties.requirements中有{"name":"pagesize","value":"167"}—— 这才是全书真实页数。脚本用这个值驱动下载。 - 图片 URL 规律:非私有 host 上每页一张 jpg,按页码顺序可直接遍历:
https://r1-ndr.ykt.cbern.com.cn/edu_product/esp/assets/{cid}.t/zh-CN/{ts}/transcode/image/{N}.jpg{ts}与时间戳相关,从任意previewURL 里原样抠出即可;{N}从 1 顺序递增到pagesize。第pagesize+1页会返回 403,可据此验证边界。 - 镜像容灾:主机
r1-ndr/r2-ndr/r3-ndr.ykt.cbern.com.cn三个等价镜像,失败自动切换。 - 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 鉴权)
- 源 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的地址,落在-privatehost。该 host 的HEAD常返回 200,但GET返回 401 Authorization Required——实体下载才强制鉴权。 - 鉴权是 HTTP MAC 方案,最简单可用的是 URL 拼
?accessToken=<token>,已实测直达 200 / 完整体积。 - token 来自登录态:浏览器登录后,
UC_TOKEN-<appId>-ncet-xeducookie 是一段 base64 JSON,用 base64 解码后含:access_token—— 即 MAC 的id,直接当?accessToken=的值;mac_key+mac_algorithm:"hmac-sha-256"—— 用于构造x-nd-auth请求头(见下);expires_at—— 约 7 天有效,过期需重新取。
- 取 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。
- 方式一(推荐,最简单):浏览器打开教材页 → F12 → Application → Cookies → 找
- 等效请求头写法(防 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。 - 平台实际用法:前端用
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定位sourcePDF 的私有地址,拼?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字段。
Scan to join WeChat group