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

资讯详情

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

Python爬虫实战:解析百度图片acjson接口与异步下载

Python爬虫实战:解析百度图片acjson接口与异步下载 周末有个学弟跑过来问我说他照着网上一篇高赞教程写的 python 爬取百度图片 脚本翻页翻到第二页就全是空列表好不容易把图片下回来打开又显示 403。我问他是不是还在解析网页源码里的 img 标签他沉默了三秒钟——这个沉默我就懂了。百度图片早就不走“网页里有图、正则直接抓”的路线了。图片数据全部来自一个叫acjson的动态接口网页 HTML 只是空壳真正的链接藏在 XHR 请求的 JSON 里。再加上图片服务器这几年陆续加了 Referer 防盗链、接口参数校验、频率限制老教程那套“requests 正则”的玩法跑不通太正常了。这篇文章就把我目前在用的这套方案完整拆开讲一遍。从抓包定位接口、解析参数含义到用 requests 抓取图片链接、再用 asyncio aiohttp 实现并发下载最后附上完整代码和避坑记录。不管你是想收集图片数据集、练手网络爬虫还是批量下载壁纸素材都可以直接参考把代码微调成自己的工具。1. 为什么说这是“升级版”百度图片反爬这几年变了什么1.1 数据早就不在网页源码里了十年前写百度图片爬虫确实可以requests.get()直接把搜索页 HTML 下载下来再用正则提取objURL。那时候搜索引擎为了 SEO 和首屏渲染会把不少图片地址直接写在img标签或页面源代码里正则能捞到不少货。现在不行了。百度图片搜索页改成了典型的 SPA 思路页面框架先加载出来图片列表全部由 JavaScript 发起异步请求拿到 JSON 数据后再动态渲染。你在浏览器里看到的每一张缩略图背后其实对应一个 XHR 请求返回的是一个很大的 JSON 数组。此时再去解析 HTML只能抓到一堆占位符、懒加载的 base64 串、还有一些统计脚本唯一捞不到的就是你真正想要的图片链接。理解这件事很重要。很多人问“我代码明明没错怎么返回空列表”其实不是语法问题是抓取目标找错了——你在盯着一个空橱窗研究怎么撬锁真正的仓库根本不在这个页面里。1.2 防盗链、参数校验、频率限制一起堆上来了百度图片遇到的第二道坎是图片 CDN 的 Referer 防盗链。浏览器打开百度图片页面时发出请求的页面地址是https://image.baidu.com/开头的页面当页面再去请求img0.baidu.com等 CDN 域名上的图片时HTTP 请求头里会带着这个 Referer。CDN 服务器检查到 Referer 来自百度页面才允许加载。用脚本直接下载图片时如果你没带 Referer 或者带的 Referer 不对CDN 直接返回 403 Forbidden。我在网上下载过不少老爬虫源码发现十个里面有八个都只设置了User-Agent完全没有 Referer 的概念。这不叫爬虫代码写得不好这是它压根不知道现代网站的防盗链机制长什么样。接口层也有参数校验。acjson这个接口不是“你传个 word 就给你返回图片”那么简单它要求tn、ipn、pn、rn、gsm等一系列参数都对齐。其中gsm看着像随机字符串实际上是pn的十六进制表达。比方说pn30gsm就得是1epn60gsm就得是3c。这个参数错了或者缺了接口偶尔会返回空数据而且不是每次必现调试起来非常恶心。1.3 升级版到底升级了什么所以这次“升级版”的核心思路可以归纳成三条抓取对象从 HTML 网页换成 XHR 接口acjson这是最根本的变化。请求头完整模拟浏览器User-Agent轮换 Referer防盗链补齐让请求和浏览器行为基本一致。下载阶段从单线程换成 asyncio 异步并发。图片下载是典型的 IO 密集型任务单线程一张张下载太浪费网络带宽用了并发之后速度提升非常明显。说白了升级版不是把代码换了个写法而是把整个爬虫的“方法论”从“静态网页抓取”调整成了“模拟浏览器接口调用”。2. 抓包分析找到百度图片背后的真实数据接口2.1 三步定位 acjson 接口先别急着写代码打开浏览器手动操作一遍看清数据从哪来。打开https://image.baidu.com/随便搜索一个关键词比如“晚霞”。按F12打开开发者工具切到Network网络标签页在筛选栏选择Fetch/XHR。鼠标滚轮滑到页面底部触发“加载更多”观察请求列表里新增的请求。这时你会看到一个名字叫acjson的请求点击它在Preview或Response标签页里能看到一大段 JSON。展开里面的data数组每个元素都包含多个图片地址字段比如thumbURL、middleURL、hoverURL、objURL。这就是我们要找的“货源”。初次抓包的同学最容易犯的毛病是忘了筛选Fetch/XHR在All列表里被几十上百个 js、css、图片请求淹没了看着眼花。筛选之后整个列表干干净净真正的接口一眼就能认出来。2.2 关键参数逐个拆解acjson接口的完整参数非常多我把最核心的几个列出来其他参数保持默认即可。参数常见取值含义tnresultjson_com接口类型标识固定值少_com可能导致结果异常word你搜索的关键词查询词queryWord与 word 相同旧版遗留参数建议保持一致pn0 / 30 / 60 ...返回结果的起始位置第一页从 0 开始rn30每页返回数量百度固定支持 30gsmpn 的十六进制字符串如 pn30 时 gsm1epn60 时 gsm3cie / oeutf-8输入输出编码nc1禁止缓存标记你可能会好奇gsm到底哪来的。我在本地测试过pn0时gsm0pn30时gsm1epn60时gsm3c。用 Python 的hex(30)得到0x1e去掉0x前缀就是1e。所以代码里直接用hex(pn)[2:]生成既简单又能保证和百度前端行为一致这就是“有依据的参数计算”而不是拍脑袋写死字符串。2.3 响应 JSON 里的 URL 字段别搞混返回的 JSON 结构大致是{ data: [ { thumbURL: https://img0.baidu.com/it/uxxxfm253fmtautoapp138fJPEG?w500h500, middleURL: https://img0.baidu.com/it/uxxxfm253fmtautoapp138fJPEG?w750h750, hoverURL: https://img0.baidu.com/it/uxxxfm253fmtautoapp138fJPEG?w800h800, objURL: ippr_z2C$qAzdH3FAzdH3Foj_g8bczm_3D..., replaceUrl: [] }, null, { ... } ] }注意三点data数组里会混入null元素遍历时要先判断。thumbURL是最稳的真实 CDN 地址优先使用middleURL更大更清晰但个别图片可能没有。objURL在部分图片上是一段加密字符串直接当 URL 去请求大概率 404老教程经常在这里翻车。我的代码里统一用item.get(thumbURL) or item.get(middleURL)做兼容提取两个都没有就跳过。这样既保证能下载又不至于因为字段缺失直接报错中断。3. 完整代码实现requests 抓列表 aiohttp 并发下载3.1 环境准备需要 Python 3.8 以上版本用到了asyncio.run等特性安装三个库pip install requests aiohttp如果你想让异步写入文件更优雅可以再安装一个aiofiles不过图片下载这个场景里文件写入量不大用普通open().write()完全够用不影响整体速度。3.2 翻页抓取图片链接这里用requests同步翻页每页间隔PAGE_DELAY秒既可以控制频率又能避免一次性请求太快触发风控。代码如下# -*- coding: utf-8 -*- 百度图片下载器【升级版】 仅用于 Python 爬虫学习与技术交流请勿用于任何商业用途。 import os import random import re import time import asyncio from urllib.parse import quote import aiohttp import requests KEYWORD 猫咪 MAX_PAGE 4 OUTPUT_DIR downloads CONCURRENCY 8 PAGE_DELAY 1.5 UA_LIST [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36, ] def fetch_image_urls(keyword, max_page4, page_delay1.5): base_url https://image.baidu.com/search/acjson seen set() result [] for page in range(max_page): pn page * 30 headers { User-Agent: random.choice(UA_LIST), Referer: https://image.baidu.com/search/index?tnbaiduimageword quote(keyword), Accept: application/json, text/javascript, */*; q0.01, Accept-Language: zh-CN,zh;q0.9, } params { tn: resultjson_com, ipn: rj, ct: 201326592, fp: result, cl: 2, lm: -1, ie: utf-8, oe: utf-8, st: -1, ic: 0, word: keyword, queryWord: keyword, pn: pn, rn: 30, gsm: hex(pn)[2:], nc: 1, } try: resp requests.get(base_url, paramsparams, headersheaders, timeout(5, 10)) resp.raise_for_status() data resp.json().get(data) or [] except Exception as exc: print(f[第{page 1}页] 请求失败: {exc}) continue page_count 0 for item in data: if not item: continue url item.get(thumbURL) or item.get(middleURL) if url and url not in seen: seen.add(url) result.append(url) page_count 1 print(f[第{page 1}页] 获取 {page_count} 条新链接累计 {len(result)} 条) time.sleep(page_delay) return result这里有几个细节值得展开说headers里的 Referer 我拼接了search/index?tnbaiduimageword关键词这是从浏览器实际请求里复制的。带不带这个 Referer对接口请求本身影响不大但带上更接近真实浏览器后续排查起来少一个变量。timeout(5, 10)是连接超时 5 秒、读取超时 10 秒。如果不设置超时某次请求卡住时程序会一直挂在那里批量运行时非常痛苦。去重用seen集合同一关键词跨页时偶尔会返回重复图片不处理的话下载阶段会重复写文件浪费时间。3.3 异步并发下载图片下载阶段是整个升级版的“重头戏”。图片下载是 IO 密集型任务网络延迟占据了绝大部分时间非常适合用asyncio并发处理。完整代码如下def guess_ext(url, content_type): ext_map { image/jpeg: .jpg, image/png: .png, image/gif: .gif, image/webp: .webp, } base os.path.splitext(url.split(?)[0])[1].lower() if base in (.jpg, .jpeg, .png, .gif, .webp): return base return ext_map.get(content_type, .jpg) async def download_one(semaphore, session, url, idx, keyword, save_dir): headers { User-Agent: random.choice(UA_LIST), Referer: https://image.baidu.com/, } async with semaphore: for attempt in range(3): try: async with session.get(url, headersheaders, timeout10) as resp: if resp.status 200: data await resp.read() ext guess_ext(url, resp.headers.get(Content-Type, )) filename os.path.join(save_dir, f{keyword}_{idx:04d}{ext}) with open(filename, wb) as f: f.write(data) print(f[OK] {filename} ({len(data)} bytes)) return if resp.status 403: print(f[403] 防盗链拦截: {url}) return except Exception as exc: print(f[重试 {attempt 1}/3] {url} - {exc}) await asyncio.sleep(1) print(f[失败] 已跳过: {url}) async def download_all(urls, keyword, concurrency8): save_dir os.path.join(OUTPUT_DIR, re.sub(r[\\/:*?|], _, keyword)) os.makedirs(save_dir, exist_okTrue) semaphore asyncio.Semaphore(concurrency) connector aiohttp.TCPConnector(limitconcurrency, sslFalse) async with aiohttp.ClientSession(connectorconnector) as session: tasks [ download_one(semaphore, session, url, idx, keyword, save_dir) for idx, url in enumerate(urls) ] await asyncio.gather(*tasks) print(f全部完成文件保存在: {os.path.abspath(save_dir)})说说我为什么这样设计文件名直接用关键词_序号的方式而不是从 URL 提取。CDN 地址经常带一长串参数直接拿 URL 当文件名会出现斜杠、问号、百分号等非法字符在不同系统上还会触发各种奇怪的保存失败。统一用keyword_0000.jpg这种格式简单可靠排序还好看。guess_ext函数先看 URL 后缀URL 没有靠谱后缀时再用响应头的Content-Type推断。thumbURL基本都有明确的fJPEG或.jpg后缀但这层兜底逻辑在遇到 webp 或者其他格式时很有价值可以避免把所有图片都写死成.jpg。3.4 主流程整合def main(): urls fetch_image_urls(KEYWORD, max_pageMAX_PAGE, page_delayPAGE_DELAY) print(f共收集 {len(urls)} 张图片链接) asyncio.run(download_all(urls, KEYWORD, concurrencyCONCURRENCY)) if __name__ __main__: main()我在本机用“猫咪”测试抓 4 页约 120 条候选链接8 并发下载整体耗时 20~30 秒如果是单线程 requests 一张一张下同等网络条件下大概要 2~3 分钟。这个数值会受本地网速和网络环境影响但并发提速的幅度是肉眼可见的。4. 并发设计到底哪种好单线程、线程池、异步的取舍4.1 三种方案实测对比“爬虫并发设计到底哪个好”是爬虫圈讨论频率很高的问题。我在百度图片这个场景下实际测过三种方案结果大致如下表100 张图片本地普通宽带结果仅供参考方案核心代码适合场景参考耗时requests 单线程for 循环逐个下载少量图片、调试验证2~3 分钟ThreadPoolExecutor requests线程池提交任务新手易理解、代码改动小30~60 秒asyncio aiohttp协程并发IO 密集、请求量大的场景10~20 秒表格可以看个趋势不用抠具体秒数。关键是理解不同方案的适用场景。4.2 为什么图片爬虫优先选异步 IO用生活化的方式理解单线程下载图片就像一个人在银行柜台办业务办完一笔再到下一笔大部分时间花在排队和等待柜员操作上真正处理数据的时间很少。异步 IO 就像同时给一排柜台都派了任务银行处理完哪一笔就先通知哪一笔人不用一直站在某个柜台前等。爬虫下载图片恰好是“网络等待为主、本地写盘为辅”的典型场景。aiohttp在等待网络响应时可以把控制权交还给事件循环让其他请求继续推进从而把网络带宽用满。那并发数设多少合适我个人长期使用的经验是 5~10 比较稳。并发太低提速不明显并发一旦加到 50、100很容易触发百度的风控大批请求开始返回 403 或者直接被断开连接得不偿失。这里用了asyncio.Semaphore(concurrency)做信号量限制确保同一时刻最多只有concurrency个请求在跑。4.3 什么时候才需要换方案如果你抓完图片之后紧接着要做 CPU 密集型的处理比如 OCR 识别图片文字、跑模型推理、批量生成缩略图那就在下载阶段用异步抓取后处理阶段用多进程ProcessPoolExecutor。这种“异步下载 多进程处理”的组合在构建图片数据集时很常见。我的原则是看瓶颈在哪。下载慢就先优化下载处理慢就先优化处理。没有哪个并发方案是银弹只有适配当前任务的方案才是好方案。5. 避坑指南百度图片爬虫的几个典型翻车现场5.1 问题速查表下面这张表是我自己调试过程中遇到最多的问题先给一个快速索引后面挑重点展开症状常见原因解决方案返回空 data参数不完整或 IP 被风控检查 tn、gsm 参数降速重试图片下载全部 403Referer 缺失或错误请求头带上Referer: https://image.baidu.com/下载到一半全部超时并发数过高降到 5~8增加重试机制SSL 证书报错本地环境证书链不完整使用TCPConnector(sslFalse)仅限测试环境文件名乱码或保存失败URL 含非法字符用“关键词序号”命名同关键词重复图片翻页接口偶尔重复返回用集合去重5.2 三个最容易踩的细节第一个细节objURL别直接用。这个字段在部分图片上是加密串在另一部分图片上是正常 URL但加密串出现的比例不低。代码里如果优先取objURL你会遇到大量 404 和坏图。用thumbURL做保底至少保证每张下回来的图都能打开。第二个细节tn参数要写对。有些老代码写成tnresultjson少了下划线后的_com。这个接口的返回结构会因此变掉轻则字段对不上重则直接返回空数组。我的建议是严格从浏览器实际请求里复制参数不要凭记忆写。第三个细节requests的timeout一定要设置。很多新手代码长这样requests.get(url)没写超时。一旦某个请求卡住整个脚本就像死机一样停在原地你甚至不知道是网络问题还是哪里出了问题。设置了(5, 10)之后连接失败会自动抛异常配合 try/except程序才不会因为一两张图挂掉整个任务。5.3 你还需要一点“职业素养”技术讲完了聊点代码之外的东西。爬虫写起来很快但“爬什么、怎么爬”要心里有数。百度图片的素材来自大量站点的原图很多是有版权的批量抓下来用于商业用途或者在公开渠道发布很容易踩到别人的利益。我的习惯是爬虫代码只用来学习网络请求、异步并发这些技术抓下来的素材个人学习和研究用不商用、不传播、不二次分发。另外尽量控制请求频率。这篇文章里的PAGE_DELAY和CONCURRENCY都是经过实测的比较稳的配置不需要为了“更快”去挑战对方的服务极限。咱是来学习技术的不是来和对方抢带宽的。6. 这个脚本还能怎么扩展6.1 批量关键词自动抓取现在KEYWORD是一个变量想抓多个关键词就手动改字典太麻烦。最简单的改法是把入口函数包一个循环KEYWORDS [猫咪, 狗狗, 晚霞, 星空] for kw in KEYWORDS: print(f开始处理关键词: {kw}) urls fetch_image_urls(kw, max_page2) if urls: asyncio.run(download_all(urls, kw, concurrencyCONCURRENCY)) time.sleep(5)每换一个关键词中间停 5 秒再继续避免连续高频请求。目录结构会变成downloads/猫咪/、downloads/狗狗/这样所有图片按关键词分好类找起来很方便。6.2 加进度显示和断点续下下载图片量大了以后你希望看到进度希望断了之后下次能跳过已下载的文件。这两个优化都很容易加进度显示在for循环里用tqdm包一层或者在download_one完成时累计计数并打印百分比。断点续下下载之前先判断os.path.exists(filename)如果文件已经存在就跳过。这两个小功能的代码量都不超过十行但对使用体验的提升非常明显尤其是当你要跑几百上千张图片的时候。最后再聊一点我的感受。这套“抓接口代替抓网页、异步代替同步、防反爬代替裸奔”的思路并不只适用于百度图片。你理解了抓包定位 XHR 接口的过程理解了 Referer 防盗链的原理理解了 IO 密集任务为什么用异步以后再遇到任何图片站点都可以用同样的方法快速拆解。工具会过时但思路不会。希望这篇文章能帮你少踩几个坑把爬虫这块的功夫练扎实。
返回列表