Python爬虫实战:图片素材站全站图片批量采集全流程复盘

📅 发布时间:2026/10/13 3:54:10
Python爬虫实战:图片素材站全站图片批量采集全流程复盘
做爬虫这些年类似“把某个站点的图片全部下载下来”的需求我接过不少但每次做之前我都会先反问一句这个需求到底需要什么级别的工程化如果只是随便抓几张图一个requests.get加循环就够了。但如果目标是“某个站点某个分类下的所有图片”事情就完全不一样了——你会遇到分页结构、链接失效、请求频率限制、重复文件、半截图片、程序中断……这些才是爬虫实战里真正耗时间的地方。这次我完整走了一遍“某图片素材站全站图片采集”的流程用 Python 实现从页面分析到数据落地最终跑通了整套链路。这篇文章就是一次全流程复盘把我踩过的坑、绕过弯的地方、以及最终沉淀下来的代码结构都写出来。适合三类人看想系统入门 Python 爬虫的人、正在做图片批量采集项目的人、还有那些想把自己的脚本从“能用”升级到“好用”的开发者。1. 动手前的三件事目标分析、技术选型与合规模板很多新手拿到一个爬虫需求第一反应就是打开编辑器写requests.get结果往往是写了几十行代码才发现摸错了页面结构或者被反爬拦得一头雾水。我的习惯是先花半小时分析目标站点再动手。这一步做扎实了后面的代码几乎不会返工。1.1 先把“某站”的页面结构摸清楚这次的目标站点是一个图片素材社区内容按分类归档每个分类下是标准的列表页布局左侧是缩略图网格点击缩略图进入详情页详情页里才是原图地址。这种“列表页 详情页”的两层结构是图片类站点最常见的形式。我打开开发者工具把 Network 面板调出来逐个翻页观察地址栏的变化。这个站点的分页规则很规矩page参数从 1 开始递增列表页里每张图片的缩略图都被包裹在a标签里链接指向详情页的 ID详情页的图片地址则藏在img标签的>import requests 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 ), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://example.com/, # 替换为实际目标站点域名 Connection: keep-alive, }这一段代码背后是有讲究的。User-Agent用来告诉服务器“我是一个 Chrome 浏览器”Referer告诉服务器“我是从你站内页面跳转过来的”Accept-Language则匹配中文站点的语言偏好。这三样齐全之后请求外观上就是一个正常的访客了。要注意的是Referer字段要和你的实际目标域名一致如果写成别的站点反而会被视作可疑请求。有些站点甚至会校验Referer的完整路径这种情况后面在反爬章节单独说。2.2 解析列表页拿到所有详情页链接目标站点的分页 URL 有规律可循列表页是首页形式的单页结构每一页以一页文章列表的方式逐个加载。我在脚本里用一个循环去遍历所有分页解析出每张图片对应的详情页链接。解析用的就是 BeautifulSoup 的select方法它支持 CSS 选择器写起来简洁直观from bs4 import BeautifulSoup def parse_list_page(html): soup BeautifulSoup(html, html.parser) detail_links [] # 列表页中每张图片的缩略图链接都套在 a 标签里 for a_tag in soup.select(div.album-list a.album-item): href a_tag.get(href) if href and href not in detail_links: detail_links.append(href) return detail_links这里有个容易忽略的细节列表页里同一个链接可能出现多次比如缩略图和标题指向同一个详情页所以要做一次去重。我在代码里用if href not in detail_links做了个简单的列表去重量小的时候完全够用。分页循环的时候我建议在每一页请求之间做一个短暂的停顿。这不只是为了降低服务器压力也是给你自己留一个“随时可以安全中断”的缓冲——后面我会专门讲断点续爬的设计到时候你就明白这一步的用处了。2.3 从详情页定位真实图片地址并下载详情页的解析是这个爬虫的核心环节。前面提到过这个站点的图片地址在>def parse_detail_page(html): soup BeautifulSoup(html, html.parser) img_tag soup.select_one(div.photo-wrapper img) if not img_tag: return None img_url img_tag.get(data-src) or img_tag.get(src) return img_url拿到图片 URL 之后就是下载环节了。这里我踩过一个大坑图片 URL 是相对路径直接拼到新域名下就能用但是部分详情页的图片是压缩过的缩略图版本并非原图。我通过对比同一详情页的多个候选图片 URL用max()按 URL 长度选出了最长的那个——大多数情况下 URL 越长参数越多能看到原始大图的概率也越大。下载代码很简单但有几个小细节要处理好import os def download_image(img_url, save_path): resp requests.get(img_url, headersHEADERS, timeout10) if resp.status_code 200: with open(save_path, wb) as f: f.write(resp.content) return True return False先说timeout10。不加超时的requests.get在遇到连接卡死时会无限等待一个异常链接就能让整个程序停在那里。加超时是最基本的自我保护。再说文件保存。我给每张图片的命名用了“分类 ID 详情页 ID 序号”的格式避免重名覆盖。目录按分类自动创建用os.makedirs加exist_okTrue不用手动建目录省心不少。第一版跑完问题立刻暴露了下载到第 80 多张图的时候程序突然开始高频报错。我用了几乎一小时才定位到是请求频率出了问题下一章细说。3. 遭遇反爬HTTP 状态码异常与请求频率控制的博弈第一版代码跑得不算慢但跑到一半就开始出现大量403状态码和连接超时。这时候我才意识到这个站点虽然结构简单但它的反爬策略一点都不含糊。3.1 第一次被拒状态码 403 和“请求过快”的代价403 的含义很明确服务器理解了你的请求但拒绝处理。我当时的第一反应是请求头出了问题于是反复检查User-Agent和Referer发现都没错。后来我在响应内容里看到了一个提示信息大意是“访问过于频繁请稍后重试”。这就说明问题出在请求频率上而不是请求头伪装不够好。服务器虽然没有立刻封掉我的 IP但它通过统计单位时间内的请求次数把高频访问者判定为异常流量。这种情况在图片站点尤其常见——图片本身的体积大、流量消耗高服务器对图片接口的访问频率会比普通页面更敏感。单线程跑的时候还没那么明显一旦下载环节和解析环节叠加起来一两秒内就会对同一域名发起多次请求非常容易被盯上。3.2 会话保持、延时策略与重试机制找到原因之后我做了三处调整。第一处是使用requests.Session()替代裸的requests.get。Session 可以复用底层的 TCP 连接减少握手的开销同时自动管理 Cookies。有些站点会把会话状态放在 Cookie 里用 Session 能模拟出更连贯的浏览器行为。session requests.Session() session.headers.update(HEADERS) resp session.get(url, timeout10)第二处是增加随机延时。这里的关键词是“随机”而不是固定延时。固定的time.sleep(1)是很容易被识别的——真人操作不可能是准确的 1 秒间隔而是 0.8 到 1.5 秒之间浮动。我用了random.uniform(0.5, 1.5)来制造自然感。第三处是重试机制。网络请求总有偶然失败的情况直接跳过会导致数据缺失。我写了一个简单的重试装饰器对失败的请求最多重试 3 次每次重试的等待时间递增且加入随机扰动。这样既不会因为单次失败中断整个任务又不会因为连续重试给服务器造成压力。3.3 一些需要提前识别的信号可能有人会问那遇到验证码或者 IP 封禁怎么办我的经验是大部分反爬升级都有前兆。如果你发现某一类请求的失败率突然从 1% 飙升到 20%或者响应内容里频繁出现 JS 跳转页面、安全验证页面的特征那就说明目标站点的风控升级了。这种情况下与其硬刚不如立刻止损把程序停掉检查是不是请求频率还是太快降低并发数拉长延时隔一段时间再继续。大面积封 IP 的情况普通小规模爬虫很少遇到因为站点的风控策略通常是从“限频”到“验证码”再到“封禁”的递进过程。如果你做的只是个人学习级别的采集把频率控制在合理范围内基本不会触发最高等级的反制措施。这里要特别注意不要试图暴力破解验证码也不要讨论任何绕过访问控制的方案。尊重目标站点的规则该停就停这是爬虫开发者最基本的职业操守。4. 多线程下载提速并发拉取的正确打开方式频率控制做稳定之后新的问题又来了速度太慢。串行模式下一个详情页请求加一张图片下载平均耗时 1 到 2 秒几千张图跑下来要一两个小时。这个效率对于个人项目来说勉强能用但完全没有优化的空间。于是我盯上了网络 IO 的等待时间。4.1 普通 for 循环为什么慢爬虫程序的大部分时间都花在等待网络的响应上而不是 CPU 计算。一个请求发出去从建立连接到接收数据这个过程中 CPU 几乎是闲置的。传统的 for 循环是串行的必须等前一个请求完全结束才会发下一个结果是 CPU 大量空闲时间全部浪费在“等”上面。解决思路很直接在同一个进程里同时发起多个请求让这些请求的等待时间互相重叠。这就是多线程的核心意义。Python 的ThreadPoolExecutor提供了一个非常轻量的线程池实现既没有手动管理线程的复杂度又能获得不错的并发效果。4.2 ThreadPoolExecutor 加队列的并发模型我的实现思路是用生产者-消费者模型。主线程负责解析列表页和详情页把图片下载任务塞进队列线程池里的 Worker 从队列取任务执行下载。简单来说就是把“找活”和“干活”分离开。from concurrent.futures import ThreadPoolExecutor, as_completed def worker(detail_url): # 解析详情页 返回下载任务 return task def download_images(task): # 真正的下载逻辑 img_url, save_path task return download_image(img_url, save_path) with ThreadPoolExecutor(max_workers10) as executor: futures [] for detail_url in detail_urls: task worker(detail_url) if task: futures.append(executor.submit(download_images, task)) for future in as_completed(futures): result future.result() if not result: # 记录失败任务稍后统一处理 failed_tasks.append(task)线程数的选择很关键。太少了起不到提速效果太多了又会让服务器把你的 IP 标记为异常。我这里选择了 10 个 Worker实测下来请求频率稳定没有触发风控下载速度比串行提升了将近 4 倍。如果你是自己写爬虫我建议从 5 个线程起步观察目标站点的响应速度再逐步往上加。稳定永远比速度重要——一个被封了 IP 的爬虫速度快到天上去也没用。4.3 线程安全与半截文件的坑多线程引入的另一个经典问题就是线程安全。我这里主要做了两件防护。第一件是文件命名的唯一性。如果两个线程同时拿到同一个图片文件名就会出现覆盖写的情况——最终文件可能是一个损坏的混合体。我的做法是把“详情页 ID”和“图片序号”组合成文件名从根源上避免冲突。具体就是代码里命名后加个时间戳或随机串这篇我直接用了保持唯一性的格式。第二件是识别“半截文件”。网络请求中断时resp.content可能只有完整文件的一部分但由于响应状态码是 200程序不会报错只会把这个残缺文件保存下来。等到最后人工检查时才发现一堆打不开的废图。识别半截文件有两个办法。一是比较Content-Length和实际接收的字节数不一致就判定为失败二是对保存完的文件做图片完整性检查最实用的方法是读取文件头字节判断是否符合 JPEG 或 PNG 的格式规范。我在后面专门写了一个校验函数来干这件事。5. 图片去重与有效性校验别让硬盘充满重复文件第一批 3000 张图片下载完成后我清点了一下发现有个分类下面有大量看起来几乎一模一样的图片。检查了一下果然是详情页里同一个图片被不同入口重复收录了。这个问题如果不解决硬盘会被塞满垃圾文件。5.1 MD5 哈希去重策略最简单的去重方案是计算图片内容的 MD5 哈希值。一张图片不管文件名怎么变只要二进制内容相同MD5 就是相同的。我用了两个集合来记录import hashlib seen_hashes set() def is_duplicate(image_bytes): file_hash hashlib.md5(image_bytes).hexdigest() if file_hash in seen_hashes: return True seen_hashes.add(file_hash) return False def save_unique_image(image_bytes, save_path): if is_duplicate(image_bytes): return False # 跳过重复图片 # 先校验文件头再写入 if not is_valid_image(image_bytes): return False with open(save_path, wb) as f: f.write(image_bytes) return True用哈希去重有一个隐性好处它是在内存中进行比较的速度极快。对于几千张图片的量级哈希计算的耗时完全可以忽略不计。不过seen_hashes这个集合只存在内存里程序一旦重启就没了。如果你打算分多次运行来抓取就要考虑把哈希值持久化到本地文件或者 SQLite 数据库里。我在实际项目中用了一个简单的文本文件来存储哈希值每次启动时加载新图下载前先比对一遍。这个做法在数据量到十几万条时依然稳定。5.2 文件头校验识别“伪图片”半截文件的问题前面提过这里展开说一下具体的校验方法。JPEG 文件的二进制头部以FF D8 FF开头PNG 文件以89 50 4E 47开头。读取文件的前几个字节和这些魔数比对就能立刻判断这个文件是不是一个有效的图片。def is_valid_image(image_bytes): if len(image_bytes) 4: return False if image_bytes[:3] b\xff\xd8\xff: # JPEG return True if image_bytes[:4] b\x89PNG: # PNG return True # GIF 也可以加进来按需扩展 return False这个方法比单纯检查文件大小可靠得多。实际抓取过程中有些失败的请求返回的其实是一个 HTML 错误提示页字节数不小但文件头是!DO或者html——用文件头校验可以完美识别这类伪造文件。还有一个经验是下载之后立刻校验不要等全部下载完再统一检查。下载时发现问题可以马上重试等全部下完再回头找光定位问题文件就够你喝一壶的。6. 断点续爬程序中断后如何从上次位置继续爬虫跑到一半电脑没电了、网络断了、程序 OOM 崩溃了这些都是家常便饭。如果没有断点续爬能力每次中断都要从头再来几千张图片可能要重复下载好几遍。这不只是浪费时间更是对目标站点的不尊重。6.1 状态记录文件的设计我的做法非常简单粗暴用一个 JSON 文件记录每个详情页的处理状态。状态有三种pending未处理、done成功、failed失败。每次任务开始前先加载这个文件把已经标记为done的 URL 跳过。import json STATE_FILE crawl_state.json def load_state(): try: with open(STATE_FILE, r, encodingutf-8) as f: return json.load(f) except FileNotFoundError: return {} def save_state(state): with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state, f, ensure_asciiFalse, indent2)为什么用 JSON 而不是数据库因为抓取中断是低频事件JSON 文件足够可靠而且人类可以直接读懂内容排查问题的时候非常方便。如果单次任务的数据量超过几万条再考虑切换到 SQLite 不迟。保存频率的控制也很重要不用每下载一张就写一次文件那样反而拖慢速度。我是每处理完 50 个详情页保存一次状态加上程序退出前自动保存一次。这样即使崩溃最多损失 50 个任务——重跑很快就能追上。6.2 日志系统与异常追踪断点续爬解决了“从哪继续”的问题还有另一个问题同样关键程序跑挂了你怎么知道它经历了什么初期版本我用的是print打印错误信息结果控制台一刷就过去了根本没有记录。后来我换成 Python 标准库的logging模块把不同级别的日志写到文件里import logging logging.basicConfig( filenamecrawler.log, levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, ) logging.info(开始处理详情页: %s, detail_url) logging.error(下载失败: %s, 状态码: %s, img_url, resp.status_code)日志的价值在调试阶段会完全体现出来。一次大规模抓取完成之后翻一遍日志哪张图片失败了几次、失败原因是什么一目了然。我一般会记录三个关键信息时间、事件类型请求开始/请求完成/请求失败、异常详情。有了这三项绝大多数问题都能定位。另外对失败任务的处理策略是先记录到失败列表全部跑完后统一重试。千万不要在循环里反复重试同一个链接那样既浪费时间又容易触发风控。等第一轮跑完失败的任务往往数量很少那时候再集中重试效果最好。7. 实测结果与踩坑心得整套流程跑完我统计了一下。目标站点指定分类下约有 3100 个详情页最终有效图片 2600 多张去重率约 15%——也就是说有 500 张左右是重复收录的。下载成功率约 98.8%剩余 1.2% 的失败集中在网络超时和个别失效链接上。并发 10 个线程、平均 0.7 秒延时的情况下全部跑完用时约 23 分钟整个过程中没有触发站点的验证码反爬。这个结果对我来说算是比较理想的。复盘整个项目有几个细节值得单独说说。目标站点的静态化程度决定了代码的复杂程度。这次我运气不错页面是纯静态 HTML 渲染没有动态请求接口。如果遇到前端框架比如 Vue、React渲染的站点数据往往是通过 API 异步加载的这时候requests BeautifulSoup就失效了需要直接分析网络请求中的 JSON 接口用requests请求接口拿数据再解析数据。这个方案其实比解析 HTML 更简单只是需要你对浏览器开发者工具的 Network 面板足够熟悉。请求头不能照搬一套走天下。每个站点的风控逻辑不同有些站点对User-Agent的完整性很敏感有些则更关注Accept和Referer的组合。如果你遇到 403除了降频还要试着调整请求头让它和真实浏览器的请求更接近。图片站点对请求频率的容忍度比文本站点低得多。这是由业务属性决定的——图片请求占带宽大服务器成本高所以缩略图和原图接口往往有独立的风控策略。如果你发现列表页正常但图片下载请求频频报错可以把图片下载的延时调得比解析页面更久一些并发线程数也压到更低档位。爬虫代码的核心从来不在“请求”这一步而在“异常处理”。一个没有超时、重试、校验、断点的爬虫和玩具没什么区别。我第一次写完整版的时候光异常处理就占了全部代码的四分之一。这不是小题大做而是在真实网络环境中异常才是常态。最后再说一个我自己的习惯。写爬虫之前先花十分钟把目标站点的结构彻底搞清楚——分页规律、数据位置、链接格式、反爬特征用笔记记下来。磨刀不误砍柴工这十分钟省下来的时间远比想象中多。爬虫这件事代码能力只占一半另一半是分析能力和工程意识。希望这篇文章能帮你在后半段少走几条弯路。