☰
Python爬虫实战:B站视频下载器核心原理与进度条实现
2026/10/19 13:33:09 网站建设 项目流程

简介:面向Python爬虫初学者与B站内容收藏者,一个基于requests库的哔哩哔哩排行榜小视频批量下载脚本,程序遍历10页JSON数据,解析每页排行榜的视频标题与下载地址,调用requests模块实现下载,并在控制台实时打印进度百分比,适合作为入门爬虫的练手项目。压缩包为zip格式,总计1个Python文件,包体约2KB,轻量无额外依赖,下载后即可直接运行。目前已有496人学习/下载,代码结构清晰,便于快速改造复用。脚本在主入口创建Crawl类实例,逐页抓取信息并保存到video目录,自动将标题中的非法字符替换为空,下载后随机等待3-6秒再发起下一次请求,可有效规避访问频率限制。通过这个项目,读者能直观理解反爬节奏控制、文件命名规范与基础异常处理,还可将其扩展至其他视频站的批量抓取场景。

1. B站视频下载爬虫:带进度条的下载器,比网盘离线好用

某个周末想补一个系列纪录片,网页端看着看着就卡顿,官方客户端又要登录还得装全家桶。花一个下午写了个带进度条的下载器,输入视频链接,自动解析出高清音视频流,分片下载到本地再用 ffmpeg 合成完整 MP4,全程终端里能看到每个分片的实时百分比。项目本身不复杂,核心就是三步:从视频页拿到播放信息、拼接真实的流媒体地址、边下边回调进度。对正在学 Python 爬虫、想搞明白 API 签名和流媒体下载原理的人来说,这是一份很完整的实战样本;对只想批量备份收藏夹的人,换个输入源就能当工具用。爬虫的乐趣就在这种拆盲盒的过程:你永远不知道 B 站下一次改接口会带来什么惊喜(或者惊吓)。

2. 架构与选型:requests 加标准库,不引重型框架

2.1 技术栈:为什么是 requests 而不是 selenium

这类下载器最常见的翻车点,是把简单问题复杂化。有人一上来就上 scrapy 框架,有人用 playwright 模拟浏览器,本质都是杀鸡用牛刀。视频页是公开页面,真实地址藏在 JS 发起的 XHR 请求里,直接用 reqeusts 模拟这个请求就能拿到,根本不需要渲染引擎。

# 核心依赖就这么三样 requests # HTTP 请求 tqdm 或自绘进度条 ffmpeg # 合并音视频流,独立进程调用

requests 负责拿数据,ffmpeg 负责把分离的音频流和视频流合成一个文件,进度条用标准库就能自己算。整套流程没有任何需要编译的组件,Python 3.8+ 直接跑。

为什么不用 selenium?一是启动浏览器实例太笨重,二是爬取速度会被真实渲染拖慢几十倍,三是反爬识别 headless 浏览器的特征并不难。API 请求的构造逻辑并不复杂,握住 UA、Referer、Cookie 这三件套就能过。把时间花在理解接口签名上,比研究怎么藏浏览器指纹有价值得多。

提示:如果视频页强依赖 JS 渲染且接口加密复杂,再考虑上 selenium 不迟。B 站属于接口半开放型,requests 足够。

2.2 项目结构:三个模块一个入口

拿到源码第一件事是看结构,这个项目的组织方式比较简单直白,每个文件职责单一,改起来不会东拉西扯。

bili_downloader/ ├── main.py # 入口,接收 URL 参数 ├── parser.py # 解析视频页,取 cid、标题 ├── downloader.py # 下载音视频流,带进度回调 ├── merger.py # 调 ffmpeg 合并文件 ├── config.py # UA、Referer、请求超时等常量 ├── requirements.txt └── output/ # 下载产物目录

main 里调 parser,拿到流地址后交给 downloader,下完再调 merger,三个模块各管一段。我比较喜欢这种平铺式结构,新手接手时顺着调用链读一遍就能理清思路,不需要画时序图,也不存在循环引用的隐患。

config.py 单独拎出来是血泪经验换来的:B 站反爬策略变化快,UA 和 Referer 可能在某次更新后失效,集中管理方便一处修改全盘生效。搜索热时传的 python 爬虫模板大多是单文件几百行,调试时改一行要翻大半天,这种拆分方式值得参考。

2.3 完整流程:从网页链接到音视频流的四步走

整个下载链路可以拆成四个明确步骤,每一步对应一个函数,链路清晰后代码自然好写。

第一步,请求视频页 HTML。这一步拿到的是一堆包含初始化数据的 JS 变量,称为window.__INITIAL_STATE__,里面写死了视频的基本信息:标题、分P列表、每个分P对应的 cid。注意这里要带 Referer,否则返回的页面可能是验证码页。

第二步,取 cid 和分P信息。从初始数据里把cid提取出来,这是后续所有接口的「身份证」。一个视频有多个分P时,cid 是数组,需要遍历下载。

第三步,请求播放地址接口。把视频的bvid和cid传给播放接口,返回 JSON 里包含dash字段,这里就是高清流媒体地址:video数组对应视频流,audio数组对应音频流,各有不同清晰度可选。选择策略一般取bandwidth最大的那条,也就是画质优先。

第四步,并行下载音视频流,进度回报,合并。拿到 URL 后发起 GET 请求,stream=True流式写入本地文件,每写一块就计算已下载字节数占总字节的百分比,回调给进度条。两个流都下完后,调 ffmpeg 执行-i video.m4s -i audio.m4s -c copy output.mp4,秒级完成,纯复制流再封装格式,不涉及重编码。

# 流程伪代码,实际逻辑分割在三个模块里 url = "https://www.bilibili.com/video/BVxxx" title, cid_list = parser.parse(url) for cid in cid_list: video_url, audio_url = parser.get_streams(bvid, cid) downloader.download(video_url, title + "_v.m4s") downloader.download(audio_url, title + "_a.m4s") merger.merge(title)

参数说明:bvid是视频唯一标识,cid是分P唯一标识,两者组合唯一确定一条视频流。下载时把timeout设为 (连接超时, 读取超时) 二元组,避免某个分片卡死导致整个任务挂起。合并时务必用-c copy,否则高清片源会被转码压缩,画质损失肉眼可见。

3. 核心模块拆解:一个链接到成片的完整链路

3.1 解析模块:从初始化数据里挖出 cid

解析模块的核心难点不是正则,而是怎么在混杂的 JS 里稳定提取 JSON 结构。B 站的页面数据偶有变体,直接正则匹配整段变量容易翻车,我一般先定位到window.__INITIAL_STATE__=的位置,然后按花括号配对截取,再交给json.loads。

import re import json def parse_initial_state(html: str) -> dict: # 定位变量标记,后面整段是 JSON marker = "window.__INITIAL_STATE__=" start = html.index(marker) + len(marker) # 从标记之后逐字符数花括号,找到匹配的右括号 depth = 0 for i in range(start, len(html)): if html[i] == "{": depth += 1 elif html[i] == "}": depth -= 1 if depth == 0: return json.loads(html[start:i + 1]) raise ValueError("initial state parse failed")

这段代码的逻辑说明:找标记位置,然后做花括号配对,遇到{深度加一,遇到}深度减一,深度归零时说明截到了完整的 JSON 末尾。这种解法比正则靠谱,因为变量内部的字符串可能包含花括号字符,但 JSON 转义后不会让配对提前结束。

拿到初始数据后,video_data在videoData字段里,分P列表在pages数组里,每项包含cid和part标题。解析函数返回(视频标题, [(分P标题, cid), ...])的元组结构,方便调用方直接迭代。标题里如果有/或 Windows 非法字符,顺手替换成_,合并阶段就不会报路径错误。

3.2 播放地址获取:构造请求拿真实流媒体地址

B 站播放地址接口返回的数据里,dash字段是高清流的关键。构造请求时需要带完整的 Referer 和 UA,否则接口会拒绝返回dash数据,只给低清格式。这就是很多下载器「只能下 480P」的直接原因——没带 Referer 被策略降级了。

def get_stream_urls(bvid: str, cid: int) -> tuple: api = "https://api.bilibili.com/x/player/playurl" params = { "bvid": bvid, "cid": cid, "fnval": 4048, # 关键参数:16 表示 dash 格式 "fourk": 1, } headers = { "Referer": f"https://www.bilibili.com/video/{bvid}", "User-Agent": config.UA, } resp = requests.get(api, params=params, headers=headers, timeout=10) data = resp.json()["data"] # 视频流和音频流都按码率排降序,取第一条是画质最优 video = sorted(data["dash"]["video"], key=lambda x: -x["bandwidth"])[0] audio = sorted(data["dash"]["audio"], key=lambda x: -x["bandwidth"])[0] return video["baseUrl"], audio["baseUrl"]

参数说明:fnval是核心开关,它是位掩码参数,4048 展开后包含dash=1、platform=html5等多重能力,直接照抄即可;fourk=1允许返回 4K 清晰度;bandwidth是码率值,数值越大画质越高,取第一条是优先高画质。

拿到baseUrl后会发现地址带?查询参数,包含过期时间戳和签名。这类地址通常有 2 小时有效期,所以解析和下载之间不要拖太久,否则地址过期会返回 403。把两个地址一并传给下载器时,我习惯把video和audio的 URL 分别存入字典,并标注清晰度,方便二次开发时做清晰度选择。

3.3 下载与进度回调:分块写入与百分比计算

下载器是进度条的核心载体。Requests 的iter_content方法是流式下载的关键,它按chunk_size(通常是 1024 的倍数)从网络缓冲区取一块数据就写入文件一块,同时累计已下载字节数。总字节数从响应头的Content-Length字段拿,两者一比就是当前进度。

def download_file(url: str, save_path: str, progress_callback=None): # stream=True 是关键,不设置的话会将整个文件读进内存 resp = requests.get(url, headers=config.HEADERS, stream=True, timeout=(5, 30)) total = int(resp.headers.get("Content-Length", 0)) done = 0 with open(save_path, "wb") as f: # 分块写入,避免大文件撑爆内存 for chunk in resp.iter_content(chunk_size=1024 * 256): if chunk: f.write(chunk) done += len(chunk) # 回调函数计算百分比,由调用方决定怎么展示 if progress_callback: progress_callback(done, total) return save_path

逻辑说明:iter_content按 256KB 一块迭代,内存占用稳定在几 MB 级别,下载一部 2GB 的 4K 视频也不怕。Content-Length是预期的总长度,如果服务器返回空(分块传输),total为 0 时进度条会显示为无法预估,此时可以降级为「已下载 MB」展示。

进度回调函数由 main 传入,负责把(done, total)渲染成可视化的进度条。我常用tqdm库,但它也可以自定义:算百分比、渲染#字符、打印\r回车刷新。自定义的好处是能同时在进度条后面显示实时下载速度(用done的差值除时间间隔),排查网络问题更直观。回调里注意线程安全问题:如果音视频两个流并行下载,各自在自己进程里回调,就不存在锁问题。

# 简单自绘进度条示例 def show_progress(done: int, total: int): percent = done / total * 100 if total else 0 bar_len = 30 filled = int(bar_len * percent / 100) bar = "#" * filled + "-" * (bar_len - filled) print(f"\r[{bar}] {percent:.1f}% ({done // 1024 // 1024}MB/{total // 1024 // 1024}MB)", end="", flush=True)

这个进度条函数每写一块就打印一次,\r让光标回到行首实现原地刷新,flush=True强制立即输出。end=""避免换行。看起来简陋但信息量足够:百分比、已下载量、总量。参数done和total都是字节数,换算成 MB 展示更友好。下载完成后记得打印一个换行,否则终端提示符会黏在进度条末尾。

4. 运行与避坑:从环境准备到反爬踩坑实录

4.1 环境准备与参数说明

这份代码对运行环境几乎没要求,安装了 Python 3.8 以上版本就能跑。依赖用 pip 一次性装上:

pip install requests

ffmpeg 需要单独安装。Windows 下把ffmpeg.exe放进 PATH 或用merger.py里的绝对路径指定都行。在config.py里预留了FFMPEG_PATH常量,默认取ffmpeg,但遇到某些精简版系统找不到命令时,改成完整路径即可。

# config.py 完整内容,直接照抄可以用 UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" HEADERS = { "User-Agent": UA, "Referer": "https://www.bilibili.com", } FFMPEG_PATH = "ffmpeg" # 如果 ffmpeg 不在 PATH 里,改成绝对路径 OUTPUT_DIR = "output"

运行方式很直接:python main.py "视频URL链接"。main 函数从命令行参数取 URL,调用 parser 解析出标题和 cid 列表,遍历每个分P下载,最后合并。默认并发数 1 是为了稳妥,二次开发时可以用concurrent.futures把多个分P的下载丢进线程池。

下载产物在output/目录下,每个视频独立一个子目录:视频标题/分P序号_分P标题.mp4。如果下载中途中断,_v.m4s和_a.m4s半成品文件会留在目录里,重新运行时会先清理同名文件再下载,避免新老数据拼接导致花屏或音画不同步。

4.2 避坑指南:反爬、合并与中断的三个高频问题

这一节是我实际反复踩过的坑集合,每一条都对应一个「现象→原因→解决」的闭环。

问题一:播放接口返回 403 或只给低清流

现象:解析播放地址时,请求返回 403,或者dash字段缺失,只能拿到 360P/480P 的备用流。

原因:请求头里没带Referer或者 UA 太老。B 站的播放接口强制校验来源页,没有 Referer 视为非法请求;UA 不符合主流浏览器特征时,接口拒绝返回高码率流。

解决:构造请求头时固定设置Referer为https://www.bilibili.com/video/加视频 bvid,User-Agent复制最新版 Chrome 的 UA。这两项同时带上,亲测 1080P 和 4K 都可以正常拿。出现 403 时先检查这两项,不要急着怀疑 IP 被风控,八成就是请求头缺字段。

问题二:合并完成但音画不同步,或视频无法播放

现象:ffmpeg 合并没有报错,产出的 MP4 能打开,但声音和画面位置对不上,或者播放器提示文件损坏。

原因:音频流和视频流的下载时长不一致。常见原因是下载音频时被重试机制中断过,文件尾部缺了一截数据。另一种情况是只下载了部分分片就强制退出,生成的 m4s 文件本身不完整。

解决:下载阶段对每个流做完整性和大小校验,拿到的文件实际字节数要等于Content-Length声明的字节数,不一致就重新下载。合并命令务必用-c copy,不要手动指定编码格式,-c copy是流复制,不做解码重编码,速度和画质都有保证。还有个小细节:合并前确认两个流的时长误差小于 0.5 秒,超了就重新下对应流。

问题三:下载到一半卡住,长时间没有进度

现象:进度条停在某个百分比不动,等很久也没有变化,最后报了超时异常。

原因:请求的timeout参数没设置或者设置不完整。requests的timeout如果不给,连接阶段会一直阻塞;只给单一数字则只管连接不管读取超时,服务器断流后读不到数据还是会卡死。

解决:统一用元组(5, 30)设置超时:5 秒建立连接,30 秒读不到新数据就抛异常。配合自定义重试装饰器,捕获到超时异常后从头继续下载,最多重试 3 次。这类卡住现象多发生在网络高峰期,重试时也可以换个 CDN 节点,但加了超时和重试后基本能自愈。

5. 二次开发思路:改造成批量采集与监控工具

5.1 从单视频到批量下载,改动很小

当前入口只接收一个 URL,要批量备份收藏夹里的视频,一个简单的办法是在 main 里加一层循环,读取一个包含多个 BVID 的文本文件,逐行调用现有链路。改动量不到十行:文件读取、跳过空行、把原有下载逻辑包进循环,每个视频放在独立子目录。如果需要更细的控制,可以加个--max-pages参数,限制每个视频只下前几个分P,应对那些「一个视频拆了几十P」的合集。

# batch_download.py 新增的入口文件 with open("bvid_list.txt", "r", encoding="utf-8") as f: bvids = [line.strip() for line in f if line.strip()] for bvid in bvids: url = f"https://www.bilibili.com/video/{bvid}" main(url) # 复用原有主流程

这个扩展思路的关键点在于复用:主干链路不动,只替换输入源,就从一个单文件下载器变成了批处理工具。批量场景要注意请求频率,每个视频之间加 1-2 秒随机延时,防止触发风控导致整个 IP 被限。

5.2 验证方法:改完怎么确定没改坏

二次开发最怕的是改一处坏三处。我的验证习惯是:每个功能分支至少跑通一个真实 URL、一个异常 URL、一个非法参数。真实 URL 验证主链路没断,异常 URL 验证错误处理没崩,非法参数验证参数校验是否生效。特别是在改动请求头字段时,务必回归测试不同清晰度的视频,因为 UA 和 Referer 的改动影响面最大。

拿这批代码练手时,还可以顺手做一个下载记录的csv日志,记录每个视频的标题、清晰度、耗时、文件大小。跑完一批后看日志就能知道哪些视频失败了,失败原因也能一目了然。这套思路不限于这个项目,任何爬虫工具维护到后面,可观测性都比再封装一层重要。

从那以后我每次改完这类爬虫代码,都会强制走一遍「目标URL正常下载→异常网络重试→断点续传」三件事,确认没有回归才收工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询