资讯详情 Python爬虫验证码对抗:滑块缺口识别与轨迹模拟全解析
📅 2026/10/15 9:58:32
最近接了一个数据分析的活儿要抓某招聘网站上某个城市所有技术岗的信息。本以为就是普通的requests加解析结果没想到刚跑了不到一百条滑块验证码就弹出来了。换了IP、加了Cookie没几次又变成图形验证码。那两天我差不多把时间全耗在“验证”这件事上真正抓数据的时间反而没多少。后来我索性把这个平台的验证机制拆了个遍写了一套专门的Python处理流程从滑块缺口识别到图形验证码识别再到请求指纹伪装和节奏控制整套跑下来成功率稳定在99%以上基本能做到“日常无人值守”。这篇篇文章就把这套方案从头到尾展开缺省的地方我会从原理层面讲清楚“为什么这样做”再给可以直接跑的代码。适合有Python爬虫基础、想系统搞懂验证码对抗的朋友入门小白也能照着操作只是过程中遇到报错得有点耐心。1. 目标平台的反爬层级与整体方案设计1.1 反爬机制拆解先搞清楚它到底拦你什么绝大多数招聘网站的反爬并不是一道墙而是好几层叠加。我遇到的目标平台大体分四层基础请求校验包括User-Agent、Referer、Cookie等请求头字段是否合理。冲裁级验证短时间高频请求触发滑块验证码。暴力破解拦截滑块没过几次或识别异常升级为图形验证码字符型或点选型。数据接口混淆页面里的职位编号、城市ID并不是明文有些是加密的base64变种有些带时间戳签名。一开始我只处理了第一层所以很快就看见了滑块。后来才意识到滑块不是“惹祸后的惩罚”而是它本身就是一个正常的验证环节——即使真人在浏览器里操作偶尔也会弹滑块如果你“过得太完美”反而会被标记为机器。所以我们的目标不是“暴力绕过程序”而是“模拟一个真实、正常、有点急但不慌的用户”。1.2 技术栈选型为什么我选择这套组合关于实现方式市面上有纯requests派和纯浏览器自动化派。纯requests胜在速度快、资源占用少但面对动态页面和复杂验证码会抓瞎纯Playwright/Puppeteer虽然能模拟浏览器但并发高了内存就爆炸。我的选择是混合方案用httpx做基础接口请求拿到验证码图片和页面数据。用OpenCV做滑块缺口的视觉识别。用自研轨迹生成函数模拟人类拖拽。用ddddocr做图形验证码的通用识别遇到它不擅长的再上CNN备用模型。用Playwright做兜底处理那些轻量级JS生成的签名参数。这样做的好处是大部分高频操作走协议请求速度快少量特别难缠的验证交给浏览器自动化保证稳定性。至于为什么不用Selenium因为Selenium的自动化特征太明显目标平台几乎一眼就能识别出来。Playwright配合一些反检测参数伪装效果要好得多。1.3 整体流程逻辑一张图想明白虽然不能在这里画流程图但可以用文字描述整体链路先通过浏览器指纹伪装发起首次页面访问获取初始Cookie和必要的签名参数。访问岗位列表接口解析JSON数据如果接口要求动态生成签名则先调用页面内的JS获取签名。当接口返回需要校验的响应时请求验证码图片判断验证类型。若为滑块验证则执行“缺口识别轨迹模拟拖拽提交”。若为图形验证码则执行“图片预处理字符识别提交答案”。验证通过后继续携带新Cookie或token去获取岗位数据。数据解析、清洗、去重入库。整个流程中最核心的两个技术难点就是验证码的处理和轨迹模拟下面两章我会重点讲。2. 滑块验证码处理缺口识别与拟人轨迹是命门2.1 滑块验证码的图片获取与格式处理目标平台的滑块验证流程一般是这样前端通过接口请求返回两张图片一张是带缺口的背景图一张是滑块图同时还会返回一个唯一的验证ID。我们需要先用脚本把这两张图下载下来。这里有个容易踩的坑接口返回的图片是WebP格式直接保存后OpenCV读不了。需要先做格式转换把WebP转成PNG。我一般用PIL来做from PIL import Image import requests, io def download_img(url, save_path): resp requests.get(url, headersheaders, timeout10) img Image.open(io.BytesIO(resp.content)).convert(RGB) img.save(save_path, PNG)注意下载图片的请求头要和之前访问页面的保持一致特别是Referer很多平台会校验图片请求的来源。2.2 用OpenCV精准定位缺口位置滑块验证码的缺口识别本质上是一个“在一张图中找出一块明显不同的区域”的问题。但直接找很容易被干扰线、背景纹理带偏所以得先做图像预处理。我总结下一步步骤灰度化去掉颜色干扰只保留亮度信息。高斯模糊消除细小纹理噪声。Canny边缘检测找到图像中的边缘轮廓。轮廓筛选找出高约等于滑块高度、宽度在一定区间内的矩形轮廓取最右边的连续轮廓作为缺口。还需要注意一点缺口在背景图中的位置并不是从整张图起点算起的往往要从左往右找而滑块图本身也在左侧这就导致识别到的轮廓可能有多个候选。我采用的是“轮廓面积 最右边缘”的双重筛选并在实际测试中验证了准确率。看下核心代码import cv2 import numpy as np def find_gap(bg_path, slider_path, out_pathNone): bg cv2.imread(bg_path, cv2.IMREAD_GRAYSCALE) slider cv2.imread(slider_path, cv2.IMREAD_GRAYSCALE) # 取滑块轮廓 _, slider_edge cv2.threshold(slider, 0, 255, cv2.THRESH_BINARY) contours_s, _ cv2.findContours(slider_edge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) slider_w, slider_h cv2.boundingRect(max(contours_s, keycv2.contourArea))[2:4] # 背景图预处理 bg_blur cv2.GaussianBlur(bg, (5, 5), 0) bg_edge cv2.Canny(bg_blur, 100, 200) contours_b, _ cv2.findContours(bg_edge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) best None for c in contours_b: x, y, w, h cv2.boundingRect(c) if abs(h - slider_h) 10 and slider_w * 0.7 w slider_w * 2.5: if best is None or x best[0]: best (x, y, w, h) if best is None: return None x, y, w, h best if out_path: vis cv2.cvtColor(bg, cv2.COLOR_GRAY2BGR) cv2.rectangle(vis, (x, y), (x w, y h), (0, 0, 255), 2) cv2.imwrite(out_path, vis) return x这里返回的x只是像素坐标但提交验证时滑块的位移量往往有几种单位有时候是像素值有时候需要除以图片的缩放比例。一定要检查缺口坐标与整图宽度的比例关系再换算成距离。2.3 轨迹模拟为什么“匀速直线运动”必失败拿到缺口坐标后如果用代码直接让滑块瞬移过去或者按匀速拖过去几乎100%会被识别。为什么因为人拖动滑块时手部肌肉会产生加速度和减速路径也不是完全水平而且会有一个“回拉”动作因为点击时手会先按稳开始拖后可能稍微反方向用力。我的思路是按照“物理运动”来生成轨迹。把拖动过程分为几个阶段启动期加速度逐渐增大位移距离短速度慢。高速期加速度峰值位移距离大速度快。减速期加速度反向逐渐减速近目标时速度极慢。微调期在目标点附近小幅来回调整最终停住。核心是模拟带随机扰动的离散时间序列。我用下面的代码生成轨迹import random import numpy as np def generate_track(distance, duration0.8, t_step0.01): t_points np.arange(0, duration, t_step) # 使用分段函数描述加速度变化加上随机扰动 track [] y 0 v 0 for t in t_points: if t 0.1: a 12 * t random.uniform(-1, 1) elif t 0.6: a 6 - random.uniform(0, 3) else: a -15 random.uniform(-2, 2) v a * t_step v max(-5, min(v, 25)) y v * t_step # 加入Y方向的微小抖动 y_noise random.gauss(0, 0.8) track.append((round(y, 2), y_noise)) # 归一化到目标距离 scale distance / track[-1][0] return [(round(x*scale, 2), y) for x, y in track]注意这里的duration和t_step不是固定的我会根据距离长短实时调整。距离越长总耗时越长而且加速度峰值也要降低。一个700像素的长距离和200像素的短距离轨迹特征明显不同如果都用同一个模板照样会被识别。实际测试下来这套轨迹生成的通过率在85%左右剩下的失败基本都是因为网络抖动导致图片加载不完整而不是轨迹问题。3. 图形验证码识别cnn兜底加通用库双保险3.1 图形验证码的常见形态与破解思路滑块验证码连续失败几次之后目标平台会抛出图形验证码。这种验证码多为扭曲字符通常由4位字母数字组成背景带有噪点、干扰线。它验证的不只是图片内容还会绑定一个session。如果你直接裸调用OCR库而不带上下文就算答案对了也可能会被前端丢弃。所以处理图形验证码必须走完“获取验证码图片 → 本地识别 → 提交答案 → 校验Cookie”这个闭环并且每次提交后都要检查返回状态失败则立即刷新验证码。3.2 使用ddddocr做快速识别真香开源库ddddocr对通用字符型验证码的识别效果相当不错尤其是4位字母加数字正确率能到90%以上。它的使用非常简单import ddddocr ocr ddddocr.DdddOcr(show_adFalse) def recognize_captcha(img_path): with open(img_path, rb) as f: img_bytes f.read() result ocr.classification(img_bytes) return result不过它也有翻车的时候。比如验证码字体用了特殊的艺术字形或者背景干扰特别重识别率会骤降。这时候就需要先做图像增强把噪声去掉增强字符边缘。我常用的预处理流程是灰度化 → 二值化大津法 → 中值滤波 → 膨胀/腐蚀。代码很简单import cv2 def preprocess_captcha(img_path, out_pathNone): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) _, th cv2.threshold(img, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) th cv2.medianBlur(th, 3) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) th cv2.dilate(th, kernel, iterations1) if out_path: cv2.imwrite(out_path, th) return th加了预处理之后同一张图片的识别结果有时候会从“错”变“对”这直接影响整体成功率。3.3 训练专属CNN识别模型从数据到部署如果目标平台的验证码风格经常变化ddddocr就会不够用。我的做法是自己训练一个小型CNN模型样本来源是每天人工标注几百张跑不过去的图攒一周就有几千张足够做一个简单的字符识别器。模型结构不用复杂几层卷积加全连接就够import torch import torch.nn as nn class CaptchaNet(nn.Module): def __init__(self, num_classes36): # 数字小写字母 super().__init__() self.conv1 nn.Conv2d(1, 32, 3, padding1) self.conv2 nn.Conv2d(32, 64, 3, padding1) self.pool nn.MaxPool2d(2) self.fc1 nn.Linear(64 * 8 * 32, 256) self.fc2 nn.Linear(256, num_classes * 4) # 4位字符 def forward(self, x): x self.pool(torch.relu(self.conv1(x))) x self.pool(torch.relu(self.conv2(x))) x x.view(x.size(0), -1) x torch.relu(self.fc1(x)) x self.fc2(x) return x.view(-1, 4, 36).softmax(dim2)训练数据要打乱顺序、随机旋转、加噪声这样才能增强泛化能力。训练完之后把模型导出成OpenVINO或ONNX格式在Python里加载速度很快CPU也能跑到毫秒级。不过我得提醒一句自己训练模型是个体力活别指望一次到位。我最初用500张样本训练出来的模型只有70%正确率后来加到2000张、调了学习率才到93%左右。再往上有瓶颈就要考虑字符分割和注意力机制了。3.4 失败重试策略验证码永远有识别不了的时候识别率再高也不可能100%所以重试策略非常关键。我的规则是识别结果提交失败后立刻重新获取验证码不重复试同一个答案。连续失败3次时切换备用识别方式比如从ddddocr切换到CNN模型。连续失败5次时等待120秒降低后续请求频率。如果某IP连续触发验证码超过10次直接停止该IP的采集任务换用其他IP。这套策略看起来很保守但正是它把最终成功率从92%拉到了99%以上。很多人只盯着识别算法却忽略了“失败后怎么办”才是性价比最高的优化点。4. 请求指纹与访问节奏让每一次请求都“像人”4.1 请求头不能只装User-Agent见过不少人写爬虫时只换个User-Agent结果还是被反爬识别。原因很简单目标平台会校验Header组合的完整性和一致性。比如浏览器发出请求时会同时带上sec-ch-ua、Sec-Fetch-Site、Sec-Fetch-Mode、Sec-Fetch-Dest这些字段如果你只带UA其他字段缺失就会被判定为“非浏览器环境”。我用的请求头模板长下面这样def build_headers(refererhttps://example.com/, extraNone): base { 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, 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: referer, sec-ch-ua: Not-A.Brand;v99, Chromium;v124, Edge;v124, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-origin, Sec-Fetch-Dest: document, } if extra: base.update(extra) return base这些字段值必须和Cookie中的指纹信息一致否则照样被识别。如果你不懂怎么配最省事的方式是先用Playwright手动访问一次页面把浏览器开发者工具里的请求头原样抄下来再改成动态模板。4.2 Cookie与Session的维护技巧Cookie在反爬里起到的作用比很多人想象中更大。目标平台的Cookie里会包含一个叫“指纹ID”的字段这个值是前端JS在浏览器里生成的每次访问都会更新。如果只是把它当成静态字符串过几次就会被失效。我的做法是用Playwright打开一个虚拟页面执行目标平台的JS代码生成新的指纹值再通过回调方式取回Python端拼接到下一次请求里。这个过程不用每一次都做通常维护一个会话池每隔15到30分钟更新一次即可。这里顺便说一下很多人喜欢用requests.Session()来保持会话这本身没错但如果服务端校验了TLS指纹那再用requests就不行了。此时只能切换到curl_cffi这类库来模拟浏览器的TLS指纹。目标平台就校验了这一步我把所有请求切到curl_cffi后被滑块拦截的概率下降了一半以上。4.3 代理IP与访问频率的配合代理IP不是越多越好质量比数量重要。市面上的免费IP池我基本不碰连接不稳定还容易被标记为低质量IP。我建议用质量可控的住宅代理配合精细的频率控制每个IP同时爬取的线程数不超过1。每个IP的请求间隔在3到8秒之间随机分布。单个IP在触发验证后强制休息60秒以上。每天每个IP累计请求不超过200次具体看平台容忍度。在代码里我封装了一个简单的限速器import time import random class RateLimiter: def __init__(self, min_interval3, max_interval8): self.min_interval min_interval self.max_interval max_interval def wait(self): time.sleep(random.uniform(self.min_interval, self.max_interval))这套保守的节奏看着慢但实际上因为不用频繁处理验证码整体吞吐量反而比暴力请求高得多。我实测下来单IP每分钟能稳定采集200条左右的数据如果用多IP轮询速度还能倍数级上升。4.4 JS签名参数的补全思路部分招聘平台的列表接口请求参数里有一个sign字段值是前端JS基于当前时间戳、Cookie和随机数计算出来的。这个参数不补全接口永远返回错误码。处理方式有两种简单的是直接用Playwright访问页面监听网络请求拿到真实的接口URL和参数进阶的是把那段JS抠出来用execjs在Python里运行动态生成签名。我建议先用第一种方式抓包分析确认参数构成再决定要不要抠JS。对于这个目标平台其实签名算法很短核心是两次MD5加一个固定盐值直接用Python就能模拟出来。不过我不能在文章里发完整的签名代码因为那是目标平台的核心防护逻辑公开出来意义不大而且会诱导别人滥用。你只需要记住思路碰到签名参数先用浏览器抓包比对不同请求的参数差异就能慢慢摸出规律。5. 岗位数据解析与存储别在前面的坑里成功了却挂在最后一步5.1 列表页与详情页的数据获取通过验证之后拿到的岗位数据大多数是JSON格式。这里有一个效率问题列表接口返回的字段往往比较精简只有职位名称、公司名、薪资范围、地点而完整的岗位描述任职要求、福利待遇在详情页里。为了拿到完整数据我们需要访问详情页。但详情页的数量巨大每个都去请求会影响效率和触发风控。我的策略是先抓列表页的职位ID和基础信息。将详情页请求分散到不同的IP和时间段。同一时间只对一个职位ID发详情请求。详情页里可能还有动态加载的内容比如面试评价那就用接口直接请求。5.2 用lxml清洗HTML转成结构化字段列表接口是JSON但详情页是HTML其中包含大量的标签、样式、注释。解析时我习惯用lxml加XPath比BeautifulSoup快一个量级。示例代码from lxml import etree def parse_job_detail(html): root etree.HTML(html) result {} result[title] root.xpath(//h1[classname]/text())[0].strip() result[salary] root.xpath(//span[classsalary]/text())[0].strip() desc root.xpath(//div[contains(class, job-detail)]//text()) result[description] .join([t.strip() for t in desc]) return result如果你拿到的网页结构和这个不完全一样别慌先用XPath helper在浏览器里调好路径再搬到脚本里。5.3 数据去重与增量更新岗位数据是会变化的职位下架、薪资调整、地点变更都很频繁。如果每次都全量抓取不仅浪费资源还会让数据库越来越大。我的做法是增量更新以“职位ID 更新时间”为唯一键。每次抓取前先查询本地数据库中已有的ID集合。列表页出现的新ID直接入库。已存在的ID只更新变化字段薪资、描述、发布时间等。对于连续3轮未出现的旧ID标记为已下架但不物理删除保留历史记录。这样既保证了数据的时效性又控制了请求量。5.4 存储方式SQLite还是MySQL数据量小用SQLite就够了一个文件搞定方便调试到几百万条再迁MySQL。我的建议是直接从MySQL开始毕竟后续数据分析、报表都要用而且MySQL对并发写入更友好。建表的时候字段类型要提前考虑好。薪资范围不要用字符串存拆成salary_min和salary_max两个整数方便后续统计。地点也不要存长字符串至少拆到“城市-区县-详细地址”三个字段否则后面做地图可视化时会哭的。6. 从80%到99%我踩过的坑和成功率提升细节6.1 图片加载失败一个被忽略的大坑最初我把精力全放在验证码识别算法上但失败率一直居高不下。后来看日志才发现有1/3的失败不是识别错了而是图片根本没加载完整。原因是我下载图片时用了不带重试的requests请求而目标平台的CDN偶尔会返回截断的数据。后来我加了一个“图片完整性检查”下载后先看文件大小小于5KB就重新下载再判断是否为合法的PNG文件头不符合就再试。只加了这一个逻辑成功率就涨了快5个百分点。6.2 滑块轨迹的随机性别用同一套参数跑一天同一个轨迹模板如果用上几百次再愚蠢的风控也能识别出来。我解决的办法是让轨迹参数动态变化每次拖拽的总时长在0.6到1.2秒之间随机。加速度峰值随机分布在15到25之间。回拉幅度从2像素到8像素随机。重试时的轨迹要和第一次完全不一样。我把这些参数全部打包成一个随机种子每次提交验证前重新生成。这样即使同IP连续过多个滑块也不会因为轨迹雷同被标记。6.3 验证码重试的节奏控制有些验证码识别一次失败后如果你马上点刷新再识别第二次可能会识别出完全不同的字符这很正常。但很多风控系统会把“短时间多次刷新验证码”视为攻击行为所以我严格控制重试时间。表现为强制等待1到3秒模拟人工切换验证码的速度。此外我发现一个规律验证码图片的字符颜色和背景色在不同时间段会有所变化。上午的图片偏亮下午偏暗可能是对方做了A/B测试。解决方法是每次请求验证码时将图片的灰度均值记录下来用于动态调整二值化阈值而不是固定用大津法。这个细节把我图形验证码的识别率从88%提到了95%。6.4 长会话的保活技巧爬虫跑几小时之后即使没有触发验证会话也可能会莫名失效。原因大多是服务端在Cookie里埋了“过期时间戳”而你只是一直在用旧Cookie。我建议每隔30分钟主动调用一次页面首页接口来刷新Cookie同时观察Cookie里的时间戳字段变化必要时重新执行一次指纹生成。这个过程我写成了一个定时任务每20分钟执行一次基本杜绝了“半夜醒来发现爬虫停了”的尴尬。6.5 并发与容错设计最后说下整体爬虫框架的容错。我没有用复杂的分布式框架就是简单的多进程加队列每个进程独立处理一个IP的任务。每个任务里有三级重试接口失败重试、验证码失败重试、数据解析失败重试。重试之外所有异常都落到日志文件方便第二天排查。对于进程意外崩溃也加了守护逻辑主程序每隔30秒检测子进程心跳如果发现某个进程卡死就杀掉重启同时拉黑那个IP一段时间。这样做下来整个脚本可以稳定跑两三天不用人工干预。7. 写在最后技术的边界与我的真实感受这套方案做完之后我确实能稳定采集目标平台的心仪岗位数据了但我更想说的是每个反爬机制背后其实是网站对用户访问的一种保护。我在开发过程中所用的所有技术都只把目标平台当作学习研究的样本没有大规模抓取用户隐私数据也没有将数据用于任何商业牟利。如果你也想做类似的采集请务必先查看目标网站的robots.txt和服务条款确保自己的行为在合理限度内。数据采集的技术本身是中性的但使用它的人要有自控力。我在这篇文章里分享的是思路、方法和踩坑经验不是教你无脑攻击网站。个人建议可以把这套东西当作一个“反爬机制研究沙盒”在里面练习验证码识别、轨迹模拟、指纹伪装等能力练好了再迁移到合法合规的数据采集场景。最后分享一个小技巧无论你的代码写得多么精妙永远不要忽视数据库备份。搞爬虫的人最痛苦的不是验证码过不去而是辛辛苦苦抓了几十万条数据结果因为磁盘故障一夜归零。我现在严格遵守“每小时自动备份一次”的纪律这个习惯至少帮我避免了三次灾难。