news 2026/10/19 4:07:26

B站视频下载爬虫:requests取流+进度条+ffmpeg合并实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
B站视频下载爬虫:requests取流+进度条+ffmpeg合并实战

简介:针对批量下载哔哩哔哩小视频的实用爬虫脚本,面向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, title

API_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。我的习惯是写一个参数配置模块,把接口路径、参数字典、头部字典全部抽出来,不要硬编码在函数里。每次下载结果异常时,先手动抓包一次,对比参数差异,通常十分钟就能定位。从那以后,我每次写爬虫都会强制给自己留一个抓包参数对照表,下载脚本里永远不写死接口域名,这避免了很多次深夜翻车。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 4:07:04

open-code-review:基于CLI+Git Hook的本地化AI代码审查工作流

1. 项目概述&#xff1a;这不是一个“工具”&#xff0c;而是一套可落地的代码审查新工作流“open-code-review”这个词乍看像某个开源项目名&#xff0c;但实际它代表的是一种正在快速成型的工程实践范式——把大语言模型&#xff08;LLM&#xff09;深度嵌入到开发者日常的 G…

作者头像 李华
网站建设 2026/10/19 4:06:57

Activiti 5.22完整包部署指南:用explorer.war快速跑通流程引擎

简介&#xff1a;Activiti 5.22 Explorer WAR 完整部署包面向 Java 开发者与工作流平台建设者&#xff0c;可用于在 Tomcat 等 Servlet 容器中快速运行 Activiti 引擎的可视化管理界面&#xff0c;直接体验流程设计、任务审批与实例监控等核心功能。压缩包共 79 个文件&#xf…

作者头像 李华
网站建设 2026/10/19 4:06:14

Anaconda安装与Python环境配置指南:conda命令、虚拟环境与镜像源排查

最近连续帮几位开发者排查 Python 环境问题&#xff0c;十有八九都卡在同一个地方&#xff1a;Anaconda 装完了&#xff0c;却不知道下一步该怎么配置。下载安装本身并不难&#xff0c;难的是装完之后 conda 命令找不到、下载包慢到怀疑人生、pip 包装错了环境。这篇文章我会把…

作者头像 李华
网站建设 2026/10/19 4:06:02

open-code-review:开发者掌控权的CLI代码审查协议

1. 这不是又一个“AI代码审查工具”&#xff0c;而是开发者自己掌控审查权的CLI入口你有没有过这样的经历&#xff1a;PR被CI流水线里的某个AI审查机器人打了低分&#xff0c;理由是“变量命名不够语义化”&#xff0c;但你清楚地知道那个tmpBuf就是临时缓冲区&#xff0c;加_t…

作者头像 李华
网站建设 2026/10/19 4:06:00

FLAC3D PluginFiles 深度解析:源码级扩展开发指南

简介&#xff1a;本资源为FLAC3D 5.01版本的完整插件开发套件&#xff0c;面向岩土工程数值模拟领域的开发者、高校科研人员及高级用户&#xff0c;解决软件深度定制、材料模型扩展与跨平台接口集成等核心需求。压缩包共317个文件&#xff0c;涵盖232个.h头文件&#xff08;定义…

作者头像 李华
网站建设 2026/10/19 4:04:24

可调试BP神经网络MATLAB实战套件:从公式到工程落地

简介&#xff1a;本资源是一套面向MATLAB初学者与神经网络入门者的BP神经网络实践程序包&#xff0c;聚焦于算法原理理解与工程实现落地&#xff0c;适用于高校课程设计、数学建模训练及人工智能基础项目开发。压缩包共18个文件&#xff0c;含9个核心MATLAB源码&#xff08;.m&…

作者头像 李华