Python实战:搜狗图片爬虫从接口分析到并发下载全流程

📅 发布时间:2026/9/23 22:36:11
Python实战:搜狗图片爬虫从接口分析到并发下载全流程
1. 项目概述与整体设计想用程序批量收集图片素材的人大概率都会遇到同一个需求给一个关键词自动从图片搜索引擎拿回一堆高清图。搜狗图片的资源体量不算小接口相对好抓返回的是 JSON 数据字段也清晰很适合拿来做一个入门级但完整度很高的图片爬虫项目。这篇文章就以“制作搜狗图片的爬虫”为实战主题从接口分析、请求伪装、数据解析、并发下载到异常处理把完整链路拆开讲清楚。先说清楚定位这个项目解决的是“根据关键词批量获取图片 URL 并保存到本地”的问题适合刚学完 Python 基础、想练手爬虫的人也适合做素材采集、图像分类样本收集的开发者参考。文章用的技术栈是 requests BeautifulSoup ThreadPoolExecutor不依赖 Scrapy 这类重框架也不用 Selenium 或 Playwright 去渲染页面因为搜狗图片的 PC 端搜索结果是走一个独立的 JSON 接口返回的根本不需要模拟浏览器。相关热搜里很多人提到的 Requests 爬虫、图片爬虫、爬虫案例也基本都围绕这条技术路线展开。这里需要先打个预防针爬虫技术本身是中性的但使用范围一定要控制好。个人学习、公开数据采集、做图像分类实验这些都可以不要用来抓取用户隐私、商业敏感数据也不要对目标服务造成明显压力。我在下面所有的频率设计和并发设计里都会刻意保持温和这也是一个爬虫项目能长期稳定跑下去的前提。1.1 这个爬虫到底在爬什么很多人第一次做图片爬虫直觉是去解析 HTML 里的img标签先把整个搜索结果页面下载下来再用 BeautifulSoup 去筛图片链接。这个思路在十几年前还行得通现在的搜索引擎早就改了套路。搜狗图片在 PC 浏览器上你输入关键词、点击搜索页面里的图片并不是一开始就全部渲染出来的而是页面加载完以后再用 JavaScript 去请求一个后端接口拿到一份 JSON然后把数据动态填充到页面上。所以我们要爬的核心对象并不是一个 HTML 页面而是一个接口地址。搜狗图片目前常见的查询接口是https://pic.sogou.com/napi/pc/searchList你给它传上关键词、分页偏移、数量这些参数它就会返回一串 JSON里面包含图片标题、图片链接、来源页面、尺寸等信息。我们只需要把这份 JSON 拿下来解析出里面的图片地址再逐个下载到本地。这个结论不是凭空想出来的是我实际抓包看到的。后面我会专门讲一遍怎么用浏览器开发者工具自己复现这个分析过程因为接口字段和地址可能会随着网站改版而变化学会抓包分析比记住某个固定接口重要得多。1.2 为什么用 requests 而不是 Playwright 或者 Scrapy技术选型上我最终用的是 requests 同步请求加线程池的方案而不是更“重”的方案。先解释一下原因避免新手一上来就走偏。首先是 Playwright。它确实能处理很多动态渲染的页面几乎所有浏览器能看到的它都能抓到但它的开销很大要启动一个浏览器实例要加载各种资源单次请求的耗时可能是直接调接口的几十倍。搜狗图片这个场景里搜索结果是通过 JSON 接口动态返回的没有复杂的 JS 加密参数也没有登录态要求用 Playwright 属于杀鸡用牛刀。相关热搜里有人问“playwright 相比于直接解码爬虫的优点”结论是你得先分场景。场景里本身有“解码”这一步也就是拿不到现成接口、需要逆向前端加密逻辑的时候Playwright 才有优势。我们这个项目直接就有干净的接口requests 就够了。再是 Scrapy。Scrapy 的优势是框架化、自带调度器、去重、中间件适合长期运行、规模很大的爬虫。但对一个单机、单关键词、下载几千张图的场景Scrapy 反而增加了学习成本。我们要的是快速实现、能看懂、能改动所以选择 requests 作为核心 HTTP 库配合标准库的concurrent.futures做并发下载代码量小逻辑直观。1.3 运行环境和依赖开发和测试环境是 Python 3.10。项目依赖不多核心就三个requests处理 HTTP 请求下载 HTML/JSON/图片数据。beautifulsoup4解析 HTML虽然我们主数据源是 JSON但某些兼容逻辑里会用到。lxmlBeautifulSoup 的解析器比纯 Python 解析器快很多。安装命令一行搞定pip install requests beautifulsoup4 lxml我建议在项目目录下先建一个虚拟环境避免依赖污染python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate pip install requests beautifulsoup4 lxml存图片的时候需要一个download/目录代码里会自动创建不需要手动建。整个项目就一个 Python 文件结构足够简单。2. 摸清搜狗图片的接口逻辑2.1 从浏览器开发者工具抓出真正的查询接口不要凭记忆写接口地址最好自己动手抓一次。方法很简单打开浏览器进入搜狗图片首页按 F12 打开开发者工具切到 Network 面板然后在搜索框里输入一个测试关键词比如“猫”触发搜索。这时再回看 Network 面板在 Fetch/XHR 分类下面就能找到一个名为searchList或者类似的 JavaScript 请求。点开这个请求看它的 Payload 或者 Query String Parameters就能看到实际发给后端的参数。响应内容是一大段 JSON里面就是图片数据。这个分析过程只要做一次后面即使接口改版你也知道去哪里找新的入口。我见过不少新手直接把首页 HTML 下载下来解析结果什么也拿不到就是因为少了这一步。抓接口的时候记得把关键词设成英文或数字避免 URL 编码干扰你理解参数。比如拿cat测试大概率能看到querycat这个参数这样你就知道关键词是怎么传的了。2.2 请求参数逐项拆解搜狗图片这个接口的参数不算多但每一项都有它的作用。我以当前常见请求为例整理成下面这个表格参数示例值作用说明query猫搜索关键词需要 URL 编码由代码处理mode2搜索模式1 是综合2 是图片保持默认即可start0分页偏移量从第几张图开始返回xml_len48本次请求返回图片数量也决定每一页的大小cidpc客户端来源标识保持 pc 即可pic_typepc图片来源类型影响返回图片的尺寸和质量reqTypeajax请求类型标记标明这是一个异步查询groupId0分组 ID默认 0不需要改新手容易犯一个错误以为翻页是修改一个“页码”参数实际上这个接口用start做偏移每页数量由xml_len控制。如果你固定xml_len48那第 2 页就是start48第 3 页是start96。计算公式就是start (页码 - 1) × 每页数量。这个换算关系后面写代码会直接用到。2.3 请求头伪装别一上来就被识别成脚本直接拿裸的 requests 去请求很可能返回一个让你去做验证码的页面或者干脆不返回图片数据。原因不是你有恶意而是后端会根据User-Agent等请求头判断访问者是不是真实浏览器。所以请求头必须做好伪装。我实际用过的一组核心请求头如下User-Agent用一个当前常见的 Chrome 浏览器 UA比如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36Referer设成https://pic.sogou.com/因为真实的浏览器打开搜狗图片页面时所有异步请求都会带上这个来源地址。Acceptapplication/json, text/javascript, */*; q0.01Accept-Languagezh-CN,zh;q0.9UA 可以适度随机化我习惯准备三四个 UA 字符串轮换着用但不要每次请求都换一个完全随机的那样反而容易触发风控。固定一个常见浏览器 UA每几个请求换一次是比较稳妥的做法。2.4 第一版请求函数基于上面的分析第一版请求函数可以这么写import requests HEADERS { 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://pic.sogou.com/, Accept: application/json, text/javascript, */*; q0.01, Accept-Language: zh-CN,zh;q0.9, } SEARCH_URL https://pic.sogou.com/napi/pc/searchList def search_images(keyword, page1, page_size48): params { query: keyword, mode: 2, start: (page - 1) * page_size, xml_len: page_size, cid: pc, pic_type: pc, reqType: ajax, groupId: 0, } resp requests.get(SEARCH_URL, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() return resp.json()这个函数的核心就是把关键词和分页参数传给接口拿回 JSON。注意requests.get的params参数会自动完成 URL 编码不需要手动去拼 URL。timeout10是必须写的否则请求卡住时程序会一直挂在那。3. 数据解析与图片下载全流程3.1 从 JSON 数据里提取有效图片地址接口返回的 JSON 结构我在抓包时看过主体通常长这样{ code: 0, data: { items: [ { picUrl: https://img01.sogoucdn.com/app/a/100540002/..., title: 猫咪图片, locPageLink: https://www.sogou.com/..., width: 1920, height: 1080 } ] } }不同字段名可能会随版本调整所以解析时不能写死需要先做一轮防御性判断。我的做法是写一个独立的解析函数负责从 JSON 中提取图片链接列表def parse_image_items(data): items [] try: raw_items data[data][items] except (KeyError, TypeError): return items for item in raw_items: if not isinstance(item, dict): continue url item.get(picUrl) or item.get(thumbUrl) if url: items.append(url) return items注意picUrl可能拿不到这时可以退回到thumbUrl。但缩略图分辨率低如果能找到原始大图链接尽量优先原始图。有些返回还会多一层嵌套比如item[pic]里再放 URL这种情况也需要兼容。写代码时多考虑几种可能爬虫才不会因为一个字段缺失就整个崩溃。3.2 处理图片协议和防盗链搜索接口返回的图片地址有时候是完整的https://开头的链接有时候是双斜线开头的协议相对地址比如//img01.sogoucdn.com/app/a/...。这种地址浏览器能识别但 requests 不会自动补全协议直接请求会报错。处理方式很简单from urllib.parse import urljoin def normalize_url(url, basehttps:): if url.startswith(//): return base url return url图片下载还有一个绕不开的问题防盗链。搜狗图片的图片资源放在专门的对象存储服务上如果你直接拿没有来源的请求去下载服务器可能返回 403。解决办法是给图片下载请求也带上一个合适的Referer通常指向搜狗图片首页就能通过。我实际的经验是下载图片时用一套专用请求头IMAGE_HEADERS { User-Agent: HEADERS[User-Agent], Referer: https://pic.sogou.com/, }下载函数def download_image(url, filepath): url normalize_url(url) resp requests.get(url, headersIMAGE_HEADERS, timeout15) if resp.status_code ! 200: return False with open(filepath, wb) as f: f.write(resp.content) return True这里有一个细节不需要检查Content-Type是不是image/jpeg因为有些服务返回的Content-Type是application/octet-stream但内容确实是图片。更重要的是看响应的字节内容头几个字节比如 JPEG 通常是FF D8 FFPNG 是89 50 4E 47。不过对普通素材收集来说按扩展名保存就行只要文件能正常打开就不影响使用。3.3 用线程池下载图片单张图片下载如果网络正常需要几百毫秒到几秒不等。如果只有一张张循环下载一千张图片可能要跑十几分钟。用线程池做并发下载是最简单有效的加速方式而且也不需要引入额外依赖。标准库里的concurrent.futures.ThreadPoolExecutor正好够用。我建议线程数控制在 4 到 8 个不要一上来就开几十个线程。原因有两点第一服务器有频率限制短时间大量请求容易触发封禁第二搜狗图片是公共搜索服务我们下载素材时应该对它保持基本礼貌个人采集场景完全没有必要抢这点时间。代码如下from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(url_list, save_dir, max_workers4): os.makedirs(save_dir, exist_okTrue) tasks {} with ThreadPoolExecutor(max_workersmax_workers) as executor: for idx, url in enumerate(url_list): ext .jpg filepath os.path.join(save_dir, f{int(time.time())}_{idx}{ext}) future executor.submit(download_image, url, filepath) tasks[future] filepath for future in as_completed(tasks): try: ok future.result() except Exception as e: ok False print(f下载失败: {tasks[future]} - {e})这里文件名我用了“时间戳 序号”的组合避免不同图片之间重名。你也可以用图片 URL 的哈希值做文件名天然去重后面会提到。3.4 文件命名与去重图片下载多了以后重名和重复下载是最大的问题。如果每次都保存成1.jpg、2.jpg这种序号文件很容易覆盖。我常用的方案有两种。第一种使用 URL 的 SHA1 哈希作为文件名。图片地址基本是唯一的同一张图不会重复下载。实现也很简单import hashlib def url_to_name(url): return hashlib.sha1(url.encode(utf-8)).hexdigest()保存时判断文件是否已经存在存在就直接跳过。这样跑完一次后再跑一次也不会重复下载天然具备增量能力。第二种保留原始格式信息用时间戳加扩展名。这种方式的好处是排序清晰适合后续人工翻看素材。缺点是同一张图可能因为接口返回不同地址而下载两遍。我给的建议是如果后面要拿这些图做训练集用哈希命名如果是个人收集素材用时间戳命名。具体看你的用途没有标准答案。4. 让爬虫更稳限速、重试与日志4.1 请求频率控制前面说了线程数控制但光控制线程并发还是不够的请求之间最好再加一点随机延时。搜狗图片的接口并不需要每秒请求几十次我们的场景是关键词搜索加批量下载搜索翻页的请求频率其实很低主要压力集中在图片下载而图片下载走的是图片存储域名和搜索接口的压力不完全一样。我习惯在翻页循环里加一个time.sleep(random.uniform(1, 2))让两次搜索请求之间至少隔一秒以上。图片下载是并发的没法直接在每张图之间加 sleep那就靠限制线程数来控制整体速率。这个设计逻辑是低频接口请求 低频并发下载整体对服务器非常友好不容易触发风控。相关热搜里经常有人问“怎么避免被封”我的答案是不要追求极致的速度。爬虫项目能稳定跑完比一次性快几十秒重要得多。4.2 超时与重试机制网络请求没有一次就能成功的。可能是服务器短暂抖动可能是本地网络不稳定可能是代理超时。所以必须给请求加超时时间和重试逻辑。我写了一个简单的重试装饰器逻辑是请求失败后第一次等 1 秒重试第二次等 2 秒第三次等 4 秒最多重试 3 次。如果 3 次都失败就放弃这条数据。import time import requests def retry_request(func, max_retries3, base_delay1): for attempt in range(max_retries): try: return func() except (requests.Timeout, requests.ConnectionError) as e: if attempt max_retries - 1: raise delay base_delay * (2 ** attempt) random.uniform(0, 1) time.sleep(delay)为什么用指数退避因为如果服务器目前过载你立刻重试大概率还是失败等几秒再试的成功率会高很多。随机加一点时间是为了避免多个线程同时重试形成一波新的请求峰值。4.3 断点续传的思路下载几百张图片时中途程序挂了是很常见的事。如果重新跑已经下载好的图片又全部重新下一遍浪费时间。我在上面的 3.4 中提到用哈希命名这里就体现出优势了。断点续传的完整逻辑是先从接口拿到所有图片 URL 列表。对每个 URL 计算哈希文件名。检查本地文件是否存在存在就跳过不存在才下载。下载失败时记录失败的 URL 到单独的日志文件。程序重新运行时读取日志里失败的 URL继续下载。这个逻辑写起来并不复杂但能让你的爬虫项目从“一次性的玩具”变成“可持续使用的工具”。尤其是当你需要分好几批跑大量关键词时断点续传能力非常实用。4.4 日志和异常记录很多人写爬虫不记录日志程序一报错就直接黑屏退出根本不知道错在哪。我用 Python 标准库logging写了一套最简单的日志逻辑输出到控制台的同时写入文件import logging logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, handlers[ logging.FileHandler(crawler.log, encodingutf-8), logging.StreamHandler(), ] )然后在关键节点都打上日志请求成功、解析到多少条数据、下载成功、下载失败、重试被触发、程序结束。这样跑完以后打开crawler.log就能看到完整的过程记录。排障的时候这份日志比任何代码注释都管用。异常捕获也要分层处理。请求接口和下载图片这两个环节是最容易出错的错误类型也不一样搜索接口可能超时、连接错误、返回非 JSON。图片下载可能超时、404、403、写入磁盘失败。针对不同环节写不同的try/except不要让一个异常把整个循环打断。5. 常见问题与避坑实录5.1 返回结果为空或一直转圈遇到接口返回的 JSON 里items是空的或者根本没有data字段通常有几种可能。第一关键词设置有问题。搜狗图片对某些生僻词确实没有图片结果可以先在浏览器里手动搜一下确认不是关键词本身的问题。第二请求参数不对。最常见的是xml_len设置过大或过小。设置成 48 时接口正常设置成 500 时接口可能拒绝返回。我见过有人在测试时把xml_len设成 1000结果接口直接返回空列表。按官方常见的分页大小来不要贪心。第三请求头不符合要求。如果User-Agent是 Python 默认的python-requests/2.31.0接口很可能不会正常返回数据。检查一下请求头是否设成了浏览器 UA。5.2 图片下载 403 防盗链图片下载返回 403 的情况90% 以上是防盗链问题。你在浏览器里能正常打开这张图但用代码下载就被拒绝说明服务器校验了Referer或者Origin。解决方法就是我在 3.2 里写的给图片下载请求也带上Referer: https://pic.sogou.com/。如果带上还不够可以再加一个Origin: https://pic.sogou.com试试。部分图片服务还会校验 Cookie这时候就需要打开浏览器手动复制请求头里的完整 Cookie 字段加到代码里去。防盗链的根源是图片存储域名和搜索页面域名不一致业界通行的做法是通过Referer判断请求来源。我们顺着它的规则走就行不需要对抗任何机制。5.3 页码和 key 对不上我之前写过一版代码翻页时用了某个记忆里的key参数结果从第 2 页开始数据一模一样。排查了很久才发现接口的分页逻辑不是靠一个固定 key 来标记而是start偏移量在起作用。这给一个很重要的教训不要想当然地套用别人的参数。同一个接口的不同版本参数名可能完全不同。你看到某个博客里的代码跑了 5 页不代表现在也一定能跑通。最可靠的依据永远是浏览器抓包时看到的真实请求参数。另外搜狗图片在页面上往下滑动时会不断触发新的异步请求start递增。这个交互模式在新手看来像是“滚动加载”其实底层就是分页接口。理解了这一点代码里的翻页逻辑就好写了。5.4 合规使用提醒这部分我想认真说几句。爬虫技术本身不违法但使用场景必须谨慎。搜索图片接口返回的大部分是公开的图片素材但仍然可能涉及版权、肖像权、隐私等问题。我个人习惯是只爬取公开接口能拿到的数据不尝试绕过任何登录验证或权限控制。不抓取个人相册、隐私内容。不用于商业牟利尤其不能把爬下来的图片直接做成付费素材库。控制请求频率不给目标服务器造成压力。遵守网站的robots.txt协议条款虽然这个协议的约束力更多是道义上的。很多新手会把“能爬到”和“可以爬”混在一起这是不对的。技术上的可行性和法律上的允许是两码事。我在设计这个项目时特意把并发调低、加上重试延时、只做关键词搜索就是希望它运行在一个合理合规的区间里。5.5 一个小技巧先跑通单页再跑批量最后分享一个我每次写爬虫都会用的习惯不要一上来就写全功能版本。第一版只写一个搜索函数拿一个关键词跑通确认能返回数据第二版加上图片下载先下三张图验证保存逻辑第三版再加分页和批量最后才加入线程池和重试机制。预算算下来整个调试过程可能多花十五分钟但省下来的是排查一堆交互 bug 的时间。尤其是当你把线程池和重试机制加到一段还没验证过的代码里时出了问题你根本分不清是接口的问题、下载的问题还是并发的问题。做了那么多次图片爬虫我觉得这个“先单后多、先同步后并发”的原则最值得坚持。