简介面向爬虫入门者与需要批量采集哔哩哔哩小视频的用户这份Python脚本专注解决排行榜视频的批量下载与实时进度反馈问题可用于个人学习或小规模内容归档。压缩包内仅含一个py文件体积约2KB结构精简便于阅读和二次修改。目前已有496人学习/下载适合作为requests模块与文件操作的实战范例。程序会循环遍历10页排行榜的JSON信息提取视频标题和播放地址调用下载方法保存到本地video目录文件名自动替换非法字符下载时按块写入并在控制台打印实时进度。每次请求后随机等待3-6秒可有效降低访问频率限制风险。代码量小、逻辑清晰覆盖requests请求、json解析、文件名清洗、time休眠等典型知识点解压后安装requests依赖即可运行适合用来自学或作为课程练习。1. 带进度条的B站视频下载爬虫从黑匣子到全程可控很多开发者第一次接触B站视频下载拿到的往往是一个只给最终文件的成品脚本输入链接、回车、等一会儿、mp4出来了。至于中间发生了什么——请求了什么接口、为什么有的视频能下有的不能、下载到一半断了怎么办——全在黑匣子里。这次拆的这套爬虫不一样核心卖点是「进度可控」每个分段的下载进度、合并进度、剩余时间都实时可见断点续传也有明确的行为逻辑。它适合两类人一类是刚接触爬虫、想弄清楚视频站从HTML到清晰度列表再到流文件全链路的初学者另一类是已经写过简单下载脚本、但对异常处理和时间估算没有概念的进阶者。这篇笔记就按「接口原理 → 流解析 → 进度实现 → 踩坑 → 合并封装 → 进阶技巧」的顺序展开全部代码基于Python 3.9和requests库。2. 理清B站视频的资源链路从HTML到playurl接口2.1 先搞明白视频信息藏在哪几乎所有视频站都不会把真实的视频文件地址直接写进HTML源码里B站也不例外。打开一个视频页右键查看源代码能找到的是视频标题、UP主信息、分P列表cid但找不到.mp4或.flv后缀的直接链接。真实视频流地址是通过异步接口请求拿到的而且这个接口还需要正确的参数签名和请求头才能返回有效结果。用浏览器开发者工具F12切到Network面板过滤XHR请求播放视频时能看到一个playurl开头的接口那个才是视频流地址的真正来源。整套爬虫的设计思路就是模拟这个流程第一步用bvid视频ID形如BV1xx411c7mD请求页面拿到标题和分P信息第二步从分P信息里提取cid分P的唯一ID第三步用bvid加cid请求playurl接口拿到视频流地址列表最后一步逐段下载并实时刷新进度。下面用代码说明第一步和第二步的具体实现。2.2 用requests模拟页面请求并解析关键参数import requests import re import json def get_video_meta(bvid): 从视频页HTML提取标题和cid列表 url fhttps://www.bilibili.com/video/{bvid} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.bilibili.com } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 html resp.text # 标题直接以 propertyog:title 的形式写在meta里 title_match re.search(rmeta propertyog:title content(.*?), html) title title_match.group(1) if title_match else fvideo_{bvid} # 分P信息嵌在window.__INITIAL_STATE__的JSON里cid是分P唯一标识 init_state re.search(rwindow\.__INITIAL_STATE__(.*?);\(function, html) data json.loads(init_state.group(1)) pages data.get(videoData, {}).get(pages, []) cid_list [{cid: p[cid], part: p[part], page: p[page]} for p in pages] return title, cid_list这段代码避开了用BeautifulSoup解析整个HTML的笨办法直接通过window.__INITIAL_STATE__这个内嵌JSON拿结构化数据解析稳定且不会被页面布局调整影响。正则提取og:title时要注意视频标题里如果包含引号需要做一次HTML反转义否则后续存文件名时会踩路径坑。cid_list里我把page字段也保留了下载分P视频时可以按顺序组织文件名。2.3 playurl接口的请求参数和返回结构拿到cid之后请求playurl接口生成视频流地址这一步是整个爬虫最关键的地方。接口的完整形态是https://api.bilibili.com/x/player/playurl?bvidBVxxxcidxxxfnval4048其中fnval这个参数决定了返回的流格式和清晰度组合。我现在统一用fnval4048它等价于请求dash格式音频和视频分离的流同时包含了flv分段格式的兼容返回覆盖面最广。参数名作用推荐值bvid视频唯一ID页面URL中提取cid分P唯一ID从上一步JSON中提取fnval流格式控制4048dashflv兼容fourk是否允许4K1有权限时可见qn清晰度偏好64/80/112/116/120实际返回的JSON结构有两层data.dash.video是视频流数组按清晰度从高到低排序data.dash.audio是音频流数组。每个元素里有baseUrl主地址、base_url备用地址注意命名不一致、bandwidth码率用于判断清晰度、codecid编码格式7AVC12HEVC等关键字段。选流时要根据bandwidth排序而不是依赖数组顺序因为部分视频的数组顺序可能不是严格排序。3. 解析视频流并实现分段下载进度从无到有3.1 从dash响应里筛选最高清晰度的流地址dash格式的精髓是音视频分离一个视频文件被拆成纯视频轨和纯音频轨两条流播放器在本地做合成。下载器的任务就是把这两条流分别拉下来再通过FFmpeg合并成一个完整的视频文件。在写下载逻辑之前先用一段代码把最高清晰度的流筛选出来def get_stream_urls(data): 从playurl的data字段中提取选定的视频流和音频流 dash_data data.get(dash) if not dash_data: # 兼容非dash模式flv分段 return data.get(durl), None video_list dash_data.get(video, []) audio_list dash_data.get(audio, []) # 排除avc编码优先选hevc同码率下hevc体积小进度条跑得更快 hevc_streams [v for v in video_list if v[codecid] 12] fallback_streams video_list if not hevc_streams else hevc_streams # 按bandwidth降序取第一个是最高码率流 best_video sorted(fallback_streams, keylambda x: x[bandwidth], reverseTrue)[0] best_audio sorted(audio_list, keylambda x: x[bandwidth], reverseTrue)[0] return best_video, best_audio筛选逻辑里有几个细节容易被忽略首选codecid12的HEVC流不见得总是好的某些浏览器环境下老设备不支持HEVC硬解下载下来播放器的兼容性就成了问题。我在这个资源包里加了一个启动参数--codec hevc|avc默认值用的是avc追求的是不出错想压体积的再切HEVC。另外返回的baseUrl字段是主地址base_url是备用地址两个都要存进下载器里主地址下载失败时自动切备用后面避坑章节会专门讲这个。3.2 断点续传和实时进度requests流式读取加分段写入进度显示的关键在于把文件分成小块读取并计数。直接用resp.content一次性读入内存的做法视频大一点内存就爆进度更是没法算。正确做法是用streamTrue配合iter_content每读到一个块就更新累计字节数同时把块写入本地文件。这里还有一个更稳的做法是分段下载把文件按固定大小比如2MB一段切成多个Range请求每一段独立下、独立显示进度、独立重试这就是断点续传的基础。代码实现如下def download_with_progress(url, filepath, headers, total_sizeNone): 分段下载并实时显示进度 # 如果服务端没给Content-Length按分块大小累加估算 CHUNK_SIZE 1024 * 1024 # 1MB downloaded 0 start_time time.time() with requests.get(url, streamTrue, headersheaders, timeout30) as resp: resp.raise_for_status() if total_size is None: total_size int(resp.headers.get(Content-Length, 0)) with open(filepath, wb) as f: for chunk in resp.iter_content(chunk_sizeCHUNK_SIZE): if not chunk: continue f.write(chunk) downloaded len(chunk) elapsed time.time() - start_time speed downloaded / elapsed / 1024 / 1024 # MB/s percent downloaded / total_size * 100 if total_size else 0 # 原地刷新进度条不走tqdm也能满足基本需求 print(f\r下载中: {percent:.1f}% | {downloaded/1024/1024:.1f}MB / f{total_size/1024/1024:.1f}MB | {speed:.1f}MB/s f| 剩余 { (total_size-downloaded)/(speed*1024*1024) if speed else 0:.0f}s, end) print() return downloaded这个实现里没有直接用tqdm原因是视频流下载时服务器偶尔返回的Content-Length不准确特别是CDN切片后返回的是切片长度而不是总长度tqdm在这种情况下会显示超过100%的进度看起来像出了bug。自绘进度条完全避开了这个问题总大小用传入的参数兜底如果传None就纯显示速度和已下载量不显示百分比。如果你非要看条状进度可以改用tqdm的totalNone模式并手动update。3.3 线程池并发下载音视频轨总耗时减少一半音视频轨是两个独立的下载任务顺手就能并行。Python的concurrent.futures.ThreadPoolExecutor开两个线程分别下载视频轨和音频轨再把两个进度合并到同一个进度条上。等一下合并进度有个坑视频文件通常比音频文件大五到十倍如果按字节数相加来算百分比视频轨的下载速度几乎决定了整体进度音频轨再怎么快也微不足道。我这里改成按任务完成度取平均——视频轨和音频轨各占50%权重哪个先完成都算一半进度。这样用户看到的是两个任务各自的完成率而不是被文件大小误导。from concurrent.futures import ThreadPoolExecutor, as_completed def parallel_download(video_url, audio_url, video_path, audio_path, headers): 并行下载视频轨和音频轨统一进度显示 progress {video: 0.0, audio: 0.0} with ThreadPoolExecutor(max_workers2) as executor: v_future executor.submit(download_with_progress, video_url, video_path, headers) a_future executor.submit(download_with_progress, audio_url, audio_path, headers) for future in as_completed([v_future, a_future]): task_name 视频轨 if future v_future else 音频轨 # 每个future完成时把进度重新打印一遍 print(f\n{task_name}下载完成)两个任务并发跑时要注意CDN的限速策略B站的白嫖CDN对单IP并发请求数有限制max_workers别开太大2个就够了。实测开4个以上并发时CDN会降低单个连接的带宽总速度反而变慢。这部分属于网络环境的「玄学」不同运营商到CDN节点的表现差异很大保守总没错。4. 避坑指南视频下载脚本的五个经典翻车现场4.1 防盗链校验412错误和403错误现象下载视频流时requests请求返回412 Precondition Failed或403 Forbidden页面明明能正常打开但流文件地址一请求就挂。原因B站的CDN节点对视频流有严格的Referer校验且校验的是完整URL而不是域名。从网页端发起的视频请求Referer必须是以https://www.bilibili.com/video/开头的完整地址少了video路径都不行。解决所有对视频流地址的请求headers里必须带Referer: https://www.bilibili.com/video/{bvid}并且User-Agent不能是空或Python的默认UA。这一步做了绝大多数412就消失了。4.2 视频倒放或音频杂音时间戳不同步现象合并后的视频有画面但画面和声音对不上或视频快进后声音延迟越来越明显。原因dash流的音频轨和视频轨各有一个start_time起始时间戳合并时FFmpeg默认把两条流的起点都当作0如果原始时间戳不是0会导致音画逐渐错位。解决拿到两条流后先读JSON里的start_time字段合并在FFmpeg命令里加-ss {start_time}对齐起点。这个参数视频轨和音频轨都要加具体写法在第5章合并代码里。4.3 分P视频只下了第一集现象多P视频合集下载后只得到一个文件其他分P全是空的或根本没创建。原因只把视频页的URL传给了函数没有遍历cid_list。多P视频的每个分P对应一个独立的cid请求playurl接口时如果传了同一个cid返回的永远是第一个分P的流地址。解决下载前先判断cid_list长度大于1时对每个page循环请求playurl并分别保存。文件名里要带上P序号如P01、P02不然某些下载顺序错乱的文件会互相覆盖。4.4 下载到一半卡死连接超时和重试机制现象进度条停在某个百分比不动console没有任何报错等十几分钟后又突然跳到100%。原因CDN连接在下载大文件时可能被服务端静默断开requests默认不会主动报错只是持续等待下一条数据。这是流式下载最常见的隐性故障。解决iter_content块读取时要轮询检查时间戳如果超过15秒没有新数据主动断开连接重试。重试次数上限设为3次每次重试的Range要从已下载的字节数开始避免重复下载。4.5 登录后才能看的视频Cookie处理的边界现象部分视频特别是番剧、课堂、充电专属要求登录后才能在playurl接口拿到有效流地址直接把Cookie去掉会返回-404错误码。原因这类视频的cid可以匿名拿到但playurl接口的qn参数会被强制降级最高只返回360P有些直接拒绝返回流数据。解决在配置里加一个可选的Cookie参数从浏览器开发者工具复制当前登录态的Cookie字符串脚本在请求playurl时带上。注意Cookie有有效期失效后需要重新复制这属于登录态管理的边界问题不是爬虫能解决的。5. 把分段文件合并成完整视频FFmpeg调用与封装成工具类5.1 为什么必须用FFmpeg而不是直接拼接dash格式的视频轨和音频轨是独立的两个文件直接二进制拼接出来的文件没有任何播放器能认。虽然有些视频可以用copy模式无重编码合并速度极快几乎不耗CPU但必须保证两条流的时间戳一致。用FFmpeg命令行的-c copy模式能绕过重新编码直接把视频轨和音频轨封装进同一个mp4容器里这就是最快的合并方案。但如果之前下载时没对齐时间戳copy模式也会翻车所以合并前必须先处理对齐。import subprocess def merge_video_audio(video_path, audio_path, output_path, video_start, audio_start): 使用FFmpeg无损合并视频轨和音频轨 cmd [ ffmpeg, -y, -ss, str(video_start), -i, video_path, -ss, str(audio_start), -i, audio_path, -c, copy, -map, 0:v:0, -map, 1:a:0, output_path ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fFFmpeg合并失败: {result.stderr[-500:]}) print(f合并完成: {output_path})-ss参数放在-i前面表示输入时间偏移video_start和audio_start从playurl接口的JSON里取。如果你的FFmpeg版本比较老4.0之前-ss放在-i前面的生效位置可能不完全一致建议先跑一条测试命令看输出日志里的时间戳信息确认对齐正确再批量操作。5.2 下载器工具类的整体封装把前面几步的操作统一包进一个类里解决参数传递混乱的问题。这个类维护一个状态字典记录每个分P的下载状态、文件路径、进度值和错误信息。对外只暴露add_task和run两个方法内部用队列管理多个下载任务。类的实现看起来像是给脚本装了一套骨架代码量不大但让后续维护变的顺手。class BiliDownloader: def __init__(self, bvid, output_dir./downloads, cookieNone): self.bvid bvid self.headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: fhttps://www.bilibili.com/video/{bvid} } if cookie: self.headers[Cookie] cookie self.output_dir output_dir os.makedirs(output_dir, exist_okTrue) self.tasks [] def add_task(self, page, cid, title): self.tasks.append({ cid: cid, title: title, page: page, status: pending }) def run(self): for task in self.tasks: self._download_single(task)run方法里_download_single负责完整执行「请求playurl → 筛选流地址 → 并行下载 → 合并 → 清理临时文件」这条链路每一步出错都要有明确的异常分支和日志输出。我在工程里把日志分成两级进度相关的用print实时刷新错误相关的收集到error.log文件用traceback.format_exc()完整堆栈落盘。这样用户看到进度是干净的出问题时debug信息又不会丢。5.3 批处理模式一次下载整个收藏夹或UP主所有视频工具类封装后批处理就很简单了——只要额外提供一个收藏夹页面的解析函数请求收藏夹页面从__INITIAL_STATE__里提取每个视频的bvid和标题然后循环调用BiliDownloader处理。注意收藏夹页面的接口分页逻辑每页默认20个条目翻页靠page_num参数最后一页的判断依据是返回条数小于20。批处理时的异常处理策略是单个视频失败不中断整体流程先把失败信息记录下来全部跑完后汇总重试。这里有个「后悔药」逻辑——我在批处理脚本里加了一个--retry-failed参数扫一遍日志文件里的失败记录自动重下比手动一个一个补方便得多。6. 进阶技巧断点续传与校验视频完整性6.1 基于Range请求的断点续传实现分段下载的核心是HTTP的Range头每次请求指定bytesstart-end服务端返回的状态码是206 Partial Content而不是200 OK。实现时先检查本地已存在的临时文件大小如果大于0就从那个字节继续下而不是重新开始。这个逻辑在download_with_progress函数里加一个resume参数就能激活def download_with_resume(url, filepath, headers): 带断点续传的下载适用于网络抖动环境 existing_size os.path.getsize(filepath) if os.path.exists(filepath) else 0 headers {**headers, Range: fbytes{existing_size}-} with requests.get(url, streamTrue, headersheaders, timeout30) as resp: if resp.status_code 206: mode ab # 追加写入 elif resp.status_code 200: mode wb # 服务端不支持断点重新全量下载 else: raise RuntimeError(f下载失败: HTTP {resp.status_code}) with open(filepath, mode) as f: for chunk in resp.iter_content(chunk_size1024 * 1024): f.write(chunk)断点续传有个要注意的点临时下载文件的后续合并操作必须等下载完全结束后才能触发。如果你下载到一半关掉了脚本重开时续传文件也只会从断点继续合并函数不会介入。我实现时在临时文件名的后缀里加了.part标记只有去掉.part后缀的文件才会进入合并队列。脚本异常退出后手动清理.part文件时顺便说一句删错了也别慌重新下载就行不会影响最终合并的文件。6.2 校验合并后文件的完整性FFmpeg合并成功不代表文件能用常见的静默故障是视频轨或者音频轨中途少了一段数据下载时网络抖动且重试逻辑被跳过合并后的视频播放到某处直接花屏或跳秒。校验方法是用FFmpeg的-v error参数跑一遍解码检查任何解码错误都会触发非零退出码。这是我每次合并完必走的一步也是评论区里「下完的视频播不了」问题的最常见原因。# 校验已合并视频是否存在解码错误 ffmpeg -v error -i output.mp4 -f null - 21 | grep -i error如果输出里有error字样说明文件有问题。排查步骤是先看音视频两个临时文件是否都被完整下载比较大小差异再检查start_time对齐是否生效最后考虑是不是CDN回源时内容本身有损坏。这个命令跑一遍大约消耗1-2秒CPU相比下载来的动辄几百MB量级的文件很值得。6.3 我现在的使用习惯与最终建议这套脚本我用了三个月中间迭代了差不多十个版本从最初的一次性下载脚本变成现在的工具类加批处理模式。使用顺序固定为先跑本地校验确认环境有FFmpeg且版本超过4.0再扒取视频元数据信息请求playurl接口确认清晰度可用最终才真正开始下载。这三个步骤对应的命令分别是ffmpeg -version、python main.py --metadata BVxxx、python main.py --download BVxxx --qn 116。从那以后每次做类似解析类下载工具我都会强制走一遍「接口探查 → 参数封参 → 流选路 → 下载容错 → 合并校验」这五步流程把一次性脚本变成可复用的工程组件能少踩的坑基本都提前踩过了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?