资讯详情

Python爬虫实战:图片批量下载与自动分类的工程化方案

📅 2026/10/12 12:11:55 | 华诺云谱 👁 阅读
Python爬虫实战:图片批量下载与自动分类的工程化方案
我接到的这个标题乍看像是一篇入门教程但真正做过批量采集的人都知道同一套流程跑一百张图和跑一万张图完全是两回事。标题里真正的难点其实藏在“按规则自动分类”这六个字上它意味着你需要在下载之前就做好目录规划在存储层就把数据整理干净而不是让一万个文件堆在一个文件夹里最后用眼睛去找。这篇文章就围绕这条链路展开目标站点的规划思路、请求会话的工程化写法、并发下载器的重试机制、分类规则的设计与实现以及最后落地时的校验和合规边界。所有的代码片段都是实际跑过的每一步都会解释“为什么这么写”而不是只给一个能用的片段。1. 动手前的三个规划爬什么、存哪里、怎么分很多人在写爬虫的时候脑子里只有“拿到图片URL然后下载”这一件事结果往往是写的时候爽跑完之后想哭。因为当你面对几千个文件时最痛苦的问题不是“没下下来”而是“下下来了但根本找不到哪个是哪个”。所以在写第一行代码之前需要先把三件事想清楚。1.1 明确采集目标的边界与量级先问自己三个问题采集哪个网站、哪个板块、多少量级。不要上来就说“我要爬整个互联网”那是搜索引擎做的事。以图库类网站为例常见的目标源大致分三类一是免版权图库这类站点结构规整图片有固定的URL规律适合练手二是壁纸类社区这类站点通常有明确的分类栏目每个分类下面有分页量级可控三是社交媒体或用户生成内容平台这类站点的反爬机制更强图片链接往往带签名参数直接存下来的URL一段时间后会失效。先定量级再定技术方案。如果你只是要几百张图用单线程循环完全没问题代码简单、调试方便出错了也容易定位。但如果目标是几万张那就必须考虑并发下载、断点续传、去重校验这些工程化问题否则光是等待响应的时间就够你睡一觉了。1.2 设计本地目录结构让分类规则前置分类这件事不要在下载完成之后再去做“事后整理”而是要在设计存储方案的时候就定好规则。通俗地说URL解析和目录映射应该是同步完成的图片下载到本地的那一刻它就已经归入了正确的文件夹。这里我给一份可以直接抄的目录设计方案download_root/ ├── 4k_landscape/ # 按主题分类例如风景 │ ├── page_1/ │ ├── page_2/ ├── 4k_animal/ # 按主题分类例如动物 ├── 4k_city/ ├── _unsorted/ # 无法匹配规则的兜底目录 └── _failed/ # 下载失败或校验失败的图片为什么需要一个_unsorted目录而不是直接报错因为网络请求总会遇到不可控的情况——比如目标站点的分类标签改了名、某个页面的图片URL格式异常这时候如果强制让程序终止后续所有图片都不会下了。一个兜底目录可以让程序继续运行最后你再人工检查一下_unsorted里的文件该归哪一类工作量远小于让程序中断后重新跑。1.3 分类规则的来源URL结构优先页面字段兜底按什么规则分类这是整个项目最核心的设计决策。我的经验是分两套逻辑按优先级排列第一优先级看URL路径结构第二优先级看页面上下文中的分类字段。URL路径结构是什么意思很多图库站点的图片URL是有层级规律的比如https://example.com/images/landscape/beach/2024/photo123.jpg那么landscape就是主题分类。但如果URL是纯编码形式比如https://example.com/img/photo12345.jpg没有语义信息你就得去图片所在页面的HTML或JSON接口里找分类字段通常是面包屑导航或图片详情数据的category属性。我把这两套逻辑的结合方案总结成了一张表分类字段来源实现难度可靠性适用场景URL路径低直接用字符串切割高但依赖站点URL规范化URL包含语义化目录的图库页面面包屑中需要解析HTML高分类信息最完整社区型站点、论坛图集JSON接口字段低解析响应体即可高但需要找到对应的接口前后端分离的现代站点图片文件名前缀低正则匹配中命名不一定规范图集批量发布型网站实际开发中我会建议先解析URL匹配不到再请求页面或接口取分类字段再匹配不到就丢进_unsorted。这个顺序能最大程度减少不必要的页面请求因为访问页面本身会消耗时间、增加被封的风险而URL解析是零成本且最快的。2. 请求会话管理一个Session实例走到底别反复建立连接爬虫新手最容易犯的一个错误就是每次请求都新建一个requests.get()。这样做有两个问题一是每一次请求都要经历TCP握手和TLS协商效率非常低二是你没法在多次请求之间维持一个统一的身份状态相当于每敲一次门都换一个陌生面孔站点风控系统很容易把你识别成异常流量。这里就体现出requests.Session的价值了。2.1 为什么用Session而不是裸的requestsSession在底层会维护一个连接池复用TCP连接三次握手只做一次后续请求走Keep-Alive通道。同时Session对象会自动保存Cookie也就是说如果你先请求了登录接口后续访问需要登录态才能看到的高清大图时Session会自动带上Cookie完全不用手动维护。这是我常用的一个会话初始化函数字段不多但每个都有讲究import requests import time def make_session(): session requests.Session() # 设置浏览器风格的请求头重点在于User-Agent和Accept session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 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/, }) return session注意Referer这个字段很多图床会做防盗链校验检查请求图片时的Referer是否来自本站页面如果不是就直接返回403。手动设置Referer能在很大程度上避开这个常见问题。2.2 请求间隔与重试机制别当“请求轰炸机”再来是请求频率的控制。不同站点对请求频率的容忍度差别很大有的每秒10个请求完全没问题有的每2秒一个请求都会触发验证码。经验值是单线程下每请求一次就停0.5秒到1秒这个速度既不浪费太多时间又足够温和。重试机制也不能省。考虑到网络抖动、目标服务器瞬时过载、或者是被临时限流一个健壮的爬虫必须自动重试。但重试不是无脑重试我建议最多重试3次而且每次重试间隔要递增分别是2秒、5秒、10秒这叫指数退避。如果重试3次还失败就跳过这张图把它记录到日志里而不是一直卡在那里。import time from urllib.parse import urlparse import requests DOWNLOAD_RETRY_TIMES 3 def download_with_retry(session, url, save_path, retriesDOWNLOAD_RETRY_TIMES): 带指数退避重试的下载函数返回是否成功。 for attempt in range(1, retries 1): try: resp session.get(url, timeout(5, 15)) if resp.status_code 200: # 检查响应体是否符合图片格式这一步很关键 content_type resp.headers.get(Content-Type, ) if content_type.startswith(image/): with open(save_path, wb) as f: f.write(resp.content) return True else: return False elif resp.status_code in (403, 429): wait_time attempt * 5 time.sleep(wait_time) continue except requests.RequestException as e: wait_time attempt * 3 time.sleep(wait_time) return False这里有两个容易踩坑的细节。第一个是响应体格式校验有时候你请求图片URL结果返回的是一个JSON错误信息或HTML错误页如果用200状态码就直接写入文件你会得到一堆扩展名是.jpg实则是文本的垃圾文件。第二个是区分403和404404说明这个资源不存在重试也没意义403说明被拒绝访问等一会儿再试是有可能成功的。2.3 IP代理池和验证码处理什么时候才需要很多人一提到爬虫就想到代理池和验证码识别但我的建议是如果你的目标只是普通图库站点、且单机下载量控制在可接受范围内完全不需要上代理池。代理池的维护成本很高免费代理存活率低付费代理又需要额外的资金和配置。而且从合规角度来说大规模分布式采集本身就比单机温和采集更容易引发法律风险。那什么时候才需要当你明确遇到了IP级别的封禁——比如连续请求100次后所有请求都返回403或者出现了滑块验证码——这时候再做技术升级。通常我会先尝试降低请求频率、伪装更完整请求头再考虑换出口IP而不是一开始就把所有重型装备都搬上来。3. 批量下载器有限线程并发 有界队列等你在单线程下把整个流程调通跑通了5张图的下载和分类接下来要提高效率。批量采集的核心瓶颈就是网络I/O而Python的多线程虽然处理CPU密集型任务不行但处理这种I/O密集型下载任务却很合适——因为线程在等待服务器响应时让出了GIL其他线程可以继续干活。3.1 ThreadPoolExecutor的并发设计与不变量用concurrent.futures.ThreadPoolExecutor来管理线程池是Python里最标准、最好维护的并发方案。理论上的最佳并发数不固定跟目标网站的响应速度、你的网络带宽都有关系。经验上一般图库站点给到8到16个线程是比较合理的。太少了不够快太多了容易触发反爬。下面是一段有效的并发下载代码注意看队列和去重集合的实现import os import threading from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urlparse, unquote lock threading.Lock() failed_urls [] def classify_url(url): 从URL中提取分类目录。返回目录名或None。 path_segments urlparse(url).path.strip(/).split(/) for seg in path_segments: if seg in (landscape, animal, city, people): return f4k_{seg} return None def get_local_filename(url): 从URL末尾提取文件名并做URL解码和非法字符清理。 raw_name os.path.basename(urlparse(url).path) name unquote(raw_name) return name def download_one(url, base_dir, session): 单张下载并按URL规则分类。返回(成功标志, url, 分类目录名)。 category classify_url(url) target_dir os.path.join(base_dir, category) if category else os.path.join(base_dir, _unsorted) os.makedirs(target_dir, exist_okTrue) filename get_local_filename(url) save_path os.path.join(target_dir, filename) # 本地已存在且文件大小大于0则跳过 if os.path.exists(save_path) and os.path.getsize(save_path) 0: return True, url, category ok download_with_retry(session, url, save_path) return ok, url, category def batch_download(urls, base_dir, max_workers10): session make_session() total len(urls) done_count 0 with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map {executor.submit(download_one, u, base_dir, session): u for u in urls} for future in as_completed(future_map): ok, url, category future.result() with lock: done_count 1 if not ok: failed_urls.append(url) print(f[{done_count}/{total}] {OK if ok else FAIL} {category} {url}) return failed_urls这里有一个需要注意的设计点session对象在多个线程之间共享是否安全。这个在爬虫社区里是有争议的但我实测下来requests.Session在发送请求这种并发场景下是线程安全的原因在于连接池的获取和释放是由底层urllib3管理的已经加了锁。不过如果你发现程序偶发异常一个更保守的做法是每个线程单独创建一个Session代价是内存占用稍高一点。3.2 并发数怎么量化调整一个小实验我知道很多人设定max_workers都是拍脑袋。这里给一个可以量化的方法先单线程跑20张图记录总耗时t1然后4线程跑同样20张图记录总耗时t2理论上t2应该接近t1/4。然后逐步翻倍线程数当你发现耗时不再明显下降时就说明已经遇到了瓶颈。瓶颈可能来自目标服务器的响应能力也可能来自你的本地网络带宽。记住一个原则并发数要和资源情况匹配不是越大越好对对方服务器造成过大压力既不礼貌也有风险。3.3 中断续跑把“半成品”变成可恢复状态批量下载最怕的事情是跑到第9000张图程序崩了或者有人把网线踢了。如果你没有设计“断点续跑”能力那就得从头再来这体验非常崩溃。实际上最简单的“断点续跑”不需要数据库只需要两个文件一个记录完成清单一个记录失败清单。每次下载成功后把一个URL追加到完成清单里程序启动时先加载这两个文件过滤掉已完成的URL。这样哪怕程序中途挂了重新运行就能从断开的地方继续。当然如果采集量级上了几十万张建议用SQLite了。一张表记录URL、状态、分类、时间戳用CREATE TABLE IF NOT EXISTS初始化用INSERT OR IGNORE避免重复查询时加个状态过滤。SQLite自带锁单机场景完全够用比维护一个MySQL实例轻量太多了。4. 按规则自动分类的状态机从URL到目录名的一路映射分类是这个项目的题眼。前端收藏夹、云盘同步、相册整理最后拼的都是分类逻辑好不好。爬虫里也一样下载回来的一堆文件如果没有清晰分类那和一堆电子垃圾没有区别。接下来我把分类引擎的完整设计思路拆开说。4.1 关键词同义词表解决分类匹配的“语义鸿沟”最简单的分类方式就是字符串匹配URL里包含landscape就归到风景类包含animal就归到动物类。但真实网站不会这么规矩它可能用nature表示风景用pet表示动物甚至一个分类栏目下同时包含多个零散标签。我建议建立一张同义词映射表把所有可能的归一到统一目录名CATEGORY_KEYWORDS { 4k_landscape: [landscape, nature, scenery, mountain, sea, beach, sunset], 4k_animal: [animal, pet, wildlife, bird, cat, dog], 4k_city: [city, urban, architecture, building, street], 4k_people: [people, portrait, person, human], 4k_food: [food, cuisine, fruit, dish], } def classify_by_keywords(url): 全小写匹配URL中的关键词返回分类目录名。 path urlparse(url).path.lower() for category, keywords in CATEGORY_KEYWORDS.items(): for keyword in keywords: if keyword in path: return category return None这样做的好处是规则可配置、易维护新增分类只需要在字典里加一行。坏处是匹配精度有限portrait出现在URL里时可能会被误当成所有包含person的图片但这类误归总比全堆在_unsorted里好处理。4.2 无状态分类 vs 有状态上下文分类前面讲的分类逻辑说白了叫“无状态分类”——每张图片URL独立判断不依赖其他信息。这种方法快但有几个盲区比如图片URL是纯数字编号路径里什么语义信息都没有你拿关键词表去套只会返回None。这时候就需要“有状态分类”了先请求图片所在的详情页把面包屑导航里的分类名、页面标题里的主题词提取出来然后再固化到这张图片的目录里。代价是多一次页面请求效率下降了但准确率大幅提升。我一般是两种策略并行先用无状态URL匹配符合条件的直接下载不符合条件的走详情页解析。用一个简单的判断函数来做流程分叉def route_url_to_download(url, session, base_dir): category classify_by_keywords(url) if category is not None: # 快路径直接按URL关键词分类下载 return download_one(url, base_dir, session) # 慢路径请求详情页提取分类字段 category classify_by_page(session, url) if category is not None: return download_one_with_category(url, base_dir, session, category) else: # 最终兜底 return download_one_with_category(url, base_dir, session, _unsorted)4.3 处理标题里的中文主题扩展名与中文目录兼容问题做中文站点的采集时分类名往往是中文比如“风景壁纸”“萌宠图片”。直接用中文当目录名在Windows和Linux上都没有问题但会碰到两个麻烦。第一个是URL解码。中文在URL里通常是百分号编码的形态比如%E9%A3%8E%E6%99%AF表示“风景”。需要用unquote()做一次解码否则你会得到一堆乱码目录名。第二个是非法字符清理。Windows文件系统不允许文件名包含\/:*?|这些字符而URL路径里恰好可能会出现这些字符的编码形式。所以无论是目录名还是文件名保存之前都要做一次清理import re def sanitize_filename(name): name re.sub(r[\\/*?:|], _, name) name name.strip().strip(.) return name[:120] # 限制长度避免文件系统路径溢出别小看这个函数批量采集几千个文件时如果偶遇文件名带了个冒号open()会直接抛异常而且你还不容易定位到是哪一个文件出了问题有了清理函数就能避免这一整类问题。5. 落地后的质量复查去重、校验与产物完整性所有文件下载完毕并不等于大功告成。如果下载了5000个文件其中200个是空文件、150个是0字节占位、50个是HTML错误页改名为.jpg那分类再好看也是徒劳。数据质量检查是进阶爬虫和玩具脚本的分水岭。5.1 文件存在性、零字节与格式魔数的三重校验下载完成后的校验至少要覆盖三重存在性校验os.path.exists且os.path.getsize 0。这个在下载函数里已经做了算是第一道关卡。格式魔数校验检查文件头几个字节判断真实格式。JPEG文件头是FF D8 FFPNG文件头是89 50 4E 47GIF是47 49 46 38。这是一种比扩展名更可靠的格式识别方式因为扩展名是可以随便改的。终端完整性校验有的图片下载到一半连接断了文件头是正常的但文件末尾的FF D9JPEG结束标记缺失这种文件在查看器里能打开但会提示“图片已损坏”。这种校验只能读完整文件后检查末尾字节。import struct def verify_image(filepath): 返回True表示文件完整可用。 try: with open(filepath, rb) as f: header f.read(4) if header[:3] b\xff\xd8\xff: # JPEG检查末尾 FF D9 f.seek(-2, 2) return f.read(2) b\xff\xd9 elif header[:4] b\x89PNG: # PNG检查末尾 IEND chunk 标记 f.seek(-8, 2) return bIEND in f.read() elif header[:3] bGIF: f.seek(-1, 2) return f.read() b; else: return False except Exception: return False跑一遍这个校验把不通过的文件单独移动到一个_corrupted目录里下次重新抓取时只补抓这些失败的效率会高很多。5.2 文件名冲突检测两张不同图片同名的概率比你想的高下载过程中比较隐蔽的一个问题是文件名冲突。两个不同来源的图片URL可能恰好以同一个文件名结尾比如photo.jpg但内容完全不同。如果直接写入后者会覆盖前者而且是静默覆盖你在日志里根本发现不了。解决方案有两种一是在写入之前检查文件是否已存在如果存在则在文件名后追加一个随机字符串或序号二是更彻底一点用内容哈希值做文件名。但我个人不太推荐用哈希做文件名因为哈希文件名完全丢失了可读性你想人工找某张图时会非常痛苦。折中方案是保留原始文件名加短随机后缀import uuid def unique_save_path(dir_path, filename): base, ext os.path.splitext(filename) candidate os.path.join(dir_path, filename) if not os.path.exists(candidate): return candidate for _ in range(100): candidate os.path.join(dir_path, f{base}_{uuid.uuid4().hex[:8]}{ext}) if not os.path.exists(candidate): return candidate raise RuntimeError(无法生成唯一文件名)5.3 记录下载日志与分类统计给每次采集留底这一点可能最容易被忽略但它是整个项目后期维护的“账本”。我通常会写一个简单的JSON日志记录每次批量采集的起始时间、终止时间、成功数量、失败数量、各分类的数量统计、失败URL列表。这些数据对后续分析很有用比如如果某个分类的成功率特别低可能是目标站点的分类标签变了需要更新关键词表如果失败集中在某一批URL段可能是目标服务器对连续请求做了限流下次需要调低并发数如果_unsorted数量占比超过10%说明URL语义分类的覆盖率不够应该考虑补充详情页解析逻辑。日志文件本身不需要额外数据库一行JSON写一个对象就够了Python的jsonlines库可以轻松处理。6. 爬虫项目的边界与合规自检清单这个话题绕不开我也不打算绕。爬虫本身是一个中性的技术工具但使用边界完全取决于你拿它做什么、怎么用。分享几个我一直在遵守的基本原则算不上什么高深道理但都是在实际项目里被动吃过亏才沉淀下来的。6.1 robots协议与站点条款正规网站一般都会提供/robots.txt文件里面用Disallow声明了禁止爬取的路径。我的习惯是写爬虫之前先看一眼这个文件。不是说要100%无条件遵守每一条规则但至少尊重那些明确禁止的敏感路径比如用户个人中心、后台管理页面、搜索接口。采集公开图库的图片和采集用户的私密相册这是完全不同的两件事。另外很多站点的服务条款里明确写了不允许未经授权的自动化采集即使技术上行得通也应当充分评估是否值得去做。如果你做的是学习研究用途请务必控制规模、降低频率、不涉及任何个人信息和版权内容。6.2 频率控制与目标服务器压力我建议所有爬虫项目都强制加上全局最小请求间隔。即使你用并发下载也要通过一个共享的速率限制器来控制每秒请求数。比如下面的代码用threading.Semaphore保证全局最多每秒发出5个请求import threading import time rate_limit threading.Semaphore(5) def throttled_request(session, url, **kwargs): rate_limit.acquire() try: time.sleep(0.2) # 每个请求之间至少间隔200ms return session.get(url, **kwargs) finally: rate_limit.release()这个限制的意思很简单你是来拿公开数据的不是来搞垮别人服务器的。一个能正常访问的站点你抓它的图它不会有什么感知但你用50个线程以每秒100个请求的速度去怼它它可能直接就不可用了。保持克制技术才会让你走得远。6.3 不采集个人信息与版权内容图片爬虫最常见的越界场景是爬社交平台上的用户头像、相册照片。这类内容的版权归属复杂且大量涉及个人隐私不管技术多么容易实现我都不建议碰。图库类站点的免版权图、壁纸站点的官方原图通常版权声明清晰用来自用学习没有争议。如果要做商业用途请务必确认图片的授权协议并保留来源URL作为出处凭证。在你的下载记录里记一条source_url字段不是多此一举是未来追溯信息来源的唯一路径。最后再说说我对Python爬虫进阶这件事的整体观感。其实所谓的进阶技巧拆开看都是很朴素的东西会话复用、并发控制、失败重试、去重校验、规则分类。没有什么一步登天的黑魔法关键是你能不能把每一步的细节抠到位。把单个环节写结实把错误处理写周全把退出策略想明白——这些品质组合起来才是“进阶”两个字真正的含义。你按照上面这套链路搭完再去跑别的批量采集任务只需要改改关键词表和URL规则整套骨架都能直接复用。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑