Python批量下载视频并自动归档:从采集到断点续传的完整实现
1. 从手动一个个点到批量归档这个脚本到底解决了什么如果你经常在视频平台上追某个创作者的系列内容大概率经历过这种折磨打开主页往下滑找到最早的那期点进去复制链接切到下载工具粘贴选清晰度等它下完再回来找下一期。一个几十期甚至上百期的合集光重复这些动作就能耗掉一整个下午而且下完之后文件全堆在一个文件夹里名字还都是乱码一样的编号想按顺序看根本找不到北。这个项目要干的事情很明确给定一个创作者的账号标识把ta发布过的所有视频批量抓下来并且按照一定的规则自动归类存放。归类这件事听起来简单但真正做起来会碰到一堆细节问题——视频标题里带斜杠和冒号怎么处理、不同清晰度怎么选、分P的视频怎么命名、下载中断了怎么续、请求太频繁会不会被限制。这些才是决定一个脚本能不能用和好不好用的分水岭。我写这个脚本的初衷其实很朴素某个做长系列教程的创作者内容质量很高但平台没有提供批量导出功能我想把这些内容存到本地做离线整理。市面上现成的工具要么太重、要么要装一堆依赖、要么归类逻辑不符合我的习惯索性自己动手。整个过程踩了不少坑也积累了一些在官方文档里看不到的经验这里完整地梳理一遍。这篇文章适合三类人看一是有点Python基础、想拿一个真实项目练手的同学二是想理解网络请求、数据解析、文件管理这套完整链路的人三是单纯想有个能用的批量下载归档工具、愿意照着改的人。我会把每一步为什么这么做讲清楚而不是甩一段代码让你自己猜。提示本文讨论的是对公开可访问内容的本地归档整理请务必遵守平台的服务条款和版权规定仅将下载内容用于个人学习与备份不要进行二次传播或商业使用。2. 动手之前先想清楚整体架构与关键技术选型2.1 为什么是请求 解析 下载三段式很多人一上来就想找一个一键下载的库但这类需求往往没有现成的完美方案因为每个平台的接口结构、鉴权方式、分页逻辑都不一样。与其找一个半残的第三方库然后被它的bug牵着走不如自己把链路拆成三段每一段都掌控在自己手里。第一段是信息采集给定创作者标识拿到ta所有视频的列表包括每个视频的标题、唯一编号、分P信息等。第二段是地址解析根据视频编号拿到真正可下载的媒体文件地址和对应的清晰度选项。第三段是下载与归档把文件流写进本地磁盘同时按照预设规则生成目录结构和文件名。这三段分开的好处是任何一段出问题都能单独调试。比如列表拿不全那问题在第一段的分页逻辑下载下来的文件是空的那问题在第三段的流处理。如果全揉在一起排查起来就是一团乱麻。2.2 请求库的选择为什么用 requests 而不是别的Python里发请求的选择不少标准库的 urllib、第三方的 requests、还有异步的 aiohttp 和 httpx。这个项目我最终选了 requests理由很实际同步模型足够用。批量下载本身是IO密集型任务但视频文件通常很大瓶颈在带宽而不是并发数。开太多并发反而容易触发平台的风控得不偿失。API 直观。会话保持、请求头设置、超时控制、重试requests 的写法最符合直觉调试时打印响应内容也方便。生态成熟。配合requests.Session()做连接复用配合streamTrue做流式下载这些都是经过大量项目验证的用法。如果你追求极致速度可以用 aiohttp 改写但要注意并发数一定要压得很低并且加上随机延迟。我在实测中发现超过3个并发就很容易出现请求被拒的情况所以同步方案在这个场景下反而更稳。2.3 解析策略接口优先页面兜底信息采集有两条路一是直接调用平台的前端接口返回的是结构化的 JSON二是请求网页HTML然后用解析库从DOM里抠数据。这两条路的取舍很关键。接口优先是首选因为JSON结构稳定、字段明确、解析成本低。但接口有个问题它可能带签名参数或者需要特定的请求头比如 Referer、User-Agent、Cookie一旦平台调整策略接口就可能失效。页面兜底作为备选用 BeautifulSoup 或 lxml 解析HTML。它的好处是只要页面还能打开就总能抠出点东西坏处是页面结构一变选择器就得跟着改维护成本高。我的做法是主逻辑走接口同时把解析函数写成可替换的模块。一旦接口挂了改一个函数就能切到页面解析不用动整个流程。这种可插拔的设计在长期维护中能省下大量时间。2.4 归档规则的设计目录结构怎么定才合理归类存放这件事看似只是os.makedirs一句话但规则设计得好不好直接决定了你以后找文件顺不顺手。我试过几种方案方案目录结构优点缺点扁平式全部堆在一个文件夹简单视频一多就爆炸无法按系列区分按日期年/月/日时间线清晰不知道内容主题找特定系列困难按标题前缀系列名/单集主题明确需要从标题里提取系列名规则复杂混合式创作者/系列/单集兼顾归属和主题实现稍复杂最终我选了混合式顶层是创作者名第二层是系列名从标题里提取的公共前缀第三层是单集文件。这样既知道是谁的内容又能按系列聚合单集也不会混在一起。文件名则采用序号 清洗后的标题的格式序号保证排序正确标题保证可读性。这里有个坑标题里经常带/ \ : * ? |这些字符在Windows下是非法文件名必须替换掉否则创建文件时会直接报错。3. 信息采集环节把创作者的所有视频捞干净3.1 分页逻辑为什么不能只抓第一页创作者主页的视频列表几乎都是分页加载的一页可能只有20到30条。如果你只请求第一页拿到的永远是最新的那批早期的内容全丢了。所以核心是搞清楚分页参数怎么传。常见的分页方式有两种一种是页码式page1,2,3...一种是游标式返回一个next_cursor下次请求带上它。页码式好处理循环递增直到返回空列表为止游标式则需要判断返回里有没有下一个游标有就继续没有就停。我在实现时写了一个通用的翻页循环伪代码逻辑是这样的def fetch_all_videos(creator_id): all_items [] page 1 while True: resp session.get(api_url, params{mid: creator_id, pn: page, ps: 30}) data resp.json() items data.get(list, []) if not items: break all_items.extend(items) page 1 time.sleep(random.uniform(1.5, 3.0)) # 关键随机延迟 return all_items这里有个必须注意的点翻页之间一定要加随机延迟。固定延迟容易被识别出是脚本行为随机延迟更接近真人操作。我一般设置在1.5到3秒之间实测下来既不会太慢也不容易触发限制。3.2 请求头的伪装哪些字段是必须的很多新手会忽略请求头直接用默认的 requests 头去请求结果要么返回403要么返回一个空列表。这是因为平台会检查请求来源。几个关键字段User-Agent必须设置成一个真实浏览器的UA不要用 requests 默认的python-requests/x.x.x那等于直接告诉对方我是脚本。Referer告诉服务器你是从哪个页面跳过来的通常设成创作者主页的地址。Cookie有些接口需要登录态才能返回完整数据。如果你发现未登录时拿到的列表不全就需要带上自己浏览器的Cookie。注意Cookie 属于个人敏感信息不要硬编码在源码里提交到公开仓库。建议用环境变量或单独的配置文件读取并且定期更换。我踩过的一个坑是UA 用了某个旧版本浏览器的字符串结果接口返回的数据结构和新版不一样字段名对不上解析直接报错。后来统一用一个较新的、稳定的UA字符串问题就消失了。所以UA不要随便找最好从自己正在用的浏览器里复制。3.3 数据清洗从原始JSON到可用列表接口返回的原始数据往往很脏有的字段是嵌套的有的标题里带HTML标签有的分P信息藏在子数组里。直接拿来用会到处报错必须先清洗成统一的内部结构。我定义了一个标准的数据结构每个视频条目包含这几个字段bvid视频唯一编号用于后续解析下载地址title清洗后的标题去掉HTML标签、替换非法字符pages分P列表每个分P有自己的编号和标题pubdate发布时间用于排序清洗标题时我用正则把em classkeyword这类高亮标签去掉再把连续空格合并成一个。这一步看起来琐碎但不做的话后面生成文件名时会带一堆奇怪的符号。3.4 断点续采抓了一半断了怎么办批量采集最怕的就是跑到一半网络断了或者被限制了重新跑一遍又要从头开始。解决办法是边抓边存每抓完一页就把当前结果追加写入一个JSON文件。下次启动时先读这个文件看看已经抓到哪一页了从断点继续。def save_progress(items, pathprogress.json): with open(path, w, encodingutf-8) as f: json.dump(items, f, ensure_asciiFalse, indent2) def load_progress(pathprogress.json): if os.path.exists(path): with open(path, r, encodingutf-8) as f: return json.load(f) return []这个机制在抓几百个视频的时候特别有用。我有一次抓到第15页被限制了等了半小时后从断点继续省去了重复请求前面14页的麻烦也降低了对平台的请求压力。4. 下载地址解析清晰度、分P与鉴权参数4.1 清晰度是怎么来的理解返回结构拿到视频编号后请求播放信息接口返回的通常是一个包含多个清晰度选项的结构。每个清晰度对应一个quality标识比如 16、32、64、80 等数字数字越大通常越清晰和一个实际的媒体地址。这里的关键是不要硬编码清晰度数字。不同视频支持的清晰度不一样有的只有360P和720P有的有1080P甚至更高。正确的做法是把返回的所有选项列出来按清晰度排序然后按优先级选一个可用的。def pick_quality(play_info, prefer80): options play_info.get(support_formats, []) # 按清晰度从高到低排序 options.sort(keylambda x: x.get(quality, 0), reverseTrue) for opt in options: if opt.get(quality) prefer: return opt return options[0] if options else None我一般优先选1080P如果没有就退而求其次选最高的。这样既保证画质又不会因为某个视频没有目标清晰度而直接失败。4.2 分P视频的处理一个编号多个文件很多教程类、合集类视频是分P的一个视频编号下面挂着几十个分P。如果只下载第一个分P那等于白干。处理逻辑是先判断pages数组的长度如果大于1就遍历每个分P分别解析地址、分别下载文件名里带上分P序号。for idx, page in enumerate(pages, start1): cid page[cid] play_info get_play_info(bvid, cid) filename f{idx:03d}_{clean_title(page[part])}.mp4 download(play_info, filename)这里有个细节分P的标题part字段有时候是空的这时候就用主标题加上序号兜底。另外分P序号用三位数补零001、002这样在文件管理器里排序才不会乱否则会出现 1、10、2 这种顺序。4.3 媒体地址的时效性为什么不能存下来慢慢下解析出来的媒体地址通常带有时效性签名过一段时间就失效了。这意味着你不能先把所有地址解析出来存到一个列表里然后慢慢下——等你下到后面几个前面的地址可能已经过期了。正确的流程是边解析边下载解析一个视频的地址立刻下载下完再解析下一个。这样每个地址都是在有效期内被使用的。如果你确实想先解析全部再下载那就要做好失败重试的准备一旦某个地址失效重新解析一次即可。4.4 请求失败的重试与退避网络请求失败是常态不能一失败就整个脚本崩掉。我封装了一个带重试的请求函数采用指数退避策略第一次失败等1秒第二次等2秒第三次等4秒最多重试3到5次。def request_with_retry(url, paramsNone, max_retry5): for attempt in range(max_retry): try: resp session.get(url, paramsparams, timeout15) if resp.status_code 200: return resp except requests.RequestException as e: print(f请求异常: {e}) wait 2 ** attempt time.sleep(wait) return None指数退避的好处是遇到临时性的网络抖动或限流给系统一点恢复时间而不是疯狂重试把情况搞得更糟。实测下来大部分偶发失败在第二次或第三次重试时就能成功。5. 文件下载与归档把数据稳稳落到磁盘5.1 流式下载为什么不能一次性读进内存视频文件动辄几百MB甚至几个GB如果用resp.content一次性读进内存轻则内存飙升重则直接崩溃。正确做法是用streamTrue配合分块写入def download_file(url, filepath, chunk_size1024*1024): resp session.get(url, streamTrue, timeout30) total int(resp.headers.get(Content-Length, 0)) downloaded 0 with open(filepath, wb) as f: for chunk in resp.iter_content(chunk_sizechunk_size): if chunk: f.write(chunk) downloaded len(chunk) # 打印进度 if total: percent downloaded / total * 100 print(f\r进度: {percent:.1f}%, end) print()分块大小我设成1MB这是个经验值太小了写盘次数多太大了内存占用高。1MB在两者之间比较平衡。同时用Content-Length计算进度百分比让用户知道还要等多久。5.2 文件名清洗那些让脚本崩溃的非法字符前面提过标题里的非法字符是文件名的大敌。我写了一个清洗函数把Windows和Linux下的非法字符统一替换掉import re def clean_filename(name, max_len80): # 替换非法字符 name re.sub(r[\\/:*?|\r\n\t], _, name) # 去掉首尾空格和点 name name.strip( .) # 限制长度避免路径过长 if len(name) max_len: name name[:max_len] return name or untitled这里有两个容易忽略的点一是首尾的点和空格在Windows下也会导致问题必须去掉二是文件名长度限制Windows的完整路径上限是260个字符如果创作者名、系列名、文件名都很长很容易超限。限制单段长度能有效规避这个问题。5.3 目录结构的自动创建归档的核心是os.makedirs(path, exist_okTrue)exist_okTrue保证目录已存在时不会报错。但目录名同样需要清洗而且要考虑层级不要太深。def build_path(creator, series, filename): base os.path.join(OUTPUT_DIR, clean_filename(creator)) if series: base os.path.join(base, clean_filename(series)) os.makedirs(base, exist_okTrue) return os.path.join(base, filename)系列名怎么来我的做法是从该创作者所有视频标题里找公共前缀。比如一堆标题都是Python入门 第X课那Python入门就是系列名。如果找不到明显的公共前缀就不建系列层直接放在创作者目录下。5.4 断点续传已经下过的文件跳过脚本跑第二次的时候不应该把已经下好的文件再下一遍。判断逻辑很简单如果目标文件存在且大小大于某个阈值比如1MB就跳过。def is_downloaded(filepath, min_size1024*1024): return os.path.exists(filepath) and os.path.getsize(filepath) min_size但这里有个坑如果上次下载到一半中断了文件存在但大小不够这个判断会认为它没下完重新下载。这其实是我们想要的行为——重新下覆盖掉不完整的文件。不过更严谨的做法是下载时先写临时文件.part后缀下完再重命名这样就不会出现半成品被误认为完成品的情况。提示用临时文件 重命名的方式能保证目录里出现的永远是完整文件。这个习惯在处理大批量下载时特别重要。6. 实测中踩过的坑与稳定性优化6.1 请求频率过高被限制的完整排查过程第一次跑全量下载时前20个视频很顺利到第21个突然开始大量失败返回的都是拒绝访问。我一开始以为是地址过期重新解析还是失败又怀疑是网络问题换了网络依旧。最后打印响应内容才发现是触发了频率限制。排查链路是这样的先确认单个请求是否正常正常→ 再确认连续请求是否正常连续5个后开始失败→ 定位到是频率问题。解决办法就是前面说的降低并发、加随机延迟、失败后退避重试。调整之后连续下载上百个视频没有再出现批量失败。这个经历告诉我批量任务里稳比快重要得多。宁可多花两小时也不要因为触发限制导致整个任务中断。6.2 分P标题为空导致的命名冲突有一批视频的分P标题是空的如果直接用主标题命名几十个分P会全部重名后面的会覆盖前面的。我的修复方案是分P标题为空时用主标题_P序号作为文件名保证唯一性。part_title page.get(part) or f{main_title}_P{idx}这个坑很隐蔽因为大部分视频的分P标题是正常的只有少数创作者的内容会出现空标题。如果不做兜底下载完发现文件少了一大半还找不到原因。6.3 大文件下载中断的续传处理下载一个2GB的视频时网络波动导致中断重新下载又要从头开始非常浪费。后来我加上了HTTP Range请求的支持记录已下载的字节数重新请求时带上Range: bytes已下载-头从断点继续。def resume_download(url, filepath): headers {} mode wb if os.path.exists(filepath): downloaded os.path.getsize(filepath) headers[Range] fbytes{downloaded}- mode ab resp session.get(url, headersheaders, streamTrue) with open(filepath, mode) as f: for chunk in resp.iter_content(1024*1024): f.write(chunk)注意续传的前提是服务器支持Range请求。大部分媒体服务器都支持但保险起见可以先发一个HEAD请求看看返回头里有没有Accept-Ranges: bytes。6.4 编码问题中文标题乱码的根源在Windows下用默认编码写文件中文标题经常变成乱码。根源是Windows的默认编码不是UTF-8。解决办法是在所有文件操作里显式指定encodingutf-8包括读写JSON、写日志、生成文件名。with open(data.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse)ensure_asciiFalse这个参数也很关键不加的话中文会被转义成\uXXXX的形式虽然不影响程序读取但人看起来很难受。7. 源码组织与可复用性设计7.1 模块划分为什么要把功能拆开整个脚本我拆成了几个模块fetcher.py负责信息采集parser.py负责地址解析downloader.py负责下载和归档utils.py放通用工具函数清洗、重试、日志main.py做流程编排。这样拆的好处是每个模块职责单一测试和替换都方便。比如平台接口变了只需要改fetcher.py下载策略要调整只动downloader.py。如果全写在一个文件里改一处可能牵连一片。7.2 配置外置把易变的参数抽出来创作者标识、输出目录、清晰度偏好、延迟范围、并发数这些参数全部放到一个config.py或config.json里不要硬编码在逻辑代码中。这样换一个创作者、换一个输出位置改配置就行不用碰代码。CONFIG { creator_id: 你的目标创作者标识, output_dir: ./downloads, prefer_quality: 80, min_delay: 1.5, max_delay: 3.0, max_retry: 5, }7.3 日志记录出问题时能追溯批量任务跑几个小时中间出了什么问题必须能查。我用Python标准库的logging模块把关键事件都记下来开始采集、每页抓取数量、每个视频的下载结果、失败原因。import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(download.log, encodingutf-8), logging.StreamHandler() ] )日志同时输出到文件和控制台跑的时候能看进度跑完了能翻记录。有一次某个视频反复失败我翻日志发现是标题里有个特殊字符没被清洗干净定位后补了一条正则就解决了。7.4 一个完整的调用示例把上面所有部分串起来主流程大致是这样def main(): videos fetch_all_videos(CONFIG[creator_id]) logging.info(f共采集到 {len(videos)} 个视频) for video in videos: series extract_series(video[title]) for idx, page in enumerate(video[pages], start1): filename build_filename(idx, page, video[title]) filepath build_path(CONFIG[creator_id], series, filename) if is_downloaded(filepath): logging.info(f已存在跳过: {filename}) continue play_info get_play_info(video[bvid], page[cid]) quality pick_quality(play_info, CONFIG[prefer_quality]) if not quality: logging.warning(f无可用清晰度: {filename}) continue download_file(quality[url], filepath) logging.info(f完成: {filename}) time.sleep(random.uniform(CONFIG[min_delay], CONFIG[max_delay]))这段代码把采集、解析、下载、归档、跳过、日志全串起来了逻辑清晰每一环都能单独调试。8. 几个让脚本更耐用的实战心得8.1 先小批量验证再全量跑不要一上来就对几百个视频全量下载。先用前5个视频跑一遍确认采集、解析、下载、命名、归档每个环节都正常再放开全量。我吃过这个亏全量跑到一半发现命名规则有问题几百个文件全得删了重来浪费了大半天。8.2 把跳过已下载当成默认行为脚本一定要支持重复运行。第一次跑完第二次跑的时候应该自动跳过已完成的只补下失败的。这样即使中途中断重新跑一遍就能补齐不用手动挑。这个特性配合断点续采能让整个流程非常抗折腾。8.3 给下载任务留一个失败清单每次跑完把失败的视频编号和原因写到一个failed.json里。下次可以只针对这个清单重试而不用全量扫描。对于偶发失败比如临时限流这个机制能大幅减少重复工作。8.4 定期检查平台接口变化这类脚本的寿命取决于平台接口的稳定性。建议每隔一段时间跑一次小批量测试一旦发现采集数量异常或解析失败率升高就及时排查是不是接口变了。把解析逻辑写成可替换模块就是为了应对这种变化。8.5 磁盘空间和路径长度要提前规划下载几百个视频磁盘占用可能达到几十GB甚至上百GB。跑之前先确认目标盘有足够空间。另外如果创作者名和系列名都很长嵌套目录容易触及路径长度上限建议把输出目录设在层级较浅的位置比如D:/media/而不是C:/Users/xxx/Documents/.../.../。我个人在实际操作中的体会是这类批量工具的价值不在于代码多复杂而在于细节处理得够不够扎实。请求延迟、文件名清洗、断点续传、失败重试这些看起来不起眼的地方恰恰决定了脚本是跑一次就扔还是能长期用。把这几处打磨好剩下的就是耐心等它跑完了。