资讯动态

Python爬虫实战:B站视频下载与DASH流分离合并全解析

发布时间:2026/10/13 23:16:29 来源:尧图企业网站定制
简介基于Python编写的B站视频爬虫下载脚本面向需要批量保存哔哩哔哩视频的初学者与轻量工具使用者。它以视频链接中的video_bvid参数作为关键输入使用前按目标链接更新该值即可自动完成地址请求与文件下载避免手动解析页面和寻找真实地址的繁琐过程。资源zip压缩包内共1个文件为体积仅2KB的py脚本轻量精简无需复杂环境配置非常适合Python爬虫入门者阅读源码、理解请求流程并二次修改。目前已有3295人学习或下载表明该脚本在B站视频下载场景中具备较高的实用参考价值通过它还可学习BV号拼接、网络请求发送、响应数据解析、视频文件保存等完整爬虫链路在此基础上可继续扩展多P下载、清晰度选择等进阶功能。1. 说到B站视频的下载很多人以为打开网页另存为就行真到想把系列课程缓存到本地慢慢看、在无网络环境做画面分析、把片段剪进自己的剪辑项目时才发现视频流根本拿不到完整文件。B站把高清晰度视频按音频和视频分离存储播放地址带签名和登录态浏览器里点两下能看脚本想拿就得走完整接口链路。现成的下载器不是没有但接口一变就批量失效维护节奏全看作者心情。于是用Python写一个自己能改的爬虫下载脚本成了最可控的长期方案。这篇笔记从接口链路讲起用requests手写一条能下载、能合并、能批量跑的最小实现新手跟得上步骤熟手能直接看到边界参数和踩坑记录。2. 先看懂B站的视频分发链路再动手写代码2.1 从网页URL到两个关键IDbvid与cid打开任意B站视频页地址栏里的BV号是视频对外ID它对用户友好但对播放接口来说还不够。播放接口真正认的是cid也就是当前分P的内部ID。单P视频只有一个cid多P视频每一个分P都有自己的cidBV号对应的是整个视频容器。要在脚本里把视频下下来第一步永远是拿BV号换cid这一步的做法直接影响后续接口调用的成功率。我一般会先请求视频页HTML页面里内嵌了window.__INITIAL_STATE__里面带videoData.pages数组每个元素都有这个分P的cid和part标题。用正则硬取cid:\d能拿到值但多P视频只会拿到第一个P的cid想遍历所有P还是得解析完整JSON。这里演示一个比较稳的提取方式import json import re import requests session requests.Session() session.headers[User-Agent] ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 ) url https://www.bilibili.com/video/BV1xx411c7mD html session.get(url).text # 页面结构会随版本变化这段正则按当前页面形态匹配 m re.search(rwindow\.__INITIAL_STATE__(\{.*\});\s*/script, html, re.S) state json.loads(m.group(1)) pages state[videoData][pages] # 每个元素含cid、part、page for p in pages: print(p[page], p[part], p[cid])这段代码里正则从window.__INITIAL_STATE__之后贪婪取到/script前的最后一个}能覆盖大多数普通视频页面。如果页面结构变化导致这个字段找不到可以退一步直接调view接口它的返回结构比页面HTML稳定得多view_api https://api.bilibili.com/x/web-interface/view params {bvid: BV1xx411c7mD} data session.get(view_api, paramsparams).json()[data] pages data[pages] # 字段与 __INITIAL_STATE__ 里一致这里要说明session的作用。B站接口对无状态请求容易误判我习惯用一个requests.Session()保持连接池和cookie所有后续请求都走同一个实例这个习惯在后面下载分片时能省掉很多烦心事。未登录时view接口也能拿到页面公开数据普通视频的cid不需要登录态。2.2 playurl接口返回的数据结构清晰度、分片与音频流拿到cid后播放地址由playurl接口下发。这个接口是B站的私有接口参数会随时间变化目前常见的关键参数有bvid、cid、qn和fnval。qn是清晰度档位编号fnval16表示请求DASH流式格式。不传fnval时接口返回的是老式单文件FLV画质上限低DASH格式才能拿到1080P以上的视频轨和独立音频轨。play_api https://api.bilibili.com/x/player/playurl params { bvid: BV1xx411c7mD, cid: cid, qn: 80, # 64480P 801080P 1204K档位以接口实际支持为准 fnval: 16, # 16 代表 DASH 格式 } resp session.get(play_api, paramsparams).json() dash resp[data][dash] print(视频流数量:, len(dash[video])) print(音频流数量:, len(dash[audio]))返回的dash[video]是一个数组按清晰度从高到低排列每个元素里有baseUrl、bandwidth、codecs、width、height等字段。audio数组同理通常有多个码率版本。选择时不要盲取[0]最好按自己目标的清晰度去匹配height字段否则容易把4K地址当成普通地址去请求白白耗费带宽。有个容易踩的点baseUrl字段在JSON里有时是baseUrl有时是带URL编码的字符串直接用没问题但把它当字符串存到配置文件时要注意转义。这个接口还经常要求请求头带Referer指向视频页本身不带Referer返回403的概率很高这一点在后面的排错章节会细说。2.3 音频视频分离存储为什么FFmpeg合并是绕不开的一步B站DASH流把视频轨和音频轨分成两个独立文件各自以分片形式下发文件后缀通常是.m4s。这种设计是为了播放器按需拉流但对下载者来说直接拿到的是两个没有完整MP4封装的文件改后缀名放不进剪辑软件拖进播放器也常常只出画面没声音。合并这一步几乎只有FFmpeg一条主流路径。命令本身很朴素ffmpeg -i video.m4s -i audio.m4s -c copy output.mp4-c copy表示不重新编码直接复制流封装进MP4速度接近硬盘读写上限画质无损。需要说明的是-c copy要求两路流的编码格式能被MP4容器支持。B站视频轨一般是H.264或HEVC音频轨一般是AAC都在MP4兼容范围内所以这个命令绝大多数时候一次成功。少数情况会报Non-monotonous DTS这类时间戳警告输出依然可用不放心就加-fflags genpts重新生成时间戳。所以在设计下载脚本时不要把FFmpeg当可选步骤而是把下载完成—校验大小—调FFmpeg合并—删除分片串成一条固定流水线。脚本里通过subprocess.run调FFmpeg把每个视频P的输出文件按part标题命名比事后再手动整理文件名省事得多。3. 用Python跑通第一个B站视频下载脚本3.1 环境与依赖只需要 requests 和 json 就能拿地址动手之前先把环境备齐。Python 3.8以上版本就够第三方依赖只有一个requests标准库里的json、re、subprocess足以支撑其余环节。为什么不选SeleniumB站播放页没有强验证码常规接口用requests就能拿数据浏览器自动化慢还容易被风控识别。为什么不选Scrapy我们要下载的是几十个固定视频不是全站爬取用脚本串行加少量并发最合适。安装命令只有一行pip install requestsFFmpeg不是Python包需要单独安装。装好后先确认命令行可用ffmpeg -version能看到版本信息就说明FFmpeg在PATH里。脚本里后续用subprocess.run([ffmpeg, ...])调用如果Windows下报找不到ffmpeg把FFmpeg的bin目录加进系统PATH或者在脚本里写死成完整路径。3.2 最小可运行脚本拿页面、取cid、请求播放流、落盘现在把前面两章的链路串成一个能跑的最小脚本。这个脚本不做清晰度筛选、不做断点重试只负责把第一个P的视频轨和音频轨下载到本地。它的目的是让你看清每一步的连接关系先跑通再优化。import json import os import re import subprocess import time import requests UA (Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36) def get_session(): s requests.Session() s.headers[User-Agent] UA s.headers[Referer] https://www.bilibili.com/ # 需要更高清晰度时在这里补 Cookie return s def get_cid(session, bvid): url fhttps://www.bilibili.com/video/{bvid} html session.get(url).text m re.search(rwindow\.__INITIAL_STATE__(\{.*\});\s*/script, html, re.S) state json.loads(m.group(1)) return state[videoData][pages][0][cid] def get_stream(session, bvid, cid): api https://api.bilibili.com/x/player/playurl params {bvid: bvid, cid: cid, qn: 80, fnval: 16} data session.get(api, paramsparams).json()[data] dash data[dash] # 优先选1080P不存在时回退到列表第一项 v next((x for x in dash[video] if x[height] 1080), dash[video][0]) a dash[audio][0] return v[baseUrl], a[baseUrl] def download(session, url, path, timeout20, retries3): headers {Referer: https://www.bilibili.com/video/BV1xx411c7mD} for attempt in range(1, retries 1): try: with session.get(url, headersheaders, streamTrue, timeouttimeout) as r: r.raise_for_status() with open(path, wb) as f: for chunk in r.iter_content(chunk_size1024 * 512): f.write(chunk) size os.path.getsize(path) total int(r.headers.get(Content-Length, 0)) if total and size ! total: raise ValueError(文件大小不一致) return True except Exception as e: print(f第 {attempt} 次重试失败: {e}) time.sleep(2 * attempt) return False def main(): bvid BV1xx411c7mD s get_session() cid get_cid(s, bvid) video_url, audio_url get_stream(s, bvid, cid) ok_v download(s, video_url, video.m4s) ok_a download(s, audio_url, audio.m4s) if not (ok_v and ok_a): raise SystemExit(下载失败请检查网络或接口参数) subprocess.run( [ffmpeg, -i, video.m4s, -i, audio.m4s, -c, copy, output.mp4], checkTrue, ) print(下载完成) if __name__ __main__: main()这个脚本几分钟就能跑完一遍流程。代码里几个地方值得说get_stream用next搭配默认值先找1080P找不到就回退到第一个视频流避免视频最高只有720P时抛StopIteration。download里每512KB一个chunk这是兼顾内存占用和磁盘写入效率的常用值几GB的文件也不会撑爆内存。接口是B站的私有接口字段和参数随页面版本更新会调整。跑不通时第一时间用浏览器开发者工具抓一次playurl的真实请求对比一下参数名再改脚本。把这个脚本跑通的关键不是背接口而是理解页面HTML里取cid—播放接口换地址—带Referer下载—FFmpeg合并这条链路后面所有优化都围绕它展开。3.3 分片下载时的超时与重试参数最小脚本能跑通但还经不起真实网络考验。下载到一半连接断开是常态几秒钟没响应的请求也不能干等。我一般把超时和重试的默认值写在下载函数里所有文件都走同一个入口。timeout参数在requests里可以传一个数字也可以传元组(connect, read)我习惯传20秒。读超时由iter_content底层抛出的异常触发触发后进入重试逻辑。重试次数设置3次太多会导致整体流程卡在某个坏文件上太少碰上网抖就翻车。退避等待用2 * attempt秒第一次2秒、第二次4秒、第三次6秒简短等待能给网络缓冲时间又不会让批量下载的总时长失控。重试逻辑里还有一个容易被忽略的细节下载到一半失败时本地已经有一个不完整的文件。下一次重试如果不处理要么直接覆盖要么在文件大小校验时发现不一致而继续重下。我上面的写法用open(path, wb)每次重写保证落盘的文件始终是干净的重下结果。4. 把脚本调稳请求头、登录态与并发边界4.1 请求头三件套与登录态清晰度是跟账号走的很多初次写B站爬虫的人把请求头当成玄学实际上只需要搞清每个字段为什么在。User-Agent告诉服务器你是一个真实浏览器不带或带得太假会被风控直接拒掉Referer告诉服务器请求是从哪个页面发起的播放地址接口和分片下载接口都要求这个字段指向视频页或B站域名缺失时返回403的概率很高Cookie只在需要更高清晰度或访问会员内容时才必须带。清晰度这件事和登录态直接挂钩。匿名状态下playurl接口给出的最高档通常受限登录后才有1080P、4K档位。登录态在脚本里的表达就是请求头里的Cookie字段其中最关键的是SESSDATA它是B站保持网页登录的凭证。抓取方法很常规浏览器登录B站后在开发者工具里找到任意一次请求的Cookie字段复制整串填进配置。提示Cookie有时效过期后接口会退回匿名档位下载器会表现为突然只能下低清晰度。这时重新登录、更新Cookie即可不需要改代码。建议把Cookie放到独立配置文件里不要写死在代码中。换账号或Cookie过期时只需改配置不必翻源码。Session对象在登录前后是同一个实例先请求一次视频页拿到完整页面上下文再请求playurl成功率比单独发起冷请求高。这也是我坚持用Session而不是裸用requests.get的原因。4.2 并发下载的正确姿势多P线程池与限频边界DASH返回的单个视频轨和音频轨体量不小但同一个视频内没有更多可拆分的片段所以并发下载在B站场景里主要发生在多P视频或批量视频之间。常见做法是用concurrent.futures.ThreadPoolExecutor每个任务下载一个P的两条流下载完成后立即合并并删除分片。import os import re import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed def download_one_part(session, bvid, page_info, out_dir): cid page_info[cid] name re.sub(r[\\/:*?|], _, page_info[part]) video_url, audio_url get_stream(session, bvid, cid) v_path os.path.join(out_dir, f{name}_v.m4s) a_path os.path.join(out_dir, f{name}_a.m4s) if not download(session, video_url, v_path): return name, False if not download(session, audio_url, a_path): return name, False subprocess.run([ffmpeg, -i, v_path, -i, a_path, -c, copy, os.path.join(out_dir, f{name}.mp4)], checkTrue) os.remove(v_path) os.remove(a_path) return name, True with ThreadPoolExecutor(max_workers4) as pool: futures [ pool.submit(download_one_part, s, bvid, p, out_dir) for p in pages ] for fut in as_completed(futures): name, ok fut.result() print(name, 成功 if ok else 失败)max_workers4是我验证过比较稳的并发数。开8个线程大多数时候也没问题但如果同时下载多个视频系列单个IP的并发连接数叠加很容易触发B站的限频表现是接口开始随机返回412错误码或下载速度骤降。线程池开得再大也只是把触发限频的时间提前。另一个细节每个P下载完成立即合并并删除分片能控制磁盘占用否则几个4K视频的分片就能把空间吃光。4.3 下载进度、文件校验与磁盘占用没有进度的下载脚本会让人心里没底尤其单文件几百MB时。最轻量的进度实现是读取响应头的Content-Length然后在iter_content里累计已写字节并打印百分比不需要引入tqdm依赖。total int(r.headers.get(Content-Length, 0)) written 0 for chunk in r.iter_content(1024 * 512): f.write(chunk) written len(chunk) if total: print(f\r{written * 100 // total}%, end)下载完的文件要校验不能只看200状态码。对比本地文件大小和Content-Length是最简单可靠的办法前面download函数里已经演示。文件大小对不上就删掉重来分片文件不完整到了FFmpeg合并阶段才暴露排查成本会高很多。合并成功后删除分片这个动作本质就是给磁盘上真正需要保留的文件留空间。5. B站视频爬虫的常见坑与排查清单5.1 现象一解析不到cid或cid取错从页面HTML里提取__INITIAL_STATE__失败或正则匹配到别的数字是最常遇到的第一个坑。现象是后续playurl接口返回-404或者下载下来的视频是别的分P。原因分两种一是B站页面结构改版__INITIAL_STATE__的位置或字段名变了二是视频本身是互动视频、付费课程这类特殊对象它们的页面初始化数据里没有常规的videoData。解决优先改用view接口/x/web-interface/view返回结构稳定直接取data.pages数组。拿到pages后按page字段确认分P顺序不要一律取[0]。如果视频是互动视频view接口返回的data里没有pages这种视频当前脚本协议不支持直接打印提示跳过比反复尝试更有价值。5.2 现象二下载分片时返回403 Forbidden明明接口能拿到地址点击baseUrl下载却403这个现象在初次写脚本时几乎必现。原因基本是请求头里少了Referer或者User-Agent是Python默认的python-requests/x.x。B站CDN对分片下载的来路要求很严格不带Referer直接拒绝。解决下载函数的请求头里固定带上Referer: https://www.bilibili.com/video/{bvid}并且用统一的UA。个别情况下还是403通常是接口返回的baseUrl带的防盗链签名和当前请求头里的Cookie绑定检查Cookie是否完整或干脆用浏览器抓包里的完整请求头替换。注意不要同时拼两个Cookie串请求头里Cookie格式混乱时接口反而会判定为异常访问。5.3 现象三FFmpeg合并报错或音画不同步-c copy合并时提示Invalid data或输出文件播放时画面和声音对不上多数原因不是命令写错而是下载环节出了问题。分片文件没下载完整、中途断流后重试逻辑把不完整文件当成成功文件都会让FFmpeg拿到坏输入。解决先检查 video.m4s 和 audio.m4s 的大小是否接近预期最准的办法是看下载函数有没有做Content-Length校验确认源文件完整后若仍报Non-monotonous DTS给FFmpeg加-fflags genpts重新生成时间戳。实在不行的兜底方案是放弃流复制改用转码合并ffmpeg -i video.m4s -i audio.m4s -c:v libx264 -c:a aac output.mp4这会重编码视频耗时变长但兼容性最好。5.4 现象四批量下载越来越慢甚至触发风控脚本串行下前几个视频没问题到后面接口返回412或-403或者下载速度从几MB掉到几十KB。原因是短时间内的请求频率和并发连接数超过了单个账号单IP的容忍阈值相当于被临时限频。解决批量之间加time.sleep(3)起步多P之间不需要额外停顿核心是把请求接口的密度降下来。playurl接口很容易触发风控能复用地址就复用同一个视频P的音频和视频两路地址是一次playurl返回的不要在下载音频时又重新请求一次接口。保留登录态Cookie也比匿名状态更稳定匿名请求在风控判断里更容易被误伤。如果每天要下载大量视频把脚本设计成任务列表模式一天只跑一批比一次开满线程更持久。6. 把脚本升级成一个能日常用的命令行下载工具6.1 用argparse把链接、画质、保存路径参数化脚本写死链接只能自己用加一层命令行参数才能日常化。argparse是标准库不需要额外依赖四个参数就够-u视频链接、-q目标高度、-o输出目录、-p分P范围。import argparse parser argparse.ArgumentParser(descriptionB站视频下载器) parser.add_argument(-u, --url, requiredTrue, help视频页链接) parser.add_argument(-q, --quality, typeint, default1080, help目标高度) parser.add_argument(-o, --out, defaultdownloads, help保存目录) parser.add_argument(-p, --pages, helpP范围, 如 1-3 或 1,2,5) args parser.parse_args()调用方式python bili_dl.py -u https://www.bilibili.com/video/BVxxxx -q 720 -o ./video -p 1-3。解析出的args.pages在处理时展开成集合结合pages数组做过滤只下载指定分P能省掉整P下载再删除的麻烦。6.2 用history.txt记录下载历史下载多了之后最烦的是重跑脚本把已有视频又下一遍。我习惯在每个输出目录放一个history.txt每行记录一个bvid_cid启动时读进内存集合下载前比对命中就跳过。history_path os.path.join(args.out, history.txt) done set() if os.path.exists(history_path): with open(history_path, r, encodingutf-8) as f: done {line.strip() for line in f if line.strip()} # 在任务循环里 key f{bvid}_{cid} if key in done: print(已下载跳过, key) continue # 下载与合并完成后写入 with open(history_path, a, encodingutf-8) as f: f.write(key \n)这个历史记录文件同时充当日志哪天想知道某视频什么时候下载过直接看文件时间即可。比在脚本里维护SQLite更轻也不引入额外依赖。6.3 先探活再批量每次运行前的一个小习惯脚本能日常用之后最大的教训来自接口变动。B站的私有接口不定时会更新参数或签名算法我的习惯是每次批量下载前对第一个视频做一次完整探活请求一次view接口拿cid请求一次playurl接口拿地址。如果任一环节返回异常先停下载流程用浏览器开发者工具抓一次真实请求对比差异。这个小习惯看起来多花几秒钟实际能避免整批跑到一半才发现全部失败。这些年维护下来各种网络条件下都验证过下载器最大的不稳定因素往往不是网络而是接口变了脚本还在用旧参数。保持探活、保留调试入口、把download和合并逻辑拆成独立函数这三点是我反复踩坑后最想保留的习惯。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑