1. 这个工具到底解决了什么问题
国家中小学智慧教育平台上的课程资源,质量确实不错,覆盖了从小学到高中的主要学科,而且都是官方录制的完整课程。但用过的人都知道,它有一个非常让人头疼的限制:没有提供批量下载功能。你想把一节40分钟的课保存到本地,只能在线看,或者用一些不太靠谱的录屏方式,画质和音质都打折扣。
更麻烦的是,这个平台的视频采用了M3U8 分片传输的方式。简单来说,一个完整的视频被切成了几百上千个小的.ts文件,通过一个.m3u8索引文件来组织播放顺序。你打开浏览器开发者工具,看到的是密密麻麻的请求列表,手动一个个下载再合并,几乎是不可能完成的任务。
所以,smartedu-download这个项目的核心价值就一句话:把国家中小学智慧教育平台上的课程视频,一键下载到本地,自动合并成完整的 MP4 文件。它用 Python 写成,封装了从解析 M3U8 索引、批量下载 TS 分片、到调用 FFmpeg 合并输出的完整流程。对于家里网络不稳定、想给孩子反复看课、或者想把优质课程存档的家长和老师来说,这个工具非常实用。
这篇文章我会从零开始,把整个项目的设计思路、核心代码、实操步骤、以及我踩过的坑,全部拆开讲清楚。即使你之前没写过 Python,跟着走也能跑起来。
2. 整体设计思路与方案选型
2.1 为什么选择 Python 而不是其他语言
这个项目本质上是一个网络请求 + 文件处理 + 媒体合并的自动化脚本。Python 在这个场景下有天然优势:
- requests 库处理 HTTP 请求极其简洁,几行代码就能完成带 Cookie、Referer 的请求
- 多线程/多进程支持成熟,下载几百个 TS 分片时能显著提速
- FFmpeg 的 Python 封装或者直接
subprocess调用都很方便 - 跨平台,Windows、macOS、Linux 都能跑
如果用 Go 或 Rust 写,性能会更好,但开发效率和代码可读性对普通用户不够友好。这个工具的目标用户是家长和老师,不是专业程序员,所以 Python 是最平衡的选择。
2.2 M3U8 下载的核心逻辑
M3U8 是 HLS(HTTP Live Streaming)协议使用的索引格式。一个典型的 M3U8 文件长这样:
#EXTM3U #EXT-X-VERSION:3 #EXT-X-TARGETDURATION:10 #EXT-X-MEDIA-SEQUENCE:0 #EXTINF:10.000000, segment_000.ts #EXTINF:10.000000, segment_001.ts #EXTINF:10.000000, segment_002.ts ... #EXT-X-ENDLIST每一行#EXTINF后面跟着的是这个分片的时长,下一行是分片文件名。下载工具要做的就是:
- 请求 M3U8 文件,解析出所有 TS 分片的 URL
- 并发下载所有 TS 分片到本地临时目录
- 按顺序拼接所有 TS 分片
- 用 FFmpeg 把 TS 流转换为 MP4 格式
注意:有些 M3U8 文件是加密的,会有
#EXT-X-KEY标签,需要先获取密钥才能解密。国家中小学智慧教育平台的资源目前大部分没有加密,但如果你遇到加密的源,需要额外处理密钥请求。
2.3 为什么需要 FFmpeg
TS 分片直接拼接得到的文件,虽然能用某些播放器打开,但兼容性很差。FFmpeg 的作用是:
- 重新封装:把 TS 流封装成 MP4 容器,兼容所有播放器
- 修复时间戳:分片拼接后时间戳可能不连续,FFmpeg 会自动处理
- 转码(可选):如果源视频编码格式特殊,可以转成 H.264 + AAC 的通用格式
FFmpeg 的调用命令很简单:
ffmpeg -i input.ts -c copy -bsf:a aac_adtstoasc output.mp4-c copy表示不重新编码,直接复制流,速度极快。-bsf:a aac_adtstoasc是处理 AAC 音频流的比特流过滤器,避免音频播放异常。
2.4 打包成 EXE 的考量
很多用户没有 Python 环境,所以项目提供了打包成 EXE 的方案。常用的工具有PyInstaller和Nuitka。PyInstaller 更简单,一行命令就能打包:
pyinstaller -F -w smartedu_download.py-F表示打包成单个文件,-w表示不显示控制台窗口。但 PyInstaller 打包出来的 EXE 体积较大(通常 10-30MB),启动也稍慢。Nuitka 会把 Python 代码编译成 C,性能和体积都更优,但配置复杂一些。
3. 核心细节解析与实操要点
3.1 如何获取真实的 M3U8 地址
这是整个流程中最关键的一步。国家中小学智慧教育平台的视频播放页面,并不是直接把 M3U8 地址写在 HTML 里的,而是通过 JavaScript 动态加载。你需要:
- 打开课程播放页面
- 按 F12 打开开发者工具,切换到Network(网络)面板
- 筛选
m3u8关键字 - 刷新页面,找到返回 M3U8 内容的请求
- 复制这个请求的 URL
实操心得:平台的 M3U8 地址通常带有时效性 Token,过期后需要重新获取。所以下载要尽快开始,不要拖太久。
3.2 请求头的伪装
直接请求 M3U8 地址可能会被拒绝,因为服务器会检查Referer和User-Agent。你需要带上:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://basic.smartedu.cn/", }Referer告诉服务器你是从平台页面跳转过来的,User-Agent模拟浏览器。这两个字段缺一不可,否则大概率返回 403。
3.3 多线程下载的线程数选择
TS 分片数量可能从几十到上千不等。单线程下载太慢,但线程数也不是越多越好。我的经验是:
| 分片数量 | 推荐线程数 | 理由 |
|---|---|---|
| < 100 | 8-16 | 分片少,线程太多反而增加调度开销 |
| 100-500 | 16-32 | 平衡速度和服务器压力 |
| > 500 | 32-64 | 分片多,需要更高并发 |
线程数过高会导致服务器限流,甚至封 IP。建议从 16 开始测试,如果下载速度稳定,再逐步增加。
3.4 分片顺序的保证
多线程下载时,分片完成的顺序是随机的。但合并时必须按原始顺序拼接,否则视频会乱序。解决方案有两种:
- 方案一:下载时用分片索引作为文件名,如
0001.ts、0002.ts,合并时按文件名排序 - 方案二:用一个列表记录分片顺序,下载完成后按列表顺序拼接
我推荐方案一,简单可靠,而且方便断点续传。
3.5 FFmpeg 的路径问题
FFmpeg 不是 Python 标准库,需要单独安装。Windows 用户下载ffmpeg.exe后,要么放到系统 PATH 里,要么在代码里指定绝对路径:
FFMPEG_PATH = r"C:\ffmpeg\bin\ffmpeg.exe"常见坑:如果路径中有空格,
subprocess调用时需要用引号包裹,或者用列表形式传参。我建议用列表形式,避免转义问题。
4. 完整实操流程与核心代码
4.1 环境准备
首先确保你的电脑上有 Python 3.8 以上版本。Windows 用户去 Python 官网下载安装包,安装时勾选Add Python to PATH。安装完成后,打开命令行验证:
python --version pip --version然后安装依赖库:
pip install requests pip install tqdmrequests用于网络请求,tqdm用于显示下载进度条。FFmpeg 需要单独下载,解压后把bin目录添加到系统 PATH。
4.2 解析 M3U8 文件
import requests import re def parse_m3u8(m3u8_url, headers): resp = requests.get(m3u8_url, headers=headers, timeout=30) resp.raise_for_status() content = resp.text # 提取所有 TS 分片文件名 ts_list = re.findall(r'^[^#].*\.ts.*$', content, re.MULTILINE) # 处理相对路径 base_url = m3u8_url.rsplit('/', 1)[0] ts_urls = [] for ts in ts_list: ts = ts.strip() if ts.startswith('http'): ts_urls.append(ts) else: ts_urls.append(f"{base_url}/{ts}") return ts_urls这段代码的核心是正则表达式^[^#].*\.ts.*$,它会匹配所有不以#开头且包含.ts的行。re.MULTILINE让^和$匹配每一行的开头和结尾。
4.3 多线程下载分片
import os from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm def download_segment(args): url, index, save_dir, headers = args filename = os.path.join(save_dir, f"{index:05d}.ts") # 断点续传:如果文件已存在且大小合理,跳过 if os.path.exists(filename) and os.path.getsize(filename) > 0: return filename for retry in range(3): try: resp = requests.get(url, headers=headers, timeout=60) resp.raise_for_status() with open(filename, 'wb') as f: f.write(resp.content) return filename except Exception as e: if retry == 2: raise e return filename def download_all(ts_urls, save_dir, headers, max_workers=16): os.makedirs(save_dir, exist_ok=True) tasks = [(url, i, save_dir, headers) for i, url in enumerate(ts_urls)] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = [executor.submit(download_segment, task) for task in tasks] for future in tqdm(as_completed(futures), total=len(futures), desc="下载分片"): future.result()这里有几个关键点:
- 断点续传:如果文件已存在且大小大于 0,直接跳过。这样中断后重新运行,不会重复下载
- 重试机制:每个分片最多重试 3 次,避免网络抖动导致失败
- 进度条:
tqdm让用户看到下载进度,体验更好
4.4 合并分片并转 MP4
import subprocess def merge_segments(save_dir, output_file, ffmpeg_path="ffmpeg"): # 获取所有 TS 文件并按名称排序 ts_files = sorted([f for f in os.listdir(save_dir) if f.endswith('.ts')]) # 生成合并列表文件 list_file = os.path.join(save_dir, "filelist.txt") with open(list_file, 'w', encoding='utf-8') as f: for ts in ts_files: f.write(f"file '{ts}'\n") # 调用 FFmpeg 合并 cmd = [ ffmpeg_path, "-f", "concat", "-safe", "0", "-i", list_file, "-c", "copy", "-bsf:a", "aac_adtstoasc", output_file ] subprocess.run(cmd, cwd=save_dir, check=True) print(f"合并完成:{output_file}")-f concat告诉 FFmpeg 使用拼接模式,-safe 0允许使用绝对路径,-c copy不重新编码。整个过程通常只需要几秒钟。
4.5 打包成 EXE
如果你想把脚本分享给没有 Python 环境的朋友,可以用 PyInstaller 打包:
pip install pyinstaller pyinstaller -F -w -i icon.ico smartedu_download.py打包完成后,dist目录下会生成一个.exe文件。但要注意:
- FFmpeg 不会被自动打包进去,需要单独放在同目录下
- 如果代码里用了相对路径,打包后路径会变化,建议用
os.path.dirname(sys.executable)获取 EXE 所在目录
实操心得:PyInstaller 打包的 EXE 启动时会解压到临时目录,杀毒软件可能会误报。建议给 EXE 添加数字签名,或者提前告知用户添加信任。
5. 常见问题与排查技巧实录
5.1 下载速度慢怎么办
问题现象:分片下载速度只有几十 KB/s,一个视频要下几个小时。
排查思路:
- 检查网络本身是否正常,尝试访问其他网站
- 降低线程数,可能是服务器限流
- 检查是否走了代理,某些代理会限制大文件下载
- 尝试更换 DNS,有些 CDN 节点解析慢
解决方案:我实测下来,线程数从 16 降到 8,速度反而更稳定。另外,把下载任务安排在非高峰时段(比如深夜),速度会明显提升。
5.2 FFmpeg 合并报错
问题现象:ffmpeg报错Invalid data found when processing input。
原因分析:通常是某个 TS 分片下载不完整或损坏。
解决方案:
- 检查分片文件大小,异常小的文件(如 0 字节)就是损坏的
- 删除损坏的分片,重新下载
- 如果多个分片损坏,可能是网络问题,建议降低线程数重试
5.3 视频播放没有声音
问题现象:合并后的 MP4 能播放,但没有声音。
原因分析:AAC 音频流的 ADTS 头没有正确处理。
解决方案:确保 FFmpeg 命令中加了-bsf:a aac_adtstoasc。如果还是不行,尝试去掉-c copy,让 FFmpeg 重新编码音频:
ffmpeg -i input.ts -c:v copy -c:a aac -b:a 128k output.mp45.4 EXE 在别人电脑上打不开
问题现象:自己电脑上能运行的 EXE,发给别人后报错。
排查清单:
| 问题 | 检查项 | 解决方法 |
|---|---|---|
| 缺少 DLL | 是否在纯净系统测试 | 用--add-data打包依赖 |
| 路径错误 | 是否用了绝对路径 | 改用sys.executable获取路径 |
| 杀毒拦截 | 是否被 Windows Defender 拦截 | 添加信任或签名 |
| 权限不足 | 是否在 Program Files 目录 | 改用用户目录 |
5.5 M3U8 地址失效
问题现象:昨天还能下载的地址,今天报 403。
原因分析:平台的 Token 有时效性,通常几小时到一天。
解决方案:重新从浏览器获取新的 M3U8 地址。建议获取后立即开始下载,不要拖延。
5.6 常见问题速查表
| 问题 | 可能原因 | 快速解决 |
|---|---|---|
| 403 Forbidden | 缺少 Referer | 补全请求头 |
| 分片下载失败 | 网络抖动 | 增加重试次数 |
| 合并后花屏 | 分片顺序错乱 | 检查文件名排序 |
| 音频不同步 | 时间戳问题 | 用 FFmpeg 重新封装 |
| EXE 体积过大 | PyInstaller 打包 | 改用 Nuitka 或 UPX 压缩 |
| 下载中断 | 网络断开 | 利用断点续传重新运行 |
6. 进阶优化与扩展思路
6.1 支持批量下载整个课程
单个视频下载只是基础,实际使用中更常见的是下载整个课程(几十个视频)。思路是:
- 解析课程页面的 HTML,提取所有视频的 ID
- 对每个视频 ID,调用平台的 API 获取 M3U8 地址
- 循环调用下载函数
这里的关键是找到平台的 API 接口。通过浏览器开发者工具,观察播放页面加载时的 XHR 请求,通常能找到返回视频信息的接口。
6.2 添加图形界面
命令行工具对普通用户不够友好。可以用tkinter或PyQt做一个简单的 GUI:
- 输入框:粘贴 M3U8 地址
- 选择目录:选择保存位置
- 开始按钮:触发下载
- 进度条:显示下载进度
tkinter是 Python 自带的,不需要额外安装,适合快速开发。
6.3 自动识别并下载
更进一步,可以做一个浏览器插件或者油猴脚本,在播放页面自动提取 M3U8 地址,然后调用本地工具下载。这样用户只需要点一下按钮,完全不需要手动复制地址。
6.4 处理加密的 M3U8
如果遇到加密的 M3U8,需要:
- 解析
#EXT-X-KEY标签,获取密钥 URL - 请求密钥 URL,得到解密密钥
- 用 AES-128 解密每个 TS 分片
Python 的pycryptodome库可以处理 AES 解密。这部分代码稍复杂,但原理不复杂。
7. 我踩过的坑和实操建议
第一个坑是Referer 缺失。我一开始只带了 User-Agent,结果一直返回 403。后来抓包对比浏览器请求,才发现少了 Referer。这个字段在平台的反盗链机制中很关键。
第二个坑是线程数过高导致 IP 被限。我一开始设了 64 线程,下载了大概 200 个分片后,所有请求都超时了。等了几分钟才恢复。后来改成 16 线程,稳定跑完整个视频。
第三个坑是FFmpeg 路径问题。我在代码里写的是ffmpeg,但 Windows 上如果 PATH 没配好,会报FileNotFoundError。后来改成配置文件读取,让用户可以自己指定路径。
第四个坑是打包后的路径问题。PyInstaller 打包后,__file__指向的是临时解压目录,不是 EXE 所在目录。用sys.executable才能拿到正确路径。
最后分享一个小技巧:下载前先请求第一个分片,确认能正常返回,再启动多线程下载。这样可以避免因为地址失效导致所有线程都失败。
这个工具后续还可以扩展成支持其他教育平台的下载,核心逻辑是一样的,只需要适配不同的 API 和请求头。如果你对 Python 爬虫和媒体处理感兴趣,这个项目是一个很好的练手素材。