简介面向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 请求streamTrue流式写入本地文件每写一块就计算已下载字节数占总字节的百分比回调给进度条。两个流都下完后调 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: fhttps://www.bilibili.com/video/{bvid}, User-Agent: config.UA, } resp requests.get(api, paramsparams, headersheaders, timeout10) data resp.json()[data] # 视频流和音频流都按码率排降序取第一条是画质最优 video sorted(data[dash][video], keylambda x: -x[bandwidth])[0] audio sorted(data[dash][audio], keylambda x: -x[bandwidth])[0] return video[baseUrl], audio[baseUrl]参数说明fnval是核心开关它是位掩码参数4048 展开后包含dash1、platformhtml5等多重能力直接照抄即可fourk1允许返回 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_callbackNone): # streamTrue 是关键不设置的话会将整个文件读进内存 resp requests.get(url, headersconfig.HEADERS, streamTrue, 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_size1024 * 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, flushTrue)这个进度条函数每写一块就打印一次\r让光标回到行首实现原地刷新flushTrue强制立即输出。end避免换行。看起来简陋但信息量足够百分比、已下载量、总量。参数done和total都是字节数换算成 MB 展示更友好。下载完成后记得打印一个换行否则终端提示符会黏在进度条末尾。4. 运行与避坑从环境准备到反爬踩坑实录4.1 环境准备与参数说明这份代码对运行环境几乎没要求安装了 Python 3.8 以上版本就能跑。依赖用 pip 一次性装上pip install requestsffmpeg 需要单独安装。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/加视频 bvidUser-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, encodingutf-8) as f: bvids [line.strip() for line in f if line.strip()] for bvid in bvids: url fhttps://www.bilibili.com/video/{bvid} main(url) # 复用原有主流程这个扩展思路的关键点在于复用主干链路不动只替换输入源就从一个单文件下载器变成了批处理工具。批量场景要注意请求频率每个视频之间加 1-2 秒随机延时防止触发风控导致整个 IP 被限。5.2 验证方法改完怎么确定没改坏二次开发最怕的是改一处坏三处。我的验证习惯是每个功能分支至少跑通一个真实 URL、一个异常 URL、一个非法参数。真实 URL 验证主链路没断异常 URL 验证错误处理没崩非法参数验证参数校验是否生效。特别是在改动请求头字段时务必回归测试不同清晰度的视频因为 UA 和 Referer 的改动影响面最大。拿这批代码练手时还可以顺手做一个下载记录的csv日志记录每个视频的标题、清晰度、耗时、文件大小。跑完一批后看日志就能知道哪些视频失败了失败原因也能一目了然。这套思路不限于这个项目任何爬虫工具维护到后面可观测性都比再封装一层重要。从那以后我每次改完这类爬虫代码都会强制走一遍「目标URL正常下载→异常网络重试→断点续传」三件事确认没有回归才收工。希望帮到你。本文还有配套的精品资源点击获取