最近接了一个数据分析的活儿,要抓某招聘网站上某个城市所有技术岗的信息。本以为就是普通的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, headers=headers, timeout=10) 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_path=None): 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, key=cv2.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, duration=0.8, t_step=0.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_ad=False) 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_path=None): 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, iterations=1) 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_classes=36): # 数字+小写字母 super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, padding=1) self.conv2 = nn.Conv2d(32, 64, 3, padding=1) 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(dim=2)训练数据要打乱顺序、随机旋转、加噪声,这样才能增强泛化能力。训练完之后,把模型导出成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(referer="https://example.com/", extra=None): 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/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": referer, "sec-ch-ua": '"Not-A.Brand";v="99", "Chromium";v="124", "Edge";v="124"', "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_interval=3, max_interval=8): 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[@class='name']/text()")[0].strip() result["salary"] = root.xpath("//span[@class='salary']/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和服务条款,确保自己的行为在合理限度内。数据采集的技术本身是中性的,但使用它的人要有自控力。我在这篇文章里分享的是思路、方法和踩坑经验,不是教你无脑攻击网站。个人建议:可以把这套东西当作一个“反爬机制研究沙盒”,在里面练习验证码识别、轨迹模拟、指纹伪装,等能力练好了,再迁移到合法合规的数据采集场景。
最后分享一个小技巧:无论你的代码写得多么精妙,永远不要忽视数据库备份。搞爬虫的人最痛苦的不是验证码过不去,而是辛辛苦苦抓了几十万条数据,结果因为磁盘故障一夜归零。我现在严格遵守“每小时自动备份一次”的纪律,这个习惯至少帮我避免了三次灾难。