简介:四个Python脚本构成的视频下载工具包,压缩包仅2KB,体积小巧、源码开源,面向想解决网页视频无法直接保存问题的Python初学者、爬虫爱好者与前端开发者,也适用于在线课程、短视频、直播回放等常见下载场景。脚本分工清晰:有负责动态网页请求与源码获取的模块,有解析页面并定位真实视频地址的模块,还有专门处理m3u8流媒体索引及分段下载的模块,基本覆盖主流网页视频抓取流程;代码注释详细,Windows、macOS、Linux环境均可直接运行调试。资源包已有352人学习下载,从零基础语法入门到实战抓取均可一步步对照验证。通过该工具包,读者可快速掌握Python基础语法、requests库请求网页、HTML与CSS结构分析等技能;再结合多线程下载与断点续传的优化思路,即可举一反三,为独立编写针对不同网站的下载脚本打下扎实基础。
1. Python 网页视频下载:先搞清楚你面对的是哪种视频流
做爬虫和自动化的人,迟早会碰到一个需求:网页上有一段视频,既没有下载按钮,右键菜单也被禁用,但你就是想把它保存下来。用 Python 写一个视频下载脚本,本质上不是在“破解”什么,而是把浏览器播放视频时发出的网络请求复现一遍——能播放的视频,就一定能下载,这个结论对绝大多数站点都成立。唯一的例外是带 DRM 加密的内容,那个属于商业保护范畴,本文不展开,也不建议碰。
这篇文章会把完整链路拆开:从浏览器 F12 里找到视频真实地址,到用 Python 脚本分段抓取流媒体,再到用 yt-dlp 这类现成工具做工程化封装,最后讲清楚限速、断点、批量下载和最常见的翻车点。适合三类人:想批量保存课程视频的从业者、负责给项目做视频素材归档的开发、以及好奇“视频到底是怎么从网页流到本地”的爬虫入门者。跟着走完,你不只能下视频,还能知道下不下来的时候该看哪个报错。
2. 从浏览器 F12 到 Python 脚本:抓出视频真实地址的底层逻辑
2.1 为什么浏览器能播,脚本却下不了
网页视频看起来是“嵌在页面里的”,实际的播放链路通常是:HTML 里的 video 标签指向一个 URL,浏览器拿到这个 URL 后发起 HTTP 请求,服务器返回视频文件或流媒体索引文件,浏览器再边下边播。你手动访问这个 URL 时,大概率会碰到两类拦截:一类是服务器校验请求头(Referer 和 User-Agent),换一个客户端就拒绝响应;另一类是视频被切成几十上百个分片,URL 暴露的只是索引文件,真正的数据块散落在不同地址上。
在动手写脚本之前,我一般会先做一次“手工侦察”:打开某视频站的播放页,按 F12 进入开发者工具,切到 Network 面板,筛选 media 类型,再点播放。观察新出现的请求,重点看两项:一是响应的 Content-Type,如果是video/mp4,说明是直链文件,直接下载;如果是application/vnd.apple.mpegurl或application/x-mpegurl,说明是 HLS 流媒体,拿到的是 m3u8 索引;如果看到video/webm但是分片请求(URL 带range或segment字样),则是流式分片。这一步确定了下游脚本该走哪条路。
2.2 m3u8 流媒体解析:分段下载的本质
HLS 是网页视频最常见的分发形式。服务器把视频切成 3 到 10 秒不等的 TS 分片,生成一个 m3u8 索引文件,播放器读索引、按顺序拉分片、拼接播放。用 Python 下载这种视频,思路就是:先拿索引文件,解析出所有分片 URL,然后逐个下载,最后拼成一个完整文件。下面的代码演示了最核心的解析逻辑:
import requests def parse_m3u8(url, headers=None): """获取 m3u8 索引,返回分片地址列表""" headers = headers or { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } resp = requests.get(url, headers=headers, timeout=15) resp.raise_for_status() lines = resp.text.strip().splitlines() base_url = url.rsplit("/", 1)[0] # 用于拼接相对路径的分片地址 segments = [] for line in lines: line = line.strip() if line.startswith("#"): # 跳过 m3u8 的标签行 continue if line.startswith("http"): # 绝对地址直接使用 segments.append(line) else: # 相对地址按索引所在目录拼接 segments.append(f"{base_url}/{line}") return segments这段脚本的逻辑很直接:requests.get拉取 m3u8 文件内容,splitlines按行拆分,跳过所有#开头的标签(比如#EXTM3U、#EXTINF),剩下的就是分片地址。相对路径的分片需要拿索引文件的 URL 做一次目录拼接,因为大多数服务器把索引和分片放在同一路径下,但前缀是域名加目录,不能直接用。参数上,headers里至少带一个主流浏览器的 User-Agent,否则不少站点会直接拒绝非浏览器客户端的请求;timeout=15是防止某个分片地址响应缓慢时脚本卡死。
拿到分片列表后,第二步是逐个下载并拼接。注意,分片多的时候不要用单线程顺序下载——几十个分片,每个 200KB,顺序下可能要等一两分钟,用concurrent.futures开 8 到 16 个线程可以压缩到十秒内,但并发数不要开太大,否则容易触发服务器的限流策略,返回 429 或 403 就把整个任务废了。
2.3 HTTP Range 直链下载:断点续传的底层实现
另一类更简单的场景是直链视频:单个 MP4 文件,URL 直接指向资源。但这不意味着没有坑——很多站点对直链也做防盗链处理,校验 Referer 和 Cookie。简单场景下,Python 的requests配合stream=True就能下载大文件,但如果网络中断,整个文件就得重新下。更实用的方案是支持 Range 断点续传:
import os import requests def download_with_resume(url, filepath, headers=None, chunk_size=8192): """支持断点续传的文件下载""" headers = headers or {} resume_pos = os.path.getsize(filepath) if os.path.exists(filepath) else 0 if resume_pos > 0: headers["Range"] = f"bytes={resume_pos}-" # 从已有进度继续 resp = requests.get(url, headers=headers, stream=True, timeout=30) if resp.status_code == 206: # 服务器支持 Range mode = "ab" elif resp.status_code == 200: # 服务器忽略 Range,重新下载 mode = "wb" resume_pos = 0 else: resp.raise_for_status() with open(filepath, mode) as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk)这里的关键是 HTTP 的 Range 请求头:bytes=32505856-表示“从第 32505856 字节开始传输”。服务器如果支持断点续传,会返回状态码 206,正文只包含剩余部分;如果服务器不支持或校验不通过返回 200,就降级为全量重新下载。chunk_size=8192指的是每次从网络读取 8KB 写入磁盘,这个值不宜太大或太小——太大会占用内存,太小会频繁调用写入,实测 8KB 到 256KB 之间对大多数机械硬盘和 SSD 都比较友好。这个代码逻辑在后面的 yt-dlp 封装里其实用不到,但理解 Range 原理,你才知道为什么那些下载工具能断点续传、能边下边播。
3. 一键下载的工程化封装:用 yt-dlp 当“指挥中心”
3.1 为什么选 yt-dlp 而不是自己写解析器
从零写一个视频下载脚本,在单个站点上是可行的,但放到真实场景里有几个绕不开的问题:站点前端改版导致解析逻辑失效、不同视频清晰度对应不同的分片地址规则、下载完还需要音视频合并。这些问题不是不能解决,而是维护成本极高——你花两周写好的解析器,可能一个月后站点改动一次就用不了了。
yt-dlp 是目前社区维护最活跃的命令行视频下载工具,支持几千个站点,内置了格式选择、限速、断点、合并、字幕、批量任务等能力。它不是一个“破解工具”,而是把各家视频站的播放协议和反爬策略做成了可插拔的解析插件。用它的正确姿态是:把它当成下载引擎,你自己只需要管“下哪个 URL、存成什么格式、做不做后处理”。对于内部素材收集和课程下载,这是投入产出比最高的方案。
3.2 基础调用与格式选择
安装和基础调用非常简单,一行 pip 命令即可:
pip install -U yt-dlp然后直接指定 URL 下载。这里拿某视频站的单集页面举例(不写具体站点,原理通用):
yt-dlp "https://example.com/watch/12345"默认行为是下载最高画质,但 yt-dlp 的“最高画质”是按分辨率判断的,不一定是最佳选择。实际使用中我基本都显式指定格式参数。下面的命令是更实用的写法:
yt-dlp -f "bv*[height<=1080]+ba" --merge-output-format mp4 \ -o "%(title)s.%(ext)s" --limit-rate 5M "https://example.com/watch/12345"拆开解释这几个参数:-f "bv*[height<=1080]+ba"表示优先选 1080p 及以下的视频流(bv 是 best video 的缩写,*允许有多个候选)加上一条最佳音频流(ba),二者用加号合并;--merge-output-format mp4告诉工具合并产物用 MP4 封装;-o "%(title)s.%(ext)s"设定保存文件名为视频标题加扩展名;--limit-rate 5M把下载速度限制在每秒 5MB,避免占满带宽影响其他业务。参数里容易踩坑的地方是bv*和bv的区别——bv只选一个最佳视频流,bv*允许选出一条视频之后再用算法挑选能匹配的最佳组合,后者在音视频分离的站点上是必需项。
3.3 合并与转封装:FFmpeg 搭档
很多视频站(尤其是高清内容)会把视频轨和音频轨分开传输:视频是无声音的高码率流,音频是单独的 m4a 或 webm 文件。yt-dlp 把两者都下载到本地后,需要 FFmpeg 来完成合并。如果没有安装 FFmpeg,yt-dlp 会报错提示缺少二进制文件,但不会告诉你具体怎么装。
在 Windows 上,我一般从 FFmpeg 官方构建站点下载静态编译版,解压后把bin目录加入系统 PATH;Linux 上直接apt install ffmpeg或yum install ffmpeg即可。安装完成后可以用一个命令验证:
ffmpeg -version看到版本输出就说明可用了。yt-dlp 会自动探测系统 PATH 中的 ffmpeg,不需要额外配置。但如果你把 ffmpeg 放在非标准路径(比如项目目录下的tools/里),就需要显式指定:
yt-dlp --ffmpeg-location "./tools/ffmpeg/bin/ffmpeg.exe" ...--ffmpeg-location指向的是ffmpeg.exe所在目录,不是可执行文件本身,经常有人在这里写错。另外注意,--merge-output-format mp4并不是万能的——如果视频轨是 AV1 编码,合并成 MP4 没问题,但兼容性取决于播放器;如果遇到 HDR 内容,合并后的 MP4 在部分设备上颜色会发灰,那就需要保留原始封装格式,用默认的mkv更稳妥。
4. 参数调优与批量落地:把下载做成自动化流水线
4.1 常见下载参数矩阵
从“能下载”到“下得好用”,中间隔着几个参数。我把日常最常用的参数组合整理成一张表,方便直接抄:
| 目标 | 参数写法 | 说明 |
|---|---|---|
| 限制下载速度 | --limit-rate 2M | 带宽占用控制在 2MB/s,跑批任务不卡办公网 |
| 断点续传 | -c或--continue | 默认开启,分片下载中断后从断点继续 |
| 失败重试 | --retries 5 | 单个分片失败重试 5 次,超过则跳过 |
| 跳过已下载 | --download-archive archive.txt | 记录已下载的 URL,下次自动跳过 |
| 只下音频 | -f ba --extract-audio --audio-format mp3 | 适合播客、音频课程场景 |
| 下载字幕 | --write-subs --sub-langs zh-Hans,en | 先写中英字幕,语言代码按 ISO 639-1 填 |
| 自定义文件名 | -o "%(channel)s/%(playlist_index)s - %(title)s.%(ext)s" | 按频道建目录,序号加标题命名 |
两张表之外有三个容易忽略的点。第一,--download-archive是跑批任务的后悔药——它会持续追加已下载 URL 到文本文件,任务中断后重新执行,能自动跳过已完成的条目,不会重复下。第二,--limit-rate的单位是字节(B/s),不是比特(bps),5M等于 5MB/s,家用宽带上行基本不会被占满,下行则会接近占满。第三,--extract-audio --audio-format mp3不是转码而是提取,如果源视频是 AAC 音频,工具会先解封装再编码为 MP3,耗时会和视频时长成正比,批量转音频时要有心理预期。
4.2 批量下载与队列管理
单个 URL 下完下一条,这是最笨的用法。实际收集整套课程或某个系列时,URL 可能有几十上百个,需要批量喂给 yt-dlp。最省事的做法是把所有 URL 写进一个文本文件,每行一条,然后用--batch-file参数导入:
yt-dlp -f "bv*[height<=720]+ba" \ --batch-file urls.txt \ -o "%(playlist_index)s.%(title)s.%(ext)s" \ --download-archive archive.txt--batch-file让工具顺序处理文件里的每一条 URL,其中任意一条失败不会中断整体任务;配合--download-archive做去重,第二次运行同一份 urls.txt 时,已完成的 URL 会被自动跳过。为了跑批不翻车,我还会加两个参数:--socket-timeout 30防止某个站点响应慢导致连接卡死,和--retries 3控制单条 URL 的重试次数,超过次数直接跳到下一条。
还有一个不常用但很实用的参数是--playlist-start和--playlist-end——当某个“合集”页面里包含 100 集但你只需要第 20 到 40 集时,这两个参数能精确切片,不用手动整理 URL:
yt-dlp --playlist-start 20 --playlist-end 40 "https://example.com/playlist/xxx"别小看这个切片能力。某次我帮一个同事处理课程归档,合集里有 200 集,他只需要最近更新的 30 集,用 URL 列表整理要花掉半天,用切片参数改两个数字就搞定了。
4.3 命名与归档:避免重复下载
批量任务最大的隐性成本不是带宽,而是文件命名混乱导致的“以为没下过”。yt-dlp 的--output模板语法足够应付大部分命名需求,常用的变量有:%(title)s视频标题、%(id)s视频 ID、%(uploader)s上传者、%(playlist_index)s在合集里的序号、%(ext)s封装格式。组合方式决定目录结构:
yt-dlp -o "%(uploader)s/%(playlist_index)s_%(title)s.%(ext)s" ...这段模板会把文件保存到以“上传者”命名的子目录下,文件名是“序号_标题.扩展名”。用序号开头的好处是文件管理器里排序稳定,不会因为标题首字符不同而乱序;带上传者是为了多套课程并存时不会被同名标题覆盖。模板变量里不要包含/或\等路径分隔符字符,yt-dlp 虽然会自动替换,但替换后的可读性很差。
如果你需要更精细的归档(比如按年份、按项目分类),可以嵌套目录模板——--output "%(uploader)s/%(upload_date>%Y-%m-%d)s/%(title)s.%(ext)s"。这里的%(upload_date>%Y-%m-%d)s是模板的格式化语法,把原始日期字段(如20240315)重排成2024-03-15的可读格式。注意,老版本 yt-dlp 对这个语法的支持不完整,跑之前先yt-dlp --version确认版本是 2024 年之后的,否则会报模板解析错误。
5. 避坑指南:从反爬到 DRM 的四个关键排查点
5.1 现象:下载报 403 Forbidden
用 yt-dlp 或自写脚本下载时,服务器返回 403,最常见的是两种情况:第一,视频站点要求请求必须携带 Referer,而你的脚本没有带;第二,站点对请求频率有限制,短时间大量请求触发了风控。
处理方式:自写脚本时,从浏览器复制完整的请求头(包括 User-Agent、Referer、Cookie 等)填入代码;用 yt-dlp 时,执行前先确认是否可以访问该页面,如果页面在浏览器能打开但脚本 403,多半是缺少 Cookie,可以用--cookies-from-browser chrome直接把浏览器已登录的 Cookie 导出给工具使用。某开发者遇到过一个奇怪案例:同一站点同一 URL,浏览器打开正常但命令行下载 403,排查到最后发现是请求头里缺少Accept-Language——某些防爬系统会校验这个头,补上后问题消失。像这类“玄学”问题,第一反应是开着 F12 对比浏览器请求和脚本请求的完整请求头字段,通常能找到突破口。
5.2 现象:只下到音频,没有视频画面
使用-f bv*+ba时,某个视频下载后打开只有声音、画面黑屏。原因有两种:一是该视频流的视频轨和音频轨都下载成功了但合并失败,FFmpeg 合并时选了错误的编码参数;二是合并后封装格式没有正确匹配。
先看 FFmpeg 合并时的日志,如果提示类似 “Stream mapping” 的警告,说明音视频流在时间戳上不对齐。常见解法是改用--merge-output-format mkv强制走 MKV 封装,因为 MKV 对时间戳不齐的容忍度更高。如果合并日志正常但播放黑屏,用 FFprobe 检查输出文件的编码信息——如果视频轨是 AV1 而你的播放器不支持,画面自然黑掉。这种情况要么换播放器,要么用--format-sort av01参数让 yt-dlp 优先排除 AV1 编码,选择 H.264 或 H.265。
5.3 现象:批量下载中途突然全部超时
批量任务跑了一半,日志里开始连续出现timed out或ReadTimeout,且之前能正常下载的站点也一并失败。这不是单个请求的问题,大概率是请求频率过高触发了对方的限流机制。某些站点对单个 IP 的并发连接数有硬限制,超过阈值后所有新请求都会被挂起或拒绝。
解法分三步:第一步调小并发,yt-dlp 默认并发请求数不高,但如果用的是自写脚本,要把线程数从 16 降到 4;第二步加长超时时间,--socket-timeout 60给慢请求更多时间;第三步是降低限速,--limit-rate 1M反而比限速 5M 更不容易触发风控。如果还是超时,加一个随机延迟机制,自写脚本在每次请求前time.sleep(random.uniform(1, 3)),把请求间隔拉大到每秒最多一个,通常能恢复到稳定下载。这是跑批任务最快的止损手段。
5.4 现象:页面显示视频加密,下载后无法解析
这是最让人头疼的一类:页面能播放,但抓不到任何 m3u8 或直链,或者抓到了分片但下载的解码失败。这种视频通常是经过自定义加密的——分片本身被没意义化处理,真正的解密逻辑在页面 JavaScript 里,解密密钥也硬编码在部分 JS 文件中。
我的建议是:不要硬刚加密逻辑。解密整个 JS 代码链路的调试成本通常在十几个小时以上,而且一旦站点更新前端代码,解密脚本就失效了。可操作的做法有两个:一是找该站点是否提供了清晰度较低的“免密流”,部分站点为了兼容老设备会保留非加密的低清流;二是用屏幕录制的方式保存,虽然码率有损失,但至少内容拿到了。前者适用于批量场景,后者适用于单集场景。从长期维护的角度看,下载这种加密站点的时间成本几乎总是高于收益,偶尔处理一次可以,反复处理就不值得了。某项目组的同事曾花了一周时间逆向某个视频站的加密逻辑,结果第二周对方前端一改,整个脚本作废,白白消耗了精力——从那以后,遇到加密就先用排除法判断是否值得投入。
5.5 现象:下载完成但播放时没有声音
分片下载的视频在本地用播放器打开后出现音画不同步或只有画面没有声音。通常发生在两个场景:一是多音轨视频,yt-dlp 默认选了第一个音轨,但该音轨在合并时没有被正确封装;二是音视频流来自不同编码器,时间基准不同步。
排查时分两步走:先用 FFprobe 检查文件的流信息:
ffprobe -show_streams output.mp4 | grep -E "codec_name|channels"看视频轨和音频轨的codec_name是否匹配(比如视频是 H.264,音频是 AAC,这是正常组合),如果音频轨信息缺失,很可能是合并时音频流丢失。此时重新下载并指定音频语言优先级:
yt-dlp -f "bv*+ba/b" --audio-multi-lang --sub-langs "zh-Hans,en" ...ba/b表示优先最佳音频,若没有则降级到最佳任意格式。需要明确的是,真正解决音画不同步的底层手段是转封装——用 FFmpeg 把音频流的时间基准重置为视频流的基准,但这会消耗大量 CPU 时间,对批量场景不划算,所以重点是预防而不是事后修复。以我的经验,下载后立刻用播放器抽检前 30 秒的音画同步,比等全量任务跑完再检查要节省大量返工时间——最好养成这个习惯。
6. 把下载流程固化成自己的 CLI 工具箱:三个能直接抄的落地习惯
6.1 配置化:用一个脚本管理所有下载参数
不用每次都在命令行敲漫长参数,把常用配置写进一个 Python 脚本,通过读取 JSON 配置文件来切换“课程下载”“播客提取”“单集高清”等场景。这里给出一个极简实现思路:
import json import subprocess def build_cmd(config: dict, url: str) -> list: cmd = ["yt-dlp"] if config.get("format"): cmd += ["-f", config["format"]] if config.get("output_template"): cmd += ["-o", config["output_template"]] if config.get("rate_limit"): cmd += ["--limit-rate", config["rate_limit"]] cmd.append(url) return cmd with open("config.json", "r", encoding="utf-8") as f: cfg = json.load(f) url = input("请输入视频 URL:") subprocess.run(build_cmd(cfg, url))这个脚本的逻辑是把 yt-dlp 的参数拆成配置项,build_cmd函数按字典内容拼接命令行列表。config.json里只放常用参数,比如{"format": "bv*[height<=1080]+ba", "output_template": "%(title)s.%(ext)s", "rate_limit": "5M"}。好处是参数集中在配置文件中,换台电脑不用重新记忆命令语法,同时也避免日志里出现超长命令行导致 shell 转义出问题。
6.2 日志与断点:让下载任务跑过夜不翻车
跑批任务最怕的是“半夜 3 点脚本崩了,第二天起床发现白跑一夜”。我强制自己在所有下载命令里加两个参数:--newline让日志实时换行输出,--verbose打印更详细的调试信息。但比这更重要的是把输出重定向到日志文件:
yt-dlp --newline --verbose --batch-file urls.txt -o "%(title)s.%(ext)s" >> download.log 2>&1>>是追加写日志,2>&1把标准错误也并进同一文件。第二天检查日志时,主要看两处:日志中出现的ERROR和WARNING行数,以及最后一次成功的下载条目时间。如果任务在凌晨 2 点中断,日志末尾会留最后一条 URL 的处理痕迹,先把断点文件再跑一遍就能从已完成的位置继续。这样做的核心价值是:把“不确定跑没跑完”变成“看日志就能确定”。
6.3 验证输出:下载完成后自动校验文件完整性
批量下载完成后,还需要一套“交货标准”。文件不是下完就完事的——未合并成功、命名冲突、编码异常都可能让某个文件不可用。我习惯下载后跑一遍批量校验脚本:
for f in *.mp4; do if ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$f" > /dev/null 2>&1; then echo "OK: $f" else echo "FAIL: $f" fi done这个循环的逻辑是逐个检查当前目录下所有 MP4 文件,ffprobe读取文件时长,能读出说明文件结构完整,读不出说明文件已经损坏。配合前面所有流程,这就是一套完整的“从网页视频 URL 到本地可用文件”的流水线:先用 yt-dlp 拉流,再用模板管理命名,跑批时挂好日志,最后批量校验完整性。每当我拿到一个新的下载需求,都会先问三个问题:这个站点的流是直链还是 HLS、需不需要带 Cookie、能不能跑批,再用这套固定流程去执行。从最早的 F12 手工翻包,到现在一套脚本走完,规避问题的时间远大于解决问题的时间。希望这些拆解过的流程和踩过的坑能帮到你,让你在做视频归档时少走几步弯路。
本文还有配套的精品资源,点击获取