Python异步爬虫与yt-dlp实战:构建B站视频批量下载工具

📅 2026/7/29 13:43:19
Python异步爬虫与yt-dlp实战:构建B站视频批量下载工具
1. 项目缘起与核心需求解析最近在几个主流视频平台上闲逛发现一个挺有意思的现象一类被称为“宅舞”的短视频内容更新频率高得惊人而且热度持续不减。这些视频通常制作精良舞者表现力强背景音乐也多是时下流行的ACG或流行曲目观赏性确实不错。作为一个对数据收集和自动化有点兴趣的从业者我第一反应不是单纯地欣赏而是琢磨这么多高质量、持续更新的内容如果手动一个个去下载保存效率太低也容易遗漏。能不能写个工具把这些视频批量、自动地“搬”到本地硬盘里建一个自己的专属资源库想什么时候看就什么时候看还能避免某天视频突然下架的风险这个想法听起来简单但真要实现一个稳定、高效、且“讲武德”的批量下载方案里面涉及的技术点和需要注意的边界问题还真不少。它绝不仅仅是找到一个下载链接然后开下那么简单。我们需要考虑如何在海量的视频列表中精准定位到目标内容比如特定舞者、特定歌曲、特定时间段如何应对平台反爬机制保护账号安全如何设计合理的下载队列和错误重试机制确保成功率以及最重要的——如何在满足个人学习与研究需求的同时严格遵守平台规则和版权法律避免对服务器造成不必要的压力。今天我就把自己搭建这样一个自动化工具的思路、踩过的坑以及最终成型的方案详细地分享出来。如果你也对Python网络爬虫、异步编程以及多媒体处理感兴趣或者单纯想拥有一个自己的离线视频库那这篇内容应该能给你提供一条清晰的路径。2. 技术方案选型与整体架构设计面对“批量下载热点视频”这个需求我们首先要摒弃“找到一个万能下载器”的幻想。不同的平台如B站、抖音、油管等其视频流传输机制、页面结构、反爬策略截然不同一套代码通吃所有平台是不现实的。因此我们的方案必须是平台针对性的。这里我以某个国内主流视频平台我们称为平台A的“宅舞”分区为例进行拆解其技术思路可以迁移到其他平台。整个系统的核心目标可以分解为发现目标 - 解析信息 - 获取链接 - 下载媒体 - 组织管理。基于这个流程我设计了以下技术栈和架构2.1 核心工具链选型编程语言Python 3.8。这是毋庸置疑的选择。其丰富的生态库Requests, BeautifulSoup, Scrapy, Selenium, aiohttp等为网络爬虫提供了全方位支持异步编程库asyncio, aiofiles能极大提升IO密集型任务如下载的效率。请求与解析库httpx/aiohttp 用于发起异步HTTP请求比传统的requests库在并发场景下性能高出一个数量级是批量下载的利器。BeautifulSoup4/lxml 用于解析HTML页面提取视频标题、UP主信息、AV/BV号、封面图链接等元数据。Selenium/Playwright备选方案。当目标数据是通过JavaScript动态渲染直接HTTP请求获取的HTML中不包含时就需要动用这类浏览器自动化工具。它们能模拟真实用户操作获取渲染后的完整页面内容但代价是资源消耗大、速度慢。原则是能不用就不用优先尝试分析XHR/Fetch请求。下载与流处理库youtube-dl/yt-dlp社区神器。这是一个命令行程序但其Python接口非常强大。它内部集成了对上千个网站的视频流解析逻辑能自动识别最佳画质、处理加密流如m3u8。我们的核心下载功能将围绕它或它的增强版yt-dlp来构建。FFmpeg 多媒体处理领域的“瑞士军刀”。当视频和音频流是分离的常见于高清视频我们需要用yt-dlp获取流地址后调用FFmpeg进行合并。它通常作为系统级依赖存在。并发与任务调度asyncio Python的原生异步IO框架。我们将利用它来协程化下载任务实现数百个视频的同时下载而不会开数百个线程导致系统崩溃。aiofiles 提供异步文件操作避免在写入视频文件时阻塞整个事件循环。数据存储与去重SQLite 轻量级数据库。用于存储已成功下载的视频元数据ID、标题、作者、发布时间、本地路径等实现增量爬取避免重复下载。JSON/CSV 用于简单配置和日志记录。2.2 为什么选择 yt-dlp 而非直接抓取流链接这是一个关键决策。新手可能会尝试直接从网页源码或网络请求中寻找.mp4或.m3u8链接。但这存在几个大问题复杂度高 现代视频平台普遍使用动态、有时效性的签名来保护流媒体链接直接解析难度极大且规则经常变动。画质选择麻烦 你需要自己解析出不同清晰度1080P, 720P, 480P对应的链接。音视频分离 高清视频常将音视频流分开你需要分别找到并下载再用FFmpeg合并。yt-dlp完美解决了这些问题。它维护了一个庞大的“提取器”库专门针对各个网站破解其视频信息获取逻辑。我们只需要告诉它视频的网页URL它就能返回所有可用格式的流信息并自动调用FFmpeg处理合并。这让我们能专注于“找视频列表”和“管理下载任务”而不是“破解视频流”。2.3 系统架构流程图文字描述整个系统的工作流如下种子输入 提供一个或多个初始入口如某个“宅舞”合集的链接、某个UP主的主页、或一个搜索关键词的结果页。列表爬取模块 解析入口页面通过翻页或滚动加载获取所有目标视频的独立页面URL列表。这里需要处理分页逻辑。元数据提取模块 对每个视频页面URL提取核心元数据标题、作者、发布时间、描述、标签等。这些数据将存入SQLite数据库并用于后续的文件命名和分类。下载任务生成模块 检查数据库过滤掉已成功下载的视频。为未下载的视频创建下载任务包含其URL和元数据。异步下载引擎 核心模块。使用asyncio创建多个并发任务每个任务调用yt-dlp下载一个视频。需要集成进度回调、错误重试、速率限制等功能。本地存储与组织 下载完成后根据元数据如作者、年份月份自动组织文件夹结构并将视频文件移动到对应位置同时在数据库中更新状态。日志与监控 整个过程中所有操作成功、失败、跳过都需要记录到日志文件中方便排查问题。注意法律与道德边界在开始之前必须明确此方案仅适用于个人学习、研究、欣赏且不得用于任何商业用途。批量下载会对服务器造成压力务必在代码中设置合理的请求间隔如time.sleep(random.uniform(1, 3))避免高频请求。绝对不要尝试绕过付费墙或下载明确声明禁止下载的版权内容。尊重创作者和平台规则是长久之计。3. 核心模块拆解与实操要点3.1 视频列表的高效发现与爬取“宅舞”视频的发现渠道多种多样。我们的爬虫需要能适配不同的入口。3.1.1 入口策略UP主主页 最直接的来源。构造URL模式如https://space.bilibili.com/{mid}/video。爬取时需要模拟滚动加载分析XHR请求或解析分页。频道或合集 平台提供的官方或用户创建的合集页面内容更垂直。搜索接口 通过平台搜索API通常需要分析其请求参数用“宅舞”、“编舞”等关键词结合时间、播放量排序来获取列表。这种方式获取的内容最广但也最杂需要后续过滤。排行榜单 每日/每周热门榜是发现“热点”视频的好地方。实操要点请求头与Cookieimport httpx 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.bilibili.com/, # 关键很多API会校验Referer Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } # 如果需要登录后才能访问的内容如某些收藏夹则需要加入Cookie。 # 获取Cookie浏览器登录后F12打开开发者工具在Network标签下找一个请求复制其Request Headers中的Cookie字段。 # 注意Cookie是敏感信息切勿泄露或上传到公开仓库。 cookies {SESSDATA: 你的SESSDATA值} async with httpx.AsyncClient(headersheaders, cookiescookies, timeout30.0) as client: response await client.get(url)警告使用Cookie意味着你的账号在进行操作。务必确保你的爬虫行为温和低频率、有间隔否则可能导致账号被临时封禁或限制功能。对于公开内容尽量不使用Cookie。3.1.2 解析列表与翻页以B站UP主视频列表为例其数据通常通过一个JSON API返回。我们需要分析网络请求。打开UP主视频页F12进入“网络(Network)”选项卡筛选XHR/Fetch请求。滚动页面观察新出现的请求找到一个返回视频列表数据的请求响应体是JSON格式。分析这个请求的URL、Query Parameters和Headers。在代码中模拟这个请求解析返回的JSON数据提取每个视频的bvid或aid和标题。翻页逻辑通常包含在请求参数中如pnpage number和pspage size。通过循环递增pn即可获取所有页面直到返回的数据列表为空。3.2 元数据提取与数据库设计获取到视频ID如B站的BV号列表后我们需要获取每个视频的详细信息。3.2.1 调用官方API最稳定可靠的方式是使用平台提供的公开API。例如B站有https://api.bilibili.com/x/web-interface/view?bvid{bvid}。调用这个API可以获取到视频的完整信息包括标题、作者、发布时间、描述、分区、标签、视频长度、各种清晰度的流信息等。3.2.2 数据库表设计我们需要一个简单的数据库来记录状态防止重复下载和便于管理。-- 使用SQLite CREATE TABLE IF NOT EXISTS videos ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, -- 如 bilibili video_id TEXT NOT NULL UNIQUE, -- 如 BV1xx411c7mh title TEXT, author TEXT, publish_time INTEGER, -- 使用时间戳存储 description TEXT, tags TEXT, -- 可用逗号分隔存储 local_path TEXT, -- 本地存储路径 status TEXT DEFAULT pending, -- pending, downloading, completed, failed created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );在爬取到列表后先将视频ID和基础信息插入数据库状态设为pending。下载器只处理状态为pending的记录。3.3 异步下载引擎的构建核心中的核心这是整个项目技术含量最高的部分。我们将结合yt-dlp和asyncio来构建一个高性能下载器。3.3.1 封装 yt-dlpyt-dlp本身是同步的。为了在异步环境中使用它我们需要将其放入线程池中运行避免阻塞事件循环。import asyncio import yt_dlp from concurrent.futures import ThreadPoolExecutor class AsyncDownloader: def __init__(self, max_workers5): self.executor ThreadPoolExecutor(max_workersmax_workers) self.loop asyncio.get_event_loop() async def download_video(self, url, output_template): 异步下载单个视频 ydl_opts { outtmpl: output_template, # 如 downloads/%(uploader)s/%(title)s.%(ext)s format: bestvideo[height1080]bestaudio/best[height1080], # 选择1080P及以下的最佳组合 merge_output_format: mp4, quiet: False, no_warnings: False, progress_hooks: [self._progress_hook], # 进度回调 socket_timeout: 30, retries: 3, fragment_retries: 3, } # 将同步的ydl.download()放到线程池中执行 try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: # ydl.download([url]) 是阻塞的 await self.loop.run_in_executor(self.executor, ydl.download, [url]) return True, None except Exception as e: return False, str(e) def _progress_hook(self, d): 进度回调函数可以在这里打印进度或更新数据库状态 if d[status] downloading: # 示例打印进度 if _percent_str in d: print(f\r下载进度: {d[_percent_str]}, end) elif d[status] finished: print(f\n下载完成文件位于: {d[filename]})关键参数解释outtmpl: 输出模板。这是yt-dlp的强大功能可以使用%(title)s,%(uploader)s,%(upload_date)s等变量自动组织文件夹和文件名。强烈建议使用避免手动拼接路径。format: 格式选择器。bestvideo[height1080]bestaudio/best[height1080]表示优先选择视频高度不超过1080的最佳视频流最佳音频流进行合并如果不行则直接选择高度不超过1080的最佳单文件。这确保了下载的是1080P及以下最高质量。merge_output_format: 合并后的格式通常设为mp4兼容性好。3.3.2 实现并发下载与队列管理我们不能一次性发起几百个下载任务那样会撑爆网络和磁盘IO。需要一个生产者-消费者模型。import asyncio import aiosqlite from collections import deque class DownloadManager: def __init__(self, db_path, downloader, max_concurrent3): self.db_path db_path self.downloader downloader self.semaphore asyncio.Semaphore(max_concurrent) # 控制最大并发数 self.task_queue asyncio.Queue() async def producer(self): 从数据库读取pending状态的任务放入队列 async with aiosqlite.connect(self.db_path) as db: async with db.execute(SELECT video_id, title, author FROM videos WHERE statuspending) as cursor: async for row in cursor: video_id, title, author row url fhttps://www.bilibili.com/video/{video_id} await self.task_queue.put((url, video_id, title, author)) # 放入结束信号 for _ in range(self.max_workers): await self.task_queue.put(None) async def consumer(self): 从队列取任务并下载 while True: item await self.task_queue.get() if item is None: self.task_queue.task_done() break url, video_id, title, author item async with self.semaphore: # 控制并发 output_tmpl fdownloads/{author}/%(title)s.%(ext)s success, error await self.downloader.download_video(url, output_tmpl) async with aiosqlite.connect(self.db_path) as db: status completed if success else failed await db.execute( UPDATE videos SET status?, local_path? WHERE video_id?, (status, output_tmpl, video_id) # 注意实际路径需从hook或返回值获取 ) await db.commit() self.task_queue.task_done() async def run(self): 启动下载管理器 producer_task asyncio.create_task(self.producer()) consumer_tasks [asyncio.create_task(self.consumer()) for _ in range(5)] # 5个消费者 await self.task_queue.join() producer_task.cancel() for task in consumer_tasks: task.cancel()这个管理器确保了最多只有max_concurrent个视频在同时下载并且能优雅地处理所有任务。4. 环境搭建与完整流程实现4.1 项目环境准备创建项目目录mkdir video_archiver cd video_archiver python -m venv venv # 创建虚拟环境 # Windows: venv\Scripts\activate # Linux/Mac: source venv/bin/activate安装依赖pip install httpx beautifulsoup4 yt-dlp aiosqlite # 系统级安装FFmpeg (这是必须的) # Ubuntu/Debian: sudo apt install ffmpeg # macOS: brew install ffmpeg # Windows: 从官网下载编译好的二进制文件将bin目录加入系统PATH。初始化数据库 可以使用一个单独的Python脚本init_db.py来创建表结构。4.2 完整脚本示例一个简化的B站UP主视频下载器下面是一个整合了上述核心概念的简化版脚本用于下载某个UP主的所有视频。# main.py import asyncio import aiosqlite import httpx from yt_dlp import YoutubeDL from concurrent.futures import ThreadPoolExecutor import time import random # 配置 UP_MID 12345678 # 替换为目标UP主的MID DB_PATH videos.db DOWNLOAD_DIR ./downloads MAX_CONCURRENT_DOWNLOADS 2 # 并发数切勿设置过高 REQUEST_DELAY (1, 3) # 请求间隔秒数模拟人类操作 class BilibiliCrawler: def __init__(self): self.headers { User-Agent: Mozilla/5.0..., Referer: https://www.bilibili.com/, } self.api_template https://api.bilibili.com/x/space/wbi/arc/search?mid{mid}pn{pn}ps30orderpubdate async def fetch_video_list(self, mid): 获取UP主所有视频的bvid列表 video_list [] pn 1 async with httpx.AsyncClient(headersself.headers, timeout30) as client: while True: url self.api_template.format(midmid, pnpn) try: resp await client.get(url) resp.raise_for_status() data resp.json() if data[code] ! 0: print(fAPI错误: {data[message]}) break vlist data[data][list][vlist] if not vlist: break # 没有更多视频了 for video in vlist: video_list.append({ bvid: video[bvid], title: video[title], author: video[author], created: video[created], # 时间戳 }) print(f已获取第{pn}页共{len(vlist)}个视频。) pn 1 await asyncio.sleep(random.uniform(*REQUEST_DELAY)) # 礼貌性延迟 except Exception as e: print(f获取第{pn}页失败: {e}) break return video_list class AsyncVideoDownloader: def __init__(self, max_workers2): self.executor ThreadPoolExecutor(max_workersmax_workers) self.loop asyncio.get_event_loop() async def download(self, bvid, title, author): 下载单个视频 url fhttps://www.bilibili.com/video/{bvid} # 使用作者名和标题来组织路径yt-dlp会自动清理文件名中的非法字符 output_template f{DOWNLOAD_DIR}/{author}/%(title)s.%(ext)s ydl_opts { outtmpl: output_template, format: bestvideo[height1080]bestaudio/best[height1080], merge_output_format: mp4, quiet: True, no_warnings: True, socket_timeout: 30, retries: 5, fragment_retries: 5, ignoreerrors: True, # 忽略单个视频的错误继续下一个 } try: def sync_download(): with YoutubeDL(ydl_opts) as ydl: ydl.download([url]) await self.loop.run_in_executor(self.executor, sync_download) return True, None except Exception as e: return False, str(e) async def main(): # 1. 初始化数据库 async with aiosqlite.connect(DB_PATH) as db: await db.execute(CREATE TABLE IF NOT EXISTS videos (bvid TEXT PRIMARY KEY, title TEXT, author TEXT, created INTEGER, status TEXT DEFAULT pending)) await db.commit() # 2. 爬取视频列表 print(开始爬取UP主视频列表...) crawler BilibiliCrawler() videos await crawler.fetch_video_list(UP_MID) print(f共发现 {len(videos)} 个视频。) # 3. 将新视频插入数据库 async with aiosqlite.connect(DB_PATH) as db: for v in videos: try: await db.execute( INSERT OR IGNORE INTO videos (bvid, title, author, created) VALUES (?, ?, ?, ?), (v[bvid], v[title], v[author], v[created]) ) except Exception as e: print(f插入数据库失败 {v[bvid]}: {e}) await db.commit() print(视频列表已更新至数据库。) # 4. 下载 pending 状态的视频 downloader AsyncVideoDownloader() semaphore asyncio.Semaphore(MAX_CONCURRENT_DOWNLOADS) async def download_task(bvid, title, author): async with semaphore: print(f开始下载: {title}) success, error await downloader.download(bvid, title, author) async with aiosqlite.connect(DB_PATH) as db: status completed if success else failed await db.execute(UPDATE videos SET status? WHERE bvid?, (status, bvid)) await db.commit() if success: print(f下载成功: {title}) else: print(f下载失败 {title}: {error}) # 获取所有待下载任务 async with aiosqlite.connect(DB_PATH) as db: async with db.execute(SELECT bvid, title, author FROM videos WHERE statuspending) as cursor: pending_tasks [row for row in await cursor.fetchall()] print(f有 {len(pending_tasks)} 个视频待下载。) # 创建并运行所有下载任务 tasks [download_task(bvid, title, author) for bvid, title, author in pending_tasks] await asyncio.gather(*tasks, return_exceptionsTrue) print(所有任务处理完毕。) if __name__ __main__: asyncio.run(main())运行这个脚本将UP_MID替换为你想要下载的UP主的实际MID在UP主主页URL中找到。确保FFmpeg已安装并可在命令行中调用。在项目目录下执行python main.py。程序会先爬取列表、更新数据库然后开始并发下载。下载的文件会保存在./downloads/UP主名称/目录下。5. 常见问题、排查技巧与进阶优化在实际操作中你一定会遇到各种各样的问题。下面是我在多次运行中总结的“避坑指南”。5.1 常见错误与解决方案问题现象可能原因解决方案yt-dlp报错ERROR: Unable to download webpage1. 网络连接问题。2. 视频被删除/屏蔽/需要会员。3. 网站反爬需要Cookie或User-Agent。1. 检查网络增加retries参数。2. 手动访问链接确认视频存在。3. 在ydl_opts中添加cookies参数从浏览器复制Cookie文件路径或字典。下载速度极慢或卡在某个百分比1. 网络问题或服务器限速。2. 特定分片fragment下载失败。3. 并发过高被限制。1. 使用--limit-rate参数限速有时反而能稳定连接。2. 增加fragment_retries和retry-sleep。3. 降低并发数MAX_CONCURRENT_DOWNLOADS。下载完成后没有声音或音画不同步音视频流分离但合并过程出错。1. 确保FFmpeg已正确安装且版本较新。2. 在ydl_opts中指定merge_output_format: mp4。3. 尝试更换format参数如直接用best。数据库操作报错sqlite3.ProgrammingError在异步函数中错误使用了同步的sqlite3库。**必须使用aiosqlite**进行所有数据库操作确保与asyncio兼容。爬取列表时返回空数据或403错误1. API接口变更。2. 请求头不完整缺少Referer或User-Agent。3. 需要WBI签名B站新版API。1. 重新分析网络请求更新API URL和参数。2. 补全请求头模拟浏览器。3. 实现WBI签名算法较复杂可搜索开源实现。文件名乱码或包含非法字符视频标题含有/,\,:,*,?,,,, 等系统保留字符。5.2 进阶优化与功能扩展增量爬取与定时任务将主脚本封装成函数然后使用系统的定时任务如Linux的cronWindows的任务计划程序每天定时运行。脚本每次运行都从数据库pending状态开始实现增量下载。可以在爬取列表时与数据库对比created时间只添加新视频。更精细的过滤在爬取列表后、插入数据库前根据标题、标签、描述进行关键词过滤如只保留包含“宅舞”、“舞蹈”的视频。根据发布时间过滤只下载最近N天的视频。元数据丰富与封面下载yt-dlp提供了--write-thumbnail选项可以下载封面。可以将视频的描述、标签、弹幕/评论数等信息也存入数据库方便后期检索和管理。图形化界面与状态监控使用tkinter或PyQt构建一个简单的GUI用于输入UP主ID、选择下载目录、查看下载队列和进度。将日志输出到文件并提供一个实时查看日志的窗口。分布式与持久化队列对于海量下载任务可以考虑使用Redis作为任务队列配合Celery实现分布式下载提升可靠性和扩展性。尊重与合规的终极策略在代码中强制加入随机延迟time.sleep(random.uniform(2, 5))。设置合理的每日/每小时下载数量上限。最好使用一个独立的、不常用的账号进行此类操作。最终下载的资源请在本地妥善保存勿进行二次分发。最后一点个人心得构建这样一个系统最大的收获不是那几百GB的视频文件而是在解决一个个具体问题如反爬、异步调度、错误处理的过程中对Python生态和网络协议理解的加深。它像是一个微型的系统工程涵盖了数据获取、处理、存储、调度的完整链条。开始动手做吧从最简单的单线程下载一个视频开始逐步迭代你会遇到问题然后解决问题这才是学习的乐趣所在。