资讯详情 Python实现的B站视频下载器:带进度条与断点续传
📅 2026/10/19 16:51:31
简介这是一份用于批量下载哔哩哔哩小视频的Python爬虫脚本面向对爬虫实战感兴趣的初学者或个人视频收集爱好者。脚本以命令行方式运行能够循环遍历10页排行榜JSON数据提取视频标题与下载地址并通过requests模块完成下载每个文件下载时会在控制台实时打印进度便于观察任务状态。视频统一保存至本地video目录文件名采用视频标题并自动替换非法字符避免保存失败同时每完成一次下载会随机等待36秒降低请求频率过高被限制的风险。压缩包仅含1个py文件大小2KB结构精简、即下即用适合作为爬虫入门练习或快速改造为自定义下载工具。目前已有496人学习下载代码逻辑清晰可直接运行并配合简单注释理解HTTP请求、JSON解析、文件写入与延时控制等核心知识点。1. 一个需求逼出来的下载器带进度条的B站视频爬虫想离线看某个Up主的系列教程手机App缓存只能自己看电脑上却没有官方下载入口。手动一页页开开发者工具找视频地址太折腾于是我花了一个晚上写了这个脚本输入视频链接或BV号自动解析出视频流地址把分片一段段拉回本地合并成完整文件全程带进度百分比和下载速度。这个项目不依赖浏览器模拟纯 Python 实现核心只有三件事解析接口拿到真实地址、流式下载分片、合并成可播放文件。适合刚写完基础爬虫、想接触文件下载和进度回显的开发者也适合需要批量备份自己收藏视频的人。项目代码结构清楚能直接跑也能作为二次开发的脚手架。2. 技术选型与目录结构从 requests 到 ffmpeg 的一条链路2.1 依赖选择不引入重量级框架这个项目的定位是轻量、透明不搞 Selenium 无头浏览器那套。数据抓取用的是 requests解析视频流地址用的是标准库 json进度显示是自己用字节数算的。为什么不用 scrapy因为这里只有一个站点、两三个接口scrapy 的中间件和 pipeline 在这种场景下完全是负担。为什么不用 playwrightB站视频地址接口是纯 HTTP 请求不需要执行页面 JS 去动态渲染杀鸡不用牛刀。依赖清单如下requirements.txt 里就三行核心内容包名用途说明requestsHTTP 请求拿接口数据、流式下载分片ffmpeg音视频合并DASH 格式的音轨和视频轨需要它合成tqdm可选想要更花哨的进度条可以装不装也不影响这里说一句ffmpeg 不是 Python 包是独立可执行文件。它只在处理 DASH 分片时被调用Windows 用户需要把 ffmpeg.exe 所在目录加进 PATHmacOS 用户直接 brew install ffmpeg 就行。如果你的运行环境没有 ffmpeg程序会自动降级成只下载、不合并输出两个半成品文件这个逻辑后面讲。2.2 工程结构每个模块只干一件事代码按职责拆成了四个文件没有硬塞进单文件脚本里方便后续扩展。目录长这样bili_dl/ ├── bili_dl.py # 入口解析命令行参数调度整个流程 ├── config.py # 请求头、超时时间、分片大小等常量配置 ├── core/ │ ├── __init__.py │ ├── parser.py # 解析BV号、cid、视频流地址 │ ├── downloader.py # 分片下载、断点续传、进度回显 │ └── merger.py # flv拼接、dash音视频合并 └── requirements.txt入口文件只负责读参数和调流程config.py 存放 UA、Referer、超时值这些容易改的配置core 底下三个模块各司其职。这样拆的好处是你只想换一个下载方式改 downloader.py 就行不会动到解析逻辑想支持其他站点照着 parser.py 的返回值格式写一个新解析器即可。2.3 下载流程状态机从 BV 号到本地文件的四步整个下载流程可以看作一个有限状态机状态依次是解析稿件信息、获取播放地址、下载分片、合并文件。任一步失败都允许重试不会从头再来。第一步用户输入一个分享链接和 BV 号程序提取出 BV 号。第二步请求稿件信息接口拿到 cid视频分P的唯一ID。第三步用 BV 号加 cid 请求播放地址接口返回的内容里包含音频流和视频流的 URL 列表每个 URL 对应一个分片。第四步对每个分片发起流式请求一边读一边写文件同时累加字节数计算进度。所有分片下载完成后进入合并阶段。这个流程里最容易翻车的是第三步和第四步之间播放地址接口返回的 URL 有时效性通常几小时到几天不等所以解析和下载必须连贯执行不能把地址存下来第二天再用。我在代码里也做了应对如果下载时服务器返回 403 或链接过期程序自动回到第二步重新解析一次再继续下载。3. 核心模块拆解一个视频从解析到落盘的完整链路3.1 稿件信息解析拿到视频的真实“身份证”所有视频在B站内部都对应一个 cid先要拿到它才能继续。这一段代码实现了解析用户输入的链接提取 BV 号然后请求稿件接口import re import requests def extract_bvid(url_or_bvid: str) - str: 从分享链接或纯BV号中提取BV号 m re.search(r(BV[0-9A-Za-z]), url_or_bvid) if not m: raise ValueError(未识别到BV号请检查输入的链接) return m.group(1) def get_cid(bvid: str, session: requests.Session, p: int 1) - int: 根据BV号和分P序号获取cid api https://api.bilibili.com/x/web-interface/view resp session.get(api, params{bvid: bvid}) resp.raise_for_status() data resp.json()[data] if pages not in data: return data[cid] return data[pages][p - 1][cid]逻辑说明extract_bvid 用正则匹配任意包含 BV 开头的字符串这样用户粘贴整个分享链接也能处理。get_cid 请求稿件详情接口返回的 JSON 里 pages 数组按顺序对应每个分Pp 参数就是要下载第几P默认 1。pages 不存在时说明是单P视频直接返回顶层 cid。参数说明session 是复用连接用的 requests.Session能保持 keep-alive减少重复握手开销。p 从 1 开始和网页端的分P序号一致用户不需要理解 0 索引。3.2 获取播放地址选清晰度、判断DASH格式拿到 cid 之后请求播放地址接口核心参数有三个bvid、cid、fnval。fnval 决定返回的是 DASH 格式音视频分离还是老式 FLV 分片def get_playurl(bvid: str, cid: int, session: requests.Session, quality: int 64): 获取视频流地址列表 api https://api.bilibili.com/x/player/playurl params { bvid: bvid, cid: cid, fnval: 4048, # 开启DASH音视频分离返回 fourk: 1, # 允许4K清晰度 qn: quality, # 期望画质 } resp session.get(api, paramsparams) resp.raise_for_status() data resp.json()[data] dash data.get(dash) if dash: video_url dash[video][0][baseUrl] audio_url dash[audio][0][baseUrl] return {type: dash, video: video_url, audio: audio_url} # 兜底老式flv分片 urls [item[url] for item in data.get(durl, [])] return {type: flv, parts: urls}逻辑说明返回质量参数 qn 是期望值不一定能拿到接口会根据稿件最高码率自动降级。fnval 设成 4048 表示请求 DASH 格式DASH 会把视频轨和音频轨分开返回。优先取清晰度最高的一条代码里写的是 video[0]因为接口默认按码率从高到低排序如果想要第 2 个清晰度改成 video[1] 即可。参数说明quality 的取值含义是 16360P32480P64720P801080P。注意 1080P 以上需要登录 Cookie否则接口会返回 403 或者直接降级到 480P这一点在配置里预留了 cookie 字段。flv 分支是给少数不支持 DASH 的旧视频兜底的返回多个分片地址。提示播放地址接口返回的 URL 带防盗链签名必须配合正确的 Referer 和 UA 才能访问。这是整个项目里最常见的“玄学”问题来源第四章节会细说。3.3 带进度的分片下载边下边算百分比标题里的“含进度”是这个项目比较有含金量的部分。不依赖 tqdm直接用字节数计算逻辑完全透明import os import time def download_file(url, filepath, session, expected_sizeNone, progress_cbNone): 下载单个分片支持断点续传 headers {} done 0 if os.path.exists(filepath): done os.path.getsize(filepath) headers[Range] fbytes{done}- # 续传关键 start time.time() with session.get(url, headersheaders, streamTrue, timeout(5, 30)) as r: r.raise_for_status() total expected_size if total is None: total int(r.headers.get(Content-Length, 0)) done with open(filepath, ab) as f: for chunk in r.iter_content(chunk_size1024 * 32): if not chunk: continue f.write(chunk) done len(chunk) if progress_cb: speed done / max(time.time() - start, 0.001) progress_cb(done, total, speed) return filepath逻辑说明这里做了断点续传。文件已存在时先读本地大小拼上 Range 请求头告诉服务器“从第 N 个字节开始给我”服务器返回 206 而不是 200程序以追加模式写文件不会重复下载。这是对付大文件和网络抖动的关键写法很多新手下载到一半断了就要重来其实就是少了这一段。参数说明streamTrue 让响应体变成迭代流不会一次性载入内存。timeout(5, 30) 是连接超时 5 秒、读数据超时 30 秒读超时很重要B站的大文件偶尔会出现几秒不传数据的情况不设超时程序会一直卡住。chunk_size 设 32KB这个值不是随便取的实测太小则 CPU 开销高太大会导致进度条更新迟钝32KB 比较均衡。progress_cb 是回调函数接收三个参数由调用方决定怎么显示。3.4 总进度调度多分片时如何计算整体百分比单分片进度好算但视频被切成很多个分片时用户关心的是“整个视频下了多少”我写了一个调度器来汇总各分片进度class DownloadTask: def __init__(self, parts): self.parts parts # [(url, filename, size), ...] self.part_index 0 self.done_bytes 0 self.total_bytes sum(p[2] for p in parts) def download_all(self, session): for url, name, size in self.parts: download_file( url, name, session, expected_sizesize, progress_cbself.on_part_progress, ) self.part_index 1 def on_part_progress(self, done, total, speed): self.done_bytes sum(p[2] for p in self.parts[:self.part_index]) self.done_bytes done percent self.done_bytes / self.total_bytes * 100 print(f\r总进度 {percent:.1f}% f{self.done_bytes / 1024 / 1024:.1f}MB / f{self.total_bytes / 1024 / 1024:.1f}MB f{speed / 1024:.0f}KB/s, end)逻辑说明total_bytes 是所有分片大小的总和提前算好。每下载完一个分片就把这个分片的完整字节数加进 done_bytes当前分片内部的实时进度再加进来这样进度就是全局的不会出现“第一个分片卡住但显示50%”的错觉。参数说明\r 是回车不换行让进度条原地刷新。speed 是实测瞬时速度不是平均速度。这个类后续扩展成多线程也很方便每个线程持有一个 DownloadTask 实例即可。3.5 合并FLV 拼接和 DASH 音视频合成DASH 格式下载下来是 .m4s 的视频轨和音频轨直接用二进制拼接是放不了的必须走 ffmpeg。老式 FLV 分片则可以直接拼接import subprocess def merge_dash(video_path, audio_path, output_path): 用ffmpeg合并音视频轨 cmd [ ffmpeg, -y, -i, video_path, -i, audio_path, -c:v, copy, # 视频流不重编码速度快 -c:a, copy, output_path, ] subprocess.run(cmd, checkTrue, capture_outputTrue) def merge_flv(parts, output_path): 二进制直接拼接flv分片 with open(output_path, wb) as out: for p in parts: with open(p, rb) as f: out.write(f.read())逻辑说明ffmpeg 命令里 -c:v copy 和 -c:a copy 表示不重新编码只做封装格式转换几秒就能完成CPU 占用也低。如果去掉 copyffmpeg 会重新压缩视频耗时几十倍且画质有损。FLV 拼接就更简单了读一个文件往输出文件里追加适合接口没返回 DASH 的旧视频。参数说明-y 表示覆盖已存在的输出文件避免子进程询问交互。capture_outputTrue 会把 ffmpeg 日志吞掉失败时用 checkTrue 抛出异常带出 stderr 信息排查时再打印。4. 跑通与避坑从零运行到常见问题排查4.1 首次运行三步验证全链路环境准备这块假设你已经装好 Python 3.8。先创建虚拟环境再装依赖pip install requests然后找一个自己账号里公开的视频链接或者任意一个不需要登录就能看的稿件执行python bili_dl.py https://www.bilibili.com/video/BV1xx411c7XX --format dash看到终端里出现百分比数字并且不停往上涨说明解析、下载、进度回显三个阶段都正常。下载完成后在同目录检查有没有生成对应的 .mp4 文件双击能播放就算全链路通了。命令行参数说明第一个位置参数接受完整链接或 BV 号--format 指定 dash 或 flv--quality 指定期望清晰度--outdir 指定输出目录。这些在入口文件里用 argparse 实现具体代码见下一章。播放地址、cid 这些中间产物可以加一个 --save-meta 参数输出成 JSON 文件方便排查问题。第一次跑建议带上这个参数万一下载失败能直接看接口返回的原始数据结构。4.2 避坑指南五个高频踩坑点以下是根据实际使用整理的高频问题每条都是按现象、原因、解决的思路来列。第一个坑请求视频地址返回 403 Forbidden。现象解析正常download_file 一请求就是 403偶发情况是下载到一半突然 403。 原因B站视频地址有防盗链校验请求头里的 Referer 必须是视频所在站点。requests 默认不带 Referer直接请求 CDN 地址会被拒绝。 解决在 config.py 里统一加上请求头session.headers.update 设置 Referer 为 https://www.bilibili.comUA 设成 Chrome 最新版的完整字符串。代码里建议把 session 创建统一收敛到一个函数所有请求走同一个 session。第二个坑进度条卡住不动或下载速度掉到 0。现象终端里百分比停留在某个值程序既不报错也不退出看着像死循环。 原因网络波动导致 TCP 连接已断开但程序还在等数据。requests 默认没有读超时连接断开后 recv 会一直阻塞。 解决给 get 方法传 timeout(5, 30)。5 秒连不上直接抛异常30 秒内没有新数据到达也抛异常。配合上一章的断点续传逻辑重试时不会重复下载已完成的字节。异常处理里捕获 requests.exceptions.Timeout续传重试最多 3 次。第三个坑合并后视频有声音但画面卡顿或者画面和声音不同步。现象ffmpeg 合并后播放时画面和声音明显不在一个时间轴。 原因DASH 格式的音轨和视频轨时长一样但合并时如果输入参数顺序写反或者用二进制拼接方式处理 DASH 文件就会导致音画不同步。 解决DASH 文件必须走 ffmpeg merge_dash 函数绝对不能用 merge_flv 的方式处理。merge_dash 里两个 -i 的顺序不影响结果ffmpeg 会自动按时间戳对齐。第四个坑想下载 1080P 高清视频结果拿到的是 480P。现象--quality 80 传了下载回来的视频清晰度却没有变化。 原因1080P 及以上清晰度需要登录态。游客身份的接口最多给到 720P。B站的 fnval 参数已包含高清格式但 qn 超过游客上限会被静默降级接口不报错。 解决在 config.py 的 SESSION 里加 cookie 字段。登录网页版后从浏览器开发者工具复制 Cookie 字符串填进去。注意 Cookie 有过期时间失效后接口会变回游客权限。代码里对这种情况没有显式报警建议下载后用 ffprobe 检查视频分辨率。第五个坑多分P视频下载到一半第 3 集开始全部失败。现象前两集正常到第三集开始每个分片都报错报错内容却各不相同。 原因分P视频的 BV 号相同cid 不同。部分 Up 主会把失效视频替换成新视频导致旧 cid 的播放地址接口返回空数据。 解决在 get_cid 之后、get_playurl 之前增加一次校验如果接口返回的 dash 和 durl 都为 None自动重新请求一次稿件详情接口用最新的 pages 列表重新取 cid。5. 二次开发把单文件脚本变成命令行工具跑通之后我第一件事就是把它改造成一个更像正经工具的东西。核心思路是解析、下载、合并三个环节完全解耦互相只传数据不传逻辑这样后续加功能不会牵一发动全身。第一个改动是入口文件改成 argparse 参数解析支持 --format、--quality、--outdir、--cookie-file 四个参数其中 cookie 支持从文件读取而不是硬编码在 config.py 里避免每次过期都要改代码。参数值都有默认值用户不传也能跑传了就覆盖。第二个改动是增加批量任务队列。原来的 download_all 是顺序执行的改成 ThreadPoolExecutor 多线程下载不同分P每P独立进度回调。踩过的坑是线程数不能超过 3B站对同一 IP 的并发请求有限制开太多反而被限流这是典型的“多线程反而更慢”案例。第三个改动是输出文件名模板化。默认是 BV号加上标题但标题里的特殊字符在 Windows 文件名里非法我在脚本里加了一个 sanitize 函数把 / | : 这些字符替换成全角版本。另外支持用户自定义模板比如 {up主}/{标题}/第{p}P实现方式用 str.format 替换占位符即可。验证这套改动的办法是拿一个 20P 的公开课程视频跑完整的批量下载观察三件事进度回调是否按分P推进、合并后的每一集能否正常播放、文件名是否符合预期。从那以后我每次给脚本加新功能都强制走一遍这套验证流程最短的测试视频也跑到出进度条为止。希望帮到你。本文还有配套的精品资源点击获取