先说结论:表情包爬虫这个项目,几乎是所有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请求。
我总结了一套通用的分析路径:
- 先看浏览器地址栏,URL是否带
page、p、pageNum这类参数 - 如果不是,去Network面板筛选
XHR或Fetch,翻页并观察新增请求 - 如果翻页时新增了
getList、getData之类的请求,点开看响应内容是不是JSON且包含图片URL - 如果请求参数里有
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接口里。这里给出一个通用处理思路:
- 在Network面板中找到返回图片列表的XHR请求
- 复制它的URL,删掉
page、timestamp之类的可变参数,保留静态部分 - 用 requests 直接请求这个URL,看是否能拿到同样的JSON
- 能拿到就直接构造分页参数,循环请求
我用一次实际经历说明。某个表情包站,列表页数据来自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
- 出现包含“安全验证”“滑动解锁”字样的页面
应对策略按优先级排序:
- 降低请求频率,并发数调到4以下,延时加到1秒以上
- 换UA,有些站点按浏览器特征识别
- 加代理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跑。花一分钟做这些检查,能省掉半小时的排错时间:
- 先用一个URL测试解析逻辑,确认
img_urls列表非空,且贴出来的确实是图片直链而不是占位图 - 用
print打印前三条URL,放进浏览器直接访问,确认StatusCode为200且Content-Type是image格式 - 检查保存目录的写入权限,很多脚本跑一半才发现路径没权限,批量任务直接中断
爬虫的调试成本是递增的,越早发现问题越好修,跑满几百页再回头看第一页的解析错误,那种痛苦经历过的人都懂。
6. 写在实际操作之后的几点体会
表情包爬虫做完,你会发现自己突然就掌握了爬虫项目最核心的通用套路:找数据源,分析请求规律,构造解析逻辑,处理反爬,做批量化和异常恢复。这套技能平移到大尺寸壁纸站、表情包之外的图片素材站、开源素材导航站,基本是同一套思路,真正需要改的只是URL规律和解析规则。
从私心角度说,批量下载热门表情包的场景在我自己身上其实还挺常见。作为一个常年混迹各种群聊的人,斗图时找图翻半天是真的痛苦;脚本跑一次弄几百张热门图存本地,配合图片搜索工具,基本能覆盖日常99%的斗图需求。
最后说一句技术之外的题外话:热闹的表情包站,有不少图片的版权情况其实挺模糊的。自己下下来聊天用、自娱自乐完全没问题;但如果要拿去商用,或者转手做人家的付费素材包,这个边界就非常不推荐越过了。写爬虫练手、解决自己的需求,目的一定要干净,这也是每个写爬虫的人心里都应该有的一根弦。