简介:基于Python编写的B站视频爬虫下载脚本,面向需要批量保存哔哩哔哩视频的初学者与轻量工具使用者。它以视频链接中的video_bvid参数作为关键输入,使用前按目标链接更新该值即可自动完成地址请求与文件下载,避免手动解析页面和寻找真实地址的繁琐过程。资源zip压缩包内共1个文件,为体积仅2KB的py脚本,轻量精简,无需复杂环境配置,非常适合Python爬虫入门者阅读源码、理解请求流程并二次修改。目前已有3295人学习或下载,表明该脚本在B站视频下载场景中具备较高的实用参考价值;通过它还可学习BV号拼接、网络请求发送、响应数据解析、视频文件保存等完整爬虫链路,在此基础上可继续扩展多P下载、清晰度选择等进阶功能。
1. 说到B站视频的下载,很多人以为打开网页另存为就行
真到想把系列课程缓存到本地慢慢看、在无网络环境做画面分析、把片段剪进自己的剪辑项目时,才发现视频流根本拿不到完整文件。B站把高清晰度视频按音频和视频分离存储,播放地址带签名和登录态,浏览器里点两下能看,脚本想拿就得走完整接口链路。现成的下载器不是没有,但接口一变就批量失效,维护节奏全看作者心情。于是用Python写一个自己能改的爬虫下载脚本,成了最可控的长期方案。这篇笔记从接口链路讲起,用requests手写一条能下载、能合并、能批量跑的最小实现,新手跟得上步骤,熟手能直接看到边界参数和踩坑记录。
2. 先看懂B站的视频分发链路,再动手写代码
2.1 从网页URL到两个关键ID:bvid与cid
打开任意B站视频页,地址栏里的BV号是视频对外ID,它对用户友好,但对播放接口来说还不够。播放接口真正认的是cid,也就是当前分P的内部ID。单P视频只有一个cid,多P视频每一个分P都有自己的cid,BV号对应的是整个视频容器。要在脚本里把视频下下来,第一步永远是拿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(r"window\.__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, params=params).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是清晰度档位编号,fnval=16表示请求DASH流式格式。不传fnval时接口返回的是老式单文件FLV,画质上限低;DASH格式才能拿到1080P以上的视频轨和独立音频轨。
play_api = "https://api.bilibili.com/x/player/playurl" params = { "bvid": "BV1xx411c7mD", "cid": cid, "qn": 80, # 64=480P 80=1080P 120=4K,档位以接口实际支持为准 "fnval": 16, # 16 代表 DASH 格式 } resp = session.get(play_api, params=params).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足以支撑其余环节。为什么不选Selenium:B站播放页没有强验证码,常规接口用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 = f"https://www.bilibili.com/video/{bvid}" html = session.get(url).text m = re.search(r"window\.__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, params=params).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, timeout=20, retries=3): headers = {"Referer": "https://www.bilibili.com/video/BV1xx411c7mD"} for attempt in range(1, retries + 1): try: with session.get(url, headers=headers, stream=True, timeout=timeout) as r: r.raise_for_status() with open(path, "wb") as f: for chunk in r.iter_content(chunk_size=1024 * 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"], check=True, ) 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")], check=True) os.remove(v_path) os.remove(a_path) return name, True with ThreadPoolExecutor(max_workers=4) 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_workers=4是我验证过比较稳的并发数。开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(description="B站视频下载器") parser.add_argument("-u", "--url", required=True, help="视频页链接") parser.add_argument("-q", "--quality", type=int, default=1080, help="目标高度") parser.add_argument("-o", "--out", default="downloads", help="保存目录") parser.add_argument("-p", "--pages", help="P范围, 如 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", encoding="utf-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", encoding="utf-8") as f: f.write(key + "\n")这个历史记录文件同时充当日志,哪天想知道某视频什么时候下载过,直接看文件时间即可。比在脚本里维护SQLite更轻,也不引入额外依赖。
6.3 先探活再批量:每次运行前的一个小习惯
脚本能日常用之后,最大的教训来自接口变动。B站的私有接口不定时会更新参数或签名算法,我的习惯是每次批量下载前对第一个视频做一次完整探活:请求一次view接口拿cid,请求一次playurl接口拿地址。如果任一环节返回异常,先停下载流程,用浏览器开发者工具抓一次真实请求对比差异。
这个小习惯看起来多花几秒钟,实际能避免整批跑到一半才发现全部失败。这些年维护下来,各种网络条件下都验证过:下载器最大的不稳定因素往往不是网络,而是接口变了脚本还在用旧参数。保持探活、保留调试入口、把download和合并逻辑拆成独立函数,这三点是我反复踩坑后最想保留的习惯。希望帮到你。
本文还有配套的精品资源,点击获取