☰
Python爬虫实战:从零搭建表情包批量下载器
2026/10/3 4:44:24 网站建设 项目流程

先说结论:表情包爬虫这个项目,几乎是所有Python爬虫练手场景里性价比最高的一类。它麻雀虽小五脏俱全,会完整经历“找数据源 → 分析请求 → 构造下载器 → 处理反爬 → 批量落盘 → 去重命名”的全流程,而且因为目标是公开的表情包图片资源,几乎不涉及敏感的账号体系,踩坑概率远比爬商品数据、爬评论区要低。

这篇文章我会以爬取表情包网站批量下载热门表情包为线索,完整走一遍思路和代码。涉及的网站结构、解析方式、反爬应对我都会按真实项目中遇到的场景来写,代码可以直接拿去改,但更重要的是理解每一步为什么这么做。

1. 整体设计:先摸清目标网站的家底再动手

写爬虫最忌讳一上来就写代码。表情包网站看着五花八门,但从抓取难度上基本就分两类,你的整个方案设计都取决于这个分类。

1.1 静态分页站点:最简单的抓取模型

这类网站是典型的老式图站结构:一个列表页显示几十张图片缩略图,底部有页码链接,点击第2页、第3页,URL会变成/page/2、/page/3这样的规律格式。图片本身大多存在图片服务器上,HTML里的img标签直接指向图片直链,比如https://cdn.example.com/images/xxx.jpg。

这类站点的抓取逻辑最直白:

  • 构造分页URL列表
  • 请求列表页HTML
  • 解析img标签提取图片直链
  • 逐张下载

唯一的变数在于图片直链是否带防盗链。国内不少图床会检查Referer头,如果你直接用 requests 默认头去下载,会返回403,这个后面会详细讲。

1.2 动态加载站点:数据藏在接口里

另一类表情包站点用的是前端懒加载,列表页只有一张占位图,真正的图片URL藏在JS变量的数组里,或者通过XHR接口异步返回JSON。你打开网页源码,根本看不到任何图片链接。这时候硬啃HTML没有任何意义,正确做法是打开开发者工具,切换到Network面板,翻页并观察真正返回数据的请求是哪个。

大部分这类站点的动态加载都是同一个套路:

  • 页面初始化时请求一个JSON接口,参数里有page、limit、category等字段
  • 接口返回的JSON里包含图片URL列表、标题、上传时间、下载量等结构化字段
  • 翻页就是重新请求一次接口,改page参数

这个发现过程远比写代码本身重要。排版良好的JSON数据解析起来干净利落,不需要跟乱七八糟的HTML标签较劲。

1.3 选型判断:requests 还是 selenium

一句话总结:能用 requests 就不要上 selenium。requests 轻量、快速、省资源,对于静态分页站点和绝大多数能抓到XHR接口的动态站点,它都是最优解。selenium 唯一的适用场景是接口加密严重、参数签名复杂、不动浏览器渲染就拿不到数据的情况——这种站点通常也不是表情包站点的水准,遇到的可能性很低。

我之前见过有人拿 selenium 爬表情包,每个页面起一个浏览器实例,CPU和内存跑满,下载200张图花了十分钟。同样的任务 requests 并发下载半分钟就结束了。工具选错的代价在爬虫里表现特别明显。

2. 核心实操:页面分析、直链提取与图片下载

2.1 分析列表页的URL规律

拿到一个表情包网站,按下F12打开开发者工具,先翻一页,观察URL变化。保守做法是直接点第2页、第3页的页码按钮,对比地址栏变化。有些网站在页码处用了JavaScript跳转,地址栏不变,这时要看XHR请求。

我总结了一套通用的分析路径:

  1. 先看浏览器地址栏,URL是否带page、p、pageNum这类参数
  2. 如果不是,去Network面板筛选XHR或Fetch,翻页并观察新增请求
  3. 如果翻页时新增了getList、getData之类的请求,点开看响应内容是不是JSON且包含图片URL
  4. 如果请求参数里有token、sign等字段且每次都会变,说明接口有签名保护,这时才需要考虑 selenium

多数表情包站会在HTML中直接渲染图片地址,即使列表页用懒加载,图片地址也会以>import re import requests html = requests.get(url, headers=headers, timeout=10).text # 提取所有 jpg/png/gif/webp 格式的图片地址 img_urls = re.findall(r'https?://[^\s"\'<>]+?\.(?:jpg|jpeg|png|gif|webp)', html)

这段正则的写法有讲究。[^\s"\'<>]+?这段是核心,它表示匹配一段不包含空格、引号、尖括号的字符,因为HTML标签里图片地址前后一定有"或',用这个字符类天然就把URL截断取准了。结尾的\.(?:jpg|jpeg|png|gif|webp)是扩展名匹配,几个常见图片格式用非捕获分组(?:)包起来,性能比捕获分组略好。

注意这个正则没有限定协议。有些站点的图片地址是//cdn.example.com/xxx.jpg这种协议相对地址,理论上re.findall(r'//[^\s"\'<>]+?\.(?:jpg|jpeg|png|gif|webp)')能匹配到,再手动补上https:前缀即可。这块要自己观察实际页面决定,不能无脑照抄。

2.3 下载图片的正确姿势

很多人用 requests 下载图片,直接写response.content存文件,这在绝大多数场景下没问题,但有两个细节值得处理一下。

第一个是流式下载。遇到比较大的动图,一次性response.content会把整个文件读进内存。稳妥做法是开启stream=True,分块写入磁盘:

import requests def download_image(url, filepath, timeout=15): resp = requests.get(url, headers=headers, stream=True, timeout=timeout) if resp.status_code != 200: print(f"下载失败: {url} -> {resp.status_code}") return False with open(filepath, 'wb') as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) return True

第二个是链接有效性判断。很多图站会把失效图片的URL仍然留在HTML里,但访问时返回404或一张统一的“图片已被删除”占位图。下载完建议校验文件大小,文件小于10KB的,大概率是占位图,直接删除,比事后人工筛选省心得多。

2.4 反爬应对:UA、Referer 与请求频率

表情包网站的反爬通常不重,但几样基本功必须做好。UA(User-Agent)是必改的,requests 默认的python-requests/x.x.x太容易被识别,伪装成浏览器是常规操作:

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', 'Referer': 'https://example.com/', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8' }

Referer 的意义在于模拟从列表页点击图片的浏览器行为,绕过图片服务器的防盗链检查。我遇到过不少图站,正文图片不带 Referer 直接请求是403,带上就好了。

请求频率方面遵循一个朴素原则:高频请求只是让任务快点跑完,代价是触发封IP的风险。加个简单延时,在“快速完成”和“不被封”之间平衡一下:

import time for img_url in img_urls: download_image(img_url, save_path) time.sleep(0.2) # 每张图间隔0.2秒

如果你只是想下个几百张图,这种零延时的脚本问题不大。要想长期稳定运行,最好把延时改成随机数,比如time.sleep(random.uniform(0.5, 1.5)),让请求间隔不规律,被识别为脚本的概率会低不少。

3. 工程化改造:批量下载器的完整实现

把单页图片下载扩展到全站批量下载,听起来只是加个循环,实际上要处理的问题多了不少。我自己写的时候踩过几个坑,这里把改造过程完整分享一遍。

3.1 翻页循环与去重逻辑

一个常见的坑是翻页URL和页码数字映射对不上,比如第1页URL是/hot,第2页变成/hot/page/2,第3页又是/hot/page/3,首页不带页码。这种我会单独处理首尾页。

去重是批量下载的第二个关键点。表情包网站经常在不同分类或热门榜单里重复推同一个图,你不去重,最后硬盘里全是重复文件。实用做法是记录图片URL本身,放进一个集合,遇到已经见过的URL直接跳过,这种去重成本最低。进阶做法是下载后计算文件的MD5值,因为同一张图可能被不同URL引用,URL去重不够彻底。但MD5计算需要文件下完才知道,消耗IO,对于大批量任务不划算。

我的经验是:先用URL去重,这能干掉90%以上的重复;如果最终发现重复还是多,再考虑对文件名做MD5去重,批量处理工具类脚本可以二次清理。

3.2 异常处理与断点续传

爬虫跑的越久越容易出幺蛾子。网络超时、连接被重置、某些图片返回损坏数据,这些不是“会不会发生”而是“何时发生”。代码里必须有一套健壮的错误处理机制。

我用的一直是这套组合拳:

import time import requests def retry_download(url, filepath, retries=3, timeout=15): for attempt in range(retries): try: return download_image(url, filepath, timeout) except (requests.Timeout, requests.ConnectionError) as e: print(f"第{attempt + 1}次尝试失败: {e}") time.sleep(2 * (attempt + 1)) # 第二次重试加长等待 print(f"重试{retries}次仍然失败,跳过: {url}") return False

重试次数我默认设为3,第一次失败后等2秒,第二次等4秒,指数退避不要太激进。过了3次还失败就果断跳过,别在一张图上耗太久。批量任务资源有限,宁可少下载一张,也别让脚本卡死。

断点续传有更专业的做法:记录下载进度到一个文件里,脚本重启后读取已完成列表,从断点继续下。实现起来就是每次成功下载后,把URL追加写进downloaded.txt,启动时先加载这个文件到集合。

import os def load_downloaded(filepath='downloaded.txt'): if not os.path.exists(filepath): return set() with open(filepath, 'r', encoding='utf-8') as f: return set(line.strip() for line in f) downloaded = load_downloaded()

这套逻辑简单但极为实用。大批量下载时网速波动、网络断开、代码异常都是常态,有了断点续传,重新启动时完全不用从头再来,省下的时间非常可观。

3.3 并发下载:用线程池提速

单线程下载是典型的IO密集型任务,每张图下载时CPU都在空等,效率不高。用concurrent.futures.ThreadPoolExecutor改造成并发下载,提速效果显著。

from concurrent.futures import ThreadPoolExecutor, as_completed def batch_download(urls, savedir, max_workers=8, delay=0.3): # 先过滤已下载的链接 filtered_urls = [u for u in urls if u not in downloaded] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {} for idx, url in enumerate(filtered_urls): filepath = os.path.join(savedir, f'{idx}.{ext}') future = executor.submit(download_image, url, filepath) future_map[future] = url time.sleep(delay) # 限制提交频率 for future in as_completed(future_map): url = future_map[future] try: success = future.result() if success: downloaded.add(url) with open('downloaded.txt', 'a') as f: f.write(url + '\n') except Exception as e: print(f"任务执行异常: {url} -> {e}")

并发数别开太大。线程池开8个左右已经很快,开到32个会明显增加被网站封禁的风险。一旦被临时封IP,整批任务都跑不了,反而是最慢的。延时这里做的是线程池提交时的节流,保证请求均匀分布,不会瞬间涌出大量并发请求。

3.4 动态加载站点的接口抓取方案

前面说过动态加载站点的数据在XHR接口里。这里给出一个通用处理思路:

  1. 在Network面板中找到返回图片列表的XHR请求
  2. 复制它的URL,删掉page、timestamp之类的可变参数,保留静态部分
  3. 用 requests 直接请求这个URL,看是否能拿到同样的JSON
  4. 能拿到就直接构造分页参数,循环请求

我用一次实际经历说明。某个表情包站,列表页数据来自https://api.example.com/v1/hot/emoji?page={page}&limit=20&type=hot,请求头里只需要带UA和Referer就能正常返回JSON。响应格式是:

{ "code": 200, "data": { "list": [ { "title": "摸鱼", "image_url": "https://cdn.example.com/2024/07/moyu.gif", "width": 480, "height": 480, "download_count": 1024 } ], "total": 1000, "has_more": true } }

解析这种JSON,按page从1递增去请求,每次取data.list里的image_url,直到has_more为false或total达到了目标数量。逻辑非常清晰,比解析HTML省太多事。

这里要注意请求头里的Referer,头像接口通常校验来源,必须带上页面地址才能正常返回数据。签名参数那些,如果接口每次请求都会变,那基本就是加密接口了,这类直接放弃requests改selenium或者干脆换一个站点更实际。

4. 常见问题与排查技巧实录

4.1 常见问题速查表

我把自己做表情包爬虫和其他图站爬虫时踩过的坑汇总成一张表,大部分情况照着排查都能解决。

现象可能原因解决方案
请求返回403未带UA或UA是默认值设置完整的浏览器UA头
图片返回403图床防盗链检查请求头加上Referer为列表页地址
下载的文件打不开下载的是占位图或错误页检查文件大小,小于10KB的直接删除
列表页HTML里找不到图片地址懒加载,图片在data-src属性里改用正则匹配data-src或data-original
翻页后URL没变化页面用JS加载或表单提交检查XHR接口,找到真正返回数据的请求
requests超时频繁网站响应慢或连接被边缘节点拦截设置timeout,增加重试机制,降低并发
下载到一半脚本闪退没有异常处理,网络波动直接崩加try-except,做断点续传
重复图片太多不同分类重复推荐同一张图做URL去重 + 文件MD5二次去重
页面返回但解析不到任何图片网站改版或HTML结构变了重新打开页面分析,勿依赖旧解析逻辑

4.2 反爬识别与应对策略

表情包网站的封禁策略通常是“温和派”:请求频率过快时给验证码,或者临时限制IP访问。

识别被封的典型特征:

  • 本来能打开列表页,突然返回403或502
  • 返回的HTML内容明显变短,正常是几百KB,被拦截时可能只有几KB
  • 出现包含“安全验证”“滑动解锁”字样的页面

应对策略按优先级排序:

  1. 降低请求频率,并发数调到4以下,延时加到1秒以上
  2. 换UA,有些站点按浏览器特征识别
  3. 加代理IP,不过免费代理的质量参差不齐,用之前一定要测试可用性

千万别在小站点上硬刚验证码。表情包网站数量多的是,这个站的接口废了,换一个类似结构的站继续操作就行,时间和精力花在验证码识别上性价比太低。

4.3 关于批量下载脚本的进阶优化

基础版跑通之后,有几个顺手的小优化很值得做:

  • 文件名建议保留语义信息。从URL里提取文件名常用方式是用url.split('/')[-1],但不少站点图片名是无意义的数字ID,存下来没法认。如果列表页里有图片标题或所属分类,可以考虑拼接成分类_标题_文件名.jpg的格式,虽然写起来麻烦一点,但事后找图方便太多。

  • 热门表情包按榜单字段筛选。部分网站的列表接口会返回download_count、hot_score这类字段,下载前做个排序,优先下热度高的前N张,能节省不少时间和流量。

  • 下载速度和数量要可控。脚本里加一个最大下载数限制,比如MAX_COUNT = 500,防止跑偏时全站图都被拖下来,硬盘空间直接爆掉。

5. 案例实战:一个热门表情包站的完整拆解

开头分析的两类站点结构,我分别用代码完整走一遍。先看静态分页站点的方案,再看动态接口的方案,这样两种场景你都能直接参考。

5.1 静态分页站点的全套代码

import os import re import time 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', 'Referer': 'https://example.com/emoji/' } def get_page_img_urls(page_url): resp = requests.get(page_url, headers=HEADERS, timeout=10) resp.raise_for_status() html = resp.text # 优先匹配data-src,匹配不到再匹配src img_urls = re.findall(r'data-src="(https?://[^"]+?\.(?:jpg|jpeg|png|gif|webp))"', html) if not img_urls: img_urls = re.findall(r'src="(https?://[^"]+?\.(?:jpg|jpeg|png|gif|webp))"', html) return img_urls def batch_download_static(base_url, total_pages, savedir, start_page=1): os.makedirs(savedir, exist_ok=True) for page in range(start_page, total_pages + 1): page_url = f"{base_url}/page/{page}" print(f"正在处理第 {page} 页: {page_url}") try: img_urls = get_page_img_urls(page_url) except Exception as e: print(f"第 {page} 页解析失败: {e}") continue for idx, img_url in enumerate(img_urls): ext = os.path.splitext(img_url.split('?')[0])[1] or '.jpg' filename = f"page{page}_{idx}{ext}" filepath = os.path.join(savedir, filename) try: download_image(img_url, filepath) except Exception as e: print(f"下载失败 {img_url}: {e}") time.sleep(0.2)

这套代码的重点在get_page_img_urls里的两段正则:先试>import os import json import requests API_URL = "https://api.example.com/v1/hot/emoji" 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', 'Referer': "https://example.com/emoji/hot" } def fetch_api_page(page, limit=20): params = {'page': page, 'limit': limit, 'type': 'hot'} resp = requests.get(API_URL, headers=HEADERS, params=params, timeout=10) resp.raise_for_status() data = resp.json() return data.get('data', {}) def batch_download_api(savedir, max_pages=50, limit=20): os.makedirs(savedir, exist_ok=True) downloaded = load_downloaded() total_count = 0 for page in range(1, max_pages + 1): data = fetch_api_page(page, limit) img_list = data.get('list', []) if not img_list: print(f"第 {page} 页无数据,停止翻页") break print(f"第 {page} 页获取到 {len(img_list)} 张表情包") for item in img_list: img_url = item['image_url'] if img_url in downloaded: continue title = item.get('title', 'unknown') ext = os.path.splitext(img_url.split('?')[0])[1] or '.gif' # 标题清洗,去掉非法字符 title = re.sub(r'[\\/:*?"<>|]', '_', title) filename = f"{title}_{page}_{len(downloaded)}{ext}" filepath = os.path.join(savedir, filename) success = retry_download(img_url, filepath) if success: downloaded.add(img_url) total_count += 1 with open('downloaded.txt', 'a', encoding='utf-8') as f: f.write(img_url + '\n') has_more = data.get('has_more', False) if not has_more: print("没有更多数据,停止") break print(f"总共下载 {total_count} 张图片")

这个接口方案有一个细节:downloaded.txt存的是URL而不是文件名。URL对一张图来说是稳定标识,重启后就算改了保存目录也能正确去重。文件名里的len(downloaded)保证了即使同标题图片也不会重名覆盖,同时文件名因为带了title信息,后续按表情主题筛选就很方便。

5.3 代码运行前的三个必要检查

写完之后不要急着python script.py跑。花一分钟做这些检查,能省掉半小时的排错时间:

  1. 先用一个URL测试解析逻辑,确认img_urls列表非空,且贴出来的确实是图片直链而不是占位图
  2. 用print打印前三条URL,放进浏览器直接访问,确认StatusCode为200且Content-Type是image格式
  3. 检查保存目录的写入权限,很多脚本跑一半才发现路径没权限,批量任务直接中断

爬虫的调试成本是递增的,越早发现问题越好修,跑满几百页再回头看第一页的解析错误,那种痛苦经历过的人都懂。

6. 写在实际操作之后的几点体会

表情包爬虫做完,你会发现自己突然就掌握了爬虫项目最核心的通用套路:找数据源,分析请求规律,构造解析逻辑,处理反爬,做批量化和异常恢复。这套技能平移到大尺寸壁纸站、表情包之外的图片素材站、开源素材导航站,基本是同一套思路,真正需要改的只是URL规律和解析规则。

从私心角度说,批量下载热门表情包的场景在我自己身上其实还挺常见。作为一个常年混迹各种群聊的人,斗图时找图翻半天是真的痛苦;脚本跑一次弄几百张热门图存本地,配合图片搜索工具,基本能覆盖日常99%的斗图需求。

最后说一句技术之外的题外话:热闹的表情包站,有不少图片的版权情况其实挺模糊的。自己下下来聊天用、自娱自乐完全没问题;但如果要拿去商用,或者转手做人家的付费素材包,这个边界就非常不推荐越过了。写爬虫练手、解决自己的需求,目的一定要干净,这也是每个写爬虫的人心里都应该有的一根弦。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询