简介面向B站视频下载需求的Python学习者与爬虫入门者这份资源提供一款轻量级B站视频爬虫脚本。脚本基于视频链接中的bvid标识工作使用前只需将代码中的video_bvid替换为目标视频的对应值即可直接运行省去复杂接口签名与鉴权流程也避开了网页版解析易失效的痛点。压缩包内文件总数仅1个为Python脚本源码整体占用2KB体积小巧适合快速查看与二次编辑。代码结构干净配合基础注释便于初学者理解B站视频接口的调用方式也能在此基础上扩展多P下载、清晰度选择、批量收藏等实用功能。脚本既可作为爬虫学习的基础范例也可改造为个人日常下载工具完成视频资源的离线保存与分类管理。目前已有3295人学习浏览可见其在轻量爬虫场景下获得了较多认可。1. 把B站视频python爬虫下载做成一件事一份可以照着复现的资源B站视频python爬虫下载听起来像是几十行 requests 就能搞定的事真正上手才发现坑比想象中多接口返回的地址不能直接下、分段视频要手动拼、不加 Referer 就给你 403、音频和视频还是分离的。我花了一整个周末踩完这些坑最后整理出一套能完整跑通的下载脚本资源——从解析视频地址到多P合集批量拉取再到用 FFmpeg 合成音视频全程可复现不依赖浏览器插件也不需要登录任何第三方平台。这份资源适合刚学完 Python 基础、想把爬虫往真实站点落的开发者也适合需要批量备份视频素材的从业者。下面这些内容就是我拆解这套资源时记录下的完整笔记。2. 选型与原理为什么自研脚本比现成工具更可控2.1 三种主流方案的取舍在做B站视频python爬虫下载之前大部分人会先去找现成轮子。市面上常见的有三套思路某开源命令行工具you-get 类、某支持站点解析的下载器yt-dlp 类、以及自己用 requests 写脚本调接口。我三种都试过简单说下各自的状态。you-get 的上手成本最低一条命令就能把视频拉下来但它内部逻辑是黑匣子遇到清晰度不满足、B站改了接口参数的情况只能等上游更新你要想加个「只下第5P」的需求就得改它的源码。yt-dlp 覆盖面广支持的站点列表很长但它对B站的分P处理逻辑过于通用遇到特殊需求还是得现写 extractor。自研脚本的好处在于可控解析逻辑自己维护接口字段变动能立刻定位还能按需集成弹幕、字幕、封面下载。坏处是工程化需要自己做比如请求重试、文件去重、断点续传。我最终选择用自研脚本作为这套资源的主方案原因很简单——我需要的不是「能下视频」而是「知道它在哪个环节可能挂挂了怎么修」。方案上手成本可定制性接口变动影响适合场景you-get 类低低需等更新临时下载单个视频yt-dlp 类中中需匹配新 extractor跨站点批量下载自研 requests 脚本中高高自己改代码深度定制、学习原理2.2 B站视频接口的底层逻辑要自己写脚本必须先弄清B站给网页端提供的接口链路。视频页的 URL 里有 BV 号新版或 aid旧版比如https://www.bilibili.com/video/BV1xx411c7XX。拿到 BV 号之后第一件事是调x/web-interface/view接口获取该视频的元信息包括标题、分P列表cid、封面地址等。拿到 cid 之后关键一步就来了需要再调播放地址接口拿到真实的视频流地址。这个接口就是x/player/playurl老版本里它直接返回一整个 FLV 或 DASH 流的 URL 列表新版本里则需要传fnval参数决定返回格式。我一般传fnval4048这样返回的是 DASH 流格式视频轨和音频轨分开两个地址清晰度从 480p 到 1080p 甚至 4K 都有覆盖。这个过程的完整请求链就是BV号 → view接口拿cid → playurl接口拿流地址 → 拼接分段 → 合并音视频。整个链路里每一步都可能因为参数不对而失败传参格式、请求头缺失、签名时间戳过期任何一个环节出错脚本就废了。2.3 浏览器伪装与防盗链参数B站对爬虫请求的拦截主要在防盗链上。playurl 接口返回的视频流地址直接拿 requests 去 GET 是拿不到数据的会返回 403 状态码。原因是视频服务商CDN校验了请求头里的Referer字段——只有从https://www.bilibili.com/页面发起的请求才会被放行。所以我在脚本里统一维护了一个请求头模板每次请求都带上浏览器版本的 User-Agent、Referer 和 Origin。这套资源的代码里把这三项集中封装成了一个函数避免每次复制粘贴。import requests def get_headers(): return { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.bilibili.com/, Origin: https://www.bilibili.com }这段代码的逻辑很简单get_headers()返回一个字典作为基础请求头后续所有请求都调用它再追加业务参数。关键在Referer字段没有它视频流地址一定拿不到数据这是 B站 鉴权策略里最基础的一层。Origin字段部分接口也会校验加上它就多一手保险。User-Agent用 Chrome 的默认 UA 就行不用追求最新版本号。3. 核心请求链路从BV号到真实视频流地址3.1 先用 view 接口确认视频元信息写解析脚本的第一步是先定义一个函数输入 BV 号输出标题、分P数量、每个分P的 cid 和视频时长。这是整个下载流程的数据地基。view 接口不需要登录即可调用但请求频率太高会触发风控所以我一般会在请求之间加一个间隔。def get_video_info(bvid): url https://api.bilibili.com/x/web-interface/view params {bvid: bvid} resp requests.get(url, paramsparams, headersget_headers(), timeout10) data resp.json() if data[code] ! 0: raise Exception(f接口返回错误: {data[code]} - {data[message]}) result data[data] pages result.get(pages, []) cid_list [] for page in pages: cid_list.append({ cid: page[cid], page: page[page], part: page[part], duration: page[duration] }) return { title: result[title], bvid: result[bvid], cid_list: cid_list }这个接口返回的 JSON 结构里data.pages是一个数组每一个元素对应一个分P。我给params只传了bvid因为新版 view 接口支持只靠 BV 号定位视频。code ! 0是业务层面的错误判断比如视频被删除、稿件不存在都会在这里暴露出来。返回值里的cid_list会作为下一步 playurl 接口的输入。有个细节需要提醒部分视频在分P列表里包含互动视频或者充电专属内容这些视频的 cid 在未登录状态下拿到的播放地址可能不能正常播放。这套资源里我用一个skip_charge参数跳过这类分P默认值为 True。如果不需要这个过滤在调用下载函数时把参数置为 False 即可。3.2 playurl 接口的参数组合与清晰度映射把 cid 拿到手后核心就是 playurl 接口。这个接口的传参稍微微妙一点qn决定请求的目标清晰度fnval决定返回格式fourk在部分视频需要传 1 才能返回 4K 流。我清理了一批参数组合整理成下面的映射关系参数名数值作用注意点qn16360P 清晰度老接口默认值qn32480P 清晰度部分老视频无此档qn64720P 清晰度网页端默认上限之一qn801080P 清晰度需要视频源支持qn1121080P 高码率部分场景需登录fnval16返回 DASH 流音视频分离fnval4048DASH 杜比 8K综合推荐值fourk1允许返回 4K 地址仅在源视频存在时生效调用示例里我会把qn和fnval都传进去并按需追加fourk。这里的逻辑是让服务端按「最高优先级返回可用流」如果视频源本身没有该档位接口会自动降级到上一档可用的流地址。def get_play_url(bvid, cid, qn80, fnval4048): url https://api.bilibili.com/x/player/playurl params { bvid: bvid, cid: cid, qn: qn, fnval: fnval, fourk: 1 } resp requests.get(url, paramsparams, headersget_headers(), timeout10) data resp.json() if data[code] ! 0: raise Exception(fplayurl 错误: {data[code]} - {data[message]}) return data[data]这里fnval4048是一个位运算组合值二进制展开后同时启用了 DASH 流、杜比音效、8K 超高清标记位。实际下载时如果只需要普通的 1080P 视频fnval16就够了但如果希望脚本能自动兼容未来新增的格式直接传 4048 更稳妥。返回值里会包含dash.video和dash.audio两个数组video数组里每一项有id清晰度标识、baseUrl视频流地址、backupUrl备用地址、bandwidth码率等字段。3.3 从返回流中选择最佳清晰度段playurl 返回的dash.video通常有多个清晰度段需要从中挑选一个合适的分辨率。我的选型逻辑不只看id大小还会结合带宽来做判断优先选目标清晰度目标清晰度不存在时选带宽最高的一段。def select_video_stream(video_list, preferred_quality80): candidates [v for v in video_list] candidates.sort(keylambda v: v.get(bandwidth, 0), reverseTrue) for v in candidates: if v.get(id) preferred_quality: return v for v in candidates: if v.get(id) preferred_quality: return v return candidates[0]排序按带宽倒序是为了让找不到目标清晰度时回退到最高码率。判断id是否等于目标值严格匹配的是目标档位如果源视频没有该档id preferred_quality的逻辑会带出最高可用档。backupUrl字段是在主地址失败时的备选我在下载函数里加了「主地址失败自动切备用地址」的逻辑后面会具体展开。4. 一套可复现的下载脚本单P、多P、音视频合并4.1 单P下载的完整流程选好流地址之后下载这一步反而最机械但坑也最密集。视频流地址和音频流地址是两个独立的 HTTPS 文件需要分别下载后再合并。下载时要用流式请求不能一次性加载到内存否则大文件直接撑爆内存。def download_stream(url, filepath, refererhttps://www.bilibili.com/): headers get_headers() headers[Referer] referer with requests.get(url, headersheaders, streamTrue, timeout30) as r: r.raise_for_status() total int(r.headers.get(content-length, 0)) downloaded 0 with open(filepath, wb) as f: for chunk in r.iter_content(chunk_size1024 * 1024): f.write(chunk) downloaded len(chunk) return filepathstreamTrue是关键它让响应体以迭代器形式返回每次只读 1MB 分块写入文件既控制内存占用也方便后续做进度条。content-length用来计算总大小如果服务器没返回这个头降级为 0 也表示不阻塞流程。这里超时时间设成 30 秒是连接超时不是读取超时真正下载时读取超时不单独设置避免大文件下载中途因为偶发网络波动被切断。我在这里踩过一个坑直接拿baseUrl去请求时有时会遇到 403 或者连接被重置。后来确认是 CDN 节点的问题。这个资源的下载函数里实现了backupUrl的自动切换主地址下载抛异常时捕获异常后清空已写文件改用备用地址重新请求。这段逻辑很值得抄实际场景里能减少不少人工干预。4.2 多P合集循环下载与文件归档B站视频python爬虫下载最常见的使用场景其实是多P课程、合集、纪录片。单P脚本只是基础真正要跑通的是循环遍历分P并归档文件。我在脚本里定义了一个download_multi_p函数先调用get_video_info拿到 cid 列表再逐个调用 playurl 接口和下载函数。def download_multi_p(bvid, output_dirdownloads): video_info get_video_info(bvid) title sanitize_filename(video_info[title]) base_dir os.path.join(output_dir, title) os.makedirs(base_dir, exist_okTrue) for item in video_info[cid_list]: page_num item[page] part_name sanitize_filename(item[part]) play_data get_play_url(bvid, item[cid]) video_url select_video_stream(play_data[dash][video])[baseUrl] audio_url select_audio_stream(play_data[dash][audio])[baseUrl] video_tmp os.path.join(base_dir, f{page_num:02d}_{part_name}_video.m4s) audio_tmp os.path.join(base_dir, f{page_num:02d}_{part_name}_audio.m4s) final_path os.path.join(base_dir, f{page_num:02d}_{part_name}.mp4) if os.path.exists(final_path): continue download_stream(video_url, video_tmp) download_stream(audio_url, audio_tmp) merge_av(video_tmp, audio_tmp, final_path) os.remove(video_tmp) os.remove(audio_tmp) return base_dir这段代码里有两个容易被忽略的细节。第一是sanitize_filename做了转义B站部分视频的标题和分P名包含/:等不能出现在文件名里的字符不处理的话会直接 FileNotFoundError。第二是「输出文件已存在则跳过」的幂等判断这个设计让脚本天然支持断点续跑——中途挂掉后重新执行已下载完成的分P直接跳过。{page_num:02d}的格式化让文件名按两位数字编号排序后和视频顺序一致。4.3 用 FFmpeg 合并音视频参数与验证DASH 流下载下来的视频轨和音频轨是独立文件需要用 FFmpeg 合成。我这里用subprocess调用外部 ffmpeg 命令而不是 python 里的纯库因为 FFmpeg 对 H.264/AAC 的封装最稳定转封装速度快且不会引入额外依赖。def merge_av(video_path, audio_path, output_path): cmd [ ffmpeg, -y, -i, video_path, -i, audio_path, -c:v, copy, -c:a, copy, -movflags, faststart, output_path ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fffmpeg 失败: {result.stderr[-500:]})FFmpeg 命令的关键参数是-c:v copy -c:a copy意思是视频轨和音频轨都直接复制原始编码不重新编码速度快且画质无损。-movflags faststart会把 MP4 文件的元数据挪到文件头部这样视频在网页端播放时可以实现快速开始不需要等待整个文件下载完。-y表示覆盖已存在的输出文件。合并完成后我把临时文件清理掉避免磁盘占用翻倍。这里有一个前置校验不是所有视频轨都能用copy模式直接封装。比如视频轨是 AV1 编码、而播放器不支持时需要改成-c:v libx264重新编码。这套资源默认只在copy失败时提示改用重编码命令不自动执行重编码避免用户不知情的情况下 CPU 被打满。5. 高频避坑窗口防盗链、风控、清晰度翻车与合并失败5.1 防盗链 403现象、原因与三层解决现象视频流地址放进 requests 下载第一个 chunk 返回 403状态码是 Forbidden请求头看起来什么都对。原因基本都在Referer上——B站视频 CDN 校验来源页面非B站来源直接拒绝。第一层解决是给请求头加Referer: https://www.bilibili.com/此时大部分 403 问题消失。如果还 403第二层是检查User-Agent是否被识别为爬虫默认 UArequests 库默认的python-requests会被部分节点拦截。第三层是备用地址切换B站返回的 JSON 里有backupUrl数组把主地址换成备用地址重试。从我自己拆过的案例来看90% 的 403 是第一层就搞定了剩下 10% 是请求头顺序问题或者临时 IP 被限流。前者通过规范请求头解决后者只能降低请求频率比如两次请求之间 sleep 1 到 2 秒。5.2 风控 412 与验证码弹窗现象连续下载多集后接口开始返回 412 Precondition Failed甚至 view 接口都开始要求滑块验证。原因请求频率过高且请求特征明显是脚本。服务端的风控系统会在短时间内统计单 IP 的请求次数和 UA 指纹超过阈值就触发验证。解决方式是三个措施配合使用一是请求间隔提升到 3 到 5 秒二是用稳定的 Cookie 维持会话状态三是给代码加上自动重试逻辑遇到 412 就等待 60 秒后再试。def request_with_retry(url, params, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, paramsparams, headersget_headers(), timeout15) if resp.status_code 412: time.sleep(60) continue return resp except requests.exceptions.ConnectionError: time.sleep(5) continue raise RuntimeError(重试多次仍失败)这个封装函数是我常用的兜底方案。412是风控触发的特定状态码遇到就长睡 60 秒连接异常是网络层问题睡 5 秒重试。对普通量的下载需求来说这套逻辑足够温和不会触发更高级别的风控。5.3 清晰度「缩水」目标 1080P 实际只下到 480P现象qn 明明传了 80下载下来的视频分辨率只有 854x480。原因一般分两种一是该视频的 1080P 源是会员专享没登录时接口直接返回低档位二是部分视频上传时就没有 1080P 源接口按最高可用档返回。解决方式先看 playurl 返回的accept_description里有哪几档再让代码做降级而不是失败。我一般在脚本里加一个quality_report函数把接口实际返回的清晰度列表打印出来方便判断是「没有这个档」还是「被降码率」。5.4 音视频合并后不同步或时长不一致现象合并后视频画面和声音差几秒或者 ffmpeg 直接报错 duration 不匹配。原因多出在下载流不完整——某些网络情况下 DASH 流的两个文件在下载时被截断但文件头和元数据还未损坏导致 FFmpeg 没发现异常。解决方式合并前比对视频轨和音频轨的时长差值大于 1.5 秒就重下音频或视频轨。这个逻辑我在脚本里直接用一个小函数实现不依赖额外库。5.5 WBI 签名与接口变更的坑最近一两年B站部分接口开始要求wbi签名请求参数里需要带上w_rid和wts两个字段否则接口返回 -403。这套签名逻辑本质是对请求参数排序后做 HMAC 加密密钥在网页端 JS 里动态生成。遇到这类问题时常见做法是抓取网页端的签名算法用 Python 复现另一个绕过方案是给请求带上浏览器 Cookie部分接口检测到 Cookie 存在时会放宽签名校验。这套资源的代码目前把带 Cookie 作为默认方案wbi 签名实现作为独立模块放在进阶文档里。实际使用时如果发现get_video_info返回 -403优先检查是否漏带 Cookie而不是急着去撞签名算法。6. 把下载脚本升级成批量管理工具校验、增量与排队下载脚本能跑通之后下一步是让它在真实使用场景里更抗造。我常用的一个升级方向是把「下载脚本」变成「批量管理工具」核心增加三个能力下载前校验、增量更新、串行队列。下载前校验的做法是请求 view 接口时记录总时长和分P数下载完成后逐个文件用 FFprobe 读取时长差异超过 5% 就标记为失败并重下一个分P。这个步骤看起来很笨但实际价值很大能自动排除网络波动导致的残次品。增量更新解决的是「追更」场景某个合集视频你下载了一部分一周后又更新了 3 集脚本只需要比较cid_list里已有的 cid 和本地文件名把缺失的新分P补上下载即可。我在实现方案里直接把「文件已存在则跳过」的逻辑扩展成按 cid 去重这样即使中间跳过多集也能精准补漏。最后是排队下载。B站对请求频率有软限制一口气把 100 个分P灌进去大概率触发 412。我一般会用一个最简队列把待下载 cid 列表逐个取出下载一个、合并一个、歇几秒、再取下一个。这个串行模式虽然慢但稳定。想要提速的建议是并行控制在 2 到 3 个任务以内而不是盲目上高并发。资源里我用了「下载完成后自动打印校验表」的方式收尾每下载完一个分P脚本会列出文件路径、大小、时长、状态码四项跑完一遍就能快速扫出哪些文件有问题。这个习惯是我之前某次批量下载后发现有十来个文件是坏的全靠肉眼翻才发现的从那以后我每次跑批量下载都会强制走一遍校验流程哪怕只是看时长字段有没有异常值。希望这些拆解过程能帮你在做B站视频python爬虫下载时少走几趟弯路。本文还有配套的精品资源点击获取