尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

接口自动化中图形验证码OCR识别与重试降级实践

接口自动化中图形验证码OCR识别与重试降级实践 做接口自动化做到第二年基本都会撞上同一堵墙——登录接口前面杵着一张图形验证码。我最早那套跑得好好的 pytest 用例集就是因为后端给登录加了个 4 位字符的图形验证码一夜之间从全绿变成全红二十多个用例里十九个挂在初始化阶段剩下的那个还是因为 skip 了。后来我把验证码处理这一块单独拆出来研究了一遍从图像预处理到会话复用再到降级重试算是摸出了一套能长期无人值守跑下去的方案。这篇就聊聊接口自动化里图形验证码到底该怎么处理涵盖方案选型、图像预处理的参数调优、requests pytest 的落地代码、以及我踩过的那些坑。适合已经能跑通基础接口自动化、正在被验证码卡住的同学纯新手也能看懂因为我会把每一步的意图讲清楚。1. 图形验证码为什么会成为接口自动化的拦路虎1.1 一次登录链路断掉之后的连锁反应先还原一下问题现场。假设你有一套标准的分层用例结构conftest.py里挂了一个 session 级别的登录 fixture所有业务用例都依赖它拿到的 token。这套结构在验证码出现之前非常优雅一次登录、全局复用跑 200 个用例只需要 1 次 HTTP 登录请求。后端加上图形验证码之后链路变成这样先调/captcha拿图片再人工看图输入字符最后带着字符提交登录。自动化脚本卡在第二步——它看不见图片。这时候很多人的第一反应是那我加一个 input 让人工输一下。这个方案在本地调试还行扔到 CI 上直接完蛋因为流水线里没有人。于是你开始找绕过方案然后发现绕不过去因为验证码的校验逻辑就在登录接口里属于服务端强制约束。真正麻烦的不是登录这一个接口而是它的连锁反应。token 拿不到所有依赖登录态的用例全部报错如果用 function 级别的 fixture那每个用例都要重复一次验证码流程200 个用例就是 200 次识别识别率哪怕有 95%也会有 10 个用例因为识别错误而失败。这类失败最恶心的地方在于它时好时坏——重跑一次可能就过了于是团队开始习惯性重跑慢慢就没人认真看失败了整个自动化体系的可信度就此崩塌。所以验证码处理的核心指标不是能不能识别而是稳定性够不够高、失败后能不能自愈。1.2 常见图形验证码的形态与技术特征不同类型的图形验证码处理难度差着数量级先把它们分个类会省很多事。验证码类型典型特征识别难度推荐方案纯数字 4 位无干扰线字号固定极低直接 OCR准确率 98%数字字母混合有轻微噪点、颜色干扰低灰度 二值化 OCR扭曲/粘连字符字符重叠、旋转、波浪变形中图像预处理 深度学习模型算术题35?结果需计算中OCR 后正则解析再求值汉字点选按提示点击图中文字高坐标定位模型成本高滑块/行为验证拖拽轨迹、加速度校验极高不建议硬刚走测试环境策略这里有个很重要的判断原则验证码的强度是给攻击者设计的不是给测试脚本设计的。前四类属于防君子级别OCR 完全能搞定后两类已经涉及行为分析和风控体系硬碰硬投入产出比极低正确做法是跟后端协商测试环境的豁免策略而不是写一个几百行的轨迹模拟器。1.3 处理边界的界定哪些该绕、哪些必须识别我给自己定过一条线接口自动化的目标是验证业务逻辑不是验证识别算法。这条线一旦模糊你就会陷进无穷无尽的调参里。具体来说如果你的测试重点是订单创建、库存扣减、权限校验这些业务规则那验证码只是挡路石用什么方式挪开都行测试环境白名单是最优解。反过来如果你的项目本身就是做验证码安全评估、或者要给识别服务做质量回归那识别环节才是被测对象这时候必须真实识别。大部分业务团队属于前者。所以我的建议顺序是先争取测试环境的豁免能力拿不到再上 OCROCR 不稳再加图像预处理和重试最后才考虑第三方服务或自训练模型。这个顺序是按成本从低到高排的很多人一上来就去研究深度学习模型结果花了两周发现前端同学改一行配置就能解决纯属白费功夫。注意测试环境豁免必须通过配置开关控制绝不能把万能验证码硬编码在代码里合进主干分支否则一旦误发到线上就是安全事故。2. 方案选型四条技术路线怎么挑2.1 测试环境白名单与万能验证码这是成本最低、稳定性最高的方案没有之一。实现方式通常是三种后端在测试环境配置里加一个固定验证码比如0000或者约定一个特殊请求头如X-Test-Bypass: true跳过校验再或者把测试机的出口 IP 加入白名单。我一般会这样跟后端同学沟通自动化用例每天要跑几百次登录验证码会让失败率变成随机数能不能在测试环境加个开关 只要说清楚这对 CI 稳定性的影响绝大多数团队都愿意配合因为改动量真的很小——在校验逻辑最前面加个if (testMode code.equals(0000)) return true;就完事了。这个方案唯一的坑是环境隔离。我踩过一次测试配置文件和生产配置文件混在同一个仓库里靠启动参数区分某次部署脚本传错了参数导致预发环境也开了豁免。后来我们改成用独立的配置中心 namespace测试环境的开关只在那一个 namespace 里存在物理上不可能被生产读到。这个教训是任何绕过安全机制的东西都必须做到物理隔离而不是逻辑上的条件判断。2.2 会话复用一次人工登录、长期复用凭证拿不到后端配合的时候这是第二选择。思路很简单手工登录一次把浏览器里的 cookie 或者接口返回的 token 抓出来存到本地文件或者环境变量脚本启动时直接读取跳过整个登录流程。优点是不需要识别、不需要后端配合、几分钟就能搞定。缺点是凭证会过期你必须定期手工续一次。token 的有效期决定这个方案能不能用——如果是 30 天有效期的长效 token那基本可以接受每周手动刷一次如果只有 30 分钟那这个方案直接作废因为脚本跑到一半就失效了。我在一个内部系统上用过这个方案token 有效期 7 天我写了个小脚本每次运行前先检查本地 token 文件的修改时间超过 5 天就在日志里打一条醒目提醒同时给团队群发一条消息。这样既保证了自动化能跑又不会因为过期而突然全红。这里的关键是把过期这个不可控因素变成可预期的提醒而不是让脚本在某个凌晨突然失败然后没人知道。2.3 OCR 识别PIL 预处理加 ddddocr这是真正的自动化方案不需要人工介入适合长期无人值守的场景。技术栈通常就是两块图像预处理用 Pillow识别引擎用 ddddocr 或者 Tesseract。ddddocr 是这几年做接口自动化的人用得比较多的一个开源库它的优势是针对验证码场景做过专门训练对粘连字符、噪点、扭曲的鲁棒性比通用 OCR 强不少装完即用不需要额外配语言包。Tesseract 更通用但对验证码这种小尺寸、强干扰的图默认参数下识别率会低很多需要自己调 PSM 模式、字符白名单等一堆参数。实测下来对于 4 位数字字母混合、带轻微干扰的验证码ddddocr 不预处理直接上大概能到 85% 左右加上灰度化和二值化能到 93% 以上再配合失败重试三次单次登录的整体成功率能到 99.9% 以上。这个数字已经足够支撑无人值守了。2.4 第三方打码服务与自训练模型第三方打码服务分两类机器打码和人工打码。机器打码便宜但准确率跟 OCR 差不多人工打码准确率接近 100% 但每次调用都要钱而且有延迟通常 1 到 3 秒。如果你的用例量是每天几千次登录人工打码的成本会很快变得难以接受我一般只在极少数场景下用它比如关键版本的冒烟测试需要绝对可靠的登录保证。自训练模型是另一个极端。你需要先收集几百张验证码样本、人工标注、训练一个 CNN、调参、部署成服务。整个流程走下来大概一周效果可能比 ddddocr 高几个百分点。除非你的验证码样式非常特殊比如自定义字体加复杂背景否则我不建议自己训性价比太低。选型速查表方案实现成本稳定性维护成本适用场景测试环境豁免极低100%极低有后端配合业务逻辑测试会话复用低取决于有效期中拿不到配合token 有效期长OCR 识别中93%~99%中长期无人值守CI 流水线打码服务低99%高按次付费低频关键场景自训练模型高95%~99%高验证码样式特殊有算法能力3. 图像预处理把识别率从 60% 拉到 90% 的关键3.1 灰度化、二值化与阈值怎么定识别率上不去八成问题出在预处理而不是识别引擎。原始验证码图片通常是 RGB 的带彩色干扰线和渐变背景直接丢给 OCR引擎要先花力气判断哪些像素是字效果自然打折。第一步是灰度化把三通道压成单通道。这一步的本质是降维去掉颜色信息只保留亮度差异。Pillow 里一行代码img.convert(L)。第二步是二值化把灰度图变成纯黑白。这里最关键的是阈值选择。固定阈值比如 140简单粗暴对背景干净、对比度高的验证码效果不错但遇到背景有渐变或者光照不均的图就会出现一半全黑一半全白的惨状。更好的做法是用大津法Otsu自动计算阈值它通过最大化前景和背景的类间方差来找分割点原理不用深究知道它自适应就够了。from PIL import Image, ImageOps import io def to_binary(img_bytes, fixed_thresholdNone): img Image.open(io.BytesIO(img_bytes)).convert(L) # 自动对比度拉伸让明暗差异更明显 img ImageOps.autocontrast(img) if fixed_threshold is not None: img img.point(lambda p: 255 if p fixed_threshold else 0) else: # 用直方图近似 OtsuPillow 没有内置实现这里用分位数近似 hist img.histogram() total sum(hist) acc 0 for value, count in enumerate(hist): acc count if acc total * 0.5: threshold value break img img.point(lambda p: 255 if p threshold else 0) return img那什么时候该用固定阈值当你发现验证码的背景色基本恒定、光照一致的时候固定阈值反而更稳因为它不会因为某张图干扰线特别多而算偏。我的做法是先跑 50 张样本对比自动阈值和几个固定阈值的识别率哪个高用哪个然后把这个值写进配置。这活儿不优雅但有效。3.2 降噪、去干扰线与字符切割二值化之后图上通常还留着一些孤立的噪点像素和细干扰线。降噪的常用手段是中值滤波原理是把每个像素替换成周围邻域的中位数孤立的黑点会被周围的白点投票冲掉而字符的笔画因为成片存在能保留下来。from PIL import ImageFilter def denoise(img): # size3 的中值滤波对椒盐噪声效果好对字符损伤小 return img.filter(ImageFilter.MedianFilter(size3))这里有个细节滤波窗口不要开太大。我试过 size5噪点是没了但很多验证码的字符笔画本身就细会被直接抹掉识别率反而下降。size3 是验证码场景下的甜点值。尺寸放大是另一个提升明显的手段。很多验证码图片只有 100x40 甚至更小字符高度不到 20 像素OCR 引擎在这种尺寸下特征提取很吃力。用 Lanczos 插值放大 2 到 3 倍识别率经常能涨 5 个百分点以上。注意要用高质量插值算法不能用最近邻否则放大的只是马赛克块。字符切割是可选步骤只对固定位置、固定间距的验证码有效。做法是把二值图按列求和统计每列黑色像素的数量笔画所在列的黑色像素多字符之间的空隙列接近零找到这些零区就能把图切成单字符。切开后逐个识别准确率比整图识别高但一旦字符粘连哪怕只有一个像素相连这个方法就失效了而且切错位置会导致整张图全错。我的经验是能整图识别就整图识别切割只在识别率实在上不去的兜底场景里用。3.3 参数调优的实操记录我在一个 4 位数字小写字母、带 2 到 3 条干扰线、背景浅灰渐变的验证码上做过一组对比测试样本是 200 张实拍截图。结果如下处理组合识别正确数准确率备注原图直接识别11859.0%干扰线被当成字符仅灰度化14170.5%颜色干扰消除灰度 固定阈值 14017688.0%干扰线部分残留灰度 Otsu 近似阈值16884.0%渐变背景导致阈值偏斜灰度 阈值 140 中值滤波18391.5%噪点基本清除灰度 阈值 140 中值滤波 3 倍放大19195.5%笔画细节恢复上一步 识别失败重试 3 次19999.5%单次登录成功率这张表的读法是预处理每一步都有边际收益但重试策略的收益最大。因为验证码识别是独立的随机事件单次 95% 的成功率连续三次至少成功一次的概率是 1 - 0.05³ 99.9875%。所以与其死磕把单次识别率从 95% 提到 97%不如直接加个三次重试成本几乎为零。注意重试时一定要重新请求一张新验证码不能拿同一张图反复识别。我见过有同学写了个循环把同一张图识别三次然后取众数结果三次结果一样毫无意义纯粹浪费时间。4. 落地实现用 requests 加 pytest 打通登录链路4.1 目录结构与 fixture 设计先说说工程结构这个直接影响后续维护。我的习惯是按职责分层而不是按接口分层api_auto/ ├── conftest.py # 全局 fixturesession、ocr 引擎、登录态 ├── utils/ │ ├── captcha.py # 验证码下载 预处理 识别 │ ├── assert_helper.py # 断言封装 │ └── logger.py # 日志 ├── apis/ │ └── login_api.py # 登录相关接口封装 ├── testcases/ │ └── test_order.py └── pytest.ini关键点是 OCR 引擎要在 session 级别初始化一次。ddddocr 加载模型需要几百毫秒如果每个用例都新建一个实例200 个用例光初始化就要一分钟而且内存占用会飙升。用scopesession的 fixture 保证全局只有一个实例。# conftest.py 片段 import ddddocr import pytest import requests BASE_URL http://127.0.0.1:8080 pytest.fixture(scopesession) def ocr_engine(): # show_adFalse 关闭库自带的启动提示 return ddddocr.DdddOcr(show_adFalse) pytest.fixture(scopesession) def api_session(): s requests.Session() s.headers.update({ User-Agent: AutoTest/1.0, Content-Type: application/json, }) yield s s.close()这里为什么用requests.Session而不是直接requests.post因为 Session 会自动维护 cookie jar验证码接口和登录接口之间的 JSESSIONID 就是靠它传递的。如果你混用requests.get和requests.post每次都是新连接、新 cookie后端会发现这个 session 没请求过验证码直接返回验证码错误。这个坑我在第 5 章还会细说。4.2 验证码获取、识别、提交的完整链路把验证码处理和登录封装成一个函数对外只暴露给我一个可用 token这个语义这是接口封装的基本素养。# utils/captcha.py import io from PIL import Image, ImageFilter, ImageOps def preprocess(img_bytes, threshold140, scale3): img Image.open(io.BytesIO(img_bytes)).convert(L) img ImageOps.autocontrast(img) if scale 1: img img.resize((img.width * scale, img.height * scale), Image.LANCZOS) img img.filter(ImageFilter.MedianFilter(size3)) img img.point(lambda p: 255 if p threshold else 0) buf io.BytesIO() img.save(buf, formatPNG) return buf.getvalue()# apis/login_api.py import logging logger logging.getLogger(__name__) class LoginApi: def __init__(self, session, ocr): self.session session self.ocr ocr def fetch_captcha(self): resp self.session.get(f{BASE_URL}/api/captcha, timeout5) resp.raise_for_status() return resp.content def recognize(self, img_bytes): # ddddocr 的 classification 同时接受 bytes 和 PIL.Image code self.ocr.classification(img_bytes) logger.info(识别结果: %s, code) return code.strip().lower() def login(self, username, password, retry3): last_err None for i in range(retry): img self.fetch_captcha() code self.recognize(preprocess(img)) payload {username: username, password: password, captcha: code} resp self.session.post(f{BASE_URL}/api/login, jsonpayload, timeout5) body resp.json() if body.get(code) 0: logger.info(第 %d 次登录成功, i 1) return body[data][token] last_err body.get(msg) logger.warning(第 %d 次登录失败: %s, 验证码%s, i 1, last_err, code) raise AssertionError(f登录失败重试 {retry} 次后仍未通过: {last_err})这段代码里有三个设计决策值得展开说。第一个是retry参数放在登录函数内部而不是用装饰器。原因是重试必须包含重新获取验证码这一步如果只用通用的 HTTP 重试装饰器包住整个函数每次重试会重新走一遍完整流程虽然也能work但日志会乱而且不好在重试之间做特殊处理比如换一个阈值再识别一次。第二个是识别结果做了strip().lower()。ddddocr 偶尔会在结果里带空格或者大小写不一致而验证码校验通常是严格的字符串比较这一个小处理能省掉不少莫名的失败。第三个是失败时的日志。日志里必须打出来失败的验证码是什么这样当失败率异常升高时你可以直接把日志里的错误识别结果和实际图片对比快速判断是预处理参数漂移了还是验证码样式改版了。没有这条日志排查基本靠猜。4.3 会话保持与 token 注入登录成功后后续接口怎么带上身份两种常见方式cookie 和 header token。用 Session 的话cookie 是自动带的但 header 需要手动加。我通常在登录成功后统一往 session 的 headers 里塞一个 Authorizationpytest.fixture(scopesession) def auth_token(api_session, ocr_engine): api LoginApi(api_session, ocr_engine) token api.login(autotest, Test123456) api_session.headers[Authorization] fBearer {token} return token这里有个容易忽略的点token 应该挂在 session 级别的 fixture 上而不是全局模块变量。我曾经见过有人把 token 存在一个config.py的模块级变量里多个测试进程并发跑的时候互相覆盖A 进程登录的 token 被 B 进程覆盖掉然后 A 的所有用例开始报 401排查了半天。用 fixture 的好处是生命周期由 pytest 管理scope设成 session每个 worker 进程都有自己的 session互不干扰。如果你的 token 有效期很短比如 30 分钟跑大批量用例可能会中途过期。这时候可以在 Session 级别再加一层失效检测自定义一个requests.Session的子类重写request方法当返回 401 时自动调一次重新登录然后重放原请求。这个方案稍微复杂但对付短效 token 是唯一优雅的解法。4.4 断言规范接口自动化断言怎么写才不脆弱验证码这一环打通之后用例就该关注业务断言了。但断言写不好用例的稳定性照样堪忧。我把断言分成四层从外到内依次校验层级校验内容示例失败含义协议层HTTP 状态码assert resp.status_code 200服务或网关异常结构层JSON Schema字段存在、类型正确接口契约变更业务层业务返回码assert body[code] 0业务逻辑失败数据层关键字段值订单号非空、金额匹配数据计算错误结构层推荐用jsonschema库做校验比一堆assert xx in body可维护得多from jsonschema import validate LOGIN_SCHEMA { type: object, required: [code, msg, data], properties: { code: {type: integer}, msg: {type: string}, data: { type: object, required: [token, userId], properties: { token: {type: string, minLength: 10}, userId: {type: integer}, }, }, }, } def assert_login_schema(body): validate(instancebody, schemaLOGIN_SCHEMA)数据层断言有个大坑不要断言易变字段。比如创建时间的精确值、自增 ID 的具体数字、列表的排序顺序如果接口没承诺排序。我踩过的典型例子是断言订单号等于某个固定值结果数据库迁移后起始 ID 变了用例全红但业务其实完全正常。正确做法是断言订单号符合 20 位数字的格式而不是等于 12345678901234567890。断言要描述不变量而不是快照。5. 常见问题与排查技巧实录5.1 识别率忽高忽低的排查路径识别率不稳定的原因很多按排查顺序我一般这么走。先看是不是验证码本身变了。后端改版换了字体、加了新的干扰元素、调整了字符集都会导致原有参数失效。判断方法是把最近失败用例对应的图片保存下来人工看一眼如果明显能看出样式变化那就去更新预处理参数。再看是不是图片没刷新。有些实现里/captcha接口返回的是缓存的图片同一个 session 短时间内请求两次拿到的是同一张图而你第一次已经把这张图对应的答案提交过了服务端那边已经作废第二次必然失败。解决方式是每次登录前先请求一次/captcha/refresh或者带一个随机时间戳参数打散缓存。还有一种情况是并发导致的。如果你用 pytest-xdist 并行跑用例多个 worker 共享了同一个 Session 对象这种情况通常出现在你把 session 定义在模块级或者用全局单例的时候就会出现 A 拿的验证码被 B 提交了的情况。解决方式是确保每个 worker 有独立的 session或者干脆用--dist loadscope让同一个模块的用例分到同一个 worker。5.2 验证码与 session 不匹配的几个坑这个问题的表现是识别结果明明是对的但服务端就是说过期或错误。原因基本都是会话没对上。最常见的错误是混用requests.get和requests.Session。比如你用requests.get(url)拿验证码然后用session.post提交登录这两次请求的 cookie 是独立的服务端存的验证码和你提交的 session 对不上号。必须全程用同一个 Session 对象。第二个坑是手动设置了Cookieheader。有些人为了图省事直接在 headers 里硬写一个 Cookie 字符串这时候 requests 库会忽略 cookie jarSession 自动维护的 cookie 就失效了。正确的做法是让 Session 自己管 cookie不要手动碰这个 header。第三个坑是重定向。有些系统登录接口会返回 302 跳转requests 默认会跟随重定向重定向过程中可能触发一次额外的请求导致 session 状态被改变。排查方法是在请求里加allow_redirectsFalse观察一下真实响应如果确实是重定向问题就改成手动处理跳转逻辑。5.3 高频问题速查表现象最可能原因快速验证方式解决方向识别结果空字符串图片未下载完整打印 img_bytes 长度检查响应编码、加超时识别结果总是错预处理把字符抹掉了保存处理后的图片人工看降低阈值、关掉滤波提示验证码过期会话不一致打印两次请求的 cookie统一用同一个 Session登录偶发失败率高单次识别率不足统计 100 次识别正确数加三次重试token 中途失效有效期短记录 token 下发时间401 自动重登并行跑必失败session 被共享打印 Session 对象 id每 worker 独立 session图片是同一张服务端缓存连续请求两次比对 md5加随机参数打散缓存注意排查时一定要把处理后的中间图片保存到本地文件名带上时间戳和识别结果。这个习惯帮我省了大量时间因为问题往往是某几张特定样式的图识别不对光看日志根本看不出规律看图一眼就明白了。6. 稳定性与工程化的一点个人经验6.1 失败重试与降级策略重试不是简单地包个 for 循环需要有梯度。我用的策略是三级降级第一级用最优参数识别一次失败的话换一组更保守的预处理参数比如降低阈值、去掉滤波再识别一次因为不同样式的验证码对参数的敏感度不同换参数往往能救回来第二次还失败的话就休眠 200 毫秒重新请求一张全新的验证码避免陷入同一张图的死循环。三级都失败的概率大约是 0.05³两千次里出现一次这个频率下即使偶发失败也不会影响整体判断。如果真的遇到连续失败我建议直接在代码里抛异常并触发告警而不是无限重试因为连续失败通常意味着系统性故障验证码服务挂了、样式改版了无限重试只会掩盖问题。降级策略还有一个方向是缓存可用的登录态。如果你的测试环境允许多个 token 并存可以在每次成功登录后把 token 写到一个本地文件里下次脚本启动时先尝试用缓存的 token 调一个轻量接口比如获取用户信息验证是否有效有效就直接用无效再走完整的登录流程。这样即使在验证码服务临时故障的情况下已经跑过的用例也不会因为登录环节而全部失败。这个方案的关键是缓存的 token 要有明确的过期标记不能无限信任。6.2 把验证码处理封装成独立模块最后说说封装。验证码处理这块逻辑跟业务测试完全无关应该彻底隔离成一个可以单独测试的模块。我会给它设计这样几个接口fetch()负责下载、preprocess()负责图像处理、recognize()负责识别、solve()是对外的组合方法。这样分层的好处是当识别率下降时你可以写一个测试脚本直接喂 100 张历史图片给recognize()快速定位问题出在下载、预处理还是识别环节而不需要跑整个用例集。另外建议把历史图片和识别结果存一份到本地或者对象存储形成一个小型的回归集。每次调整预处理参数后用这个回归集跑一遍看看准确率是升了还是降了比凭感觉改参数靠谱得多。我这个回归集从最初的 50 张积累到了现在的 400 多张覆盖了各个时期的不同样式每次改参数跑一次只要十几秒已经成为我改这块代码的固定动作。提示回归集里的图片记得脱敏验证码本身虽然价值不高但如果图片里混进了其他敏感信息比如截图带上了页面其他内容存档到仓库里就不合适了。我一般只裁剪验证码区域再保存。
返回列表