简介:这是一份面向Python初学者与爬虫实践者的轻量级B站小视频批量下载工具,解决用户手动逐个保存短视频效率低、缺乏进度反馈的痛点。资源为单文件Python脚本(task_4.py),压缩包仅1个ZIP文件、大小2KB,代码结构清晰,涵盖JSON接口解析、requests流式下载、控制台实时进度条显示、非法文件名过滤及请求间隔随机延时等核心技巧,适合用于理解HTTP爬虫基础逻辑与文件IO实操。程序自动遍历10页B站小视频排行榜,提取标题与直链并保存至本地video目录,每下载完一个视频后智能休眠3–6秒以降低反爬风险。目前已有496人学习下载,可直接运行调试,是掌握网络请求、异常处理与用户体验优化的典型入门级实战脚本。
1. B站视频下载爬虫(含进度):不是“一键下载”,而是能看清每一步网络请求、文件分片、合并逻辑的可控工具
你有没有试过用某个“B站下载器”点几下就卡死,进度条停在97%不动,日志里全是ConnectionResetError或412 Precondition Failed,重试三次后缓存目录里堆了十几个不完整的.mp4碎片?这不是玄学——B站的视频资源从来就不是单个HTTP链接直出,而是通过DASH协议分发的多码率音视频流,需先解析playurl接口返回的dash结构,再并发拉取video和audio两个独立base_url,最后用ffmpeg按时间戳对齐合成。这个“B站视频下载爬虫(含进度)”项目,本质是一套可调试、可打断、可观察的下载状态机:它把session管理→cookie注入→BV号解析→API鉴权→分片请求→断点续传→mux合成全链路拆成可打印日志、可设断点、可改超时参数的Python模块。适合两类人:一是想搞懂B站真实播放链路的新手,二是需要嵌入到自动化剪辑流水线、要求失败可重试、进度可上报的工程场景。它不承诺“免登录下载”,但把登录态怎么维持、buvid3和SESSDATA怎么复用、csrf怎么随请求更新这些黑匣子全摊开给你看。
2. 核心流程拆解:从BV号到本地MP4,每一步都带状态反馈与日志钩子
这个爬虫不是靠模拟浏览器点击,而是直击B站PC端Web API,因此必须理解其服务端交互逻辑。整个流程分为五个原子阶段,每个阶段都内置print()级日志输出和try/except边界捕获,方便你定位卡点在哪一环。
2.1 BV号解析与基础信息获取:绕过前端跳转,直调/x/web-interface/view
B站短链接(如b23.tv/xxxxx)或分享文案里的BV1xx4y1c7xx,不能直接丢给下载器。必须先解析出真实aid(稿件ID)和bvid(新版ID),再请求https://api.bilibili.com/x/web-interface/view?bvid={bvid}。该接口返回JSON中包含标题、UP主、分P数、以及最关键的pages数组——每一页对应一个视频分P,含cid(分P唯一标识),这是后续请求playurl的必要参数。
import requests import json def get_video_info(bvid: str) -> dict: url = f"https://api.bilibili.com/x/web-interface/view?bvid={bvid}" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.bilibili.com/" } # 注意:此处必须携带有效的 cookies,否则返回 {"code":-400,"message":"请求错误","ttl":1} # 后续章节会说明如何安全注入 session response = requests.get(url, headers=headers, cookies=load_cookies()) if response.status_code != 200: raise ConnectionError(f"获取视频信息失败,HTTP {response.status_code}") data = response.json() if data.get("code") != 0: raise ValueError(f"API返回异常:{data.get('message', '未知错误')}") info = { "title": data["data"]["title"], "owner": data["data"]["owner"]["name"], "pages": [] } for p in data["data"]["pages"]: info["pages"].append({ "cid": p["cid"], "page": p["page"], "part": p["part"] }) return info # 示例调用 info = get_video_info("BV1xx4y1c7xx") print(f"标题:{info['title']}") print(f"共 {len(info['pages'])} 个分P,第1P CID:{info['pages'][0]['cid']}")提示:
load_cookies()是一个占位函数,实际应读取你浏览器导出的cookies.txt或手动维护的字典。B站校验SESSDATA有效期(通常14天)、buvid3设备绑定、bfe_id地域路由,三者缺一不可。硬编码Cookie字符串是短期方案,长期建议用browser_cookie3库自动提取Chrome/Firefox当前登录态。
2.2 DASH流地址解析:解析/x/player/playurl并提取base_url与segment_base
拿到cid后,请求https://api.bilibili.com/x/player/playurl?cid={cid}&bvid={bvid}&qn=116&fnver=0&fnval=4048&fourk=1。关键参数说明:
qn=116:请求1080P60最高画质(值越大质量越高,116=1080P60,120=4K,需大会员)fnval=4048:启用DASH格式(bitmask,4048 = 16+64+4096 = DASH+HDR+杜比视界)fourk=1:允许4K流(即使没大会员,部分UP主投稿也开放)
响应体中data.dash.video和data.dash.audio是两个列表,每个元素含base_url(主分片URL)、backup_url(备用)、segment_base.index_range(索引范围)、segment_base.initialization(初始化段路径)。注意:base_url末尾带?e=...&u=...&v=...&sign=...这类动态签名,不可缓存、不可拼接、不可省略,必须原样使用。
def get_dash_urls(bvid: str, cid: int) -> dict: url = f"https://api.bilibili.com/x/player/playurl?cid={cid}&bvid={bvid}&qn=116&fnver=0&fnval=4048&fourk=1" response = requests.get(url, headers=get_headers(), cookies=load_cookies()) data = response.json() if data.get("code") != 0: raise ValueError(f"DASH地址获取失败:{data.get('message')}") video_stream = data["data"]["dash"]["video"][0] # 取最高码率 audio_stream = data["data"]["dash"]["audio"][0] return { "video": { "base_url": video_stream["base_url"], "width": video_stream["width"], "height": video_stream["height"], "bandwidth": video_stream["bandwidth"] }, "audio": { "base_url": audio_stream["base_url"], "bandwidth": audio_stream["bandwidth"] } } urls = get_dash_urls("BV1xx4y1c7xx", 123456789) print(f"视频流:{urls['video']['width']}x{urls['video']['height']},{urls['video']['bandwidth']/1000:.0f}kbps") print(f"音频流:{urls['audio']['bandwidth']/1000:.0f}kbps")参数说明:
bandwidth单位为bps(比特每秒),除以1000得kbps。实测中,qn=116在非大会员账号下可能 fallback 到qn=80(1080P),此时bandwidth会显著下降,需检查响应中实际返回的qn值。
2.3 分片下载与进度追踪:用requests.Session+Content-Range实现断点续传
DASH流的base_url指向一个大文件,但B站实际采用Content-Range分片传输。例如,GET /xxx.mp4 HTTP/1.1响应头含Content-Range: bytes 0-1048575/12345678,表示这是0~1048575字节(1MB),总长12345678字节。下载器需:
- 先HEAD请求获取
Content-Length - 检查本地文件是否存在且大小匹配,若匹配则跳过
- 若不匹配,用
Range: bytes={start}-发起续传请求 - 每次写入前校验
Content-Range起始位置是否正确,防错位
import os from tqdm import tqdm def download_segment(url: str, filepath: str, chunk_size: int = 8192): # 1. HEAD 获取总大小 head_resp = requests.head(url, headers=get_headers(), cookies=load_cookies(), timeout=10) total_size = int(head_resp.headers.get("Content-Length", 0)) # 2. 检查本地文件 if os.path.exists(filepath): local_size = os.path.getsize(filepath) if local_size == total_size: print(f"[跳过] {filepath} 已完整") return True else: print(f"[续传] {filepath} 已下载 {local_size}/{total_size} 字节") else: local_size = 0 # 3. Range 下载 headers = get_headers().copy() headers["Range"] = f"bytes={local_size}-" response = requests.get(url, headers=headers, cookies=load_cookies(), stream=True, timeout=30) # 4. 写入文件(追加模式) mode = "ab" if local_size > 0 else "wb" with open(filepath, mode) as f: with tqdm( total=total_size, initial=local_size, unit="B", unit_scale=True, desc=os.path.basename(filepath), ncols=80 ) as pbar: for chunk in response.iter_content(chunk_size=chunk_size): if chunk: f.write(chunk) pbar.update(len(chunk)) return os.path.getsize(filepath) == total_size # 示例:下载视频分片 video_path = "video_123456789.m4s" success = download_segment(urls["video"]["base_url"], video_path)逻辑说明:
tqdm进度条直接绑定Content-Length,比单纯计数iter_content更准;mode="ab"确保续传时数据追加而非覆盖;timeout=30防止慢网卡死,后续可封装重试逻辑。
2.4 音视频合成:用ffmpeg命令行精准对齐时间戳
DASH流中video.m4s和audio.m4s是独立编码的,直接cat拼接会导致音画不同步。必须用ffmpeg做mux(复用),且需指定-avoid_negative_ts make_zero强制时间戳归零,否则常见报错Invalid timestamps while mixing audio and video。
ffmpeg -y \ -i "video_123456789.m4s" \ -i "audio_123456789.m4s" \ -c copy \ -avoid_negative_ts make_zero \ -movflags +faststart \ "output_BV1xx4y1c7xx_P1.mp4"参数说明:
-c copy:不重新编码,纯拷贝流,速度极快(<1秒)-avoid_negative_ts make_zero:关键!解决B站DASH流初始PTS为负的问题-movflags +faststart:将moov box移到文件开头,使MP4支持网页边下边播-y:自动确认覆盖,避免交互阻塞
Python中调用:
import subprocess def mux_video_audio(video_path: str, audio_path: str, output_path: str): cmd = [ "ffmpeg", "-y", "-i", video_path, "-i", audio_path, "-c", "copy", "-avoid_negative_ts", "make_zero", "-movflags", "+faststart", output_path ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise RuntimeError(f"合成失败:{result.stderr}") print(f"✅ 合成完成:{output_path}") mux_video_audio("video_123456789.m4s", "audio_123456789.m4s", "final.mp4")3. 避坑指南:5个真实翻车现场与血泪修复方案
这套流程看着线性,实操中90%的失败都集中在以下5个点。每一处我都经历过至少3次重试,下面按「现象→原因→解决」给出可立即抄的方案。
3.1 现象:playurl接口返回{"code":-400,"message":"请求错误"}
原因:cookies中缺失buvid3或SESSDATA过期,B站服务端拒绝解析bvid。buvid3是设备指纹,每次浏览器重装或清除数据都会变;SESSDATA有效期约14天,过期后返回-101(账号未登录)。
解决:
- 不要手输Cookie字符串,用
browser_cookie3自动提取:pip install browser_cookie3import browser_cookie3 cj = browser_cookie3.chrome(domain_name='.bilibili.com') cookies = {c.name: c.value for c in cj} - 每次请求前加校验:
def validate_cookies(cookies: dict) -> bool: if not cookies.get("SESSDATA"): return False # 检查 SESSDATA 是否含有效字段(非空且长度合理) if len(cookies["SESSDATA"]) < 32: return False return True
3.2 现象:download_segment下载的.m4s文件无法被ffmpeg识别,报错Invalid data found when processing input
原因:base_url带动态签名,但HEAD请求时签名已失效,导致Content-Length返回0或错误值,后续Range请求被服务端拒绝,返回HTML错误页(如403页面)混入二进制流。
解决:
- 禁用HEAD,改用GET +
stream=True+response.headers.get("Content-Range")解析真实大小:def get_real_length(url: str) -> int: resp = requests.get(url, headers=get_headers(), cookies=load_cookies(), stream=True, timeout=10) range_header = resp.headers.get("Content-Range", "") if "/" in range_header: return int(range_header.split("/")[-1]) else: # 无Range头,说明是完整文件 return int(resp.headers.get("Content-Length", "0")) - 下载时用
response.raw.read()替代iter_content(),避免requests自动解压gzip导致二进制错乱。
3.3 现象:合成后的MP4播放时音画严重不同步(音频快1秒以上)
原因:video.m4s和audio.m4s的moovbox中timescale不一致,或ffmpeg未强制对齐起始PTS。B站DASH流中,视频流timescale常为1000,音频为44100,直接-c copy会继承原始时间基。
解决:
- 改用
-vsync vfr -async 1强制音视频帧率同步:ffmpeg -y -i video.m4s -i audio.m4s \ -c:v copy -c:a copy \ -vsync vfr -async 1 \ -avoid_negative_ts make_zero \ output.mp4 - 或更彻底:重编码音频为AAC(损失小,耗时2秒内):
ffmpeg -y -i video.m4s -i audio.m4s \ -c:v copy \ -c:a aac -b:a 192k \ -avoid_negative_ts make_zero \ output.mp4
3.4 现象:多分P视频下载时,程序在第2P卡住,日志显示ConnectionResetError
原因:B站服务端对同一IP的QPS有限制(实测>3次/秒触发限流),playurl接口返回412 Precondition Failed,但代码未捕获该HTTP状态码,直接抛出连接异常。
解决:
- 在
get_dash_urls()中显式检查response.status_code:if response.status_code == 412: print("⚠️ 触发限流,等待3秒后重试...") time.sleep(3) return get_dash_urls(bvid, cid) # 递归重试 - 全局加请求间隔:
time.sleep(0.5)放在每次API调用后,比暴力重试更稳定。
3.5 现象:tqdm进度条显示100%,但文件大小比Content-Length少几KB,播放到结尾卡顿
原因:requests的iter_content()在遇到网络抖动时可能提前结束迭代,未读完最后一块数据。
解决:
- 改用
response.content一次性读取(内存换可靠性,适合<500MB文件):response = requests.get(url, headers=headers, cookies=cookies, timeout=60) with open(filepath, "wb") as f: f.write(response.content) - 或校验MD5:下载后计算
hashlib.md5(open(filepath,"rb").read()).hexdigest(),与playurl响应中video[0].md5字段比对(如有)。
4. 多分P与合集处理:批量下载、智能命名、失败隔离策略
单个视频只是起点。真实场景中,你要面对的是UP主的100期系列、课程合集、直播回放切片。这时必须把下载器升级为“任务队列+状态持久化+失败隔离”的工作流。
4.1 批量任务定义:用YAML描述下载清单,支持跳过、重试、优先级
放弃硬编码BV1xx4y1c7xx,改用结构化配置。创建tasks.yaml:
version: "1.0" jobs: - bvid: BV1xx4y1c7xx pages: [1, 2, 3] # 只下前三P output_dir: "./courses/python_basics" priority: 10 # 数值越大越先执行 skip_if_exists: true - bvid: BV2yy4y1d7yy pages: "all" # 下全部分P output_dir: "./lectures/ai_fundamentals" priority: 5 max_retries: 3Python加载逻辑:
import yaml def load_tasks(config_path: str) -> list: with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) jobs = config["jobs"] # 按priority降序排列 jobs.sort(key=lambda x: x.get("priority", 0), reverse=True) return jobs tasks = load_tasks("tasks.yaml") for job in tasks: print(f"准备下载 {job['bvid']} → {job['output_dir']}")4.2 智能文件命名:用UP主名+标题+分P序号,规避Windows非法字符
B站标题含/ \ : * ? " < > |等Windows非法字符,直接os.path.join(dir, title)会报错。需清洗:
import re def sanitize_filename(name: str) -> str: # 移除非法字符,替换空格为下划线 name = re.sub(r'[\\/*?:"<>|]', "", name) name = re.sub(r"\s+", "_", name) # 截断过长文件名(NTFS限制255字符) if len(name) > 200: name = name[:180] + "_truncated" return name.strip("_") # 示例 title = "【Python教程】从入门到放弃 / 为什么yield比return好???" clean = sanitize_filename(title) # "Python教程_从入门到放弃_为什么yield比return好"完整下载路径生成:
def build_output_path(job: dict, page_info: dict, ext: str = "mp4") -> str: up_name = page_info["owner"].replace(" ", "_") title = sanitize_filename(page_info["title"]) part = f"_P{page_info['page']}" if len(job["pages"]) > 1 else "" filename = f"{up_name}_{title}{part}.{ext}" return os.path.join(job["output_dir"], filename) path = build_output_path( job={"output_dir": "./courses"}, page_info={"owner": "张老师", "title": "Python循环详解", "page": 1} ) # → "./courses/张老师_Python循环详解_P1.mp4"4.3 失败隔离与状态记录:用SQLite存每一段下载状态,支持断点续跑
不要让一个分P失败导致整套合集重来。建downloads.db表:
| id | bvid | cid | page | status | file_path | error_msg | updated_at |
|---|---|---|---|---|---|---|---|
| 1 | BV1xx | 123 | 1 | done | ./a.mp4 | NULL | 2024-05-20 |
import sqlite3 def init_db(): conn = sqlite3.connect("downloads.db") conn.execute(""" CREATE TABLE IF NOT EXISTS download_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, bvid TEXT, cid INTEGER, page INTEGER, status TEXT DEFAULT 'pending', file_path TEXT, error_msg TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit() return conn def mark_status(conn, bvid, cid, page, status, file_path=None, error_msg=None): conn.execute(""" INSERT OR REPLACE INTO download_tasks (bvid, cid, page, status, file_path, error_msg) VALUES (?, ?, ?, ?, ?, ?) """, (bvid, cid, page, status, file_path, error_msg)) conn.commit() # 下载前查状态 def should_download(conn, bvid, cid, page) -> bool: cur = conn.execute( "SELECT status FROM download_tasks WHERE bvid=? AND cid=? AND page=?", (bvid, cid, page) ) row = cur.fetchone() return row is None or row[0] != "done"调用时:
conn = init_db() if should_download(conn, "BV1xx", 123, 1): try: download_and_mux(...) mark_status(conn, "BV1xx", 123, 1, "done", "./out.mp4") except Exception as e: mark_status(conn, "BV1xx", 123, 1, "failed", error_msg=str(e))好处:下次运行
python runner.py,自动跳过所有status='done'的记录,只处理失败项。再也不用人工删文件重跑。
4.4 进度可视化:终端实时显示队列状态,非仅单个文件进度条
tqdm只能管单文件,整套合集需要更高维进度。我用rich库做终端仪表盘:
pip install richfrom rich.console import Console from rich.table import Table from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn console = Console() def show_overview(tasks: list, conn: sqlite3.Connection): table = Table("BVID", "分P数", "已完成", "进度", "状态") for job in tasks: # 统计该BV下各P状态 cur = conn.execute( "SELECT COUNT(*) FROM download_tasks WHERE bvid=? AND status='done'", (job["bvid"],) ) done = cur.fetchone()[0] total = len(job["pages"]) if isinstance(job["pages"], list) else "all" progress = f"{done}/{total}" status = "✅" if done == total else "🔄" table.add_row(job["bvid"], str(total), str(done), progress, status) console.print(table) # 在主循环中每10秒刷新一次 with Progress( TextColumn("[progress.description]{task.description}"), BarColumn(), TaskProgressColumn(), console=console ) as progress: task = progress.add_task("下载中...", total=len(tasks)) for i, job in enumerate(tasks): process_job(job) # 你的下载逻辑 progress.update(task, advance=1) if i % 10 == 0: # 每10个任务刷新总览 show_overview(tasks, conn)效果是终端左侧是当前任务进度条,右侧是所有BV的完成表格,一眼看清哪套课卡住了。
5. 实战技巧:用ffmpeg探针反向验证下载完整性,把“能播”变成“一定播对”
很多同学下载完就ffplay output.mp4看一眼,觉得画面出来就OK。但B站DASH流有个隐藏坑:视频流可能含B帧(双向预测帧),而某些老旧播放器或剪辑软件无法正确解码B帧序列,导致花屏或卡顿,但ffplay默认能硬解,给你虚假安全感。真正可靠的验证,是用ffmpeg探针(probe)读取关键元数据,并与原始playurl响应比对。
5.1 提取原始DASH流的duration与bit_rate,作为黄金标准
playurl接口返回的video[0]和audio[0]中,有duration(毫秒)和bandwidth(bps)。这是B站服务端计算出的理论值,必须与本地文件一致:
# 假设已拿到 urls = get_dash_urls(...) orig_video_duration_ms = 1000 * (urls["video"]["duration"] / 1000) # 转为秒 orig_video_bitrate = urls["video"]["bandwidth"] # 用 ffprobe 获取实际文件参数 import json result = subprocess.run( ["ffprobe", "-v", "quiet", "-show_entries", "format=duration,bit_rate", "-of", "json", "output.mp4"], capture_output=True, text=True ) data = json.loads(result.stdout) actual_duration = float(data["format"].get("duration", "0")) actual_bitrate = int(data["format"].get("bit_rate", "0")) print(f"原始时长:{orig_video_duration_ms:.2f}s,实际:{actual_duration:.2f}s") print(f"原始码率:{orig_video_bitrate/1000:.0f}kbps,实际:{actual_bitrate/1000:.0f}kbps")判断标准:
abs(actual_duration - orig_video_duration_ms) < 0.5且abs(actual_bitrate - orig_video_bitrate) < 50000(50kbps容差)才算合格。差太多说明分片漏传或合成失败。
5.2 检查关键帧(I帧)分布,排除B帧兼容性问题
B站1080P以上视频普遍用H.264 High Profile,含B帧。但如果你要导入Premiere或Final Cut,它们对B帧支持不一。用ffprobe查I帧间隔:
ffprobe -v quiet -skip_frame nokey -show_entries frame=pkt_pts_time -of csv=p=0 output.mp4 | head -n 5输出类似:
0.000000 2.000000 4.000000 6.000000 8.000000这说明I帧每2秒一个(GOP=2s),是标准设置。如果输出是0.000000, 0.033333, 0.066666,...(即每30fps一个I帧),说明是All-I帧编码(少见),兼容性最好;若I帧间隔>5秒,则可能在剪辑时卡顿。
Python封装:
def check_keyframe_interval(filepath: str) -> float: cmd = [ "ffprobe", "-v", "quiet", "-skip_frame", "nokey", "-show_entries", "frame=pkt_pts_time", "-of", "csv=p=0", filepath ] result = subprocess.run(cmd, capture_output=True, text=True) lines = result.stdout.strip().split("\n") if len(lines) < 2: return 0.0 # 计算前5个I帧间隔均值 intervals = [] for i in range(1, min(5, len(lines))): t1 = float(lines[i-1]) t2 = float(lines[i]) intervals.append(t2 - t1) return sum(intervals) / len(intervals) if intervals else 0.0 interval = check_keyframe_interval("output.mp4") print(f"I帧平均间隔:{interval:.3f}秒(<3秒为佳)")5.3 自动化校验流水线:把探针结果写入SQLite,形成质量报告
在mark_status()后,追加校验并写库:
def record_quality_check(conn, file_path: str, bvid: str, cid: int, page: int): try: duration = get_actual_duration(file_path) bitrate = get_actual_bitrate(file_path) keyframe = check_keyframe_interval(file_path) conn.execute(""" INSERT INTO quality_report (file_path, duration, bitrate, keyframe_interval, checked_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) """, (file_path, duration, bitrate, keyframe)) conn.commit() except Exception as e: print(f"质量校验失败:{e}") # 调用位置 if success: mark_status(conn, bvid, cid, page, "done", file_path) record_quality_check(conn, file_path, bvid, cid, page) # 新增这样,你就能用SQL查出所有“时长偏差>1秒”的文件:
SELECT file_path, duration, (duration - 123456) as diff FROM quality_report WHERE ABS(diff) > 1000;从那以后我每次交付下载成果给同事,必跑一遍这个校验脚本,把quality_report表导出CSV发过去,附上“所有文件I帧间隔≤2.5秒,时长误差<0.3秒”的结论——再没人质疑“为什么剪辑时卡”。工具的价值,不在于它多酷,而在于它能把模糊的“差不多”变成可量化的“就是对”。
希望帮到你。
本文还有配套的精品资源,点击获取