
简介一个使用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?bvidBVxxxxxxcid视频cidfnval16其中cid是视频分P的ID和bvid是一一对应的关系。fnval16代表请求的是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是1080P64是720P32是480P。audio数组里也有不同码率可选。每个流都提供了baseUrl和backupUrl优先用baseUrl下载如果中途断流就切换到backupUrl。实际写爬虫时我们通常手动选择id80的1080P视频流和id30280的高码率音频流。2.3 获取cid的两种方式下载前要知道cid。最简单的方式是请求这个接口https://api.bilibili.com/x/player/pagelist?bvidBVxxxxxx返回的JSON里有一个cid字段和page字段。如果视频是合集多Ppagelist会返回多个条目每个条目对应一P的标题和cid。另一个方式是解析播放页HTML里的window.__INITIAL_STATE__变量。不少爬虫教程喜欢教这个因为它一次性能拿到大量视频元数据。但我的建议是能用API就用APIHTML解析对页面结构变动太敏感B站前端经常改版今天能解析的字段明天可能就被移除了。反而pagelist接口相对稳定适合长期维护。3. 进度显示的核心实现请求头里的Range与分块下载很多爬虫下载视频都是直接r requests.get(url)一把梭然后r.content写入文件。这种方式有两个致命问题一是大文件容易内存爆掉二是完全无法显示进度。要实现进度条必须使用流式分块下载。3.1 HTTP Range请求与断点续传原理HTTP协议支持Range请求头。当客户端发送Range: bytes0-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, refererhttps://www.bilibili.com/): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: referer } # 第一次请求获取文件总大小 resp requests.get(url, headersheaders, streamTrue) total_size int(resp.headers.get(content-length, 0)) # 使用streamTrue迭代响应内容 with open(filepath, wb) as f: with tqdm(totaltotal_size, unitB, unit_scaleTrue, descfilepath, ncols100) as pbar: for chunk in resp.iter_content(chunk_size1024 * 1024): if chunk: f.write(chunk) pbar.update(len(chunk))iter_content会按指定的chunk_size迭代响应内容每次读入内存的只有1MB既不会撑爆内存又能通过tqdm实时更新进度。unit_scaleTrue会自动把字节数转换成KB、MB、GB显示。运行效果是这样的video_1080p.mp4: 23%|██▎ | 56.8M/247M [00:1200:40, 4.66MB/s] audio_320k.m4a: 100%|██████████| 32.4M/32.4M [00:0800: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: fbytes{start}-{end} } r requests.get(url, headersheaders, streamTrue) with open(f{filepath}.part{idx}, wb) as f: for chunk in r.iter_content(chunk_size1024*1024): f.write(chunk) def multi_download(url, filepath, num_threads4): # 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_workersnum_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 SESSDATAxxx; bili_jctxxx; DedeUserIDxxx; ... 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音频是AACffmpeg支持将它们封装进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]因为这两个数组的顺序不一定对应。正确的做法是分别遍历选择你期望的iddef select_stream(dash_data, target_video_id80, target_audio_id30280): 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收藏夹IDpn页码ps20返回数据里有每个视频的bvid和标题拿到bvid列表后就可以循环调用单视频下载逻辑。6.1 生产者-消费者模式如果收藏夹里有100个视频最简单的方式是for循环一个接一个下载。但这有个问题每个视频的下载时间是不同的有的快有的慢如果一个视频卡住了整个队列都会卡住。我的方案是使用线程池做并发控制。下载器本身是多线程的多线程分块下载音视频流但同一时间只处理1个视频避免对服务器造成过大压力也降低被风控的概率。实际使用中1个视频1个视频串行下载是最稳的。如果你想同时下多个视频可以开一个2-3个线程的池子不建议更多。6.2 错误重试与日志记录网络环境再好也难免遇到超时。我在项目中实现了一个带重试机制的下载函数import time def download_with_retry(url, filepath, retries3): for i in range(retries): try: download_with_progress(url, filepath) return True except requests.exceptions.RequestException as e: print(f第{i1}次下载失败: {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 下载到一半突然403CDN节点抽风现象视频下载到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: fbytes{downloaded}- } with requests.get(url, headersheaders, streamTrue) as r: with open(filepath, ab) as f: for chunk in r.iter_content(chunk_size1024*1024): f.write(chunk)断点续传的关键是Range从downloaded开始文件以追加模式打开。如果服务器返回的是200而不是206说明不认Range这时就要考虑切换backupUrl。7.2 CDN返回的空Content-Length导致进度条卡死现象有时候resp.headers.get(content-length)返回Nonetqdm的进度条无法初始化。原因部分CDN节点对HEAD请求不返回Content-Length或者返回的格式是Content-Range没有Content-Length。解决方案加一个兜底逻辑如果拿不到total_size就设置totalNonetqdm会进入无总量模式只显示下载速度和已下载量不显示百分比。另外也可以在第一次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, rb) as f: f.truncate(total_size)这样做之后合并的视频再也没出现过不同步的问题。8. 性能实测与参数调优参考最后聊一聊实际运行效果。我的测试环境是Windows 11千兆宽带Python 3.10requests2.31tqdm4.65。测试对象是一个BV号下的1080P视频视频时长45分钟视频流大小约2.1GB音频流约90MB。单线程视频流下载平均速度约8MB/s4线程视频流下载平均速度约25MB/s音频流下载平均速度约12MB/s音视频合并时间约3秒。一个45分钟的视频从开始下载到最终得到合并完成的MP4总耗时约2分钟。这个速度对于个人使用已经非常满意。如果遇到速度上不去的情况建议从以下方向排查确认本地网络到B站CDN节点延迟是不是过高尝试切换DNS尝试更换baseUrl和backupUrl有时候备用节点速度反而更快线程数不是越多越好4到6线程通常是最优区间避开晚高峰时段CDN出口带宽有限晚间拥堵是常态。另外提一个建议如果你只是在B站网页上直接右键下载或者用浏览器插件就能解决需求其实没必要自己写爬虫。但当你的需求变成“批量下载某个UP主的所有视频”、“下载收藏夹里超过500个视频”、“下载后自动合成”、“定时增量备份某个专题”时自己写一个工具才是效率最高的方案。最后我在开发过程中还有一个体会比较深爬虫代码本身不难难的是处理各种边界情况——文件不存在、网络波动、权限限制、格式兼容、磁盘空间不足。一个真正能长久使用的下载工具不是跑通一个demo就完了而是要在真实环境中反复打磨异常处理逻辑。这也是这篇文章想传递的核心经验不只是给你一个能跑的脚本而是让你理解它背后的原理和取舍这样你才能在自己的场景下灵活扩展。本文还有配套的精品资源点击获取