ARTICLE DETAIL

资讯详情

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

Python爬虫实战:用Requests高效采集百度图片搜索资源

Python爬虫实战:用Requests高效采集百度图片搜索资源 做图像类项目最烦的不是模型调参而是数据从哪来。之前为了给一个图像分类任务凑样本集我把主意打到了百度图片搜索上——公开、量大、还能按关键词批量取图。对熟悉 Python 爬虫的人来说第一个直觉往往是用 Requests 直接请求搜索页然后解析 HTML。真这么做你会发现页面源码里根本找不到图片列表数据全在背后异步加载的接口里。所以基于 Requests 的百度图片搜索爬取这个需求本质是模拟浏览器和百度图片服务端之间的 JSON 交互而不是文本匹配式的网页抓取。这篇文章我从头到尾梳理一遍完整链路先搞清楚百度图片搜索到底从哪里拿数据再讲怎么用 Requests 构造请求、解析 JSON、翻页拉取然后把我在实际爬取中遇到的 429 限流、图片防盗链、URL 失效等几个典型坑一并拆开说。最后给出一段工程化实践的思路。无论是准备做图像数据集采集还是想入门接口型爬虫这篇都可以直接照着跑。1. 百度图片搜索的请求链路拆解别急着写代码很多爬虫教程一上来就让你requests.get(https://image.baidu.com/search/index?tnbaiduimageword猫)然后丢给你一堆BeautifulSoup选择器结果你复制过去一跑发现提取出来的图片链接要么是空的要么是几个固定占位图。这不是代码写错了而是百度图片搜索这套页面本来就是异步渲染的。1.1 图片数据根本不在初始 HTML 里用浏览器打开百度图片搜索首页输入猫在页面加载完成之后你会看到满屏的图片缩略图。这时候按下 F12 打开开发者工具切成 Network 面板刷新页面你会在 XHR 类别里看到一大堆acjson请求比如https://image.baidu.com/search/acjson? tnresultjson_com ipnrj word%E7%8C%AB pn0 rn30 ...这里返回的是 JSON不是 HTML。浏览器先把搜索页面这个壳渲染出来然后通过 JS 向acjson这个接口发起请求拿到图片数据后再动态填充到页面上。所以爬虫如果只盯着search/index这个入口页面去解析等于在空壳里找内容当然找不到。我当时的做法是先在 Network 面板里找到这条 XHR 请求右键 Copy as cURL然后放到 Postman 或直接转成 Python 代码看它到底请求了哪些参数。这样能拿到最真实、最完整的请求头集合比自己瞎猜稳得多。1.2 核心请求参数逐个说清楚从复制出来的 cURL 里能看到这个接口的参数很多但真正决定查询结果的核心参数只有这几个参数名作用示例值必填情况tn接口类型标识不同值对应不同返回结构resultjson_com必填ipn固定请求标识相当于接口版本号rj建议保留word搜索关键词需要 URL 编码%E7%8C%AB必填pn起始序号从 0 开始0, 30, 60...必填rn单页返回条数一般最多 3030必填ie字符编码utf-8可省略oe输出字符编码utf-8可省略logid日志 ID通常由服务端下发的 JS 生成一长串数字不带也能请求成功pn是分页的关键第一次请求pn0返回第 1 到第 30 张第二次pn30返回第 31 到第 60 张以此类推。rn虽然可以设成 60 或者更大但我实测单页rn超过 30 时百度经常不按设置的数值返回有时候直接给你截断所以老老实实用 30 最稳。还有个容易被忽略的点接口返回 JSON 的data数组里第一个元素经常是空对象{}这是占位符。解析的时候如果直接遍历data取thumbURL第一轮肯定会报KeyError或拿到None需要先做个类型判断跳过空元素。2. 用 Requests 实现核心请求与解析从零写一个最小可用版理清楚接口逻辑之后用 Requests 写一个最小可用版本其实非常快。关键不在于发请求本身而在于请求头怎么构造、返回的 JSON 怎么解析、翻页循环怎么写才不容易被卡死。2.1 使用 Session 而不是裸 requests.get为什么用requests.Session()因为它会自动保存 Cookie并且可以统一设置请求头后续每一个请求都会带上省去每次手动传 headers 的麻烦。百度图片接口对Referer有校验如果Referer不是百度图片域名很容易被拒或者返回异常内容。import requests session requests.Session() session.headers.update({ User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 ), Referer: https://image.baidu.com/, Accept: application/json, text/javascript, */*; q0.01, Accept-Language: zh-CN,zh;q0.9, })注意User-Agent不要用 Requests 的默认值一串裸的python-requests/2.31.0挂在请求头上服务器一眼就能识别出这是脚本。用新版 Chrome 的 UA 字符串伪装成浏览器是最基本也是最有效的一层手段。2.2 构造请求参数请求 acjson 接口核心参数在上一节已经列过直接组织成字典传给paramsRequests 会自动完成 URL 编码def fetch_images(keyword: str, page: int, per_page: int 30): params { tn: resultjson_com, ipn: rj, word: keyword, pn: page * per_page, rn: per_page, ie: utf-8, oe: utf-8, } resp session.get(https://image.baidu.com/search/acjson, paramsparams, timeout10) resp.raise_for_status() return resp.json()这里有个小细节pn传的是page * per_page也就是第 0 页对应pn0第 1 页对应pn30。不要把页码和偏移量混在一起否则会重复拿同样的数据。2.3 JSON 字段解析选对 URL 字段能省一半麻烦接口返回的data列表里每个图片元素大致长这样{ thumbURL: https://img0.baidu.com/it/uxxx, middleURL: https://img0.baidu.com/it/uyyy, objURL: ippr_x2..., fromPageTitle: 小猫-壁纸, fromURL: https://www.example.com/xxx.html, replaceUrl: [ { ObjUrl: https://img0.baidu.com/it/uzzz, ObjUrlType: thumb } ] }这几个 URL 字段的含义和可用性差别很大thumbURL缩略图压缩比较严重但最稳定下载基本不会失败。middleURL中等尺寸质量比缩略图好偶尔有防盗链。objURL早期版本里是原图地址直链但现在很多时候是一段编码串比如ippr_x2...需要进一步拼接转换直接拿来下载大概率失败。replaceUrl[0].ObjUrl比较可靠的原图/中等图地址下载成功率比objURL高。我在实际项目里的选择是优先用replaceUrl里的ObjUrl失败再回退到middleURL再失败才用thumbURL保底。这样能兼顾清晰度和成功率。代码里可以写一个简单的提取函数def extract_image_info(item: dict): if not item: return None thumb_url item.get(thumbURL) or middle_url item.get(middleURL) or obj_url replace_url item.get(replaceUrl) or [] if replace_url: obj_url replace_url[0].get(ObjUrl, ) return { thumbURL: thumb_url, middleURL: middle_url, objURL: obj_url, title: item.get(fromPageTitle, ).strip(), page_url: item.get(fromURL, ), }2.4 翻页拉取与终止条件百度图片搜索最多能翻约 1000 张左右再往后data就会返回空数组。所以循环里需要做两件事一是达到设定的最大数量就退出二是发现返回空列表或data里全是空元素就退出。下面是一个很常见的外层封装def crawl_page(keyword: str, max_count: int 300): results [] page 0 per_page 30 while len(results) max_count: data fetch_images(keyword, page, per_page) items data.get(data) or [] valid [extract_image_info(it) for it in items if it] if not valid: break results.extend(valid) page 1 # 每爬一页停一下后面会详细说限速 time.sleep(1.5) return results[:max_count]这里time.sleep(1.5)不是可选项是给请求降频用的别贪图速度把它删掉。3. 429 与反爬我在爬取中踩过的几个典型坑如果只是写个几十行的脚本跑通抓个几十张图可能也就一两分钟的事。但当你真的想批量下载几千张图片时很快就绕不开一个问题请求被限流。最典型的报错是这样的urllib3.exceptions.MaxRetryError: HTTPConnectionPool(hostimage.baidu.com, port443): Max retries exceeded with url: /search/acjson... (last status: 429 too many requests)这不是代码崩溃而是服务端明确告诉你请求太频繁请稍后再来。3.1 429 为什么会发生根据我的排查经验429 的触发原因通常有这几类请求头暴露了爬虫身份。默认 UA 是最典型的信号服务器一看python-requests就直接进风控名单。短时间请求频率过高。比如没有time.sleep的裸循环几秒钟内刷几十个请求很容易触发接口的 QPS 限制。缺少 Cookie 或 Cookie 不完整。正常浏览器首次访问百度图片时服务端会下发BAIDUID等 Cookie后续请求带着这些 Cookie 才被认为是正常会话。Requests 的 Session 需要先访问一次搜索页拿到 Cookie再请求接口成功率会高很多。IP 归属地或出口特征异常。数据中心 IP、代理 IP 池里的共享 IP都容易直接命中风控。第 4 条个人很难完全规避前三条是自己掌控范围内的。3.2 最简单的缓解方案随机延时 请求前预热 Cookie我在脚本里通常加两步。第一步先 GET 一次搜索首页让 Session 种下 Cookiesession.get(https://image.baidu.com/, timeout10)第二步在每次请求接口前做一个随机延时延时范围不要是固定值否则服务器的风控系统更容易从时间间隔上识别出机器行为import random import time def polite_wait(): time.sleep(random.uniform(1.2, 2.8))延时不是越久越好而是要让请求节奏看起来像人。如果你每 1 秒请求一次延时 1.5 秒前后浮动整体速度尚可又不至于高频到被封。3.3 用 urllib3.Retry 处理 429 时的正确姿势Requests 底层用的是 urllib3而 urllib3 自带Retry机制配合HTTPAdapter挂载到 Session 上遇到 429 可以自动退避重试from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter retry Retry( total3, connect3, read3, status3, status_forcelist[429, 500, 502, 503, 504], backoff_factor2, allowed_methodsfrozenset([GET, POST]), ) adapter HTTPAdapter(max_retriesretry) session.mount(https://, adapter) session.mount(http://, adapter)backoff_factor2的意思是第一次重试等待 2 秒第二次等待 4 秒第三次等待 8 秒指数递增。这个策略比固定延时重试科学因为它给服务端留出了限流窗口恢复的时间。但必须提醒一句Retry只是重发同样的请求如果触发限流的根本原因是请求频率太高那么 Retry 一轮之后大概率还是 429。真正解决问题还是得降频、加延时、减少单批次数量。3.4 手动重试逻辑更可控的兜底自动重试虽然方便但它不区分 429 和 200也不便于记录日志。我更建议在核心请求函数里手动包一层重试这样每次被限流时都能看到具体原因方便调整参数def get_with_retry(url: str, params: dict, max_retries: int 3): for attempt in range(max_retries): try: resp session.get(url, paramsparams, timeout10) if resp.status_code 429: wait 5 * (attempt 1) random.uniform(0, 2) print(f[429] 第 {attempt 1} 次触发限流等待 {wait:.1f} 秒) time.sleep(wait) continue resp.raise_for_status() return resp except requests.RequestException as exc: wait 3 * (attempt 1) print(f[{type(exc).__name__}] 请求异常{wait} 秒后重试) time.sleep(wait) return None用文字描述一下这套逻辑请求发送后如果状态码是 429就等待一段时间再试如果连接超时或读超时说明网络层面有问题也可以退避重试。连续 3 次失败就放弃这个请求记日志而不是无限重试。无限重试在某些极端情况下会变成对服务器的持续骚扰这不是爬虫该做的事。4. 图片下载与数据清洗从搜索 URL 到本地文件很多初学者以为爬到了图片 URL 列表就算完事实际上下载这一步才是真正容易翻车的地方。百度图片的搜索接口返回的图片 URL 大多指向百度自己的 CDN但这些 CDN 链接对下载场景有严格校验直接requests.get(url)过去十有八九会失败。4.1 下载图片时的请求头坑直接下载图片报 403 或返回一个极小的占位图通常是因为这几个原因Referer不对图片 CDN 要求 Referer 是https://www.baidu.com/或https://image.baidu.com/空 Referer 会被当作盗链拒绝。User-Agent不对CDN 同样会检查 UA默认 requests UA 会被拦截。原图 URL 本身带过期时间百度图片的部分 CDN 链接有有效期从搜索结果拿到 URL 后如果不尽快下载过几小时就失效了。我给下载函数准备了一套独立的请求头单独写了一个download_imagedef download_image(url: str, save_path: str): headers { User-Agent: session.headers[User-Agent], Referer: https://www.baidu.com/, Accept: image/avif,image/webp,image/apng,image/*,*/*;q0.8, } try: resp requests.get(url, headersheaders, timeout(5, 10), streamTrue) if resp.status_code ! 200: print(f下载失败 {url} - HTTP {resp.status_code}) return False content_type resp.headers.get(Content-Type, ) if image not in content_type: print(f非图片内容跳过: {url}) return False with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) return True except requests.RequestException as exc: print(f下载异常 {url}: {exc}) return False注意这里用的是streamTrue配合iter_content分块写文件。这样对大图比较友好不会一次性把整个文件读进内存下载到一半失败时也不会把内存拖垮。4.2 格式化文件名与内容去重文件名我建议用数字序号加短哈希的组合既保证可读性又避免重名。更重要的是内容去重因为同一个关键词的不同分页里百度偶尔会返回重复图片。去重不能只看 URL要看文件内容简单做法是计算 SHA1import hashlib def file_sha1(file_path): h hashlib.sha1() with open(file_path, rb) as f: for chunk in iter(lambda: f.read(4096), b): h.update(chunk) return h.hexdigest()每下载一张图后就计算一个哈希存进一个 set。遇到重复的直接删除这样能显著减少数据集的冗余度。4.3 下载失败的 URL 重试策略图片下载相比接口请求更容易超时因为图片 CDN 的可用性参差不齐特别是某些第三方来源的图片源站在国外或者已经失效。我的策略是每个 URL 最多重试 2 次超过 2 次就放弃把 URL 写进failed.txt等整批跑完之后再统一补试一轮。failed_urls [] # 在循环里 success download_image(url, save_path) if not success: failed_urls.append(url) # 全部结束后 for url in failed_urls: filename ... # 重新生成文件名 download_image(url, filename) time.sleep(1)这种两轮下载的逻辑比单轮无脑重试高效得多因为很多 URL 只是暂时性失败第一轮之后等了几分钟源站可能已经恢复。5. 工程化落地多关键词批量采集的一些实践经验如果只是抓一个关键词、下载几十张图前面的代码已经够用了。但实际做数据集时通常要同时采集几十个关键词每个关键词下要几百张图再加一些过滤规则。这时候脚本就需要往工程化的方向靠一靠。5.1 多关键词批量调度最简单的批量做法就是用 for 循环遍历关键词每个关键词之间间隔更长keywords [猫, 狗, 风景, 汽车, 建筑] for keyword in keywords: print(f开始抓取: {keyword}) results crawl_page(keyword, max_count300) for idx, img_info in enumerate(results): save_path fimages/{keyword}_{idx:04d}.jpg download_image(img_info[middleURL] or img_info[thumbURL], save_path) time.sleep(random.uniform(5, 10))关键词之间的间隔建议至少 5 秒以上原因很简单切换关键词相当于开启一个新的搜索会话如果上一轮的请求频率还比较高紧接着换关键词容易连坐限流。我见过有人把间隔设成 1 秒结果跑了三个关键词就整体 429得不偿失。5.2 记录日志与断点续爬批量下载超过 1000 张图之后不可能每次都从头跑。所以我在脚本里维护了一个downloaded.txt每成功下载一张图就把图片 URL 追加一行。下次启动时先加载这个文件遇到重复 URL 就跳过。这个做法比什么花哨的数据库方案都实用downloaded set() try: with open(downloaded.txt, r, encodingutf-8) as f: downloaded set(line.strip() for line in f if line.strip()) except FileNotFoundError: pass # 下载循环里 if img_url in downloaded: continue success download_image(img_url, save_path) if success: downloaded.add(img_url) with open(downloaded.txt, a, encodingutf-8) as f: f.write(img_url \n)如果中途断网、程序崩溃只需要重新运行脚本它会自动跳过已经下载过的 URL继续拉剩下的。这个小小的文件起到的作用有时候比代码结构设计还重要。5.3 关于数据使用和合规边界的几点提醒作为一个常年在数据采集一线折腾的人我最后想聊几句技术之外的事。爬取百度图片搜索的公开接口从技术上就是一次普通的 HTTP GET 请求逻辑上不复杂。但用这个能力干什么是有边界的。我个人在项目中只爬公共互联网上可公开访问的图片并且仅用于个人学习、算法研究和非商业用途。如果你要拿这些图片训练商业模型、做商品展示或者直接打包出售就要自己评估版权授权问题。另一方面控制请求频率不只为避免封 IP也是不给目标服务器添麻烦。无限并发、高频循环导致对方接口压力过大这种行为不仅不体面也容易让接口进一步收紧反爬策略最终影响的是所有依赖公开数据的人。如果你想在这个项目上继续扩展可以考虑加关键词去重、图片质量过滤比如按宽高比筛选、图像感知哈希去重或者把结果写入数据库而不是文本文件。这些都是很自然的下一步。实际上我的习惯是把采集、去重、清洗、预览这四步做成一条独立流水线和后续的训练脚本解耦这样任何时候数据出问题都能快速定位到具体环节。这个方向够你玩很久也真的能帮你在图像类项目上省下大量时间。
返回列表