简介:一个使用Python编写的B站小视频批量下载爬虫程序,适合刚接触爬虫的开发者或需要批量保存B站短视频内容的用户。程序通过解析每页排行榜JSON信息,自动获取视频标题与地址,用requests模块完成下载,并在控制台实时打印进度;下载后自动将文件名中的非法字符替换为空,同时随机等待3~6秒再发起下一次请求,以降低被限制访问的风险。压缩包内共有1个文件,为task_4.py脚本,整体约2KB,代码简洁可直接阅读或改写。目前已有495人学习过这份资源。通过阅读源码,可以学习到requests下载文件、JSON数据遍历、正则替换文件名以及time.sleep控制请求频率等实用技巧,也可直接运行用于下载指定页面的小视频,适合作为爬虫入门练习或日常小工具。
1. 写在前面:为什么我决定自己写一个带进度的B站视频下载器
事情要从一次很普通的下载需求说起。我平时有收藏B站技术视频的习惯,尤其是一些长篇幅的教程和直播回放,想离线看。市面上现成的下载工具不少,但要么是图形界面太重,要么是命令行工具不支持断点、不显示进度——下载一个两小时的视频,屏幕上只有光标在闪,根本不知道是卡了还是在跑,那种体验实在太煎熬。
后来我干脆用Python自己写了一个小工具,专门针对B站视频下载,核心诉求就三个:
- 能同时下载视频流和音频流,然后合成一个完整文件;
- 实时显示下载进度,包括速度、剩余时间、百分比;
- 对单个UP主的视频列表、收藏夹、合集做批量下载时,能稳定不崩。
这篇文章不会贴完整的几千行源码,那没有意义。我会把整个项目的设计思路、关键技术点、实际遇到的坑、以及最终的性能表现全部讲清楚。无论你是刚开始学Python爬虫的新手,还是写过不少爬虫想了解B站视频流下载细节的开发者,都能从这里拿到可复用的思路。
先说明一点:本项目仅用于个人学习、备份已授权内容和个人收藏,请勿用于任何商业用途或侵犯UP主权益。
2. 解剖B站视频的下载链路:网页端播放器的背后发生了什么
想要写一个成功的B站视频下载器,必须先搞清楚B站网页端播放视频的完整流程。很多人直接拿requests.get去请求视频页面的HTML,然后试图从里面找mp4链接,结果发现根本找不到——这是最典型的初阶错误。
2.1 从播放页到视频流地址的关键请求
用户访问一个视频页面(比如https://www.bilibili.com/video/BVxxxxxx),页面本身是个SPA应用,视频地址是页面上的一段JavaScript代码动态请求回来的。真正的视频流地址藏在另一个API里:
https://api.bilibili.com/x/player/playurl?bvid=BVxxxxxx&cid=视频cid&fnval=16其中cid是视频分P的ID,和bvid是一一对应的关系。fnval=16代表请求的是DASH格式的视频流,这是B站目前主力使用的流媒体协议。
这里有个容易被忽略的点:B站把视频画面和音轨拆成了两个独立文件。视频流是纯画面(没有声音),音频流是纯声音(没有画面)。这就是为什么很多人第一次下载B站视频,拿到的文件播放时只有画面没有声音——因为他只抓了视频流。
用Python模拟请求时,关键请求头是Referer和User-Agent。Referer必须设置为https://www.bilibili.com/,否则接口会返回-412状态码(请求被拦截)。
2.2 解析视频流地址:理解JSON返回结构
请求playurl接口后,返回的JSON结构大致如下(简化版):
{ "code": 0, "data": { "dash": { "video": [ { "id": 80, "baseUrl": "https://upos-sz-mirrorcos.bilivideo.com/...", "bandwidth": 2000000, "width": 1920, "height": 1080 } ], "audio": [ { "id": 30280, "baseUrl": "https://upos-sz-mirrorcos.bilivideo.com/...", "bandwidth": 320000 } ] } } }video数组里有多档清晰度(用id区分),比如80是1080P,64是720P,32是480P。audio数组里也有不同码率可选。每个流都提供了baseUrl和backupUrl,优先用baseUrl下载,如果中途断流就切换到backupUrl。
实际写爬虫时,我们通常手动选择id=80的1080P视频流和id=30280的高码率音频流。
2.3 获取cid的两种方式
下载前要知道cid。最简单的方式是请求这个接口:
https://api.bilibili.com/x/player/pagelist?bvid=BVxxxxxx返回的JSON里有一个cid字段和page字段。如果视频是合集(多P),pagelist会返回多个条目,每个条目对应一P的标题和cid。
另一个方式是解析播放页HTML里的window.__INITIAL_STATE__变量。不少爬虫教程喜欢教这个,因为它一次性能拿到大量视频元数据。但我的建议是:能用API就用API,HTML解析对页面结构变动太敏感,B站前端经常改版,今天能解析的字段明天可能就被移除了。反而pagelist接口相对稳定,适合长期维护。
3. 进度显示的核心实现:请求头里的Range与分块下载
很多爬虫下载视频都是直接r = requests.get(url)一把梭,然后r.content写入文件。这种方式有两个致命问题:一是大文件容易内存爆掉,二是完全无法显示进度。要实现进度条,必须使用流式分块下载。
3.1 HTTP Range请求与断点续传原理
HTTP协议支持Range请求头。当客户端发送:
Range: bytes=0-1023服务器就会只返回从第0字节到第1023字节的数据块,状态码为206 Partial Content。利用这个特性,我们可以把一个大文件切成很多小块逐个下载,每下载完一块就更新进度,还能在断线后从上次的位置继续下载。
B站的视频流CDN是支持Range的,这为自主控制下载过程提供了基础。
3.2 Python requests流式下载的代码模板
用requests库实现带进度的分块下载,核心代码如下:
import requests from tqdm import tqdm def download_with_progress(url, filepath, referer="https://www.bilibili.com/"): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": referer } # 第一次请求,获取文件总大小 resp = requests.get(url, headers=headers, stream=True) total_size = int(resp.headers.get("content-length", 0)) # 使用stream=True迭代响应内容 with open(filepath, "wb") as f: with tqdm(total=total_size, unit="B", unit_scale=True, desc=filepath, ncols=100) as pbar: for chunk in resp.iter_content(chunk_size=1024 * 1024): if chunk: f.write(chunk) pbar.update(len(chunk))iter_content会按指定的chunk_size迭代响应内容,每次读入内存的只有1MB,既不会撑爆内存,又能通过tqdm实时更新进度。unit_scale=True会自动把字节数转换成KB、MB、GB显示。
运行效果是这样的:
video_1080p.mp4: 23%|██▎ | 56.8M/247M [00:12<00:40, 4.66MB/s] audio_320k.m4a: 100%|██████████| 32.4M/32.4M [00:08<00:00, 3.91MB/s]3.3 多线程分块加速:把Range用到极致
单线程下载在带宽充足的情况下已经很快了,但如果你的宽带是500M以上,单线程往往跑不满。这时可以用concurrent.futures的ThreadPoolExecutor,把一个大文件切成多个区块并行下载,每个线程负责一块Range区间,最后再按顺序拼接。
这里给出一个简化版的多线程分块下载核心逻辑:
import requests from concurrent.futures import ThreadPoolExecutor def download_range(url, start, end, filepath, idx): headers = { "User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com/", "Range": f"bytes={start}-{end}" } r = requests.get(url, headers=headers, stream=True) with open(f"{filepath}.part{idx}", "wb") as f: for chunk in r.iter_content(chunk_size=1024*1024): f.write(chunk) def multi_download(url, filepath, num_threads=4): # 1. 获取文件总大小 resp = requests.head(url, headers={"User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com/"}) total = int(resp.headers.get("content-length", 0)) # 2. 计算每个线程的区间 part_size = total // num_threads ranges = [] for i in range(num_threads): start = i * part_size end = start + part_size - 1 if i < num_threads - 1 else total - 1 ranges.append((start, end, i)) # 3. 并发下载 with ThreadPoolExecutor(max_workers=num_threads) as executor: futures = [executor.submit(download_range, url, start, end, filepath, idx) for start, end, idx in ranges] for f in futures: f.result() # 4. 合并分片 with open(filepath, "wb") as out: for i in range(num_threads): with open(f"{filepath}.part{i}", "rb") as part: out.write(part.read())这里的核心技巧是:先通过HEAD请求拿到总大小,然后均分成N份,每个线程只下载属于自己的那一段。合并时按顺序写入即可,不需要额外处理脏数据。要注意,Range的起始和结束位置是闭区间,如果计算不当会导致字节错位,下载出来的文件会损坏。
我在实际项目中测试过,4线程下载B站1080P视频,跑满300M带宽没问题。但线程数不是越多越好,开8线程以上反而会因为CDN限速机制触发风控,建议4到6线程比较稳妥。
4. 处理登录态与高清画质的鉴权问题
B站很多视频的高清画质(1080P以上、4K、8K)需要登录账号才能访问,部分视频甚至需要大会员权限。如果你用无登录状态的requests直接请求playurl接口,返回的video数组里只会包含480P以下的选项。
4.1 Cookie注入:让爬虫带上登录态
最简单的方案是手动从浏览器复制Cookie。
具体操作:打开B站并登录,按F12打开开发者工具,切换到Network面板,刷新页面,随便点一个请求,在Request Headers里找到Cookie字段,整段复制出来,粘贴到代码中即可。
COOKIES = "SESSDATA=xxx; bili_jct=xxx; DedeUserID=xxx; ..." headers = { "User-Agent": "Mozilla/5.0", "Referer": "https://www.bilibili.com/", "Cookie": COOKIES }其中SESSDATA是核心登录凭证,没有它,B站就认为你是游客。bili_jct是CSRF Token,主要给POST请求用,下载视频用不到,但爬取评论、点赞等操作会用到。
4.2 防盗链Referer的坑
我在早期版本里吃过一次大亏:请求playurl接口时Referer设置成了https://www.bilibili.com/video/BVxxxxxx,结果接口返回正常,但拿到的baseUrl下载时返回403。
原因是:B站CDN拦的是视频流的Referer,要求的Referer是播放页URL,而有些旧的CDN节点则要求Referer: https://www.bilibili.com/。不同节点策略不完全一致。
最终我采用了一个通用策略:下载视频流时,把Referer设置为视频播放页的完整URL,即https://www.bilibili.com/video/{bvid}。实测这样最稳,绝大多数节点都能放行。
4.3 大会员视频的处理方案
遇到大会员专属视频(比如4K、8K画质),普通Cookie无法获取。这时候要么使用有大会员的账号Cookie,要么接受现实,下载1080P。我个人的原则是:爬虫工具不应该去绕过付费权益,这是底线问题。工具只提供技术框架,用什么权限取决于用户自己账号的合法权利。
5. 下载后处理:音视频合并与文件命名
把视频流和音频流分别下载完成后,还差最后一步:把它们合成一个带画面和声音的完整文件。这一步我用ffmpeg命令行来搞定。
5.1 ffmpeg无损合成命令
安装ffmpeg后,一行命令即可完成合并:
ffmpeg -i video_1080p.mp4 -i audio_320k.m4a -c copy output.mp4参数说明:
-i指定输入文件,可以同时指定多个;-c copy指定不重新编码,直接把两个流的编码数据复制到输出文件,这个操作速度极快,几乎不损失画质和音质,也不会消耗CPU。
为什么视频流和音频流能直接合并?因为B站DASH流的视频编码是HEVC或AVC,音频是AAC,ffmpeg支持将它们封装进MP4容器。如果遇到编码格式不兼容的情况(比如某些特殊视频流),可以把-c copy改成-c:v libx264 -c:a aac强制转码,但会拖慢速度且损失一定画质,一般用不上。
5.2 文件名清洗与合规处理
B站的视频标题可以包含任意字符,包括/\:*?"<>|这些在Windows文件名中不允许出现的字符。直接拿标题当文件名,保存时会报错。所以我写了一个清洗函数:
import re def clean_filename(title): return re.sub(r'[\\/:*?"<>|]', '_', title)[:200]把非法字符替换成下划线,同时限制长度在200个字符以内,避免超出文件系统限制。
5.3 视频流与音频流的匹配问题
批量下载合集时,每个分P都有独立的cid,请求playurl接口时,video和audio数组是按清晰度/码率排列的。选流时要注意:不要简单取video[0]和audio[0],因为这两个数组的顺序不一定对应。正确的做法是分别遍历,选择你期望的id:
def select_stream(dash_data, target_video_id=80, target_audio_id=30280): video_url = None audio_url = None for v in dash_data["video"]: if v["id"] == target_video_id: video_url = v["baseUrl"] break for a in dash_data["audio"]: if a["id"] == target_audio_id: audio_url = a["baseUrl"] break return video_url, audio_url如果找不到对应id,再考虑降级策略:视频流寻找最低匹配的可用id,音频流同理。
6. 批量下载的架构设计:从单视频到整个收藏夹
单个视频下载跑通之后,很容易就能扩展到批量下载。收藏夹列表接口是:
https://api.bilibili.com/x/v3/fav/resource/list?media_id=收藏夹ID&pn=页码&ps=20返回数据里有每个视频的bvid和标题,拿到bvid列表后,就可以循环调用单视频下载逻辑。
6.1 生产者-消费者模式
如果收藏夹里有100个视频,最简单的方式是for循环一个接一个下载。但这有个问题:每个视频的下载时间是不同的,有的快有的慢,如果一个视频卡住了,整个队列都会卡住。
我的方案是使用线程池做并发控制。下载器本身是多线程的(多线程分块下载音视频流),但同一时间只处理1个视频,避免对服务器造成过大压力,也降低被风控的概率。实际使用中,1个视频1个视频串行下载是最稳的。如果你想同时下多个视频,可以开一个2-3个线程的池子,不建议更多。
6.2 错误重试与日志记录
网络环境再好,也难免遇到超时。我在项目中实现了一个带重试机制的下载函数:
import time def download_with_retry(url, filepath, retries=3): for i in range(retries): try: download_with_progress(url, filepath) return True except requests.exceptions.RequestException as e: print(f"第{i+1}次下载失败: {e}") time.sleep(2) return False如果连续3次失败,就把这个视频的bvid和失败原因写入一个error.log文件,跳过它继续下载下一个。全部下载完后,再统一处理error.log里的失败项,而不是让程序中断。
6.3 避免被B站风控:请求频率控制
B站对爬虫的检测是分梯度的。短时间大量请求playurl接口,可能会触发验证码或者返回-412。我的做法是:
- 每下载完一个视频,
time.sleep(3)休息几秒; - 每个视频只请求1次
playurl接口,拿到地址后直接下载,不反复请求; - 下载视频流时,不额外增加无谓的请求频率。
实测下来,一个200个视频的收藏夹,串行下载完毕不会触发风控。如果你发现自己的IP被限制,最简单的办法是更换IP或等待一段时间自动解封。
7. 踩坑实录:那些年我跌进去过的三个深坑
这一节分享一下我开发过程中遇到的最典型的三个问题,每个都花了不少时间排查。
7.1 下载到一半突然403,CDN节点抽风
现象:视频下载到50%左右,requests抛出了403 Forbidden异常。
排查过程:我一开始以为是Cookie失效或Referer错误,后来发现不是。用浏览器直接访问这个baseUrl,发现可以正常播放,只有程序下载到一半时会断。
最终发现原因:B站的CDN节点有单次请求的大小限制。对于超过一定体积的文件,长连接会在中途被服务器主动断开,断开后客户端继续请求就会返回403。
解决方案:在下载函数中捕获requests.exceptions.ChunkedEncodingError和ConnectionError,然后从断点处继续下载。断点续传的实现需要记录当前已经写入的字节数,然后在新的请求中设置Range: bytes={current}-。
def download_resumable(url, filepath, referer): downloaded = 0 if os.path.exists(filepath): downloaded = os.path.getsize(filepath) headers = { "User-Agent": "Mozilla/5.0", "Referer": referer, "Range": f"bytes={downloaded}-" } with requests.get(url, headers=headers, stream=True) as r: with open(filepath, "ab") as f: for chunk in r.iter_content(chunk_size=1024*1024): f.write(chunk)断点续传的关键是Range从downloaded开始,文件以追加模式打开。如果服务器返回的是200而不是206,说明不认Range,这时就要考虑切换backupUrl。
7.2 CDN返回的空Content-Length导致进度条卡死
现象:有时候resp.headers.get("content-length")返回None,tqdm的进度条无法初始化。
原因:部分CDN节点对HEAD请求不返回Content-Length,或者返回的格式是Content-Range没有Content-Length。
解决方案:加一个兜底逻辑,如果拿不到total_size,就设置total=None,tqdm会进入无总量模式,只显示下载速度和已下载量,不显示百分比。另外也可以在第一次GET请求中从resp.headers.get("content-range")解析总大小:
content_range = resp.headers.get("content-range") if content_range: total_size = int(content_range.split("/")[1])7.3 音视频不同步问题
现象:合并出来的文件,画面比声音慢了一秒左右。
排查过程:我一开始以为是-c copy的问题,后来仔细查看,发现是下载速度不同造成的。视频流文件大,下载时间长;音频流文件小,下载时间短。但网络波动可能导致视频流中间断流重续,重续的时候又从头开始读,最终视频文件字节数比实际范围多出来一段冗余数据。
解决方案:在断点续传逻辑中,确保追加写入后整个文件大小不超过服务器返回的总大小。如果超过,就截断到正确的字节数:
if file_size > total_size: with open(filepath, "r+b") as f: f.truncate(total_size)这样做之后,合并的视频再也没出现过不同步的问题。
8. 性能实测与参数调优参考
最后聊一聊实际运行效果。我的测试环境是:Windows 11,千兆宽带,Python 3.10,requests2.31,tqdm4.65。
测试对象是一个BV号下的1080P视频,视频时长45分钟,视频流大小约2.1GB,音频流约90MB。
- 单线程视频流下载:平均速度约8MB/s;
- 4线程视频流下载:平均速度约25MB/s;
- 音频流下载:平均速度约12MB/s;
- 音视频合并时间:约3秒。
一个45分钟的视频,从开始下载到最终得到合并完成的MP4,总耗时约2分钟。这个速度对于个人使用已经非常满意。
如果遇到速度上不去的情况,建议从以下方向排查:
- 确认本地网络到B站CDN节点延迟是不是过高,尝试切换DNS;
- 尝试更换
baseUrl和backupUrl,有时候备用节点速度反而更快; - 线程数不是越多越好,4到6线程通常是最优区间;
- 避开晚高峰时段,CDN出口带宽有限,晚间拥堵是常态。
另外提一个建议:如果你只是在B站网页上直接右键下载,或者用浏览器插件就能解决需求,其实没必要自己写爬虫。但当你的需求变成“批量下载某个UP主的所有视频”、“下载收藏夹里超过500个视频”、“下载后自动合成”、“定时增量备份某个专题”时,自己写一个工具才是效率最高的方案。
最后,我在开发过程中还有一个体会比较深:爬虫代码本身不难,难的是处理各种边界情况——文件不存在、网络波动、权限限制、格式兼容、磁盘空间不足。一个真正能长久使用的下载工具,不是跑通一个demo就完了,而是要在真实环境中反复打磨异常处理逻辑。这也是这篇文章想传递的核心经验:不只是给你一个能跑的脚本,而是让你理解它背后的原理和取舍,这样你才能在自己的场景下灵活扩展。
本文还有配套的精品资源,点击获取