十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

B站漫画爬虫实战:从API逆向到异步下载的完整实现

B站漫画爬虫实战:从API逆向到异步下载的完整实现 1. 项目缘起从“追更”到“备份”的刚需作为一名老二次元我追B站漫画BiliBili漫画也有好几年了。平台体验确实不错正版高清、更新及时但有两个痛点一直让我如鲠在喉一是网络波动时加载慢得让人心焦二是有些特别喜欢的作品总担心哪天会因为版权问题下架想收藏起来反复看就成了奢望。手动截图效率太低而且会带上阅读界面的UI观感很差。于是一个念头自然就冒了出来能不能写个程序自动、完整地把漫画章节下载下来保存成本地的高清图片这就是我动手研究B站漫画爬虫的初衷。它不是什么高大上的商业项目纯粹是一个漫画爱好者为了解决个人需求而折腾的“小工具”。整个过程涉及对B站漫画网页端和App端接口的分析、反爬策略的应对以及图片的自动化处理。今天我就把这段时间的研究心得、踩过的坑以及最终的实现代码已开源在Github完整地分享出来。请注意本教程及工具仅用于个人学习与技术交流下载的内容请尊重版权勿用于商业传播。2. 核心目标拆解一个爬虫需要做什么在动手写代码之前我们必须先明确这个爬虫的核心任务是什么。不能一上来就requests.get那样大概率会碰一鼻子灰。我把目标拆解成了几个必须攻克的关卡2.1 目标一找到真正的图片数据源这是最核心的一步。你在网页上看到的漫画图片并不是直接存放在一个像/chapter/1/page/1.jpg这样简单的静态链接里的。现代网站尤其是大型平台普遍采用动态加载技术。图片地址很可能被编码、加密或者通过JavaScript异步请求一个API接口来获取。所以我们的第一个任务就是“抓包”。使用浏览器开发者工具F12的“网络”Network面板在浏览漫画页时监控所有的网络请求。你需要过滤出XHR或Fetch类型的请求重点寻找那些返回数据里包含图片URL的接口。这个过程需要耐心和一点直觉通常接口的命名会包含chapter、image、index等关键词。2.2 目标二理解请求的认证与参数找到接口后你会发现直接访问这个接口链接很可能返回错误比如403 Forbidden或者提示“未登录”。这是因为请求需要携带一些认证信息或特定的参数。Cookie/Token这是最常见的身份认证方式。登录B站后你的浏览器会保存一个包含登录状态的Cookie。爬虫需要模拟这个行为在请求头Headers中正确设置Cookie字段。有时认证信息也可能放在Authorization头或者作为一个token参数。Query参数/请求体接口URL后面通常会跟一堆参数比如chapter_id123456signabcde×tamp...。或者参数可能放在POST请求的body里。这些参数中chapter_id这类是业务参数容易理解但像sign、_signature这类很可能是防爬虫的签名参数需要逆向分析前端JavaScript代码才能知道其生成算法这是爬虫中最具挑战性的部分之一。请求头Headers除了CookieUser-Agent模拟浏览器、Referer来源页等头信息也经常被服务器用来校验请求的合法性。缺少或错误可能导致请求被拒。2.3 目标三处理反爬机制B站这类大型平台没有反爬措施是不可能的。除了上述的签名还可能遇到IP频率限制短时间内从一个IP地址发起大量请求可能会被暂时封禁。对于漫画爬虫由于下载图片是连续的、密集的IO操作这个问题尤为突出。验证码当检测到异常行为时可能会弹出验证码。对于个人低频使用可以手动处理但对于自动化脚本则需要集成打码平台或更复杂的绕过方案。数据加密接口返回的图片URL本身可能是加密的需要解密后才能得到真实的图片地址。2.4 目标四实现稳定高效的下载与存储攻克了数据获取的难关后剩下的就是“体力活”并发控制为了加快下载速度可以使用多线程或异步IO如asyncioaiohttp并发下载多张图片。但必须控制并发数避免对服务器造成过大压力或触发反爬。错误重试与断点续传网络不稳定、服务器临时错误都可能导致单张图片下载失败。代码需要有重试机制。对于长篇漫画最好能记录下载进度支持从断点继续下载。本地化组织下载的图片需要按“漫画名/章节号/页码”这样的目录结构妥善保存。同时要规范命名如001.jpg,002.jpg方便后续用阅读器查看或打包。明确了这四大目标我们的开发就有了清晰的路线图。接下来我将带你一步步深入每个环节的实战细节。3. 逆向工程实战定位与解析核心API理论说再多不如动手干。我们直接进入实战环节看看如何找到并破解B站漫画的数据接口。3.1 环境准备与抓包工具首先你需要一个浏览器Chrome/Firefox和其开发者工具。我强烈建议在分析阶段使用一个“干净”的浏览器配置文件或者无痕模式避免其他插件干扰网络请求的观察。打开B站漫画的网页版manga.bilibili.com登录你的账号。然后打开开发者工具F12切换到“网络”Network面板。记得勾选“保留日志”Preserve log并清空当前的请求记录。3.2 寻找图片数据接口打开任意一个你有权限阅读的漫画章节。在页面加载和翻页的过程中仔细观察网络请求列表。你会看到大量对.js、.css、图片资源的请求。我们需要过滤出携带数据的API请求。在过滤器Filter中输入XHR或Fetch或者直接搜索关键词如api、chapter、image、index。很快你会发现一个关键的请求它的响应Preview里是一段JSON数据其中包含了当前章节所有图片的信息。以我分析时的接口为例接口地址和参数可能随时间变化但原理相通https://manga.bilibili.com/twirp/comic.v2.Comic/GetImageIndex?devicepcplatformweb这是一个POST请求。查看它的“载荷”Payload会发现类似这样的数据{ ep_id: 1234567 }这里的ep_id就是章节的唯一ID。这个ID可以从漫画章节列表页的URL或页面源代码中找到。3.3 破解请求关键参数找到接口只是第一步。尝试用Python的requests库直接模拟这个POST请求你可能会收到一个错误响应提示“签名错误”或直接返回空数据。这说明请求缺少了必要的校验信息。回到开发者工具仔细查看这个GetImageIndex请求的“标头”Headers。除了常见的Content-Type、User-Agent有几个头信息至关重要Cookie包含了你的登录会话SESSDATA等这是身份凭证。Origin/Referer通常需要设置为漫画网站的域名表明请求来源。一个自定义头如x-bili-signature或类似名称这就是签名Sign。它是服务器用来验证请求是否由合法前端生成的关键。这个签名值通常是使用一个密钥Secret Key对请求的URL、参数、时间戳等元素按照特定算法如MD5, HMAC-SHA256计算得出的。签名的生成逻辑被混淆在前端的JavaScript代码中。要找到它需要一些耐心在“网络”面板中找到这个GetImageIndex请求右键点击它选择“复制” - “复制为cURLbash”。这将得到一串完整的命令行指令里面包含了所有头信息和参数。更深入的做法是在“源代码”Sources面板中搜索与签名参数名如sign或相关API路径如/twirp/相关的JS文件通过断点调试来跟踪签名值的计算过程。注意逆向签名算法是爬虫开发中最复杂的部分可能涉及扣代码、补环境等操作。对于B站漫画其签名机制可能更新。一个更简单但可能不稳定的替代方案是直接复用从浏览器捕获的完整请求头。在爬虫代码中将cURL命令里所有的-H参数即请求头原封不动地设置到requests的headers字典里。这种方法避开了逆向算法但缺点是如果B站更新了签名规则你的爬虫就会立刻失效需要重新抓包更新请求头。3.4 解析接口返回的数据当我们成功模拟请求后会得到一个JSON响应。其结构大致如下{ code: 0, msg: success, data: { images: [ { path: xxxxxx.jpg, x: 0, y: 0, width: 800 }, // ... 更多图片信息 ] } }关键字段在data.images里。每个图片对象中的path字段看起来像是一个相对路径或经过处理的标识符它并不是可以直接访问的图片URL。通常需要将这个path与一个基础图片服务器域名拼接才能得到真实的图片地址。这个基础域名可能隐藏在其他的接口响应或页面全局变量中。例如真实图片URL的格式可能是https://i0.hdslb.com/bfs/manga/path。至此我们完成了最关键的逆向工作找到了数据接口知道了如何构造合法的请求并解析出了图片的存储路径。接下来就是构建一个健壮的下载器。4. 构建健壮的下载器代码设计与避坑指南掌握了核心API我们就可以用Python来构建爬虫了。我将以模块化的方式讲解核心代码并穿插大量我在实践中踩过的坑和总结的经验。4.1 项目结构与依赖首先创建一个清晰的项目目录。我推荐的结构如下bilibili-manga-downloader/ ├── main.py # 主程序入口 ├── spider/ │ ├── __init__.py │ ├── api_client.py # 封装API请求 │ ├── downloader.py # 处理下载逻辑 │ └── utils.py # 工具函数如解析URL、创建目录 ├── config.py # 配置文件如请求头、超时设置 ├── requirements.txt # 项目依赖 └── logs/ # 日志目录在requirements.txt中我们至少需要requests2.28.0 aiohttp3.8.0 asyncio tqdm4.64.0 # 用于显示进度条4.2 核心请求模块api_client.py这个模块负责与B站服务器通信必须处理签名、Cookie和错误重试。import requests import time import hashlib import json from typing import Optional, Dict, Any class BilibiliMangaAPIClient: def __init__(self, cookie: str): 初始化客户端 :param cookie: 从浏览器复制的Cookie字符串 self.session requests.Session() self.base_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, Origin: https://manga.bilibili.com, Referer: https://manga.bilibili.com/, Cookie: cookie, # 重要以下签名相关头部需要从实际抓包中获取并替换 x-bili-signature: YOUR_SIGNATURE_HERE, x-bili-signature-version: 1.0, x-bili-timestamp: str(int(time.time())), # 动态时间戳 } self.api_base https://manga.bilibili.com/twirp/comic.v2.Comic def _make_request(self, method: str, endpoint: str, data: Optional[Dict] None, max_retries: int 3) - Optional[Dict]: 封装请求包含重试逻辑 url f{self.api_base}/{endpoint} headers self.base_headers.copy() # 动态更新时间戳 headers[x-bili-timestamp] str(int(time.time())) for attempt in range(max_retries): try: if method.upper() GET: resp self.session.get(url, headersheaders, paramsdata, timeout10) else: # POST resp self.session.post(url, headersheaders, jsondata, timeout10) resp.raise_for_status() # 检查HTTP错误 result resp.json() if result.get(code) ! 0: print(fAPI Error [{endpoint}]: {result.get(msg)}) return None return result.get(data) except requests.exceptions.RequestException as e: print(fRequest failed (attempt {attempt1}/{max_retries}) for {endpoint}: {e}) if attempt max_retries - 1: time.sleep(2 ** attempt) # 指数退避 else: return None return None def get_chapter_images(self, ep_id: int) - Optional[list]: 获取章节图片索引信息 data {ep_id: ep_id} return self._make_request(POST, GetImageIndex, datadata) def get_manga_detail(self, comic_id: int) - Optional[Dict]: 获取漫画详情如标题、章节列表 data {comic_id: comic_id} return self._make_request(POST, GetComicDetail, datadata)关键点与避坑Cookie管理Cookie是核心。直接从登录后的浏览器开发者工具中复制Cookie请求头的完整值。注意Cookie会过期需要定期更新。签名头x-bili-signature这是最大的坑。如上所述你需要从抓包的真实请求中复制这个头的值。这个值很可能是动态生成的并且与时间戳等其他参数绑定。直接写死一个值可能只能用一次。更可靠的方法是逆向其JS生成逻辑但难度极高。对于个人偶尔使用可以写一个脚本每次运行前先手动抓一次包把新的签名头更新到配置里。这不是优雅的方案但是个可行的“捷径”。时间戳x-bili-timestamp需要是当前时间戳秒级。服务器会校验这个时间如果偏差太大请求会被拒绝。所以我们在每次请求前都更新它。错误处理与重试网络请求不稳定是常态。_make_request方法内置了带指数退避的重试机制对于临时性的网络抖动或服务器繁忙很有效。User-Agent模拟一个常见的桌面浏览器UA降低被识别为脚本的风险。4.3 异步下载模块downloader.py下载图片是IO密集型任务使用异步可以极大提升效率。我们使用aiohttp和asyncio。import aiohttp import asyncio import aiofiles from pathlib import Path from tqdm.asyncio import tqdm_asyncio class AsyncImageDownloader: def __init__(self, save_dir: Path, max_concurrent: int 5): self.save_dir save_dir self.semaphore asyncio.Semaphore(max_concurrent) # 控制并发数 async def download_single(self, session: aiohttp.ClientSession, img_url: str, save_path: Path, pbar: tqdm_asyncio): 下载单张图片 async with self.semaphore: # 限制并发 for retry in range(3): try: async with session.get(img_url, timeoutaiohttp.ClientTimeout(total30)) as resp: resp.raise_for_status() async with aiofiles.open(save_path, wb) as f: await f.write(await resp.read()) pbar.update(1) return True except (aiohttp.ClientError, asyncio.TimeoutError) as e: if retry 2: print(f\nFailed to download {img_url} after 3 retries: {e}) return False await asyncio.sleep(1 * (retry 1)) return False async def download_chapter(self, image_info_list: list, chapter_name: str): 下载一个章节的所有图片 :param image_info_list: 包含图片path和可能其他信息的字典列表 :param chapter_name: 章节名用于创建文件夹 chapter_dir self.save_dir / chapter_name chapter_dir.mkdir(parentsTrue, exist_okTrue) # 构建真实的图片URL列表 # 假设从 image_info[path] 和已知的基础URL拼接 base_image_url https://i0.hdslb.com/bfs/manga/ tasks [] connector aiohttp.TCPConnector(limit0, sslFalse) # 调整连接器参数 timeout aiohttp.ClientTimeout(total300) # 长超时设置 async with aiohttp.ClientSession(connectorconnector, timeouttimeout) as session: # 添加必要的请求头到session如图片服务器可能需要的Referer session.headers.update({Referer: https://manga.bilibili.com/}) with tqdm_asyncio(totallen(image_info_list), descfDownloading {chapter_name}, unitimg) as pbar: for idx, img_info in enumerate(image_info_list, start1): img_path img_info.get(path) if not img_path: continue full_url base_image_url img_path save_path chapter_dir / f{idx:03d}.jpg # 格式化命名如001.jpg task asyncio.create_task(self.download_single(session, full_url, save_path, pbar)) tasks.append(task) results await asyncio.gather(*tasks, return_exceptionsTrue) success_count sum(1 for r in results if r is True) print(fChapter [{chapter_name}] finished: {success_count}/{len(image_info_list)} images downloaded.)关键点与避坑并发控制Semaphore使用信号量限制同时发起的下载请求数量。设置太高如50会疯狂占用带宽也容易被服务器限制设置太低如1则失去了异步的优势。max_concurrent5是一个比较平衡的起点可以根据网络情况调整。超时设置下载图片可能很慢尤其是大图。aiohttp.ClientTimeout(total300)将总超时设置为5分钟避免单个慢请求卡死整个程序。连接器TCPConnectorlimit0表示不限制连接池大小让信号量来控制并发。sslFalse在某些环境下可以避免SSL证书验证问题但会降低安全性生产环境应谨慎使用。错误重试在download_single方法中我们对网络错误进行了最多3次重试每次重试间隔递增。这能有效应对偶发的网络波动。进度反馈使用tqdm库添加进度条让漫长的下载过程有直观的反馈体验好很多。图片URL拼接base_image_url需要根据实际情况确定。有时不同的漫画或章节可能使用不同的CDN域名这需要更复杂的逻辑来处理。一个更健壮的方法是从其他接口如漫画详情接口动态获取这个基础URL。4.4 主程序逻辑main.py最后我们把所有模块串联起来。import asyncio from pathlib import Path from spider.api_client import BilibiliMangaAPIClient from spider.downloader import AsyncImageDownloader from spider.utils import parse_ep_id_from_url async def main(): # 1. 配置 YOUR_COOKIE 从浏览器复制的完整Cookie字符串 # 务必替换 COMIC_ID 123456 # 漫画ID从漫画主页URL获取 SAVE_ROOT Path(./downloads) # 2. 初始化客户端 client BilibiliMangaAPIClient(cookieYOUR_COOKIE) # 3. 获取漫画详情和章节列表 print(Fetching comic details...) detail client.get_manga_detail(COMIC_ID) if not detail: print(Failed to get comic detail. Check your cookie and network.) return comic_title detail.get(title, fcomic_{COMIC_ID}) save_dir SAVE_ROOT / comic_title save_dir.mkdir(parentsTrue, exist_okTrue) episodes detail.get(ep_list, []) print(fFound comic: {comic_title}, total {len(episodes)} episodes.) # 4. 遍历章节并下载 downloader AsyncImageDownloader(save_dir, max_concurrent3) # 保守的并发数 for ep in episodes: ep_id ep.get(id) ep_title ep.get(title, fep_{ep_id}) print(f\nProcessing chapter: {ep_title} (ID: {ep_id})) # 检查是否已下载简易版根据文件夹存在判断 ep_save_dir save_dir / ep_title if ep_save_dir.exists() and any(ep_save_dir.iterdir()): print(f Chapter already exists, skipping.) continue # 获取图片索引 images_data client.get_chapter_images(ep_id) if not images_data or images not in images_data: print(f Failed to get image index for chapter {ep_id}.) continue image_list images_data[images] print(f Found {len(image_list)} images.) # 异步下载该章节 await downloader.download_chapter(image_list, ep_title) print(\nAll tasks completed!) if __name__ __main__: asyncio.run(main())这个主程序流程清晰配置 - 初始化 - 获取元数据 - 遍历章节 - 下载。其中加入了简单的“跳过已下载章节”的逻辑避免重复工作。5. 高级话题与长期维护策略一个能跑起来的爬虫只是开始。要想让它稳定、可用、可持续还需要考虑更多。5.1 应对反爬升级动态签名与行为模拟如前所述硬编码的签名头是最大的弱点。长期的解决方案包括逆向与模拟深入分析前端JS用Python复现签名算法。这可能涉及execjs调用JS代码或者用requests-html、selenium等工具无头运行浏览器来获取动态Token。复杂度高但一劳永逸。中间人代理使用mitmproxy等工具在本地架设一个代理。让浏览器正常通过这个代理访问B站漫画然后在代理层截获所有请求直接提取出有效的签名和Cookie供爬虫程序使用。这样爬虫本身无需关心签名生成但需要一直开着浏览器和代理。定期人工更新对于低频使用的个人脚本每次运行前手动抓包更新config.py里的请求头是最简单粗暴但也最有效的方法。5.2 效率优化分布式与增量更新如果要下载整个漫画库效率至关重要。任务队列将不同漫画、不同章节的下载任务放入队列如Redis由多个工作进程/协程消费。可以轻松实现横向扩展。增量爬取记录已下载章节的ep_id到数据库或文件。每次运行只获取漫画的最新章节列表与记录对比只下载新增的章节。连接复用与池化确保aiohttp.ClientSession在整个应用生命周期内复用并合理配置连接池参数。5.3 道德、法律与风险规避这是最重要的一部分。遵守Robots协议检查https://manga.bilibili.com/robots.txt。即使没有明确禁止也应保持克制。控制请求频率在代码中主动添加延迟asyncio.sleep模拟人类阅读速度。避免在短时间内发起海量请求这既是道德要求也能有效降低被封IP的风险。我的代码中将并发数设得比较低3-5就是一种频率控制。明确用途本代码及教程仅用于个人学习Python网络爬虫技术以及个人对已购买章节的备份收藏。请勿用于大规模抓取并建立镜像站。在第三方平台传播抓取内容。任何商业用途。尊重版权下载的内容请妥善保存在个人设备中。优秀的作品值得支持如果条件允许请在B站漫画平台购买正版支持创作者和平台。5.4 代码维护与社区爬虫是与平台防御措施持续对抗的过程。B站的接口和签名机制很可能在未来发生变化。因此关注变化当爬虫突然失效时首先检查Cookie是否过期然后重新抓包对比请求参数和头信息有何变化。模块化设计将API请求、签名生成、下载逻辑分离。当签名算法改变时你只需要修改api_client.py中的相关部分。利用开源社区我将这个项目的核心代码开源在了Github链接见文末。如果你在运行中遇到问题可以在项目Issues中提出。同时也欢迎有能力的开发者贡献代码共同维护这个项目例如实现自动化的签名破解。研究并实现一个B站漫画爬虫是一次非常综合的技术练习它涵盖了HTTP协议、前端逆向、异步编程、错误处理、工程化设计等多个方面。希望这篇长文不仅能帮你成功下载到想看的漫画更能让你理解爬虫技术背后的原理、挑战和边界。技术是用来解决问题的但务必在合法合规、尊重他人的前提下使用。
返回列表