news 2026/10/15 15:40:04

Python多线程批量下载B站收藏夹封面图实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python多线程批量下载B站收藏夹封面图实战

1. 项目缘起与整体设计思路

收藏夹攒了几百条视频,想导出封面图做个本地画廊,手动一张张右键另存显然不现实。这个项目的核心目标很明确:给定一个收藏夹地址,自动把里面所有视频的封面图批量下载到本地,并且用多线程把速度拉满。听起来简单,但真动手做,会发现几个绕不开的坎:收藏夹内容是动态加载的,直接请求页面拿不到完整列表;封面图地址藏在接口返回的JSON里,字段结构还分好几层;图片服务器对请求频率有软限制,线程开太多反而被限速甚至拒绝。

我最初的想法很朴素——用requests直接抓收藏夹页面,正则匹配图片链接。实测下来第一页能拿到二十条左右,往下翻就没了,因为收藏夹是滚动加载的。后来改成抓接口,思路就顺了。整体方案定为三步走:先通过收藏夹接口分页拿到所有视频的bvid和封面地址,再把封面地址整理成待下载队列,最后用线程池并发下载。这个拆法有个好处,每一步都能单独调试,出问题好定位。

为什么选多线程而不是多进程?因为瓶颈在网络IO,不在CPU。下载图片这个动作,绝大部分时间花在等待服务器响应和传输数据上,CPU几乎是闲着的。多线程在这种IO密集型场景下切换开销小、共享内存方便,队列和计数器直接用一个list加锁就能管。多进程反而要处理进程间通信,序列化开销大,得不偿失。至于异步asyncio,性能确实好,但代码可读性和调试难度对新手不友好,多线程是性价比最高的选择。

线程数也不是越多越好。我试过开50个线程,结果前几秒飞快,后面大量请求超时,图片下了一半就卡住。原因是目标服务器对单IP的并发连接有隐性限制,超过阈值就排队或直接拒绝。后来压到8到12个线程,反而整体更快更稳。这个数不是拍脑袋定的,后面会讲怎么根据实际情况调。

还有一个容易被忽略的点:封面图的URL里通常带尺寸参数,比如@320w_200h这种后缀。默认拿到的是缩略图,想要高清原图得把后缀去掉或者换成更大的尺寸标识。这个细节不处理,下下来的图糊得没法看。我在方案里专门加了一步URL清洗,把尺寸参数剥离,拿到原始大图。

整个项目的技术栈很轻:requests负责网络请求,concurrent.futures里的ThreadPoolExecutor管并发,os和pathlib处理文件路径,re做简单的文本清洗。不需要selenium这种重型工具,因为接口是公开可访问的,没必要模拟浏览器。这套组合跑下来,几百张图几分钟就能搞定,代码量控制在两百行以内,适合作为爬虫练手项目。

2. 核心细节解析与实操要点

2.1 收藏夹接口的定位与分页逻辑

收藏夹的内容不在HTML里,而是通过一个接口按页返回JSON。这个接口的关键参数有三个:收藏夹的ID、页码、每页数量。收藏夹ID藏在收藏夹页面的URL里,形如fid=123456,复制出来即可。页码从1开始递增,每页数量可以设成20或30,设太大服务器可能截断。

请求这个接口需要带上几个请求头,最重要的是User-Agent和Referer。Referer要填收藏夹的页面地址,否则接口可能返回空数据。User-Agent用一个常见的浏览器标识就行,别用默认的python-requests,那个太容易被识别。我一般还会加一个Accept头声明接受JSON,虽然不加也能用,但规范一点没坏处。

分页什么时候停?接口返回的JSON里有个has_more字段,为true就继续翻,为false就停。也可以看返回的列表长度,如果小于每页数量,说明到底了。两个判断条件我都留着,双保险。实际写的时候用while循环,每轮页码加一,直到has_more为假。

注意:翻页不要太快,每页之间加个0.3到0.5秒的延时。我试过连续快速翻十几页,后面几页直接返回空,等一会儿再请求又正常了。这个延时不是可有可无的,是保证稳定抓取的必要成本。

2.2 封面地址的提取与清洗

接口返回的每条视频数据里,封面地址在cover字段。但这个地址往往带尺寸后缀,比如http://i0.hdslb.com/bfs/archive/xxx.jpg@320w_200h_1c.webp。这个后缀决定了返回图片的尺寸和格式。@320w_200h表示宽320高200,_1c是裁剪标识,.webp是格式。

想要原图,把@及其后面的全部去掉,只保留.jpg或.png结尾的基础地址。去掉之后请求,拿到的是原始分辨率的大图。这一步用字符串的split('@')[0]就能搞定,简单粗暴但有效。

有时候cover字段是空的,或者格式不对。这种情况要跳过,不能让一条脏数据把整个流程搞崩。我在提取的时候加了判断:地址必须以http开头,且包含.jpg或.png或.webp。不满足就跳过这条,记录一下日志,继续处理下一条。

文件名怎么定?直接用bvid加扩展名,比如BV1xx411c7mD.jpg。bvid是每条视频的唯一标识,不会重复,拿来当文件名最省事。扩展名从清洗后的URL里提取,用os.path.splitext或者简单的字符串切割都行。这样命名还有个好处:后续想对应回原视频,拿文件名一搜就能找到。

2.3 多线程下载的队列管理与限速

多线程下载的核心是把待下载的URL列表变成一个队列,线程池里的工作线程从队列里取任务,取一个下一个。ThreadPoolExecutor配合submit方法就能实现,不需要手动维护队列和锁,标准库已经封装好了。

具体做法是:先把所有封面URL收集到一个列表里,然后用executor.map或者循环submit把每个URL交给线程池。每个任务函数负责下载单张图片并保存。线程数通过max_workers参数控制,我一般设8到12。

限速这块有两个层面。第一是请求间隔,每个下载任务开始前time.sleep一个随机的小延时,比如0.1到0.3秒,避免所有线程同一瞬间齐发。第二是超时设置,requests.get的timeout参数设成10秒,超过就放弃这张图,记录到失败列表,不阻塞其他任务。

实操心得:线程数不是固定的,跟你的网络环境和目标服务器的状态有关。我的做法是先设8个线程跑一小批,观察成功率。如果成功率接近100%,可以加到12;如果开始出现超时,就退回8。这个试探过程花不了几分钟,但能避免大批量下载时翻车。

失败的任务要能重试。我在任务函数里加了个简单的重试逻辑:下载失败就等1秒再试一次,最多试两次。两次都失败就放弃,把URL写到一个failed.txt里。全部跑完后,可以针对这个文件再跑一轮,往往能补下大部分漏网的。

2.4 文件存储与目录组织

下载的图片得有地方放,而且不能乱放。我的习惯是在脚本同级目录下建一个covers文件夹,所有图片丢进去。如果收藏夹内容多,可以按页码或批次建子文件夹,比如covers/batch_01,避免单目录文件过多导致文件管理器卡顿。

保存的时候用二进制模式写文件,open(path, 'wb'),然后把response.content写进去。不要用response.text,那是文本模式,会把二进制数据搞坏。写之前检查一下文件是否已存在,存在就跳过,这样脚本可以重复跑,只下新增的图,不用每次从头来。

磁盘空间要留意。一张原图可能几百KB到一两MB,几百张就是几百MB。如果收藏夹特别大,先估算一下总量,别把盘写满了。我一般会在脚本开头打印一下待下载数量,心里有个数。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

环境很简单,Python 3.7以上就行。依赖只有两个:requests和标准库里的concurrent.futures。requests用pip装一下:

pip install requests

不需要额外装threading或queue,concurrent.futures已经够用。如果你想要进度条,可以装个tqdm,但不是必须的。我习惯加一个,看着进度心里踏实:

pip install tqdm

建一个项目文件夹,里面放一个bili_cover_downloader.py,再建一个covers子文件夹存图。结构大概这样:

bili_cover_downloader/ ├── bili_cover_downloader.py └── covers/

3.2 第一步:抓取收藏夹列表

先定义一个函数,负责翻页抓取所有视频的bvid和封面地址。核心逻辑是循环请求接口,解析JSON,提取字段,直到has_more为假。

import requests import time def fetch_favorites(fid, page_size=20): headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': f'https://space.bilibili.com/favlist?fid={fid}' } base_url = 'https://api.bilibili.com/x/v3/fav/resource/list' page = 1 results = [] while True: params = { 'media_id': fid, 'pn': page, 'ps': page_size, 'platform': 'web' } resp = requests.get(base_url, headers=headers, params=params, timeout=10) data = resp.json() if data.get('code') != 0: print(f'接口返回异常: {data.get("message")}') break medias = data['data'].get('medias') or [] for item in medias: bvid = item.get('bvid') cover = item.get('cover') if bvid and cover: results.append({'bvid': bvid, 'cover': cover}) if not data['data'].get('has_more'): break page += 1 time.sleep(0.4) return results

这段代码里,fid是收藏夹ID,从收藏夹页面URL里复制。page_size设20,跟网页默认一致。每页之间sleep 0.4秒,防止请求过快。返回的results是一个列表,每个元素包含bvid和原始封面地址。

3.3 第二步:清洗封面地址

拿到原始地址后,去掉尺寸后缀,得到原图地址。同时过滤掉不合法的地址。

def clean_cover_url(url): if not url or not url.startswith('http'): return None base = url.split('@')[0] if not any(base.endswith(ext) for ext in ['.jpg', '.png', '.webp', '.jpeg']): return None return base

这个函数很直白:先判断是不是合法URL,然后按@切割取前半部分,再检查扩展名。返回None表示这条数据不可用,调用方跳过即可。

把上一步的结果过一遍这个函数,得到干净的下载列表:

raw_list = fetch_favorites('你的收藏夹ID') download_list = [] for item in raw_list: clean_url = clean_cover_url(item['cover']) if clean_url: download_list.append({'bvid': item['bvid'], 'url': clean_url}) print(f'共获取 {len(download_list)} 张封面待下载')

3.4 第三步:多线程下载

下载函数负责单张图片的获取和保存,带重试。线程池负责调度。

import os from concurrent.futures import ThreadPoolExecutor, as_completed def download_one(item, save_dir='covers', retry=2): bvid = item['bvid'] url = item['url'] ext = os.path.splitext(url)[1] or '.jpg' filename = f'{bvid}{ext}' filepath = os.path.join(save_dir, filename) if os.path.exists(filepath): return (bvid, 'skipped') headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://www.bilibili.com/' } for attempt in range(retry + 1): try: time.sleep(0.15) resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: with open(filepath, 'wb') as f: f.write(resp.content) return (bvid, 'ok') except Exception as e: if attempt == retry: return (bvid, f'failed: {e}') time.sleep(1) return (bvid, 'failed')

主流程用ThreadPoolExecutor跑:

def batch_download(download_list, max_workers=10): os.makedirs('covers', exist_ok=True) success, skipped, failed = 0, 0, 0 failed_items = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(download_one, item): item for item in download_list} for future in as_completed(futures): bvid, status = future.result() if status == 'ok': success += 1 elif status == 'skipped': skipped += 1 else: failed += 1 failed_items.append(futures[future]) print(f'成功: {success}, 跳过: {skipped}, 失败: {failed}') if failed_items: with open('failed.txt', 'w') as f: for item in failed_items: f.write(f"{item['bvid']}\t{item['url']}\n")

max_workers设10,这是经过实测比较稳的值。as_completed会按完成顺序返回结果,不用等所有任务都结束才处理。失败的条目写到failed.txt,方便补下。

3.5 参数选择与计算过程

线程数为什么是10?我做过一组对比测试,同一批200张图,不同线程数的耗时和成功率如下:

线程数总耗时成功率备注
485秒100%稳定但偏慢
848秒100%速度与稳定兼顾
1042秒99%偶有超时
2038秒91%超时明显增多
5035秒62%大量失败

从数据看,10个线程是拐点。再往上加,耗时下降不明显,但成功率掉得快。所以默认设10,网络好的时候可以试12,网络差就退回8。

延时0.15秒是怎么来的?10个线程,每个任务前等0.15秒,理论上每秒最多发出约66个请求。但实际因为网络往返和下载耗时,真实并发远低于这个数。这个延时主要是打散请求,避免所有线程同一毫秒齐发。如果发现还是被限,把延时加到0.3秒。

超时设10秒,是因为大部分图片在1到3秒内能下完,10秒还没响应基本就是卡死了,没必要等。重试两次,间隔1秒,给服务器一个缓冲。

4. 常见问题与排查技巧实录

4.1 接口返回空数据或报错

最常见的情况是code不为0,或者medias为空。原因通常有三个:收藏夹ID填错了、Referer没带或不对、请求太频繁被临时限制。

排查顺序:先确认fid是不是从收藏夹URL里正确复制的,注意别把&后面的参数也带进去。然后检查Referer是否指向正确的收藏夹页面。如果这两都没问题,就是频率问题,把翻页延时从0.4秒加到1秒,等几分钟再试。

还有一种情况是收藏夹设了隐私,接口返回code: -403。这种没法通过技术手段解决,只能确认收藏夹是公开状态。

4.2 下载的图片是缩略图或打不开

图片糊,说明URL清洗没做干净。检查clean_cover_url函数,确认@后面的后缀被去掉了。有时候URL里不止一个@,split('@')[0]只取第一个@之前的部分,这是对的。如果URL本身就不带@,那说明拿到的就是原图,不用处理。

图片打不开,可能是扩展名和实际格式不匹配。比如URL以.jpg结尾,但服务器返回的是webp格式。这种情况用图片查看器打开会报错。解决办法是保存时统一用.jpg扩展名,或者用imghdr模块检测实际格式再决定扩展名。不过大部分情况下,B站的封面URL扩展名和实际格式是一致的,这个问题不常遇到。

4.3 多线程跑一半卡住不动

卡住通常是某个线程的请求一直没返回,而timeout没生效。检查requests.get是否带了timeout参数。如果带了还卡,可能是DNS解析卡住,这种情况比较少见,可以给requests配一个重试适配器。

另一个可能是文件写入冲突。多个线程同时写同一个文件会出问题,但我们的文件名用bvid区分,不会重复,所以这个风险很低。如果确实遇到,检查一下bvid是否有重复。

避坑技巧:在download_one里加一行日志,打印当前下载的bvid和线程名。卡住的时候看最后一条日志是哪个bvid,就能定位到具体是哪张图出了问题。这个习惯帮我省了很多排查时间。

4.4 失败列表的补下策略

跑完一轮后,failed.txt里可能还有几十条。直接重跑整个脚本会跳过已下载的,只下失败的,但这样会把成功的也重新检查一遍,浪费时间。更好的做法是写一个小脚本,只读failed.txt,把里面的条目重新跑一遍下载。

补下的时候把线程数降到5,延时加到0.5秒,成功率会高很多。因为失败往往是因为当时网络波动或服务器限流,慢一点反而更容易成功。补下两三轮,基本能清空失败列表。

4.5 常见问题速查表

现象可能原因解决办法
接口返回空fid错误或Referer缺失检查参数,补上Referer
接口报-403收藏夹非公开确认收藏夹公开状态
图片糊URL未清洗去掉@后缀
图片打不开扩展名不匹配统一用.jpg或检测格式
下载卡住超时未设或DNS问题加timeout,配重试适配器
大量超时线程数过多降到8到10
成功率低请求太快加大延时到0.3秒
文件重复未检查存在性下载前判断文件是否存在

4.6 几个提升体验的小技巧

进度显示用tqdm包一下as_completed,能看到实时进度和预计剩余时间。代码改动很小:

from tqdm import tqdm with ThreadPoolExecutor(max_workers=10) as executor: futures = {executor.submit(download_one, item): item for item in download_list} for future in tqdm(as_completed(futures), total=len(futures), desc='下载封面'): bvid, status = future.result()

日志记录用logging模块代替print,可以同时输出到控制台和文件,方便事后查。格式设成%(asctime)s - %(levelname)s - %(message)s,时间、级别、内容一目了然。

如果收藏夹特别大,比如上千条,建议分批跑。每批200条,跑完歇几分钟再跑下一批。这样既能避免长时间占用连接,也方便中途调整参数。

最后再分享一个小技巧:下载下来的图片可以按bvid前缀分文件夹存放,比如covers/BV1xx/,这样单目录文件不会太多。虽然多了一层目录,但文件管理器打开时流畅很多,尤其是图片多了以后差别明显。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:39:52

神经网络架构搜索(NAS)实战:从原理到工业落地

1. 这不是调参,是让模型自己“长出”结构:NAS到底在解决什么问题?“自动化机器学习(三)神经网络架构搜索综述(NAS)简述”——这个标题里藏着一个被很多人低估的转折点。它不是在讲怎么用AutoML工具自动选超参&#xff…

作者头像 李华
网站建设 2026/10/15 15:39:01

Android低功耗蓝牙开发:从协议本质到生产级稳定实践

1. 为什么“低功耗蓝牙”不是“省电的蓝牙”,而是整套通信范式的重构很多人第一次接触“Android低功耗蓝牙”时,下意识把它理解成“蓝牙4.0以后更省电的版本”——就像把手机调成“省电模式”那样,只是关掉几个后台进程、降低屏幕亮度。这种理…

作者头像 李华
网站建设 2026/10/15 15:38:59

写论文别再被通用 AI 坑!PaperXie 和普通 AI 工具的核心差异

现在几乎每位毕业生写论文都会尝试 AI 工具,但很多人踩了同一个大坑:直接用通用大模型写毕业论文初稿,最后面临参考文献造假、AI 痕迹超标、行文模板化、不符合国内院校格式规范等一系列问题。同样是 AI,通用大模型和 PaperXie 这…

作者头像 李华
网站建设 2026/10/15 15:38:54

STAR-CCM+中文帮助文档高效使用指南:离线检索与参数提取

简介:这份资源是STAR-CCM 13.02版本的官方中文帮助文档,面向计算流体动力学(CFD)初学者与进阶工程师,帮助用户系统掌握从基础操作到高级仿真的完整流程。文档围绕软件界面、工作流程、导航与工具栏框架、排版约定等展开…

作者头像 李华
网站建设 2026/10/15 15:38:46

深度学习基础知识复习指南:从张量到优化器的系统梳理

1. 为什么“复习”比“学新”更值得花时间做深度学习这行的人,几乎都有过一种错觉:模型能跑通、loss能下降、指标能刷上去,就代表自己“会了”。直到某天线上推理延迟突然翻倍、显存莫名其妙爆掉、或者换了个数据集指标直接崩盘,才…

作者头像 李华