Python3批量下载UP主视频:自动归类与多线程实战 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 fhttps://api.example.com/videos?uid{uid}page{page}size30 resp requests.get(url, headersheaders, timeout10) 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(fhttps://www.example.com/video/{bv_id}, headersheaders) html resp.text match re.search(rwindow\.__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_workers10) 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})每个下载任务内部要加超时和重试。我设置timeout30失败后重试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都是标准库不用额外装。如果你想要进度条可以加装tqdmpip 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 fhttps://api.example.com/user/{uid} resp requests.get(url, headersHEADERS, timeout10) data resp.json() return data[data][name] def get_all_videos(uid): 分页获取所有视频 all_videos [] page 1 while True: url fhttps://api.example.com/videos?uid{uid}page{page}size30 resp requests.get(url, headersHEADERS, timeout10) 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 fhttps://www.example.com/video/{bv_id} resp requests.get(url, headersHEADERS, timeout10) match re.search(rwindow\.__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, headersHEADERS, streamTrue, timeout30) total int(resp.headers.get(Content-Length, 0)) with open(filepath, wb) as f: for chunk in resp.iter_content(chunk_size1024*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_okTrue) print(fUP主: {up_name}, 保存目录: {save_dir}) videos get_all_videos(uid) print(f共获取到 {len(videos)} 个视频) with ThreadPoolExecutor(max_workers10) as executor: futures {executor.submit(download_video, v, save_dir): v for v in videos} for future in tqdm(as_completed(futures), totallen(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_size1024*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字符内存爆满一次性读取大文件用streamTrue分块下载速度太慢线程数太少调到10个线程失败率太高线程数太多降到10个线程5.5 独家避坑技巧技巧一先跑10个视频测试。别一上来就下载几百个先改代码只取前10个跑通了再全量。我朋友第一次没测试跑了半小时才发现保存路径写错了白等。技巧二日志记录。把成功和失败的视频分别写到两个文本文件里。失败了可以单独重跑不用全部重来。with open(success.log, a, encodingutf-8) as f: f.write(f{title}\n) with open(failed.log, a, encodingutf-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批跑一次都没出问题。这个脚本的源码核心部分都在上面了你可以直接复制去用。接口地址需要你自己通过开发者工具分析获取不同时期可能不一样。记住技术是用来解决问题的用的时候注意分寸尊重内容创作者的权益下载的内容自己看看就好别到处传播。