简介:针对批量下载哔哩哔哩小视频的实用爬虫脚本,面向Python入门至进阶的爬虫学习者,解决视频批量采集与下载进度可视化问题。程序按循环遍历10页排行榜JSON信息,自动提取标题与视频地址,通过requests模块逐一下载,并在控制台实时显示进度;下载后随机等待3-6秒,降低请求频率过高被限制的风险,视频按标题保存于video目录并自动过滤非法字符。包内包含单个Python脚本文件,压缩包约2KB,代码简洁明了,单文件即可运行,非常适合学习requests请求、JSON解析、文件读写及进度条打印等核心知识点。已有496人学习浏览,足见其参考价值。读者可获得可直接运行的爬虫脚本与完整的下载逻辑拆解,包括翻页抓取、请求间隔控制、文件名清洗等细节,便于在此基础上进一步扩展为多线程或GUI版本。
1. B站视频下载爬虫:为什么带进度条的实现更值得抄
第一次用Python写B站视频下载器时,我以为难点在解析播放地址。写完后发现,真正卡人的是:下载到一半连接突然挂起、音视频合并时路径出错、同一资源在不同机器上表现完全不一样。这个带进度的下载爬虫项目,把“能看到进度”而不是“黑匣子式等待”作为基本功能,意味着作者处理了流式写入、Content-Length、异常重试这些环节,比网上那些只有bs4加正则的脚本靠谱得多。
适合谁?有Python基础、经常需要批量备份B站视频或做数据集收集的人。它能解决多P视频批量下载、音视频合并、登录态清晰度等问题。新手照着代码改改参数就能用,熟手可以直接拿走进度条和并发控制部分。
2. 先懂取流逻辑:BV号、cid与音视频分离的必然性
B站视频下载不是拿着BV号直接下载。播放器实际经历两次接口请求:第一次用BV号换视频详情,第二次用详情里的cid去换视频流地址。BV号是视频的唯一标识,但一个视频可能有多P,每一P有独立的cid,所以必须逐P处理。很多下载器只做了第二步,却把第一步的pages循环漏了,导致多P视频只下到第一个分P。
2.1 从BV号到cid:playurl接口的两次请求
先看第一步,用BV号请求视频详情接口。返回值里有一个独立的pages列表,每个元素对应一个分P。下面的代码完成这一步:
import requests API_BASE = "https://your-bili-api-host" # 运行前用抓包工具确认 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://www.bilibili.com", } def get_pages(bvid): params = {"bvid": bvid} resp = requests.get(API_BASE + "/x/web-interface/view", params=params, headers=HEADERS) resp.raise_for_status() data = resp.json() if data["code"] != 0: raise RuntimeError(f"API返回错误: {data['message']}") pages = data["data"]["pages"] title = data["data"]["title"] return pages, titleAPI_BASE是占位符,不是说用这个地址就能跑通。B站接口的Host和路径改过多次,而且不同业务线用的域名还不一样。我一般用浏览器开发者工具过滤playurl请求,把实际Host填进来。Referer必须写,很多人在这一步会漏,导致第二步下载时403。
resp.json()里的code字段很关键,0表示正常。如果返回-404,通常是视频不存在、已删除或地区限制。pages是一个列表,每个元素至少包含三个字段:page(从1开始的序号)、cid(这个分P的唯一内容ID)、part(分P标题)。拿到的cid要留着,下一步请求播放流全靠它。
注意bvid和av号的区别。老版本的下载器用aid参数,新接口推荐bvid。如果你手里只有av号,可以用公开算法转成bvid,但更省事的是直接用浏览器地址栏里的BV号。这个项目里全程用bvid,避免历史兼容问题。
这一步看起来简单,但它是整个下载链路的“地图”。我拿到视频后,习惯先把pages打印出来,确认总P数和标题,再决定是单P下载还是批量。有些视频标题和分P标题带表情符号,保存文件名时要处理掉,否则在Windows下会写入失败。
2.2 为什么音视频流要分离,dash与flv怎么选
拿到cid后,下一步是请求播放流接口。B站现在默认返回dash流,也就是视频轨和音频轨分开。这种设计最初是为了服务多码率切换,但对下载器意味着:你要同时下载两个文件,再用ffmpeg合并。好处是dash流支持更高清晰度,而且每个分片稳定,失败重试成本低;坏处是多一步合并。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| dash | 清晰度高,音视频分离,分段下载稳定 | 需要ffmpeg合并 | 默认首选 |
| flv | 单文件无需合并 | 清晰度受限,可能拿不到高清 | 没有ffmpeg时应急 |
请求dash流的关键参数是fnval=16。16这个数字是位掩码,表示让服务端返回dash格式。如果不传或传错,接口可能返回flv列表,但你想要的高清晰度不在里面。经验是:遇到清晰度问题,先检查fnval,再检查Cookie,最后才考虑接口变化。
params = { "bvid": bvid, "cid": cid, "fnval": 16, "fnver": 0, } resp = requests.get(API_BASE + "/x/player/playurl", params=params, headers=HEADERS) data = resp.json() dash = data["data"]["dash"] video_url = dash["video"][0]["baseUrl"] audio_url = dash["audio"][0]["baseUrl"]注意dash["video"]是一个列表,按清晰度从高到低排列。video[0]取第一个通常没问题,但严谨一点应该按bandwidth字段排序后取最大值。因为不同清晰度的同一条视频流,bandwidth不同,直接取第一个不一定就是最高码率。音频流同理。
dash返回结构里还有backupUrl字段,即备用地址。有些CDN不稳定,主地址下载到一半会断,此时可以把backupUrl列表里其他地址拿来做重试。我一般把baseUrl和backupUrl都存在字典里,下载失败时逐个尝试。
提示:播放流接口需要带Referer,否则会返回403。这也解释了为什么同一个直链在浏览器里能打开,在requests里却不行。
看到这里,B站视频下载的基本链路就清楚了:BV号 → 详情接口 → pages列表 → 每个分P的cid → playurl接口 → dash流。这个链路里最容易被忽略的是fnval=16和Referer,两行参数能拦住大多数新手。
3. 核心实现:requests取流 + ffmpeg合并 + 自定义进度
上一章把取流逻辑讲清楚了,这一章直接写下载器核心。我不会用现成的you-get或yt-dlp,而是站在这个项目角度把requests方案摊开,因为只有自己控制了取流和落盘,才能精确实现进度显示、断点续传和并发控制。
3.1 用playurl接口解析视频流地址
解析这一步的核心函数就是上一章的取流逻辑。实际项目里我会把它放到一个类里,同时缓存已经请求过的cid,避免重复请求。对于多P视频,每个cid请求一次playurl就够了,重复调用不仅慢,还可能触发频控。
def get_best_stream_urls(bvid, cid): params = { "bvid": bvid, "cid": cid, "fnval": 16, "fnver": 0, } resp = requests.get(API_BASE + "/x/player/playurl", params=params, headers=HEADERS) resp.raise_for_status() data = resp.json() if data["code"] != 0: raise RuntimeError(f"playurl接口错误: {data['message']}") dash = data["data"]["dash"] video_list = dash["video"] audio_list = dash["audio"] # 取码率最高的流,而不是简单取第一个 video_list.sort(key=lambda item: item.get("bandwidth", 0), reverse=True) audio_list.sort(key=lambda item: item.get("bandwidth", 0), reverse=True) video_url = video_list[0]["baseUrl"] audio_url = audio_list[0]["baseUrl"] return video_url, audio_url这里有一个工程习惯:video_list.sort会修改原列表,如果你后面还要用,可以改用sorted。为什么按bandwidth排序?因为清晰度不是直接写进video[0]字段的,它是个综合值,bandwidth越高的流通常画质越好。我见过一份下载器翻了车,就是因为写死了取第0个流,结果某天接口改成了音频在前,下载下来的全是音频文件。
dash里还可能没有audio,比如纯音乐视频或只有画面没有音轨的资源。此时代码会抛KeyError,我一般会先判断"audio" in dash,不存在的话只下视频轨。这种边界情况在B站鬼畜区和一些老视频上真实存在,不加判断直接卡死。
3.2 进度条:Content-Length与流式写入
进度显示的底层原理,是HTTP响应头里的Content-Length告诉我们总字节数,然后我们用stream=True分块读取,每写一块就累计一次,实时算百分比。这样做不会把整个视频加载进内存,对几GB的大文件尤其重要。
import os def download_with_progress(url, filepath, session): with session.get(url, stream=True, timeout=(5, 30)) as resp: resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) downloaded = 0 chunk_size = 1024 * 1024 with open(filepath, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) downloaded += len(chunk) if total > 0: percent = downloaded / total * 100 print(f"\r下载进度:{percent:.1f}% {downloaded}/{total}", end="") print()注意chunk_size的选择。我用1MB,对B站视频来说比较均衡。太小比如1KB会导致频繁I/O,太大比如16MB,遇到网络波动时一个块要等很久,进度条卡住会让用户以为程序死了。Content-Length有时候没有返回,这种情况下total为0,就不能算百分比,只能显示已下载字节数。
iter_content返回的是迭代器,它会在内部处理解码,但我们需要的是原始字节,所以直接用requests.get而不是requests.post,不要把stream=True写在session里,而是每次请求单独传。进度打印用了\r回到行首,效果是同一行刷新,兼容大部分终端。如果你要在日志里记录,就把print替换成logging.info,注意加一个分隔符,不然日志会糊成一行。
真正的进度条不是玄学,它就是已下载字节数 / Content-Length。这个项目做得好的地方在于把下载和进度回调分离开,下载函数只负责写文件,外部传入一个回调函数显示进度。后续想接入GUI进度条,只需要替换回调,不用改下载逻辑。
3.3 多P与批量下载:命名规则和并发上限
多P下载的核心是遍历pages,依次处理每个分P。命名要谨慎,因为Windows文件名不允许包含 / \ : * ? " < > |,B站的分P标题里经常带冒号和竖线。我先用正则清洗一遍,再拼上视频标题和分P序号。
import re def sanitize_filename(name): return re.sub(r'[\\/:*?"<>|]', "_", name) def process_page(bvid, page, title, out_dir): cid = page["cid"] part = sanitize_filename(page["part"]) base = f"{out_dir}/{title}/{page['page']:02d}_{part}" video_url, audio_url = get_best_stream_urls(bvid, cid) download_with_progress(video_url, base + "_v.mp4", session) download_with_progress(audio_url, base + "_a.mp4", session) return base文件名里我把分P序号补成两位,比如01、02,这样文件在资源管理器里按名称排序时才不会出现10排在2前面。视频标题如果太长,最好截断到50个字符以内,防止文件路径超过系统限制。out_dir是顶层输出目录,每个视频单独建一个子目录,所有分P的临时文件和最终文件都放在这个子目录里。
并发不是越多越好。B站有一个发送频率检测,短时间内请求大量接口会触发-412状态码,表现为所有请求都报错,甚至可能要输验证码。我用ThreadPoolExecutor控制最大线程数,通常设2或3。实测并发下载三个分P视频时,总时间比单线程快一倍,但线程数超过5之后,速度不再提升,反而频控风险大增。
from concurrent.futures import ThreadPoolExecutor def download_video(bvid, out_dir="downloads"): pages, title = get_pages(bvid) title = sanitize_filename(title) try: with ThreadPoolExecutor(max_workers=2) as executor: futures = [executor.submit(process_page, bvid, page, title, out_dir) for page in pages] for future in futures: future.result() except Exception as e: print(f"下载失败: {e}")executor.submit提交后返回Future,调用future.result()会等待任务完成并抛出异常。这里我没有用as_completed,是因为希望保持输出顺序和分P顺序一致,方便核对。如果某个分P失败,整个批量会中断,这是故意的,先保证能暴露问题,后续再看如何做部分失败重试。
合并音视频用ffmpeg命令,注意参数顺序。基本命令是:
ffmpeg -i input_v.mp4 -i input_a.mp4 -c copy output.mp4在Python里调用时,用subprocess.run传列表参数,不要用字符串拼接,避免路径空格问题:
import subprocess def merge(video_path, audio_path, output_path): cmd = ["ffmpeg", "-y", "-i", video_path, "-i", audio_path, "-c", "copy", output_path] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"ffmpeg合并失败: {result.stderr}")-y参数表示覆盖已有文件,不加的话如果输出文件存在,命令会交互式询问,导致subprocess.run一直挂起。-c copy是无损复制,不重新编码,速度非常快,几乎等同文件拷贝。如果两个文件的音视频编码格式不兼容,可能需要改成-c:v copy -c:a aac重新编码音频,但B站下载的dash流通常直接用copy就能合成。
4. 避坑清单:403、卡死、合并失败与清晰度限制
下面这些是实际跑下载器的血泪经验。每一条都对应一个真实翻车场景,按“现象 → 原因 → 解决”写,方便你对照排查。遇到问题不要瞎猜,先看状态码,再看请求头,然后看参数,最后看运行环境。
4.1 HTTP 403:Referer没带
现象:拿到直链后下载,requests返回403 Forbidden,但复制URL到浏览器能下,甚至用wget也能下。
原因:浏览器请求视频文件时会带上当前页面的Referer,而B站视频CDN会校验这个字段,必须是B站页面域名,否则拒绝。requests默认不带Referer,所以被拒。很多人在查询接口时记得加headers,下载文件时却新建了一个session,导致headers丢失。
解决:用一个全局session对象,并把HEADERS设在session上,确保所有请求都带着Referer。
session = requests.Session() session.headers.update(HEADERS)注意:即使把Referer写进全局headers,也不能保证所有请求都带,因为某些接口可能需要不同的Referer。比如直链下载的Referer应该是视频播放页的地址,而不是首页。如果403持续,直接抓包看你浏览器下载视频时的请求头,一字不差地复制到代码里。
我遇到过一次诡异情况:代码里明明带了Referer,还是403。后来发现是fiddler抓包时开了代理,requests默认会走系统代理,导致请求头被代理篡改。排查时如果确认代码没问题,就把proxies设为空再试一次。
4.2 下载到一半卡死,进程无响应
现象:进度条走到某个百分比后不再变化,程序不退出,也不报错。等十几分钟还是原样。
原因:requests.get如果不指定timeout,连接和读取都没有超时限制。B站CDN在某些网络环境下会建立TCP连接后长时间不发送数据,导致iter_content一直空转。另外,chunk_size设置过大也会放大不稳定网络的影响。
解决:给requests.get设置timeout=(5, 30)。第一个数字是连接超时,第二个是读超时。然后外层加一个重试循环,单次下载失败后重新发起请求,并利用第5章的断点续传逻辑接着下,不浪费已下载的部分。
for attempt in range(3): try: download_with_progress(url, filepath, session) break except requests.exceptions.Timeout: print(f"第{attempt+1}次下载超时") else: raise RuntimeError("连续多次下载失败")重试次数我一般设3,超过就抛出异常,不做无限重试。如果卡死后按Ctrl+C,Traceback会停在iter_content那行,说明它在等数据。此时用抓包工具看TCP连接,如果对端一直不返回数据,基本是CDN到你的线路问题,换个备用地址试试。
4.3 ffmpeg合并失败,报“File not found”
现象:音视频都下载成功,但跑ffmpeg合并时立刻报文件找不到,或者提示unknown encoder。
原因:两个细节。第一,subprocess.run里传的文件路径带空格,如果用字符串拼接命令,路径在空格处被拆成了两个参数,ffmpeg自然找不到文件。第二,ffmpeg根本没安装,或者不在系统的PATH里,尤其是在只装了Python环境的服务器上。
解决:用列表参数调用subprocess,避免shell解析路径。同时检查环境变量:
ffmpeg -version如果找不到命令,先安装ffmpeg并确认ffmpeg在PATH中。代码里还可以用shutil.which("ffmpeg")检查,找不到就给出清晰提示,而不是让ffmpeg的异常裸奔。另一个隐藏坑是临时文件名为空,比如base里包含的标题经过清洗后变成空字符串,导致路径末尾多一个点,下载出来的文件只有几百字节,合并时自然报错。
我自己习惯在合并前打印命令和文件大小,如果发现视频文件小于1MB,先怀疑下载不完整,而不是直接跑ffmpeg。ffmpeg的报错信息其实很有用,比如No such file or directory会告诉你具体是哪个路径的问题,但前提是你用参数列表而不是字符串拼接,否则报错位置很难回溯。
4.4 清晰度只有480P,登录Cookie没带上
现象:浏览器里能看1080P,下载出来只有480P,甚至只有360P。
原因:playurl接口根据Cookie判断账号的清晰度权限。未登录用户只能拿最低档;登录但没有大会员,也只能到1080P。另一个容易被忽视的参数是fnval=16,不带它时返回的dash列表里可能没有高码率档位。
解决:从浏览器开发者工具复制登录后的Cookie,添加到HEADERS["Cookie"]里。注意Cookie有过期时间,如果脚本运行久了清晰度突然降档,先换Cookie再试。
HEADERS["Cookie"] = "你的登录Cookie"还要注意,Cookie和账号绑定,不要泄露给别人。下载器并不能突破大会员权益,它只是如实获取当前账号能访问的最高清晰度。如果请求参数里加了fourk=1,有时候能解锁一些额外档位,但能不能下载取决于账号权限和接口是否支持。
验证清晰度的方法很简单:在get_best_stream_urls里把dash["video"]完整打印出来,看每个元素里的id和codecs字段。id不同的数值对应不同清晰度,比如30000通常是1080P,40000是1440P,50000是4K。如果你发现列表里最高只有30000,那就是Cookie权限问题;如果列表里有但下载后画质还是差,可能要重新检查排序逻辑。
5. 进阶:断点续传、并发下载与接口变化应对
基础下载器能跑通后,要进入真实长期使用的阶段,最重要的三个能力是断点续传、可控并发和接口变化防御。
断点续传的实现思路:下载前先看本地文件大小,然后给请求头加一个Range参数。服务器支持的话会返回206 Partial Content,文件打开模式变为追加写入;不支持则返回200,降级为重新下载。
def resume_download(url, filepath): existing_size = os.path.getsize(filepath) if os.path.exists(filepath) else 0 headers = dict(HEADERS) headers["Range"] = f"bytes={existing_size}-" resp = requests.get(url, headers=headers, stream=True, timeout=(5, 30)) if resp.status_code == 206: mode = "ab" else: existing_size = 0 mode = "wb" total = int(resp.headers.get("Content-Length", 0)) + existing_size downloaded = existing_size with open(filepath, mode) as f: for chunk in resp.iter_content(chunk_size=1024 * 1024): f.write(chunk) downloaded += len(chunk) print(f"\r下载进度 {downloaded/total:.1%}", end="")这一段代码看起来和普通下载差不多,但细心的人会发现,我先把已存在文件的大小算出来,再接上Range。真正的坑在于:如果服务器返回的是200而不是206,说明它忽略了Range头,此时必须清空文件从头下,否则文件会越写越坏。
并发下载方面,我推荐的方案依然是ThreadPoolExecutor加信号量,最大线程数2到3之间。B站有接口频控,短时间大量请求会触发验证码,那时候不是下载器能解决的。每个分P下载之间加入0.5到1秒的间隔,避免请求突刺。如果你要下载的列表很长,还可以加入任务队列,把失败的分P重新排队,而不是一次全部失败。
接口变化应对是所有B站爬虫的生命线。B站的前端逻辑会随版本更新,今天能用fnval=16,明天可能要多传一个platform=pc。我的习惯是写一个参数配置模块,把接口路径、参数字典、头部字典全部抽出来,不要硬编码在函数里。每次下载结果异常时,先手动抓包一次,对比参数差异,通常十分钟就能定位。从那以后,我每次写爬虫都会强制给自己留一个抓包参数对照表,下载脚本里永远不写死接口域名,这避免了很多次深夜翻车。希望帮到你。
本文还有配套的精品资源,点击获取