资讯动态

Python爬虫实战:解析B站视频流与FFmpeg合并下载

发布时间:2026/8/15 2:18:10 来源:尧图企业网站定制
1. 项目概述为什么选择Python来“搞定”B站视频作为一个经常在B站上找学习资料、收藏技术教程的Python开发者我经常遇到一个痛点看到一些非常棒的课程或分享想下载下来离线观看、反复学习或者整理成自己的资料库却发现B站官方并没有提供直接的下载入口。网页上的“缓存”功能限制多客户端下载的格式又可能不通用。于是我决定自己动手用Python写一个工具来解决这个问题。这不仅仅是“下载”这么简单它涉及到网络请求分析、数据解析、流媒体处理等多个环节是一个挺有意思的练手项目。用Python来做这件事优势非常明显。首先Python的requests、aiohttp等库处理HTTP请求非常方便其次像BeautifulSoup、lxml、正则表达式这些工具能轻松应对网页内容解析再者对于视频流合并、文件处理Python也有成熟的库支持。整个过程实际上是一个小型爬虫项目但目标不是文本而是二进制视频流。今天我就把自己从零搭建这个工具的思路、踩过的坑以及最终成型的方案详细地分享出来。无论你是想学习Python网络编程还是单纯想拥有一个自己的B站视频下载器这篇文章都能给你一份清晰的“地图”。注意本教程及代码仅供个人学习与研究Python网络请求、数据解析技术之用。请务必尊重视频创作者版权仅下载已获得授权或用于个人学习目的的公开视频切勿用于任何商业或损害他人权益的用途。滥用可能导致你的B站账号触发安全风控显示“由于触发哔哩哔哩安全风控策略该次访问请求被拒绝”甚至被封禁。2. 核心思路与技术选型拆解在动手写代码之前我们必须搞清楚B站视频是怎么提供给用户播放的。直接右键“另存为”肯定是行不通的我们看到的网页只是一个播放器外壳。真正的视频数据是通过一系列网络请求动态加载的。我们的核心任务就是模拟浏览器找到这些请求并从中提取出真正的视频和音频流地址最后把它们下载并合并起来。2.1 B站视频的加载流程分析当你打开一个B站视频页面时背后发生了很多事情加载基础页面获取包含视频基本信息的HTML。获取视频播放凭证API请求页面中的JavaScript会向B站的后端API发送请求携带视频IDbvid或aid、用户登录信息如果有、清晰度选择等参数请求获取视频的播放链接列表。这个列表里包含了不同清晰度如360P、720P、1080P、4K对应的视频流和音频流地址。分片传输M3U8与MP4B站主要使用两种格式DASH格式这是目前的主流。它会将视频和音频分离成独立的流video.m4s和audio.m4s每个流可能还会被切成很多个小片段segment。客户端根据当前网速动态选择清晰度片段加载这就是所谓的“动态自适应流”。对应的播放列表文件是.m3u8。老式MP4/FLV格式一些老视频或低清晰度选项可能直接提供一个完整的.mp4或.flv文件链接。解密与播放对于某些需要会员或付费的视频流地址可能是加密的需要特定的token或密钥才能解密播放。这是我们可能遇到的主要障碍之一。我们的Python脚本目标就是自动化第2步和第3步并处理第4步可能遇到的问题。2.2 技术栈选择与理由基于以上分析我选择了以下技术栈并解释一下为什么请求库requests与aiohttprequests同步HTTP库简单易用适合初学者理解和构建基础爬虫。我们将先用它来完成核心逻辑。aiohttp异步HTTP库。当需要批量下载多个视频或者一个视频的成百上千个分片时同步下载会非常慢。aiohttp能并发发起大量请求极大提升下载效率。我们会在后续优化部分引入它。解析库json,re(正则表达式) 以及BeautifulSoup从API返回的数据基本都是JSON格式直接用Python内置的json模块解析即可。有时一些关键信息如初始的playinfo可能嵌在HTML的script标签里需要用正则表达式re来提取。BeautifulSoup主要用于解析HTML获取视频标题、作者等元信息虽然不是核心下载流程必需但能让工具更完善。流处理与文件合并FFmpeg这是最关键的一环。当我们分别下载了DASH格式的视频流无声音和音频流后必须将它们合并成一个完整的MP4文件。FFmpeg是处理音视频的行业标准工具功能强大且稳定。在Python中我们可以通过subprocess模块调用系统安装的FFmpeg命令行工具来完成合并。这意味着你的电脑上需要预先安装FFmpeg并将其添加到系统环境变量PATH中。进度显示tqdm下载大文件时一个美观的进度条能极大提升体验。tqdm库可以轻松地为循环添加进度提示。应对反爬headers与cookies模拟浏览器请求是绕过基础反爬的关键。我们需要在请求头headers中设置合理的User-Agent、Referer等字段。对于需要登录才能观看如大会员专享清晰度的视频我们需要在请求中携带登录后的cookies。这部分涉及账号安全需要谨慎处理。3. 实战步骤从零构建下载器接下来我们一步步实现这个工具。我会先实现一个基础的单线程同步版本确保逻辑清晰然后再讨论优化方案。3.1 环境准备与依赖安装首先确保你的Python环境建议3.7以上已经就绪并且安装了FFmpeg。1. 安装FFmpeg:Windows: 从官网下载编译好的可执行文件解压后将bin文件夹路径例如C:\ffmpeg\bin添加到系统环境变量PATH中。macOS: 使用Homebrew安装brew install ffmpeg。Linux (Ubuntu/Debian): 使用apt安装sudo apt install ffmpeg。 安装完成后在命令行输入ffmpeg -version如果显示版本信息则成功。2. 安装Python库打开你的终端或命令提示符使用pip安装必要的库。pip install requests beautifulsoup4 tqdm如果后续想用异步再安装pip install aiohttp aiofiles。3.2 第一步解析页面获取视频基本信息我们的入口是一个B站视频的URL例如https://www.bilibili.com/video/BV1xx411c7mD。import re import json import requests from bs4 import BeautifulSoup def get_video_info(bvid): 根据视频BV号获取视频标题、作者、以及最重要的API接口所需参数。 url fhttps://www.bilibili.com/video/{bvid} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Referer: https://www.bilibili.com/ } resp requests.get(url, headersheaders) resp.raise_for_status() # 检查请求是否成功 soup BeautifulSoup(resp.text, html.parser) # 获取标题 title_tag soup.find(h1, class_video-title) title title_tag.get(title) if title_tag else 未知标题 # 清理标题中的非法文件名字符 import re title re.sub(r[\\/*?:|], , title) # 关键从页面脚本中提取包含视频数据的window.__playinfo__对象 # 这个对象里包含了视频流和音频流的地址信息 pattern rwindow\.__playinfo__\s*\s*({.*?})/script match re.search(pattern, resp.text) if match: play_info_str match.group(1) play_info json.loads(play_info_str) return { title: title, play_info: play_info } else: # 如果没找到__playinfo__可能需要从另一个叫__INITIAL_STATE__的脚本里找cid等参数再去调用另一个API print(未在页面中找到直接播放信息可能需要二次API请求。) # 这里为了简化我们先尝试直接找cid pattern_state rwindow\.__INITIAL_STATE__\s*\s*({.*?}); match_state re.search(pattern_state, resp.text) if match_state: state_info json.loads(match_state.group(1)) cid state_info.get(videoData, {}).get(cid) return { title: title, cid: cid, bvid: bvid } return None # 测试 bvid BV1xx411c7mD # 请替换成你想下载的视频BV号 info get_video_info(bvid) if info: print(f视频标题{info.get(title)}) if play_info in info: print(已获取到直接播放信息。) else: print(f需要二次请求CID: {info.get(cid)})实操心得B站的页面结构可能会变window.__playinfo__这个关键对象的名字或位置可能发生变化。如果上述代码提取失败你需要用浏览器的开发者工具F12在“网络”(Network)选项卡中筛选XHR或JS请求寻找包含playurl或dash等关键词的响应那里才是真正的数据源。这是爬虫工程中最常遇到的“对抗”。3.3 第二步从播放信息中提取音视频流地址如果我们成功从__playinfo__中拿到了数据那么接下来的解析工作就相对直接。这个play_info对象结构复杂但我们需要的数据藏在data.dash或data.durl中。def parse_play_info(play_info): 解析播放信息提取出不同清晰度的音视频流列表。 返回一个列表每个元素是一个字典包含清晰度描述、视频流url、音频流url等。 data play_info.get(data, {}) formats [] # 情况1DASH格式 (视频音频分离) if dash in data: dash data[dash] video_list dash.get(video, []) audio_list dash.get(audio, []) # 通常每个清晰度对应一个video流所有清晰度可能共享同一个或少数几个audio流 # 这里我们简化处理取最高清晰度的video和第一个quality最高的audio if video_list and audio_list: # 按带宽或id排序假设带宽越高清晰度越高 video_list.sort(keylambda x: x.get(bandwidth, 0), reverseTrue) audio_list.sort(keylambda x: x.get(bandwidth, 0), reverseTrue) chosen_video video_list[0] chosen_audio audio_list[0] format_info { quality: chosen_video.get(id), # 清晰度ID如80表示1080P quality_desc: f{chosen_video.get(width)}x{chosen_video.get(height)}, # 如1920x1080 video_url: chosen_video[baseUrl], audio_url: chosen_audio[baseUrl], codecs: chosen_video.get(codecs, ), format: dash } formats.append(format_info) # 情况2老式FLV/MP4格式 (音视频合一) elif durl in data: durl data[durl] # durl可能是一个列表包含多个分段(segment) # 对于非DASH格式我们取第一个通常是完整的 if durl: format_info { quality: data.get(quality, N/A), quality_desc: flv/mp4, video_url: durl[0][url], # 音视频在一起的url audio_url: None, # 没有单独的音频 format: flv } formats.append(format_info) return formats # 接续上一步的info if info and play_info in info: available_formats parse_play_info(info[play_info]) for fmt in available_formats: print(f清晰度: {fmt[quality]} ({fmt[quality_desc]}), 格式: {fmt[format]}) print(f视频流: {fmt[video_url][:100]}...) # 打印前100字符 if fmt[audio_url]: print(f音频流: {fmt[audio_url][:100]}...)如果第一步没有直接拿到play_info而是拿到了cid那么我们需要调用另一个API来获取播放地址。这个API需要构造签名参数更复杂一些。一个相对稳定的方式是模拟播放页面的另一个请求。这里给出一个常见API端点的例子def get_play_info_by_api(bvid, cid): 通过API接口获取播放信息。需要构造params和sign。 注意B站的API接口和签名算法可能频繁变动此方法可能失效。 import time import hashlib # 这是一个常见的API端点参数需要按特定规则构造 api_url https://api.bilibili.com/x/player/playurl params { bvid: bvid, cid: cid, qn: 80, # 清晰度选择80代表1080P64代表720P32代表480P fnval: 4048, # 这个参数很重要4048代表请求DASH格式 fnver: 0, fourk: 1, # 是否请求4K session: xxx, # 可以从页面或其他请求中获取 otype: json, } # 这里省略了复杂的sign计算实际中需要逆向JS或寻找现成的计算方式 # 为了教程的可行性和安全性我们更推荐使用从页面直接提取__playinfo__的方法。 headers { User-Agent: ..., Referer: fhttps://www.bilibili.com/video/{bvid} } # resp requests.get(api_url, paramsparams, headersheaders) # 由于sign问题直接请求很可能返回-403错误。 print(警告直接调用API需要处理签名较为复杂。本示例优先使用页面解析法。) return None重要注意事项直接调用B站内部API涉及反爬和签名算法难度和维护成本很高。优先推荐从window.__playinfo__提取数据的方法它更稳定因为这是B站前端直接使用的数据。如果页面改版导致提取失败应首先检查新的页面结构而不是盲目去攻破API签名。3.4 第三步下载音视频流文件拿到纯净的video_url和audio_url后下载就相对简单了。但需要注意这些URL通常带有有效期如expire、sign参数需要尽快下载。另外对于DASH格式video_url可能指向一个.m3u8播放列表文件里面包含众多.m4s分片这需要我们进一步解析m3u8文件并下载所有分片。为了简化我们先处理直接返回mp4或m4s单一文件URL的情况。import os from tqdm import tqdm def download_file(url, filepath, headers): 下载文件并显示进度条。 # 流式下载避免内存占用过大 resp requests.get(url, headersheaders, streamTrue) resp.raise_for_status() total_size int(resp.headers.get(content-length, 0)) with open(filepath, wb) as f, tqdm( descos.path.basename(filepath), totaltotal_size, unitB, unit_scaleTrue, unit_divisor1024, ) as bar: for chunk in resp.iter_content(chunk_size1024*1024): # 每次1MB if chunk: f.write(chunk) bar.update(len(chunk)) print(f下载完成: {filepath}) def download_video_and_audio(format_info, title, output_dirdownloads): 根据格式信息下载视频和音频文件。 os.makedirs(output_dir, exist_okTrue) headers { User-Agent: Mozilla/5.0 ..., Referer: https://www.bilibili.com/ } video_url format_info[video_url] audio_url format_info.get(audio_url) format_type format_info[format] video_ext .mp4 audio_ext .mp3 # 根据url判断扩展名 if m4s in video_url: video_ext .video.m4s if audio_url and m4s in audio_url: audio_ext .audio.m4s video_filename f{title}{video_ext} video_path os.path.join(output_dir, video_filename) print(f开始下载视频流...) download_file(video_url, video_path, headers) audio_path None if audio_url and format_type dash: audio_filename f{title}{audio_ext} audio_path os.path.join(output_dir, audio_filename) print(f开始下载音频流...) download_file(audio_url, audio_path, headers) return video_path, audio_path, format_type # 整合测试 if __name__ __main__: bvid BV1xx411c7mD # 替换为实际BV号 info get_video_info(bvid) if not info: print(无法获取视频信息) exit() if play_info in info: formats parse_play_info(info[play_info]) if formats: chosen_format formats[0] # 这里选择第一个最高清晰度 print(f选择清晰度: {chosen_format[quality_desc]}) v_path, a_path, fmt download_video_and_audio(chosen_format, info[title]) print(f视频文件: {v_path}) print(f音频文件: {a_path}) print(f格式类型: {fmt}) else: print(未解析到可用的音视频流格式。) else: print(需要更复杂的API请求本基础示例暂不处理。)3.5 第四步使用FFmpeg合并音视频针对DASH格式如果下载的是分离的video.m4s和audio.m4s文件我们需要用FFmpeg将它们合成为一个MP4文件。import subprocess import sys def merge_media_with_ffmpeg(video_path, audio_path, output_path): 使用FFmpeg合并视频和音频文件。 if not audio_path or not os.path.exists(audio_path): print(无独立音频文件可能已是完整视频或为FLV格式。) # 如果是FLV/MP4格式直接重命名或转换 if video_path.endswith(.flv) or video_path.endswith(.mp4): os.rename(video_path, output_path) print(f文件已重命名为: {output_path}) return True else: return False # FFmpeg命令-i 输入文件 -c copy 表示直接流复制不重新编码速度极快 cmd [ ffmpeg, -i, video_path, -i, audio_path, -c, copy, # 关键参数复制流不重新编码 -y, # 覆盖输出文件 output_path ] print(f正在合并音视频...) try: # 运行FFmpeg并捕获输出 result subprocess.run(cmd, capture_outputTrue, textTrue, checkTrue) print(合并成功) # 合并成功后可选择性删除临时分片文件 os.remove(video_path) os.remove(audio_path) print(已清理临时音视频分片文件。) return True except subprocess.CalledProcessError as e: print(fFFmpeg合并失败错误信息) print(e.stderr) return False except FileNotFoundError: print(错误未找到FFmpeg。请确保FFmpeg已安装并添加到系统PATH环境变量中。) return False # 在下载完成后调用 output_mp4_path os.path.join(downloads, f{info[title]}.mp4) success merge_media_with_ffmpeg(v_path, a_path, output_mp4_path) if success: print(f最终视频已保存至: {output_mp4_path})核心技巧FFmpeg的-c copy参数是精髓。它不对视频和音频进行重新编码只是将流“复制”到新容器中因此速度非常快几乎是瞬间完成并且没有画质损失。千万不要使用-c:v libx264这类参数那会触发漫长的重新编码过程。4. 进阶优化与问题排查基础版本已经能跑通但在实际使用中你会遇到各种问题。下面分享一些进阶处理和避坑经验。4.1 处理M3U8分片下载很多时候video_url或audio_url指向的是一个.m3u8文本文件而不是直接的媒体文件。m3u8文件里列出了一系列.ts或.m4s分片的地址。我们需要下载所有分片然后用FFmpeg合并或者直接让FFmpeg读取m3u8链接来下载合并最方便。def download_m3u8_via_ffmpeg(m3u8_url, output_path, headers): 最省事的方法让FFmpeg直接处理m3u8链接。 注意需要将headers传递给FFmpeg否则可能403。 # 将headers转换为FFmpeg能识别的格式 header_str for key, value in headers.items(): header_str f{key}: {value}\\r\\n # 构建一个临时的headers文件内容 header_file_content fGET {m3u8_url} HTTP/1.1\\r\\n{header_str}\\r\\n import tempfile with tempfile.NamedTemporaryFile(modew, suffix.headers, deleteFalse) as f: f.write(header_file_content) header_file_path f.name cmd [ ffmpeg, -headers, f{header_file_path}, # 从文件读取headers -i, m3u8_url, -c, copy, -y, output_path ] try: subprocess.run(cmd, checkTrue) print(f已通过FFmpeg下载并合并: {output_path}) return True except subprocess.CalledProcessError as e: print(fFFmpeg处理m3u8失败: {e}) return False finally: # 清理临时headers文件 import os os.unlink(header_file_path) # 在解析出url后判断是否为m3u8 if format_info[video_url].endswith(.m3u8) or /index.m3u8 in format_info[video_url]: print(检测到M3U8格式使用FFmpeg直接下载...) output_temp_path os.path.join(output_dir, f{title}_temp.mp4) success download_m3u8_via_ffmpeg(format_info[video_url], output_temp_path, headers) # 注意如果是DASH且音视频分离这里只下载了视频流。音频流可能也是m3u8需要同样处理然后再用FFmpeg合并两个mp4。4.2 异步加速下载当需要下载多个视频或者一个视频的数百个分片时同步下载效率极低。使用aiohttp进行异步并发下载可以大幅提升速度。import aiohttp import asyncio import aiofiles async def async_download_file(session, url, filepath, headers): 异步下载单个文件。 async with session.get(url, headersheaders) as resp: resp.raise_for_status() total_size int(resp.headers.get(content-length, 0)) async with aiofiles.open(filepath, wb) as f: downloaded 0 # 这里简化了实际可以配合tqdm实现异步进度条但较复杂 async for chunk in resp.content.iter_chunked(1024*1024): # 1MB chunks if chunk: await f.write(chunk) downloaded len(chunk) print(f下载完成: {filepath}) async def download_multiple_files(url_list, headers): 并发下载多个文件。 async with aiohttp.ClientSession() as session: tasks [] for url, path in url_list: task asyncio.create_task(async_download_file(session, url, path, headers)) tasks.append(task) await asyncio.gather(*tasks) # 使用示例假设我们已经解析出一个m3u8文件的所有分片地址列表 segment_urls # segment_paths [os.path.join(temp_dir, f‘seg_{i}.ts’) for i in range(len(segment_urls))] # url_path_pairs list(zip(segment_urls, segment_paths)) # asyncio.run(download_multiple_files(url_path_pairs, headers))4.3 常见问题与排查技巧实录在实际操作中你肯定会遇到各种错误。下面是一个速查表问题现象可能原因排查与解决思路请求返回403错误1. Headers不完整或不对。2. URL过期带sign参数的URL有效期很短。3. IP或账号被风控。1. 检查User-Agent,Referer是否与浏览器一致。可复制浏览器请求中的Headers。2. 重新获取视频页面提取最新的__playinfo__。3. 降低请求频率更换IP或检查Cookie是否有效。window.__playinfo__找不到页面结构已更新。1. 在浏览器开发者工具中搜索__playinfo__或playinfo看它是否被放到了其他变量里。2. 搜索videoInfo或initialState尝试从中提取cid然后寻找其他API接口。下载下来的文件无法播放1. 音视频流未正确合并。2. 下载的文件本身损坏网络问题。3. 是加密流如付费课程。1. 检查FFmpeg合并命令确保输入文件顺序正确视频在前音频在后。用ffprobe检查下载的单个文件是否有有效流。2. 重新下载或检查网络。3. 加密流需要解密密钥通常来自另一个API。普通公开视频极少加密付费内容受严格保护破解难度极高且涉及法律风险不建议尝试。FFmpeg合并失败1. FFmpeg未安装或PATH错误。2. 输入文件格式FFmpeg不支持。3. 音视频流编码不兼容。1. 命令行输入ffmpeg -version确认。2. 尝试先用ffmpeg -i file.m4s查看文件信息。3. 尝试不使用-c copy而是指定编码器如-c:v libx264 -c:a aac但这会重新编码耗时长且损画质。“由于触发哔哩哔哩安全风控策略”请求行为被B站服务器判定为异常。1.最重要的措施降低请求频率在代码中增加随机延迟如time.sleep(random.uniform(1, 3))。2. 模拟更真实的浏览器行为如携带完整的Headers链。3. 考虑使用代理IP池但成本高个人项目不推荐。4.遵守Robots协议仅用于个人学习勿高频爬取。清晰度选择不生效解析时默认选了最高清晰度但可能想下载720P。在parse_play_info函数中不要直接取video_list[0]而是遍历video_list根据id或width/height选择你想要的清晰度对象。清晰度ID对照16360P, 32480P, 64720P, 801080P, 1121080P, 1164K。4.4 代码封装与用户体验优化最后我们可以将上述所有功能封装成一个类并添加一些实用功能比如清晰度选择、批量下载、断点续传等。class BilibiliVideoDownloader: def __init__(self, cookie_strNone): self.session requests.Session() self.headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://www.bilibili.com/, } if cookie_str: self.session.headers.update({Cookie: cookie_str}) def choose_quality(self, formats): 让用户选择清晰度 print(可用的清晰度选项) for i, fmt in enumerate(formats): print(f{i1}. {fmt[quality_desc]} (ID:{fmt[quality]}) - {fmt[format]}) choice int(input(请输入序号选择清晰度: )) - 1 return formats[choice] def run(self, bvid): 主流程 print(f正在处理视频: {bvid}) info self.get_video_info(bvid) # ... 整合之前的各个步骤函数作为类的方法 ... # 1. 解析信息 # 2. 选择清晰度 # 3. 下载 # 4. 合并 print(全部完成) # 使用 if __name__ __main__: downloader BilibiliVideoDownloader() # 可以循环输入BV号进行批量下载 bvid_list [BV1xx411c7mD, BV1K4411E7pm] # 示例 for bvid in bvid_list: downloader.run(bvid) time.sleep(5) # 非常重要的延迟避免触发风控我个人在实际操作中的体会是这个项目的核心难点不在于下载文件本身而在于如何稳定地获取到那个“真正”的媒体流地址。B站的前端代码和API接口并非一成不变今天能用的方法下个月可能就失效了。因此最重要的技能是学会使用浏览器的开发者工具学会分析网络请求找到数据源头。当__playinfo__消失时不要慌张去“网络”选项卡里仔细寻找那些包含playurl、dash、.m3u8等关键词的请求总能找到新的突破口。把这个过程当成一个有趣的解谜游戏你的爬虫技术会在这个过程中飞速成长。最后再次强调工具虽好请务必尊重版权合理使用。

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

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

免费获取报价