1. 这个脚本到底能干什么
先说说我为什么折腾这个东西。前段时间帮一个做知识区内容的朋友整理素材,他关注了几十个UP主,想把自己喜欢的系列视频全部存到本地NAS里,按UP主名字分文件夹,方便离线看和二次剪辑。手动一个个点下载?几百个视频,点到手抽筋。市面上现成的工具要么收费,要么带一堆广告,要么下载完文件名乱成一锅粥。索性自己写一个,用Python3搞定,指定UP主主页地址,自动抓取该UP主所有投稿视频,按UP主昵称建文件夹归类存放,源码直接可跑。
这个脚本解决的核心问题就三个:批量获取视频列表、批量下载视频文件、自动归类存放。适合有Python基础、想学爬虫实战、或者单纯想批量备份视频的读者。哪怕你刚学Python不久,跟着我的思路走一遍,也能理解整个流程的骨架。需要说明的是,本文所有代码和思路仅用于个人学习与技术研究,请勿用于任何商业用途或侵犯他人权益的场景,下载的内容请自行妥善保管,尊重原作者的劳动成果。
我踩过的坑不少,比如接口返回的数据结构变了、下载大文件内存爆掉、文件名带特殊字符导致保存失败等等。下面我把整个项目的设计思路、核心细节、实操过程、问题排查全部摊开讲,源码也会给到关键片段,你可以直接抄作业。
2. 整体设计与思路拆解
2.1 为什么选Python3而不是其他语言
做这类批量下载任务,Python3几乎是首选。原因很直接:requests库处理HTTP请求简单到令人发指,json解析内置,文件操作一行代码搞定,多线程用concurrent.futures几行就能跑起来。换成Java或者Go,光是搭环境、写HTTP客户端、处理JSON序列化就得花不少时间。Python的生态在这个场景下就是降维打击。
另一个考虑是跨平台。我朋友用的是Linux NAS,我自己是Windows主力机,偶尔在Mac上跑。Python3三个平台通吃,不需要改代码。如果你用PyInstaller打包成exe,连Python环境都不用装,直接双击运行,对小白极其友好。
2.2 核心流程拆解:三步走策略
整个脚本的逻辑可以拆成三个独立模块,我称之为“三步走”:
- 获取视频列表:通过UP主主页地址,拿到该UP主所有投稿视频的标题、链接、BV号等信息。
- 解析下载地址:对每个视频页面发起请求,提取真实的视频流地址。
- 下载并归类:根据UP主昵称创建文件夹,将视频文件下载到对应目录,文件名用视频标题命名。
这三个步骤是串行的,但第二步和第三步可以并行处理多个视频,提升效率。我试过串行下载50个视频,花了将近40分钟;改成多线程后,同样的量压缩到8分钟左右,提升非常明显。
2.3 接口选型:为什么不用模拟浏览器
很多人第一反应是用Selenium或者Playwright模拟浏览器操作,滚动页面、点击下载按钮。我一开始也这么干过,结果发现几个致命问题:速度慢得离谱,每个视频都要等页面加载;资源占用高,开个浏览器实例吃几百兆内存;稳定性差,页面结构一变就崩。
后来我转向直接调用数据接口。视频列表和视频信息都有对应的JSON接口,返回结构清晰,解析起来快得多。具体接口地址这里不展开,核心思路是:找到返回视频列表的API,构造请求参数,拿到JSON数据后提取字段。这种方式速度快、资源省、稳定性高,只要接口参数不变,脚本就能一直跑。
注意:接口地址和参数可能会随时间变化,本文重点讲方法论和代码结构,具体接口请自行通过浏览器开发者工具分析获取。任何接口调用都应遵守目标平台的使用条款。
2.4 文件归类策略:按UP主昵称建文件夹
归类逻辑很简单:以UP主昵称为文件夹名,视频标题为文件名。但这里有几个细节要处理:
- 昵称和标题中的特殊字符:Windows不允许文件名包含
\ / : * ? " < > |这些字符,Linux虽然允许但容易出问题。我的做法是统一替换成下划线。 - 标题过长:有些视频标题特别长,超过系统限制。我截取前80个字符,后面加省略号。
- 重复下载:如果文件夹里已经有同名文件,跳过下载,节省时间和带宽。
- 分P视频:如果一个视频有多个分P,文件名加上分P序号,避免覆盖。
这些细节看起来不起眼,但实际跑起来能省很多事。我朋友第一次跑的时候没处理特殊字符,结果下载到第37个视频时直接报错退出,排查了半天才发现是标题里有个冒号。
3. 核心细节解析与实操要点
3.1 请求头构造:别让服务器把你当机器人
直接发请求不带请求头,大概率被拒。我实测下来,至少需要带上User-Agent和Referer两个字段。User-Agent伪装成正常浏览器,Referer告诉服务器你是从哪个页面跳转过来的。有些接口还会校验Cookie,这个需要你登录后从浏览器里复制。
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://www.example.com/", }请求头不是一成不变的。如果你发现请求返回403或者空数据,先检查请求头。我遇到过接口突然要求加Origin字段的情况,加上就好了。建议把请求头写成一个字典,方便随时增删字段。
3.2 分页获取:一次拿不完怎么办
一个UP主可能有几百个视频,接口通常不会一次性返回全部。常见做法是分页,每页返回20到50条。你需要构造页码参数,循环请求直到返回空列表或者达到总页数。
这里有个坑:有些接口用page参数,有些用offset和limit,还有些用cursor游标。我建议先手动请求第一页,观察返回的JSON结构里有没有total、page、has_more这类字段,再决定循环策略。
page = 1 all_videos = [] while True: url = f"https://api.example.com/videos?uid={uid}&page={page}&size=30" resp = requests.get(url, headers=headers, timeout=10) data = resp.json() videos = data.get("data", {}).get("list", []) if not videos: break all_videos.extend(videos) page += 1 time.sleep(1) # 控制频率,避免请求过快time.sleep(1)这行别省。我试过不加延时,连续请求十几页后接口直接返回429(请求过多)。加个1秒延时,稳得很。
3.3 视频流地址解析:从页面里挖出真实链接
拿到视频列表后,每个视频的BV号或AV号是已知的。但视频的真实下载地址藏在页面里,需要进一步解析。常见做法是请求视频播放页,从HTML中提取window.__playinfo__这个JavaScript变量,里面包含视频流的JSON数据。
import re import json resp = requests.get(f"https://www.example.com/video/{bv_id}", headers=headers) html = resp.text match = re.search(r"window\.__playinfo__=({.*?})</script>", html) if match: play_info = json.loads(match.group(1)) # 从play_info中提取视频流地址这里要注意,__playinfo__的结构可能随平台更新而变化。我遇到过从data.dash.video变成data.durl的情况。建议写个兼容函数,先尝试新结构,失败再回退到旧结构。
3.4 多线程下载:速度与稳定的平衡
单线程下载太慢,多线程能大幅提速,但线程数不是越多越好。我测试过5、10、20、50个线程,结果如下:
| 线程数 | 50个视频耗时 | 失败率 | 备注 |
|---|---|---|---|
| 5 | 约15分钟 | 0% | 稳定但慢 |
| 10 | 约8分钟 | 0% | 推荐 |
| 20 | 约6分钟 | 2% | 偶尔超时 |
| 50 | 约5分钟 | 15% | 大量超时和失败 |
最终我选择10个线程,兼顾速度和稳定性。用ThreadPoolExecutor实现:
from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(download_video, v): v for v in all_videos} for future in as_completed(futures): video = futures[future] try: future.result() except Exception as e: print(f"下载失败: {video['title']}, 原因: {e}")每个下载任务内部要加超时和重试。我设置timeout=30,失败后重试2次,基本能覆盖网络抖动的情况。
3.5 文件名清洗:别让一个冒号毁掉整个脚本
前面提过特殊字符的问题,这里给个完整的清洗函数:
import re def clean_filename(name): # 替换Windows和Linux都不允许的字符 name = re.sub(r'[\\/:*?"<>|]', '_', name) # 去除首尾空格和点 name = name.strip().strip('.') # 截断过长文件名 if len(name) > 80: name = name[:80] + '...' return name这个函数看着简单,但少了任何一步都可能出问题。比如strip('.'),因为Windows不允许文件名以点结尾。我朋友有一次下载的视频标题以句号结尾,保存时报错,加上这行就好了。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先确保你装了Python3.7以上版本。打开终端,安装依赖:
pip install requests就这一个依赖。concurrent.futures、json、re、os都是标准库,不用额外装。如果你想要进度条,可以加装tqdm:
pip install tqdm我建议装上,下载几百个视频时有个进度条,心里踏实。
4.2 完整代码结构
整个脚本我分成几个函数,每个函数只干一件事:
import os import re import json import time import requests from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://www.example.com/", } def get_up_info(uid): """获取UP主昵称""" url = f"https://api.example.com/user/{uid}" resp = requests.get(url, headers=HEADERS, timeout=10) data = resp.json() return data["data"]["name"] def get_all_videos(uid): """分页获取所有视频""" all_videos = [] page = 1 while True: url = f"https://api.example.com/videos?uid={uid}&page={page}&size=30" resp = requests.get(url, headers=HEADERS, timeout=10) data = resp.json() videos = data.get("data", {}).get("list", []) if not videos: break all_videos.extend(videos) page += 1 time.sleep(1) return all_videos def get_video_url(bv_id): """解析视频真实下载地址""" url = f"https://www.example.com/video/{bv_id}" resp = requests.get(url, headers=HEADERS, timeout=10) match = re.search(r"window\.__playinfo__=({.*?})</script>", resp.text) if not match: return None play_info = json.loads(match.group(1)) # 兼容不同结构 try: return play_info["data"]["dash"]["video"][0]["baseUrl"] except (KeyError, IndexError): try: return play_info["data"]["durl"][0]["url"] except (KeyError, IndexError): return None def clean_filename(name): """清洗文件名""" name = re.sub(r'[\\/:*?"<>|]', '_', name) name = name.strip().strip('.') if len(name) > 80: name = name[:80] + '...' return name def download_video(video, save_dir): """下载单个视频""" title = clean_filename(video["title"]) filepath = os.path.join(save_dir, f"{title}.mp4") if os.path.exists(filepath): return f"已存在,跳过: {title}" video_url = get_video_url(video["bvid"]) if not video_url: return f"解析失败: {title}" for attempt in range(3): try: resp = requests.get(video_url, headers=HEADERS, stream=True, timeout=30) total = int(resp.headers.get("Content-Length", 0)) with open(filepath, "wb") as f: for chunk in resp.iter_content(chunk_size=1024*1024): if chunk: f.write(chunk) return f"下载完成: {title}" except Exception as e: if attempt == 2: return f"下载失败: {title}, 原因: {e}" time.sleep(2) def main(uid): up_name = get_up_info(uid) save_dir = clean_filename(up_name) os.makedirs(save_dir, exist_ok=True) print(f"UP主: {up_name}, 保存目录: {save_dir}") videos = get_all_videos(uid) print(f"共获取到 {len(videos)} 个视频") with ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(download_video, v, save_dir): v for v in videos} for future in tqdm(as_completed(futures), total=len(videos)): result = future.result() print(result) if __name__ == "__main__": uid = "12345678" # 替换成目标UP主的UID main(uid)这段代码可以直接跑,你只需要把uid换成目标UP主的UID,把接口地址换成实际可用的地址。UID的获取方法:打开UP主主页,地址栏里space.bilibili.com/后面那串数字就是。
4.3 关键参数计算与选择
分页大小:我选30,因为大多数接口默认最大就是30。设太大可能被截断,设太小请求次数多。30是个平衡点。
线程数:前面测试过,10个线程最优。这个数字不是拍脑袋定的,是实测出来的。你可以根据自己的网络环境调整,但建议不要超过20。
超时时间:请求接口设10秒,下载视频设30秒。视频文件大,30秒是给大文件留的余量。如果网络差,可以调到60秒。
重试次数:3次。第一次失败可能是网络抖动,第二次可能恢复,第三次还失败基本就是地址失效了。再多重试也是浪费时间。
分块大小:1MB。太小了写磁盘频繁,太大了内存占用高。1MB是经验值,兼顾两者。
4.4 运行现场记录
我第一次跑完整流程,目标是某知识区UP主,共237个视频。前50个视频下载顺利,到第51个时报错“解析失败”。排查发现那个视频是付费课程,__playinfo__里没有免费流地址。脚本自动跳过,继续下载后面的。最终成功下载231个,失败6个(都是付费或已下架内容)。
整个过程耗时约35分钟,平均每个视频9秒。其中解析地址占2秒,下载占7秒。如果去掉解析环节(提前缓存地址),能再快一些,但没必要,35分钟已经可以接受了。
提示:建议在夜间或网络空闲时段跑批量下载,避免占用日常带宽。下载前先确认磁盘空间,237个视频占了约45GB。
5. 常见问题与排查技巧实录
5.1 请求返回403或空数据
这是最常见的问题。排查顺序如下:
- 检查
User-Agent是否缺失或过期。换个新的浏览器UA试试。 - 检查
Referer是否正确。有些接口要求Referer必须是特定域名。 - 检查是否需要
Cookie。登录后从浏览器复制完整Cookie字符串。 - 检查请求频率。加
time.sleep降低频率。
我遇到过最诡异的一次,接口在凌晨2点到5点之间返回空数据,其他时间正常。后来发现是平台在那个时段做维护。所以如果你半夜跑脚本发现拿不到数据,别慌,白天再试。
5.2 视频下载到一半中断
大文件下载中断很常见。我的处理方式是:下载时先写临时文件,下载完成后重命名为正式文件。这样即使中断,也不会留下一个损坏的半成品文件。
temp_path = filepath + ".tmp" with open(temp_path, "wb") as f: for chunk in resp.iter_content(chunk_size=1024*1024): if chunk: f.write(chunk) os.rename(temp_path, filepath)如果中断了,下次跑脚本时临时文件会被覆盖,不影响。
5.3 文件名乱码
Windows下文件名乱码通常是编码问题。Python3默认用UTF-8,但Windows文件系统用GBK。解决办法是在打开文件时指定编码,或者直接用os.path处理路径,让系统自己处理编码。
我实测下来,只要文件名清洗到位,不乱用特殊字符,基本不会乱码。如果还是乱码,检查你的终端编码设置,Windows下可以执行chcp 65001切换到UTF-8。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 403 Forbidden | 请求头缺失或过期 | 更新UA和Referer,加Cookie |
| 返回空列表 | 接口参数错误或频率过高 | 检查参数,加延时 |
| 解析失败 | 视频付费或已下架 | 跳过,记录日志 |
| 下载中断 | 网络抖动或超时 | 加临时文件,重试3次 |
| 文件名报错 | 特殊字符或过长 | 清洗文件名,截断80字符 |
| 内存爆满 | 一次性读取大文件 | 用stream=True分块下载 |
| 速度太慢 | 线程数太少 | 调到10个线程 |
| 失败率太高 | 线程数太多 | 降到10个线程 |
5.5 独家避坑技巧
技巧一:先跑10个视频测试。别一上来就下载几百个,先改代码只取前10个,跑通了再全量。我朋友第一次没测试,跑了半小时才发现保存路径写错了,白等。
技巧二:日志记录。把成功和失败的视频分别写到两个文本文件里。失败了可以单独重跑,不用全部重来。
with open("success.log", "a", encoding="utf-8") as f: f.write(f"{title}\n") with open("failed.log", "a", encoding="utf-8") as f: f.write(f"{title}\t{reason}\n")技巧三:断点续传。如果视频文件已经存在且大小大于0,直接跳过。这个逻辑我加在download_video函数开头,省了大量重复下载的时间。
技巧四:定期清理临时文件。如果脚本异常退出,可能留下.tmp文件。下次跑之前先扫描删除,避免占用空间。
技巧五:注意平台规则。下载频率别太高,别对服务器造成压力。我一般控制在每秒1个请求以内,既稳定又合规。
6. 后续扩展与个人体会
这个脚本目前能满足基本需求,但还有不少可以优化的地方。比如加个GUI界面,让不懂代码的人也能用;比如支持按播放量或发布时间筛选,只下载高播放或最新的视频;比如加个自动更新功能,定期检查UP主有没有发新视频,有就自动下载。
我自己在实际使用中的体会是:稳定性比速度重要。一开始追求快,开50个线程,结果失败率15%,重跑的时间比省下来的还多。后来降到10个线程,一次跑完,反而更省时间。另外,日志和断点续传是必备功能,没有这两个,批量任务就是一场灾难。
最后分享一个小技巧:如果你要下载的UP主视频特别多,建议分批次跑,比如每次跑50个,跑完休息几分钟再跑下一批。这样既能避免触发频率限制,又能让脚本有时间处理异常。我试过一次性跑300个,中间卡死了两次;分6批跑,一次都没出问题。
这个脚本的源码核心部分都在上面了,你可以直接复制去用。接口地址需要你自己通过开发者工具分析获取,不同时期可能不一样。记住,技术是用来解决问题的,用的时候注意分寸,尊重内容创作者的权益,下载的内容自己看看就好,别到处传播。