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号到cidplayurl接口的两次请求先看第一步用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, paramsparams, headersHEADERS) resp.raise_for_status() data resp.json() if data[code] ! 0: raise RuntimeError(fAPI返回错误: {data[message]}) pages data[data][pages] title data[data][title] return pages, titleAPI_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流的关键参数是fnval16。16这个数字是位掩码表示让服务端返回dash格式。如果不传或传错接口可能返回flv列表但你想要的高清晰度不在里面。经验是遇到清晰度问题先检查fnval再检查Cookie最后才考虑接口变化。params { bvid: bvid, cid: cid, fnval: 16, fnver: 0, } resp requests.get(API_BASE /x/player/playurl, paramsparams, headersHEADERS) 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流。这个链路里最容易被忽略的是fnval16和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, paramsparams, headersHEADERS) resp.raise_for_status() data resp.json() if data[code] ! 0: raise RuntimeError(fplayurl接口错误: {data[message]}) dash data[data][dash] video_list dash[video] audio_list dash[audio] # 取码率最高的流而不是简单取第一个 video_list.sort(keylambda item: item.get(bandwidth, 0), reverseTrue) audio_list.sort(keylambda item: item.get(bandwidth, 0), reverseTrue) 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告诉我们总字节数然后我们用streamTrue分块读取每写一块就累计一次实时算百分比。这样做不会把整个视频加载进内存对几GB的大文件尤其重要。import os def download_with_progress(url, filepath, session): with session.get(url, streamTrue, 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_sizechunk_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不要把streamTrue写在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_dirdownloads): pages, title get_pages(bvid) title sanitize_filename(title) try: with ThreadPoolExecutor(max_workers2) 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_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fffmpeg合并失败: {result.stderr})-y参数表示覆盖已有文件不加的话如果输出文件存在命令会交互式询问导致subprocess.run一直挂起。-c copy是无损复制不重新编码速度非常快几乎等同文件拷贝。如果两个文件的音视频编码格式不兼容可能需要改成-c:v copy -c:a aac重新编码音频但B站下载的dash流通常直接用copy就能合成。4. 避坑清单403、卡死、合并失败与清晰度限制下面这些是实际跑下载器的血泪经验。每一条都对应一个真实翻车场景按“现象 → 原因 → 解决”写方便你对照排查。遇到问题不要瞎猜先看状态码再看请求头然后看参数最后看运行环境。4.1 HTTP 403Referer没带现象拿到直链后下载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第{attempt1}次下载超时) else: raise RuntimeError(连续多次下载失败)重试次数我一般设3超过就抛出异常不做无限重试。如果卡死后按CtrlCTraceback会停在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。另一个容易被忽视的参数是fnval16不带它时返回的dash列表里可能没有高码率档位。解决从浏览器开发者工具复制登录后的Cookie添加到HEADERS[Cookie]里。注意Cookie有过期时间如果脚本运行久了清晰度突然降档先换Cookie再试。HEADERS[Cookie] 你的登录Cookie还要注意Cookie和账号绑定不要泄露给别人。下载器并不能突破大会员权益它只是如实获取当前账号能访问的最高清晰度。如果请求参数里加了fourk1有时候能解锁一些额外档位但能不能下载取决于账号权限和接口是否支持。验证清晰度的方法很简单在get_best_stream_urls里把dash[video]完整打印出来看每个元素里的id和codecs字段。id不同的数值对应不同清晰度比如30000通常是1080P40000是1440P50000是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] fbytes{existing_size}- resp requests.get(url, headersheaders, streamTrue, 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_size1024 * 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站的前端逻辑会随版本更新今天能用fnval16明天可能要多传一个platformpc。我的习惯是写一个参数配置模块把接口路径、参数字典、头部字典全部抽出来不要硬编码在函数里。每次下载结果异常时先手动抓包一次对比参数差异通常十分钟就能定位。从那以后我每次写爬虫都会强制给自己留一个抓包参数对照表下载脚本里永远不写死接口域名这避免了很多次深夜翻车。希望帮到你。本文还有配套的精品资源点击获取