资讯详情

B站视频下载爬虫:Python流式下载、音视频分离与批量备份技巧

📅 2026/10/19 9:09:11 | 华诺云谱 👁 阅读
B站视频下载爬虫:Python流式下载、音视频分离与批量备份技巧
简介这是一个用Python编写的B站小视频批量下载爬虫主要面向Python初学者、爬虫学习者及有视频资源整理需求的用户。脚本会循环访问前10页排行榜并解析JSON数据从中提取视频标题与下载地址使用requests模块下载视频流并写入本地文件同时把实时下载进度打印在控制台。下载完成后视频以标题命名并保存到video目录文件名中的非法字符会被自动替换每轮请求结束后还会随机等待3-6秒以降低被网站限制的风险这为读者演示了循环取数、JSON字段提取、文件命名清洗与延时控制等一系列基础而实用的爬虫思路。整个资源以zip压缩包形式提供包含1个py脚本大小为2KB结构轻量便于直接运行或拆解学习。已有496人学习通过阅读该脚本可以掌握接口数据解析、文件读写以及sleep延时等关键细节适合作为入门级爬虫项目的参考模板也可根据需求调整页码范围、保存目录和等待间隔后复用。1. B站视频下载爬虫一个带进度条的 Python 工具到底能干什么B站视频下载爬虫核心就是几行 Python 请求加文件写入但很多人第一次写就栽在“下载下来只有画面没有声音”和“进度条卡住不动”这两件事上。这个资源是一份完整可跑的爬虫脚本把视频流解析、音频视频分离下载、进度显示、多P批量和弹幕保存都串在了一起。它能解决的不只是“把视频存到本地”更实际的是批量备份课程合集、离线看高清视频以及留存弹幕。适合经常需要归档B站视频内容、又不想每次手动开客户端缓存的人尤其是前端、运营和内容剪辑方向的朋友。2. 先把真实地址挖出来cid、playurl 与音频视频分离2.1 播放页到 cid网页里没有直接能用的 mp4B站播放页的 HTML 里没有一段完整的 mp4 地址页面里真正有价值的是视频的 cid 和 aid。cid 是分P的唯一编号aid 是稿件编号后续所有接口调用都依赖这两个参数。常见的做法是先请求播放页再从中提取这两个值。播放页数据被塞在一个全局变量window.__INITIAL_STATE__里可以直接用正则抠出来也可以用 json 模块先解析再取键。import re import requests def fetch_cid(aid, page1): url fhttps://www.bilibili.com/video/av{aid} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: fhttps://www.bilibili.com/video/av{aid}, } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() # 播放页里的初始状态数据是 JSON 字符串直接抠出来转 dict m re.search(rwindow\.__INITIAL_STATE__(.*?);, resp.text) data json.loads(m.group(1)) video_pages data.get(videoData, {}).get(pages, []) if page len(video_pages): raise ValueError(fpage {page} 超出总P数 {len(video_pages)}) cid video_pages[page - 1][cid] part_title video_pages[page - 1][part] return aid, cid, part_title这个函数的核心是正则拿到完整的 JSON 字符串再交给 json 解析。很多老脚本用html.find配合字符串切片也能拿到 cid但播放页结构一调整就会挂掉建议直接用正则匹配__INITIAL_STATE__。需要注意aid是数字如果你从分享链接里拿到的是BV1xx411c7mD这种 BV 号需要先通过bvid转aid接口是https://api.bilibili.com/x/web-interface/view?bvid...返回结构里同样有cid和pages列表。timeout10是防止某个网络环境下请求挂死整包卡住不动。Referer头在第一步就能带上养成习惯后面解析视频流时如果不带它接口很可能直接返回 403。2.2 playurl 接口durl 和 dash 两种返回格式拿到 cid 后真正的视频地址来自playurl接口。这个接口返回两种结构老式格式durl完整的单文件 mp4/flv和新式格式dash视频流和音频流彻底分离。目前绝大多数视频走的是dash意思是拿到手的不是一个文件而是两个独立的流一段纯画面、一段纯声音需要下载后自己合并。def fetch_playurl(bvid, cid, cookie): api https://api.bilibili.com/x/player/playurl params { bvid: bvid, cid: cid, qn: 80, fnver: 0, fnval: 16, fourk: 1, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: fhttps://www.bilibili.com/video/{bvid}, } if cookie: headers[Cookie] cookie resp requests.get(api, paramsparams, headersheaders, timeout10) data resp.json()[data] if data.get(durl): return [item[url] for item in data[durl]] dash data[dash] video_url dash[video][0][baseUrl] audio_url dash[audio][0][baseUrl] return video_url, audio_urlqn80是清晰度档位80 对应 1080P64 是 720P32 是 480P16 是 360P。没有登录 Cookie 时即使 qn 设了 80实际给到你的可能还是 480P 的流这是个非常隐蔽的降级策略后面避坑章会细说。fnval16是让接口返回 dash 格式的关键参数如果不带它返回的 durl 通常是低清晰度的 flv。fourk1表示允许 4K 分辨率。第二个函数返回的是元组或列表后面下载逻辑要根据类型区分处理。这里还有个细节baseUrl字段有概率是空字符串这时候要退而求其次用backupUrl列表里的第一项。接口返回的 URL 带签名和过期时间通常几小时有效所以解析和下载最好放在同一个任务里一口气跑完不要先批量解析、隔天再下载那必然翻车。2.3 为什么拿到的是两个流文件而不是一个 mp4很多第一次接触这套东西的人会疑惑预览时明明是一个 mp4下载下来怎么变成 video.m4s 和 audio.m4s 两个文件。这是因为B站为了节省带宽和兼容不同清晰度采用了 DASH 分段编码画面和声音分别用不同码率编码。爬虫要做的是分别下载两个流再通过 ffmpeg 合并。video_url, audio_url fetch_playurl(bvid, cid, cookie) # 下载完成后合并-c copy 表示不重新编码速度最快 cmd [ ffmpeg, -i, video.m4s, -i, audio.m4s, -c, copy, output.mp4, ] subprocess.run(cmd, checkTrue)合并时用-c copy直接复制流不重新编码几分钟的视频几秒就合成完。只有一种情况会碰到音画不同步两个文件的时长戳基准不一样或者其中一个下载不完整。所以下载阶段就要校验 Content-Length别等到合并完才发现声音少了半截。这也是这个项目里进度条存在的意义没有它你根本看不出请求是否真的在传数据。3. 写一个看得见进度的下载器断点续传与进度刷新3.1 流式下载与 Range 请求进度条的数据来源大多数下载脚本用requests.get(url)拿到完整响应再写文件视频一大会直接占掉几百兆内存。正确的做法是streamTrue按块写入同时用Content-Length算出总量配合 tqdm 刷新进度。另外一个容易被忽略的是B站视频流的 URL 虽然带签名但某些情况下会跳转到 CDN 地址CDN 对 Range 请求的支持比较完整这给断点续传留了余地。def download_stream(url, filepath, referer): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: referer, } # 如果文件已存在断点续传从已有大小继续拉取 downloaded os.path.getsize(filepath) if os.path.exists(filepath) else 0 if downloaded: headers[Range] fbytes{downloaded}- with requests.get(url, headersheaders, streamTrue, timeout(5, 30)) as r: r.raise_for_status() total int(r.headers.get(Content-Length, 0)) downloaded with open(filepath, ab) as f, tqdm( totaltotal, unitB, unit_scaleTrue, descos.path.basename(filepath) ) as bar: bar.update(downloaded) for chunk in r.iter_content(chunk_size8192): if chunk: f.write(chunk) bar.update(len(chunk))这里的核心思路是Range头告诉服务器只返回从指定字节开始的内容文件以追加模式打开。tqdm初始化时total是服务器返回的剩余长度加已下载大小然后手动update(downloaded)把进度条拉到正确位置。chunk_size8192是经过权衡的太大会让进度条刷新迟钝太小会增加请求次数实际测试 4K~16K 都够用。你可能会问如果服务器不支持 Range 怎么办。实测B站绝大多数 CDN 节点是支持的因为视频播放本身就有拖拽进度条的需求。如果返回状态码是 200 而不是 206说明 CDN 忽略了 Range 头此时要把文件重新写而不是追加否则会重复一段数据。判断方式很简单r.status_code 206就是支持范围请求。3.2 重试与超时进度条卡住的时候到底发生了什么爬虫下载视频最容易出现的情况是文件下到一半进度条停在不动的百分比上然后几分钟后报错或者永远卡着。用户感觉是“卡死”其实大部分原因是网络连接被对端静默关闭读不到新数据。下载函数里要加超时和重试避免整个脚本挂在某个文件上。def download_with_retry(url, filepath, referer, retries3): for attempt in range(retries): try: download_stream(url, filepath, referer) return True except (requests.ConnectionError, requests.Timeout) as e: print(f第 {attempt1} 次下载失败: {e}) # 失败后延迟重试给网络和服务器一个缓冲时间 time.sleep(5 * (attempt 1)) return Falseretries3已经是比较保守的配置单文件重试三次还失败基本可以断定是 URL 过期或本地网络有断流问题。这里特别要注意的是重试时必须再次走download_stream它会检查文件已有大小自动拼接 Range 头相当于把断点续传嵌进去了。不要把重试写在循环请求外面否则每次都会从头下载前面的进度全白费。有些资源在下载过程中会遇到requests.exceptions.ChunkedEncodingError这是底层收到了不完整的 chunk多半是 CDN 节点切换导致的。把异常类型加进捕获列表即可。我一般会再加一个timeout(5, 30)第一个数字是连接超时第二个是读超时。读超时 30 秒意味着如果 30 秒内一个 chunk 都没到就认为连接已死进行重试这个参数在不同网络环境下要按实际情况调。3.3 单文件下载的完整流程从 URL 到本地文件的组装把前面的模块串起来一个完整的单视频下载流程应该是解析 cid → 获取 playurl → 分别下载音视频 → ffmpeg 合并 → 删除临时文件。这个过程要设计成函数级别可复用方便后续扩展多P批量下载。def download_single(bvid, cid, title, cookie): video_url, audio_url fetch_playurl(bvid, cid, cookie) referer fhttps://www.bilibili.com/video/{bvid} v_path f{title}_video.m4s a_path f{title}_audio.m4s ok download_with_retry(video_url, v_path, referer) and \ download_with_retry(audio_url, a_path, referer) if not ok: return False time.sleep(1) # 给文件落盘留一点缓冲 if merge_video_audio(v_path, a_path, f{title}.mp4): os.remove(v_path) os.remove(a_path) return True return False这个函数预设了一个约定音频视频临时文件以_video.m4s和_audio.m4s结尾合并成功后立即删除避免磁盘堆积大量中间文件。time.sleep(1)这个细节是给文件系统一点同步缓冲虽然在大多数机器上不是必要的但在 Windows 下偶尔会出现文件句柄未释放导致 ffmpeg 读取失败的情况加上这行能减少玄学问题。4. 多P和弹幕一起收任务排队与并发上限4.1 多P视频的 cid 列表与任务队列组织B站的视频分两种单P和多P。多P常见于课程、合集、纪录片一个稿件下面有几十个分P每个分P都有一个独立 cid。批量下载时第一步不是直接循环而是先拉一次完整的分P列表再统一进入下载队列。这样做的好处是能提前知道总量给进度条和日志输出留出整体视图。def fetch_all_pages(bvid): api https://api.bilibili.com/x/web-interface/view params {bvid: bvid} headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)} data requests.get(api, paramsparams, headersheaders, timeout10).json()[data] pages data[pages] return [ {cid: p[cid], title: p[part], page: p[page]} for p in pages ]每个分P的下载任务其实就是一个download_single调用但组织方式会影响整个批次的体验。最简单的做法是for循环逐个下载视频全的情况下要跑几个小时任务挂掉就要从头再来。稍微好一点的做法是把分P列表写入一个tasks.json文件程序启动时读入已完成的列表跳过已完成的任务这样中间断掉也能续。import json import os def load_done(): if not os.path.exists(done.json): return set() with open(done.json, encodingutf-8) as f: return set(json.load(f)) def save_done(done_set): with open(done.json, w, encodingutf-8) as f: json.dump(list(done_set), f, ensure_asciiFalse)done.json存的是已经成功合并的分P标题或 cid每次循环开始前检查一下已经在集合里的直接跳过。这个“断点续批”的设计比单文件断点续传更实用尤其是下载几十个分P的课程视频时中途断电或手动停止重跑一遍就知道哪些已完成。配合并发使用时要加锁避免多个线程同时写同一个文件后面小节会提到。4.2 线程池下载与并发上限为什么推荐 3 个而不是 10 个多P下载通常会想到用 ThreadPoolExecutor 提高速度但这里有个边界B站对单一 IP 的并发请求有限制超过阈值会直接返回 -412 状态码也就是平台主动拒绝请求。经验值是一次同时下载 2~3 个文件最稳速度既不会慢到不能忍也不容易触发频控。from concurrent.futures import ThreadPoolExecutor def download_all_pages(bvid, max_workers3): pages fetch_all_pages(bvid) done load_done() with ThreadPoolExecutor(max_workersmax_workers) as executor: futures {} for page in pages: if page[cid] in done: continue fut executor.submit( download_single, bvid, page[cid], f{page[page]:02d}_{page[title]}, cookie ) futures[fut] page[cid] for fut in concurrent.futures.as_completed(futures): cid futures[fut] if fut.result(): done.add(cid) save_done(done)max_workers3是反复测试下来的折中值。设成 10 时前半段下载速度确实快但十几分钟后会开始出现大量 403 和超时整体吞吐反而下降。其实平台风控更精确的维度是每秒请求数而不是并发连接数所以脚本里每提交一个任务就在下载线程里加一个随机的 0.5~1.5 秒延时比单纯限制并发更有效。丢给线程池的每个任务都是独立的download_single函数内部有自己的重试和超时机制不会因为某个分P失败导致整个批次崩溃。唯一的风险是多个线程同时调用save_done会写坏 JSON 文件因为save_done不是线程安全的。简单处理办法是收到完成通知后统一在主线程更新或者给写文件操作加一把threading.Lock()。4.3 弹幕下载与 XML 转 ASS把弹幕变成可以刻进视频的字幕弹幕是B站内容生态里很重要的一部分下载视频时如果能同时保存弹幕归档价值会高很多。弹幕接口按 cid 返回 XML 文件解析后可以转成 ASS 字幕格式用播放器挂载或直接用 ffmpeg 烧录进视频。import xml.etree.ElementTree as ET def download_and_convert_danmaku(cid, output_path): api fhttps://api.bilibili.com/x/v1/dm/list.so?oid{cid} headers {User-Agent: Mozilla/5.0, Referer: https://www.bilibili.com} resp requests.get(api, headersheaders, timeout10) resp.encoding utf-8 root ET.fromstring(resp.content) lines [] for d in root.iter(d): attrs d.attrib[p].split(,) # 弹幕出现时间秒、模式、字号、颜色 appear_sec float(attrs[0]) mode int(attrs[1]) font_size int(attrs[2]) color int(attrs[3]) text d.text lines.append((appear_sec, mode, font_size, color, text)) write_ass_file(lines, output_path)输出 ASS 的关键是时间轴换算把秒数转成H:MM:SS.cc格式并依据 mode 判断是滚动弹幕还是顶端固定弹幕。滚动弹幕的时长可以粗略估计为屏幕宽度除以速度一般取 6~8 秒。如果你不需要烧录字幕只是想留个存档保存 XML 原文件就够了转换还容易因为特殊字符踩坑。在批量下载场景里弹幕下载和视频下载是两条互相独立的流水线弹幕接口没有严格的频控可以在视频下载完成后再统一拉取避免请求密度叠加。5. 避坑排查403、降清晰度、合并失败与频控封禁5.1 明明拿到了直链却 403防盗链只认读不到的 Referer现象playurl 接口返回了完整的 URLrequests 也能拿到 200但真正下载时第一个请求就 403进度条还没亮就失败了。 原因B站的视频流地址在 CDN 层做了防盗链校验必须校验请求头里的Referer是否来自 bilibili.com 域名。请求不带带 Referer或者 Referer 写成别的站点CDN 直接拒绝。 解决在download_stream函数里显式带上Referer值改成视频播放页地址例如https://www.bilibili.com/video/{bvid}。这个参数应该从上层传进来而不是写死一个常量。5.2 页面显示 1080P下载下来却是 480PCookie 才是清晰度钥匙现象qn 参数设成了 80接口也没有报错但返回的视频流码率明显不对音频码率也只有 64Kbps。 原因未登录状态访问 playurl 接口服务端会把未登录用户的清晰度上限约束在 480P 甚至更低qn 参数只起到“我想要的清晰度”的作用不是“服务端给得起的清晰度”。 解决在配置里预留COOKIE变量填上自己账号的 SESSDATA 字段。登录 Cookie 能解锁 1080P 和更高码率部分视频还需要大会员权限Cookie 里没有对应权益时依然会被降级。代码里判断逻辑是if cookie: headers[Cookie] cookie没填也能跑只是画质不同。5.3 合并出来的视频音画不同步两个流的时长本来就不同现象ffmpeg 合并成功但播放时声音和画面越差越远具体表现为声音先出几秒或落后几秒。 原因B站 DASH 流的视频和音频轨道拥有不同的时间戳基准直接用-c copy复制流时ffmpeg 默认取第一个输入的时长为基准导致封装参数失真。 解决合并时强制重设时间基准或者用-fflags shortest参数也可以先分别用 ffprobe 检查两个流的 duration以较短的流为基准截断。还有一个更隐蔽的点如果音频流下载不完整文件时长比真实的短合并结果就会整体变速先看临时文件的大小是否和 Content-Length 一致。5.4 下载速度越来越慢直到全部失败并发太高触发频控现象批量任务前 10 个分P 跑得很顺越到后面越慢最后所有任务都在重试日志里全是 412 状态码。 原因短时间内请求密度过高平台对单 IP 的访问频率进行了限制返回 412 状态码。继续重试只会加重限制没有等待时间的话就会变成 IP 被封一段时间的局面。 解决把并发数降回 3每个任务之间加随机延时同时在捕获 -412 响应后做指数退避从 30 秒开始倍增重试间隔。我一般会在代码里保留一个DOWNLOAD_DELAY配置默认 1~2 秒批量任务不管并发是多少都强制休眠。这是“慢就是快”的典型场景。5.5 文件名里的非法字符Windows 下字幕和视频编码串线现象视频标题包含\/:*?|这些字符合并步骤报文件找不到或者明明下载成功但重命名失败。 原因B站视频标题经常带斜杠、冒号比如“课程 3/4”这些字符在 Windows 文件系统里是非法的。脚本里直接拿标题当文件名导致文件创建或重命名静默失败。 解决文件名生成时统一做清洗函数替换非法字符为空或下划线再拼接后缀。顺带一个小细节网络请求拿到的标题字符串不一定能直接用要encode(ascii, ignore).decode(utf-8)过滤不可见字符尤其是从 XML 弹幕转换文件里提取标题时。6. 自验清单与进阶校验、定时抓取与一次性参数修复视频下完不等于任务结束我每次批量跑完都会做一轮完整性检查。最简单的做法是用 ffprobe 读取每个 mp4 的 duration和播放页返回的pages列表中每个分P的时长对比误差超过 2 秒就标记为异常。这个校验脚本是最后的后悔药能在你意识到“视频怎么少了五分钟”之前发现问题。import subprocess, json def check_duration(filepath, expected_sec): cmd [ffprobe, -v, quiet, -print_format, json, -show_format, filepath] out subprocess.run(cmd, capture_outputTrue, textTrue) info json.loads(out.stdout) real_sec float(info[format][duration]) return abs(real_sec - expected_sec) 2进阶玩法是把爬虫改造成定时任务适合订阅某一类持续更新的内容。把fetch_all_pages的结果缓存到一个本地文件每次启动时对比新增的 cid只下载新出现的分P再用系统级的定时任务每天触发一次脚本。这里要注意接口返回的pages列表是动态的发布者重新编辑视频后列表顺序可能变化不能用位置下标去匹配必须以 cid 为准。最后一个常见工作是接口参数的修复。B站某些接口在调用时会校验请求参数签名如果你拿到的代码包跑出“风控校验失败”之类的错误先检查代码里是否带有签名相关参数。不同时期的接口策略不一样修的时候要对照实际返回的 JSON 里有没有code: -403或data: null来定位。我的习惯是每次跑批量任务前先单次调用fetch_playurl把它打出来的 URL 复制到浏览器里访问能播放说明接口没失效再放完整队列。从那以后我每次改完代码都会强制走一遍这个单P自检确认进度条、合并、命名都没问题再放批量任务希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑