ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python实现的B站视频下载器:带进度条与断点续传

Python实现的B站视频下载器:带进度条与断点续传

简介:这是一份用于批量下载哔哩哔哩小视频的Python爬虫脚本,面向对爬虫实战感兴趣的初学者或个人视频收集爱好者。脚本以命令行方式运行,能够循环遍历10页排行榜JSON数据,提取视频标题与下载地址,并通过requests模块完成下载;每个文件下载时会在控制台实时打印进度,便于观察任务状态。视频统一保存至本地video目录,文件名采用视频标题并自动替换非法字符,避免保存失败;同时每完成一次下载会随机等待3~6秒,降低请求频率过高被限制的风险。压缩包仅含1个py文件,大小2KB,结构精简、即下即用,适合作为爬虫入门练习或快速改造为自定义下载工具。目前已有496人学习下载,代码逻辑清晰,可直接运行并配合简单注释理解HTTP请求、JSON解析、文件写入与延时控制等核心知识点。

1. 一个需求逼出来的下载器:带进度条的B站视频爬虫

想离线看某个Up主的系列教程,手机App缓存只能自己看,电脑上却没有官方下载入口。手动一页页开开发者工具找视频地址太折腾,于是我花了一个晚上写了这个脚本:输入视频链接或BV号,自动解析出视频流地址,把分片一段段拉回本地,合并成完整文件,全程带进度百分比和下载速度。这个项目不依赖浏览器模拟,纯 Python 实现,核心只有三件事:解析接口拿到真实地址、流式下载分片、合并成可播放文件。适合刚写完基础爬虫、想接触文件下载和进度回显的开发者,也适合需要批量备份自己收藏视频的人。项目代码结构清楚,能直接跑,也能作为二次开发的脚手架。


2. 技术选型与目录结构:从 requests 到 ffmpeg 的一条链路

2.1 依赖选择:不引入重量级框架

这个项目的定位是轻量、透明,不搞 Selenium 无头浏览器那套。数据抓取用的是 requests,解析视频流地址用的是标准库 json,进度显示是自己用字节数算的。为什么不用 scrapy?因为这里只有一个站点、两三个接口,scrapy 的中间件和 pipeline 在这种场景下完全是负担。为什么不用 playwright?B站视频地址接口是纯 HTTP 请求,不需要执行页面 JS 去动态渲染,杀鸡不用牛刀。

依赖清单如下,requirements.txt 里就三行核心内容:

包名用途说明
requestsHTTP 请求拿接口数据、流式下载分片
ffmpeg音视频合并DASH 格式的音轨和视频轨需要它合成
tqdm可选想要更花哨的进度条可以装,不装也不影响

这里说一句,ffmpeg 不是 Python 包,是独立可执行文件。它只在处理 DASH 分片时被调用,Windows 用户需要把 ffmpeg.exe 所在目录加进 PATH,macOS 用户直接 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 数组按顺序对应每个分P,p 参数就是要下载第几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, params=params) 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 的取值含义是 16=360P,32=480P,64=720P,80=1080P。注意 1080P 以上需要登录 Cookie,否则接口会返回 403 或者直接降级到 480P,这一点在配置里预留了 cookie 字段。flv 分支是给少数不支持 DASH 的旧视频兜底的,返回多个分片地址。

提示:播放地址接口返回的 URL 带防盗链签名,必须配合正确的 Referer 和 UA 才能访问。这是整个项目里最常见的“玄学”问题来源,第四章节会细说。

3.3 带进度的分片下载:边下边算百分比

标题里的“含进度”是这个项目比较有含金量的部分。不依赖 tqdm,直接用字节数计算,逻辑完全透明:

import os import time def download_file(url, filepath, session, expected_size=None, progress_cb=None): """下载单个分片,支持断点续传""" headers = {} done = 0 if os.path.exists(filepath): done = os.path.getsize(filepath) headers["Range"] = f"bytes={done}-" # 续传关键 start = time.time() with session.get(url, headers=headers, stream=True, 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_size=1024 * 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,程序以追加模式写文件,不会重复下载。这是对付大文件和网络抖动的关键写法,很多新手下载到一半断了就要重来,其实就是少了这一段。

参数说明:stream=True 让响应体变成迭代流,不会一次性载入内存。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_size=size, progress_cb=self.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, check=True, capture_output=True) 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 占用也低。如果去掉 copy,ffmpeg 会重新压缩视频,耗时几十倍且画质有损。FLV 拼接就更简单了,读一个文件往输出文件里追加,适合接口没返回 DASH 的旧视频。

参数说明:-y 表示覆盖已存在的输出文件,避免子进程询问交互。capture_output=True 会把 ffmpeg 日志吞掉,失败时用 check=True 抛出异常带出 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.com,UA 设成 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独立进度回调。踩过的坑是线程数不能超过 3,B站对同一 IP 的并发请求有限制,开太多反而被限流,这是典型的“多线程反而更慢”案例。

第三个改动是输出文件名模板化。默认是 BV号加上标题,但标题里的特殊字符在 Windows 文件名里非法,我在脚本里加了一个 sanitize 函数,把 / | : 这些字符替换成全角版本。另外支持用户自定义模板,比如 {up主}/{标题}/第{p}P,实现方式用 str.format 替换占位符即可。

验证这套改动的办法是拿一个 20P 的公开课程视频跑完整的批量下载,观察三件事:进度回调是否按分P推进、合并后的每一集能否正常播放、文件名是否符合预期。从那以后我每次给脚本加新功能,都强制走一遍这套验证流程,最短的测试视频也跑到出进度条为止。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进