本人在复习本科最后一门课程时突发奇想,用codex临时做了
fdu-icourse-slides课件整理工具(正文几乎由AI撰写,因为本人懒)。该工具的适用范围为:文科或数学等课件中文字、公式较多的课程,且教师授课速度适中,不会飞速带学生过ppt。本文说明如何使用 fdu-icourse-slides ,将复旦 iCourse 回放中的 PPT 课件截图批量导出,并按每节课一个 PDF的形式合成文件。项目中的 Python 脚本可在本人的GitHub仓库中下载:
https://github.com/chichung0921/fdu-icourse-slides
本文流程适用于复旦 iCourse 中当前登录账号有权限访问的课程资源。脚本不保存账号密码,不用于绕过课程访问权限。使用时只需要替换目标课程的 course_id、tenant_code 和输出目录。
1. 准备环境
先下载 GitHub 仓库,并进入项目目录。目录中应包含:
scripts/fudan_icourse_slides.py
确认 Python 能运行脚本:
python3 scripts/fudan_icourse_slides.py --help
如果提示缺少依赖,安装:
python3 -m pip install requests pillow
其中:
2. 找到课程参数
打开目标课程详情页,URL 通常类似:
https://icourse.fudan.edu.cn/coursedetail?course_id=COURSE_ID&tenant_code=TENANT_CODE
记录两个参数:
例如:
https://icourse.fudan.edu.cn/coursedetail?course_id=35035&tenant_code=222
对应参数为:
COURSE_ID=35035TENANT_CODE=222
后续命令中凡是出现 COURSE_ID、TENANT_CODE 的位置,都需要替换成目标课程的真实值。
3. 登录课程页
在 Safari 中打开目标课程详情页,并确认已经登录,且能够看到课程回放列表。
注意:后续生成索引 JSON 时,脚本会在 Safari 当前页面中执行 JavaScript,因此 Safari 当前标签页必须是:
不要停留在 GitHub、教程页面或其他网页。
如果 Safari 当前页面错误,生成的 JSON 可能会出现:
{ ”has_token”: false, ”lesson_count”: 0, ”lessons”: []}
这种情况通常不是课程没有内容,而是脚本运行在了错误页面上。
4. 开启 Safari JavaScript 自动化
Safari 菜单栏打开:
Develop -> Allow JavaScript from Apple Events
如果没有 Develop 菜单,先到:
Safari -> Settings -> Advanced -> Show features for web developers
然后再回到菜单栏启用:
Develop -> Allow JavaScript from Apple Events
5. 生成课件索引 JSON
课件索引 JSON 用于记录课程课次和 PPT 截图接口返回值。该文件不保存账号密码。
先创建临时目录:
然后运行下面的命令。使用前需要替换其中的 COURSE_ID 和 TENANT_CODE:
osascript <<'APPLESCRIPT' > tmp/icourse/icourse_COURSE_ID_ppt_index.jsontell application id ”com.apple.Safari” set js to ”(async () => { const COURSE_ID = 'COURSE_ID'; const TENANT_CODE = 'TENANT_CODE'; function authHeaders() { const rawCookieToken = (document.cookie.match(/(?:^|;\\s*)_token=([^;]+)/) || [])[1] || ''; let token = rawCookieToken ? decodeURIComponent(rawCookieToken) : (localStorage.getItem('token') || sessionStorage.getItem('token') || ''); const match = token.match(/_token\\\”;i:\\d+;s:\\d+:\\\”(.+?)\\\”/) || token.match(/_token[^\\\”]*\\\”(.+?)\\\”/); if (match) token = match[1]; const headers = {}; if (token) { headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`; } return headers; } async function getJson(path, query = {}) { const url = new URL(path, location.origin); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, value); } }); const res = await fetch(url, { headers: authHeaders(), credentials: 'include' }); const text = await res.text(); try { return JSON.parse(text); } catch (error) { return { parse_error: String(error), status: res.status, text: text.slice(0, 1000) }; } } const catalogue = await getJson('/courseapi/v2/course/catalogue', { course_id: COURSE_ID }); const rawLessons = []; function walk(value) { if (Array.isArray(value)) { value.forEach(walk); } else if (value && typeof value === 'object') { if (value.sub_id || value.id) rawLessons.push(value); Object.values(value).forEach(walk); } } walk(catalogue); const seen = new Set(); const lessons = []; for (const item of rawLessons) { const subId = String(item.sub_id || item.id || ''); if (!subId || seen.has(subId)) continue; seen.add(subId); const title = item.sub_title || item.title || item.name || item.subject_name || ''; const status = String(item.sub_status || item.status || ''); const pptIndex = await getJson('/pptnote/v1/schedule/search-ppt', { course_id: COURSE_ID, sub_id: subId, page: 1, per_page: 200 }); lessons.push({ course_id: COURSE_ID, sub_id: subId, tenant_code: TENANT_CODE, title, date: title, status, ppt_index: pptIndex }); } return JSON.stringify({ course_id: COURSE_ID, tenant_code: TENANT_CODE, safari_url: location.href, has_token: Boolean(authHeaders().Authorization), generated_at: new Date().toISOString(), lesson_count: lessons.length, lessons }, null, 2);})()” do JavaScript js in current tab of front windowend tellAPPLESCRIPT
生成后检查 JSON 格式:
python3 -m json.tool tmp/icourse/icourse_COURSE_ID_ppt_index.json > /dev/null
如果没有报错,说明 JSON 文件格式正常。
6. 检查索引 JSON
打开生成的 JSON,重点检查以下字段:
{ ”course_id”: ”35035”, ”tenant_code”: ”222”, ”safari_url”: ”https://icourse.fudan.edu.cn/coursedetail?course_id=35035&tenant_code=222”, ”has_token”: true, ”lesson_count”: 10, ”lessons”: []}
其中最重要的是:
safari_urllesson_countlessons
safari_url 应当是目标课程详情页,而不是 GitHub 或其他页面。
如果生成结果类似:
{ ”safari_url”: ”https://github.com/...”, ”has_token”: false, ”lesson_count”: 0, ”lessons”: []}
说明 Safari 当前标签页不正确。需要切换到 iCourse 课程详情页后重新运行索引生成命令。
has_token 为 false 不一定表示失败。部分情况下浏览器会通过 Cookie 保持登录态。只要 lesson_count 大于 0,并且 lessons 中包含课次数据,就可以继续执行后续步骤。
7. 批量生成每节课 PDF
索引 JSON 生成后,运行:
python3 scripts/fudan_icourse_slides.py \ --browser-json tmp/icourse/icourse_COURSE_ID_ppt_index.json \ --out-dir output/icourse_COURSE_ID_slides
脚本会自动完成以下操作:
输出目录通常包含:
课程名_sub_id.pdf课程名_sub_id/downloads/课程名_sub_id/urls.txt课程名_sub_id/api_payloads.json
其中:
课程名_sub_id.pdfdownloads/urls.txtapi_payloads.json
8. 只导出某一节课
如果已经知道某节课的 sub_id,可以只生成这一节:
python3 scripts/fudan_icourse_slides.py \ --browser-json tmp/icourse/icourse_COURSE_ID_ppt_index.json \ --sub-id SUB_ID \ --out-dir output/icourse_COURSE_ID_slides
如果不知道 sub_id,可以从索引 JSON 中查看:
python3 - <<'PY'import jsonfrom pathlib import Pathdata = json.loads(Path(”tmp/icourse/icourse_COURSE_ID_ppt_index.json”).read_text())for lesson in data.get(”lessons”, []): print(lesson.get(”sub_id”), lesson.get(”title”) or lesson.get(”date”))PY
9. 换一门课程时怎么做
换课程只需要重新执行三步:
- 重新运行索引生成命令,替换
COURSE_ID 和 TENANT_CODE;
建议每门课使用独立输出目录:
output/icourse_COURSE_ID_slides
这样不同课程的 PDF 和下载图片不会混在一起。
10. 常见问题
10.1 生成的 JSON 中 lesson_count 为 0
优先检查:
如果 safari_url 不是 iCourse 课程详情页,说明 Safari 当前页面不正确。切换到课程详情页后重新运行命令。
如果 safari_url 正确,但 lesson_count 仍为 0,检查是否已经登录,或者当前账号是否有权限访问该课程。
10.2 has_token 为 false
has_token 为 false 不一定影响后续流程。重点看 lesson_count 和 lessons 是否有内容。
如果课程目录和 PPT 索引已经成功写入 JSON,可以继续运行 Python 脚本。
如果同时出现:
”lesson_count”: 0,”lessons”: []
则需要重新检查 Safari 当前页面和登录状态。
10.3 出现 Auth Forbidden 或下载失败
通常是登录态失效。解决方法:
如果仍然失败,可以从浏览器开发者工具复制完整 Cookie,并在运行脚本时使用:
python3 scripts/fudan_icourse_slides.py \ --browser-json tmp/icourse/icourse_COURSE_ID_ppt_index.json \ --cookie '这里粘贴完整Cookie' \ --out-dir output/icourse_COURSE_ID_slides
10.4 生成的 PDF 数量少于课次数量
常见原因:
/pptnote/v1/schedule/search-ppt
可以用下面的命令检查哪些课没有 PPT 截图索引:
python3 - <<'PY'import jsonfrom pathlib import Pathdata = json.loads(Path(”tmp/icourse/icourse_COURSE_ID_ppt_index.json”).read_text())for lesson in data.get(”lessons”, []): ppt = lesson.get(”ppt_index”) or {} if int(ppt.get(”total”) or 0) == 0: print(lesson.get(”sub_id”), lesson.get(”title”) or lesson.get(”date”), ”no ppt screenshots”)PY
如果某节课 ppt_index.total 为 0,说明平台没有提供 PPT 截图索引,脚本不会生成该节课的 PDF。
10.5 图片顺序不正确
脚本默认按照文件名中的数字自然排序。正常情况下,平台图片名就是页码顺序。
如果某门课文件名异常,可以检查:
output/icourse_COURSE_ID_slides/<lesson>/urls.txt
必要时可以手动调整图片文件名后重新合成 PDF。
11. 安全说明
请仅导出本人账号有权限访问的课程资源。
不要将账号密码写入脚本或教程。不要公开包含个人登录态的 Cookie、token 或完整浏览器会话信息。不要将导出的课程资料公开传播。
本流程只使用已登录浏览器当前用户可访问的课程资源,主要用于个人学习资料整理。