reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
135
vendor-new/xbsReverseSkill/web-verify-patcher/SKILL.md
Normal file
135
vendor-new/xbsReverseSkill/web-verify-patcher/SKILL.md
Normal file
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: web-verify-patcher
|
||||
description: "网页验证码识别、方案选择与授权验证流程分析技能。当用户询问验证码识别、验证码类型、这是什么验证码、验证码方案、风控验证、WAF challenge、captcha recognition/type,或提到滑块/拼图、点选/文字点选/九宫格、旋转、文字/数字/算术、语音、拖放、轨迹绘制、刮刮卡、图片/图像复原、切片乱序、分块乱序、图片分割、瓦片重排、分割顺序打乱、区域/面积选择、差异点击/找茬、字体识别、空间语义、小游戏、PoW/工作量证明、无感/无痕/风险评分、一键/checkbox、多轮、问答、活体/人脸,或提到极验、易盾、腾讯、阿里云、数美、顶象、百度、京东云、云片、reCAPTCHA、hCaptcha、Turnstile、AWS WAF、DataDome、Arkose/FunCaptcha、Akamai、Imperva、PerimeterX/HUMAN、Kasada、ALTCHA/FriendlyCaptcha 等国内外验证码/风控厂商时使用。用于识别网页验证码/验证产品、输出方案,并在用户确认后编排离线求解、切片乱序图片还原、用户手动成功样本基线采集、坐标/轨迹生成、打码平台请求模板、失败复盘/方案切换和授权验证测试;打开真实网页时按 ruyiPage/Camoufox/CloakBrowser 取证模式。"
|
||||
---
|
||||
|
||||
# Web Verify Patcher(网页验证码识别与验证方案分析)
|
||||
|
||||
使用这个技能分析网页验证码或网页验证材料,输出安全、可落地的“识别 + 厂商判断 + 验证分析方案 + 授权验证流程”。第一阶段做类型/厂商识别和方案选择;真实网页取证时先建立用户手动成功样本基线;第二阶段只在用户明确选择方案并确认授权后,编排离线求解、坐标/轨迹生成、平台请求模板、失败复盘、方案切换或授权验证测试。
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 优先基于用户已提供的离线证据分析:HTML 片段、脚本 URL、iframe URL、页面可见提示文案、截图元信息、厂商参数名、网络接口名。
|
||||
2. 如果必须打开真实网页取证,先读取 `references/browser-acquisition.md`,并按其中的取证模式执行。启动任何浏览器前先让用户确认模式:ruyiPage + RuyiTrace、仅 ruyiPage、Camoufox + camoufox-reverse-mcp、仅 Camoufox、CloakBrowser、用户手动取证或 AI 自行决定。用户未确认前,不要打开页面、截图、抓包、注入 Hook、读取 Cookie/Storage 或启动任何浏览器工具。
|
||||
3. 打开网页时不要直接使用普通 Playwright、Puppeteer、系统浏览器或 CDP 路线;已选模式不可用时,暂停并让用户确认安装、提供路径、降级或切换,不要静默 fallback。验证码、登录、MFA 或设备验证出现时暂停,让用户手动完成或改为离线分析;授权取证时让用户多次手动完成验证码成功样本,用 `scripts/evaluate_success_baseline.py` 判断成功基线是否足够。
|
||||
4. 用现有证据运行离线分类脚本:
|
||||
- `python scripts/classify_verify.py --html page.html --url "https://example.test/login" --text "拖动滑块完成拼图" --pretty`
|
||||
- `--html`、`--text`、`--screenshot-meta` 既可以传文件路径,也可以直接传字符串。
|
||||
5. 得到初步分类后,再按需读取参考文件:
|
||||
- 需要判断厂商/产品特征时读 `references/provider-products.md`。
|
||||
- 需要判断验证码形态和证据要求时读 `references/captcha-types.md`。
|
||||
- 需要按类型给方案时读 `references/solution-playbooks.md`。
|
||||
- 需要打开真实网页、截图、抓包或采集页面证据时读 `references/browser-acquisition.md`。
|
||||
- 如果 `image-restore` 命中 `captcha_variant: tile-scramble`,先用 `scripts/analyze_tile_restore.py` 判断是否是切片/分块乱序图,再分析 `tileOrder`、`pieceOrder`、`background-position`、`drawImage` 或纯图片边缘连续性。
|
||||
6. 用户从 `solution_options` 中选择方案并明确确认后,进入第二阶段:
|
||||
- 总流程必须读 `references/verification-workflow.md`。
|
||||
- 使用开源/本地方案时读 `references/open-source-recipes.md`。
|
||||
- 使用打码平台时读 `references/solver-platform-recipes.md`。
|
||||
- 需要坐标换算、滑块/拖放/刮刮卡/轨迹绘制时读 `references/motion-and-coordinate.md`,优先用 `scripts/map_coordinates.py` 和 `scripts/generate_motion_track.py` 生成离线结果。
|
||||
- 需要厂商执行注意点时读 `references/provider-execution-notes.md`。
|
||||
- 进入真实网页验证前,先评估用户手动成功样本基线:默认同一授权目标至少 5 次成功样本;若观察到新的验证码类型,该类型至少 2 次成功样本。基线不足时输出强提示,但用户确认后仍可继续离线分析或受控验证。
|
||||
- 同一授权目标、同一验证码类型、同一用户选择方案出现连续失败时,用 `scripts/evaluate_verification_attempts.py` 复盘 attempts JSON;连续 5 次失败且图片/坐标/轨迹/切片还原/补环境/challenge 新鲜度均无明显异常时,主动建议 `recommended_next_route: platform-control`。
|
||||
- 真实页面点击、拖动、提交或抓取 Cookie/Storage 前必须再次让用户确认授权目标、执行模式和浏览器取证模式。
|
||||
7. 输出报告时必须包含:
|
||||
- `captcha_type`
|
||||
- `provider`
|
||||
- 置信度和命中的信号
|
||||
- 为什么判断为该类型/厂商
|
||||
- 推荐方案:先给开源/本地方案,再给低通过率时的打码平台或人工/厂商备选,最后说明切换条件
|
||||
- 关键风险和缺失证据
|
||||
- 第二阶段执行时还必须包含:用户选择的方案、执行前检查结果、是否需要真实网页操作、需要用户确认的动作、产物路径或 JSON 结果
|
||||
- 真实网页取证或第二阶段执行前还必须包含:`success_baseline_status`、`success_baseline_summary`、`missing_success_samples`
|
||||
- 第二阶段失败复盘时还必须包含:`attempt_summary`、`diagnosis_status`、`switch_triggered`、`recommended_next_route`、`platform_control_plan`、`requires_user_confirmation`
|
||||
|
||||
## 分类标签
|
||||
|
||||
使用这些固定类型标签,便于脚本和报告保持一致:
|
||||
|
||||
- `text`:文字、数字、字母数字混合或简单图片验证码。
|
||||
- `math`:算术验证码,需要先 OCR 再解析表达式。
|
||||
- `slider`:滑块/拼图验证码,需要识别缺口或目标偏移。
|
||||
- `click-select`:点选文字、图标、物体或按顺序点击目标。
|
||||
- `rotate`:旋转图片或物体,使其转正或对齐。
|
||||
- `grid`:九宫格或多宫格图片分类验证码。
|
||||
- `audio`:语音/音频验证码,需要识别播放内容。
|
||||
- `drag-drop`:拖放物体到目标区域,不等同于单纯滑块。
|
||||
- `trace-draw`:轨迹绘制、连线或画线验证。
|
||||
- `scratch`:刮刮卡式验证,需要刮开或覆盖指定比例。
|
||||
- `image-restore`:图片/图像复原、乱序拼图、滑动还原;切片/分块顺序打乱时保留主类型,并补充 `captcha_variant: tile-scramble`。
|
||||
- `area-select`:框选、圈选或选择图片区域。
|
||||
- `difference-click`:找不同、找茬或点击差异点。
|
||||
- `font-identify`:选择相同/不同字体或字体样式识别。
|
||||
- `semantic-reasoning`:空间语义、视觉关系或逻辑图片题。
|
||||
- `game-challenge`:小游戏、3D、骰子或 Arkose/FunCaptcha 类交互题。
|
||||
- `pow-challenge`:工作量证明/浏览器计算挑战,如 FriendlyCaptcha、ALTCHA、Cap.js、mCaptcha。
|
||||
- `risk-score`:无感、无痕、隐形或风险评分验证,如 reCAPTCHA v3 类。
|
||||
- `one-click`:一键、checkbox、按住或点击完成验证。
|
||||
- `multi-step`:多轮、多步或分页挑战;优先同时记录具体子类型。
|
||||
- `qa-logic`:问答、逻辑题或安全问题式验证码。
|
||||
- `biometric-liveness`:活体、人脸或生物识别验证;只做边界清晰的识别和合规建议。
|
||||
- `token-widget`:基于 `sitekey`、`pageurl`、`action` 的组件,如 reCAPTCHA、hCaptcha、Turnstile,当前未展示具体图片题。
|
||||
- `waf-challenge`:反自动化/WAF 验证,常见输出是 clearance cookie、WAF token、PoW 结果或环境检查结果。
|
||||
- `unknown-custom`:自研、未知或证据不足。
|
||||
|
||||
使用这些固定厂商标签:
|
||||
|
||||
- `recaptcha`、`hcaptcha`、`cloudflare-turnstile`、`cloudflare-waf`、`geetest`、`tencent-tcaptcha`、`netease-yidun`、`aliyun-captcha`、`shumei-captcha`、`dingxiang-captcha`、`baidu-captcha`、`jdcloud-captcha`、`yunpian-captcha`、`huawei-captcha`、`tongdun-risk`、`aws-waf`、`datadome`、`arkose-funcaptcha`、`mtcaptcha`、`keycaptcha`、`friendlycaptcha`、`altcha`、`yandex-smartcaptcha`、`captchafox`、`prosopo-procaptcha`、`trustcaptcha`、`private-captcha`、`capjs`、`mcaptcha`、`iconcaptcha`、`botdetect`、`securimage`、`visualcaptcha`、`amazon-captcha`、`cybersiara`、`aj-captcha`、`tianai-captcha`、`easycaptcha`、`happycaptcha`、`kaptcha`、`akamai-bot-manager`、`imperva-incapsula`、`perimeterx-human`、`kasada`、`netacea`、`radware-bot-manager`、`f5-bot-defense`、`custom-or-unknown`。
|
||||
|
||||
## 输出格式
|
||||
|
||||
面向用户输出时建议使用这个结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"captcha_type": "slider",
|
||||
"captcha_variant": null,
|
||||
"variant_confidence": null,
|
||||
"restore_strategy": null,
|
||||
"tile_restore_evidence": [],
|
||||
"provider": "geetest",
|
||||
"confidence": 0.91,
|
||||
"signals": ["命中 geetest 脚本 URL", "命中 captcha_id 参数", "页面文案包含滑块/拼图"],
|
||||
"recommended_playbook": "references/solution-playbooks.md#slider",
|
||||
"solution_options": {
|
||||
"open_source_first": ["ddddocr slide-match/slide-comparison", "OpenCV 模板/边缘/差分匹配", "轨迹模型与坐标校准"],
|
||||
"fallback_platforms": ["云码/JFBYM 滑块类型", "超级鹰滑块/坐标类型", "CapSolver", "2Captcha GeeTest/slider 类任务"],
|
||||
"when_to_switch": ["缺口弱边缘", "背景乱序/透明块", "厂商行为评分导致视觉偏移正确但验证失败"],
|
||||
"notes": "视觉偏移、DOM 坐标、拖动轨迹和厂商加密参数要分开分析。"
|
||||
},
|
||||
"summary": "疑似极验滑块验证。建议重点分析图片提取、缺口/偏移识别,以及授权测试流程中的参数绑定关系。",
|
||||
"missing_evidence": ["挑战区域 HTML", "背景图/滑块图 URL", "verify 接口名"]
|
||||
}
|
||||
```
|
||||
|
||||
默认优先建议开源/本地方案,例如 ddddocr、OpenCV、Tesseract、Whisper/faster-whisper、YOLO/CLIP/VLM;当开源方案样本通过率不足、题型过于复杂时,再建议用户自行选择打码平台或人工接管,例如云码/JFBYM、超级鹰、2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha、YesCaptcha/NoCaptchaAI。不要替用户执行提交、token 注入或未授权绕过。
|
||||
|
||||
第二阶段执行计划建议使用这个结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "verification-flow",
|
||||
"chosen_solution": "open-source-slider",
|
||||
"authorization_scope": "用户确认的自有/授权测试目标",
|
||||
"preflight": ["证据足够", "依赖可用", "不需要真实网页操作"],
|
||||
"success_baseline_status": "insufficient",
|
||||
"success_baseline_summary": {
|
||||
"total_success_samples": 0,
|
||||
"min_total_success_samples": 5,
|
||||
"min_success_samples_per_type": 2,
|
||||
"observed_captcha_types": []
|
||||
},
|
||||
"missing_success_samples": [],
|
||||
"offline_steps": ["识别缺口偏移", "换算 DOM 坐标", "生成滑块轨迹 JSON"],
|
||||
"requires_live_browser": false,
|
||||
"attempt_summary": null,
|
||||
"diagnosis_status": null,
|
||||
"switch_triggered": false,
|
||||
"recommended_next_route": "continue-current-route-with-diagnostics",
|
||||
"platform_control_plan": {
|
||||
"role": "授权 QA 对照,不默认发送请求",
|
||||
"send_request": false
|
||||
},
|
||||
"requires_user_confirmation": ["如需在真实页面拖动并提交,必须再次确认"],
|
||||
"artifacts": ["offset/coordinates/track JSON"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Web Verify Patcher"
|
||||
short_description: "识别网页验证码类型、厂商产品,并分析成功样本基线、切片乱序还原、失败复盘和授权验证流程"
|
||||
default_prompt: "使用 $web-verify-patcher 识别网页验证码类型、厂商产品,给出方案;真实网页取证时先采集用户手动成功样本基线;如果遇到切片乱序图片,先判断分块还原逻辑;在确认授权后编排验证分析流程,并在连续失败时复盘是否需要切换到平台对照。"
|
||||
@@ -0,0 +1,286 @@
|
||||
# 浏览器取证与打开网页规则
|
||||
|
||||
本文件用于验证码识别任务中必须打开真实网页、截图、抓包、收集脚本或观察验证组件时。默认优先离线分析用户已提供的 HTML、截图、URL、脚本片段和页面文案;只有证据不足且用户确认授权范围后,才进入浏览器取证。
|
||||
|
||||
## 何时读取
|
||||
|
||||
出现以下任一情况时读取本文件:
|
||||
|
||||
- 需要打开目标网页。
|
||||
- 需要截图、查看页面标题、读取组件 DOM 或 iframe。
|
||||
- 需要抓包、导出 HAR/cURL、查看请求 initiator。
|
||||
- 需要收集 JS bundle、chunk、sourcemap。
|
||||
- 需要注入最小 Hook、观察 XHR/fetch、Cookie 或 Storage 写入。
|
||||
- 页面出现验证码、登录、MFA、设备验证或风控验证。
|
||||
- 需要采集用户手动完成验证码后的成功基线样本。
|
||||
- 用户明确要求避免普通自动化、CDP 或 WebDriver 检测。
|
||||
|
||||
## 硬性规则
|
||||
|
||||
- 启动任何浏览器前,先让用户确认取证模式。
|
||||
- 用户未确认前,只能做离线材料分析和缺失证据提示。
|
||||
- 不要直接使用普通 Playwright、Puppeteer、Selenium、系统 Chrome、系统 Firefox 或 CDP 路线打开目标页。
|
||||
- 已选模式后,后续所有浏览器取证动作都沿用该模式。
|
||||
- 工具不可用、路径缺失、runtime 不合格、需要登录或必须更换工具时,暂停并让用户确认;不要静默 fallback 到普通浏览器自动化。
|
||||
- 出现登录、验证码、MFA、设备验证时暂停,让用户手动完成;不要自动破解、代过验证、注入 token 或调用第三方打码服务。
|
||||
- 授权取证中需要建立成功基线时,用户手动完成验证码;AI 只采集经确认的证据、成功状态和时间线,不替用户提交或代过。
|
||||
- 浏览器取证只用于识别和证据采集,不进入最终交付方案作为自动化执行路径。
|
||||
|
||||
## 取证模式
|
||||
|
||||
让用户从以下模式中选择:
|
||||
|
||||
| 模式 | 说明 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| ruyiPage + RuyiTrace | 使用 ruyiPage 打开页面并采集网络/页面证据,RuyiTrace 采集运行时日志 | 高风控、需要环境日志时推荐 |
|
||||
| 仅 ruyiPage | 使用 ruyiPage 打开页面、截图、抓包、收集 JS,不采集 RuyiTrace 日志 | 只需要网页证据时 |
|
||||
| Camoufox + camoufox-reverse-mcp | 使用 Camoufox 反指纹浏览器和 MCP 工具做网络、脚本、Hook 与调用栈取证 | 需要 Firefox/Camoufox 路线和 MCP 工具链时 |
|
||||
| 仅 Camoufox | 使用 Camoufox 官方 API 做轻量取证 | 用户已有 Camoufox 或只需打开页面时 |
|
||||
| CloakBrowser | 使用 CloakBrowser 有头 + humanize 的 Chromium 路线 | 目标更适合 Chromium 或用户已有 CloakBrowser 时 |
|
||||
| 用户手动取证 | 用户自行提供 HTML、截图、HAR、cURL、JS 文件、调用栈截图 | 不允许自动化或需要真实登录态时 |
|
||||
| AI 自行决定 | 先检测本机工具,提出将使用的模式,用户确认后再启动 | 用户不确定时 |
|
||||
|
||||
## 各模式最低要求
|
||||
|
||||
### ruyiPage / RuyiTrace
|
||||
|
||||
- 使用有头模式。
|
||||
- 使用专用临时 profile。
|
||||
- 从第一次打开目标页开始使用 ruyiPage,不先用普通浏览器探测。
|
||||
- 如果选择 ruyiPage + RuyiTrace,但 RuyiTrace 不可用,不得静默降级;让用户确认安装、提供路径或明确降级为仅 ruyiPage。
|
||||
- 导航后应确认 `navigator.webdriver === false`。
|
||||
- 只检测到系统 Firefox fallback 不视为通过;必须确认 ruyiPage 定制 Firefox runtime 可用。
|
||||
|
||||
### Camoufox / camoufox-reverse-mcp
|
||||
|
||||
- 使用 Camoufox 官方入口或 MCP 工具,不用普通 Playwright 指向系统浏览器。
|
||||
- 默认有头:`headless=False`。
|
||||
- 默认启用拟人行为:`humanize=True`。
|
||||
- 如使用代理,需考虑语言、时区、地理位置、WebRTC 与出口 IP 的一致性。
|
||||
- 选择 MCP 但 MCP 不可用时,不得静默降级为仅 Camoufox;先让用户确认。
|
||||
|
||||
### CloakBrowser
|
||||
|
||||
- 使用 CloakBrowser 官方包装器入口。
|
||||
- 默认有头并启用 `humanize`。
|
||||
- 不直接调用普通 `chromium.launch()`、`puppeteer.launch()` 或普通 browserType 启动。
|
||||
- 需要登录态或高风控时,优先使用持久化 profile;profile 是否保留或删除由用户确认。
|
||||
|
||||
## 工具缺失时的引导
|
||||
|
||||
所选模式的工具缺失、包可导入但浏览器本体缺失、路径缺失或 runtime 不合格时,必须先向用户说明当前缺口,并提供“已安装则提供路径 / 未安装则确认安装目录和安装方式 / 明确降级或切换模式”的选项。不要静默 fallback 到普通 Playwright、Puppeteer、Selenium、系统 Chrome、系统 Firefox 或 CDP 路线。
|
||||
|
||||
真实安装、下载、克隆或修改环境前,先输出安装计划,等待用户确认后再执行。安装计划中只使用相对目录、用户提供目录或占位符,不要写入当前机器的绝对路径。
|
||||
|
||||
### ruyiPage / RuyiTrace 缺失
|
||||
|
||||
优先检测:
|
||||
|
||||
```bash
|
||||
python -c "import ruyi; print('ruyiPage package ok')"
|
||||
python -c "import requests; print('requests ok')"
|
||||
python -c "from pathlib import Path; print('请在检测脚本或用户提供路径中校验 ruyiPage 定制 Firefox runtime')"
|
||||
```
|
||||
|
||||
如果已有外部检测脚本,可使用等价命令检查 ruyiPage browsers 目录、定制 Firefox 可执行文件和 RuyiTrace 目录:
|
||||
|
||||
```bash
|
||||
node scripts/check_external_tools.js --markdown
|
||||
node scripts/check_external_tools.js --python <python> --ruyipage-install-dir <ruyipage-browsers-dir> --markdown
|
||||
node scripts/check_external_tools.js --python <python> --ruyipage-browser-path <firefox-exe> --ruyitrace-home <RuyiTrace-dir> --json
|
||||
```
|
||||
|
||||
未检测到 ruyiPage 定制 Firefox runtime 时,使用这个提示:
|
||||
|
||||
```markdown
|
||||
当前没有检测到 ruyiPage 定制 Firefox runtime,或 ruyiPage 可能会退回系统 Firefox。系统 Firefox 不视为通过。
|
||||
|
||||
请确认:
|
||||
1. 你是否已经提前安装好 ruyiPage 定制 Firefox?
|
||||
2. 如果已经安装,请提供 ruyiPage browsers 安装目录或定制 Firefox 可执行文件路径。
|
||||
3. 如果没有安装,请提供希望安装到的目录;我会先输出安装计划,确认后再安装或下载 runtime。
|
||||
|
||||
在检测通过前,我不会改用普通 Playwright、Puppeteer、系统 Firefox 或 CDP 取证。
|
||||
```
|
||||
|
||||
选择 `ruyiPage + RuyiTrace` 但未检测到 RuyiTrace 时,使用这个提示:
|
||||
|
||||
```markdown
|
||||
当前没有检测到可用 RuyiTrace,或 RuyiTrace 目录不完整。你选择的是 ruyiPage + RuyiTrace,因此不能静默降级为仅 ruyiPage。
|
||||
|
||||
请确认:
|
||||
1. 是否已经安装 RuyiTrace?如果已安装,请提供 RuyiTrace 目录。
|
||||
2. 如果没有安装,请提供希望下载或解压到的目录;我会先输出安装计划,确认后再下载或安装。
|
||||
3. 如果暂时不需要 RuyiTrace NDJSON,请明确回复“降级为仅 ruyiPage”。
|
||||
```
|
||||
|
||||
### Camoufox / camoufox-reverse-mcp 缺失
|
||||
|
||||
优先检测:
|
||||
|
||||
```bash
|
||||
python -c "from camoufox.sync_api import Camoufox; print('Camoufox package ok')"
|
||||
python -m camoufox path
|
||||
```
|
||||
|
||||
Camoufox 官方流程不只是安装 Python 包,还需要下载浏览器本体。常见安装和下载命令:
|
||||
|
||||
```bash
|
||||
python -m pip install camoufox --upgrade
|
||||
python -m camoufox fetch
|
||||
python -m camoufox path
|
||||
```
|
||||
|
||||
如果已有外部检测脚本,可使用等价命令检查 Python 包、浏览器缓存目录和 MCP 项目目录:
|
||||
|
||||
```bash
|
||||
node scripts/check_external_tools.js --python <python> --require-camoufox --markdown
|
||||
node scripts/check_external_tools.js --python <python> --require-camoufox --camoufox-install-dir <camoufox-cache-dir> --markdown
|
||||
node scripts/check_external_tools.js --python <python> --require-camoufox --require-camoufox-mcp --camoufox-mcp-project-dir <camoufox-reverse-mcp-dir> --json
|
||||
```
|
||||
|
||||
未检测到 Camoufox 或未检测到 `python -m camoufox fetch` 下载的浏览器本体时,使用这个提示:
|
||||
|
||||
```markdown
|
||||
当前没有检测到可用 Camoufox,或只检测到 Python 包但未检测到 `python -m camoufox fetch` 下载的浏览器本体。
|
||||
|
||||
请确认:
|
||||
1. 你是否已经在某个 Python / venv 中安装 Camoufox?如果已安装,请提供 Python 解释器路径或 venv 激活方式。
|
||||
2. 你是否已经执行过 `python -m camoufox fetch`?如果已执行,请提供 `python -m camoufox path` 输出,或 Camoufox 缓存目录。
|
||||
3. 如果没有安装,请提供希望使用的 Python / venv 和下载缓存目录;如果不提供目录,我会说明将使用 Camoufox 默认缓存目录。
|
||||
|
||||
在检测通过前,我不会改用普通 Playwright、Puppeteer、系统 Firefox 或 CDP 取证。
|
||||
```
|
||||
|
||||
选择 `Camoufox + camoufox-reverse-mcp` 但 MCP 缺失时,使用这个提示:
|
||||
|
||||
```markdown
|
||||
当前没有检测到 camoufox-reverse-mcp。你选择的是 Camoufox + camoufox-reverse-mcp,因此不能直接降级为仅 Camoufox。
|
||||
|
||||
请确认:
|
||||
1. 是否已经克隆并安装 camoufox-reverse-mcp?如果已安装,请提供项目目录或可导入该包的 Python / venv。
|
||||
2. 如果未安装,请提供希望克隆到的目录;我会先输出安装计划,确认后再执行 `git clone <camoufox-reverse-mcp-repo-url> <camoufox-reverse-mcp-dir>` 与 `python -m pip install -e .`。
|
||||
3. 如果你不想安装 MCP,请明确回复“降级为仅 Camoufox”。
|
||||
```
|
||||
|
||||
### CloakBrowser 缺失
|
||||
|
||||
优先检测 Python 或 Node.js 包,以及 stealth Chromium 二进制是否存在:
|
||||
|
||||
```bash
|
||||
python -c "import cloakbrowser; print('CloakBrowser Python package ok')"
|
||||
python -m cloakbrowser info
|
||||
npx cloakbrowser info
|
||||
```
|
||||
|
||||
如果已有外部检测脚本,可使用等价命令检查 Python 包、Node 项目目录和二进制路径:
|
||||
|
||||
```bash
|
||||
node scripts/check_external_tools.js --require-cloakbrowser --markdown
|
||||
node scripts/check_external_tools.js --require-cloakbrowser --cloakbrowser-project-dir <node-project-dir> --markdown
|
||||
node scripts/check_external_tools.js --python <python> --require-cloakbrowser --cloakbrowser-binary-path <chromium-or-chrome-path> --json
|
||||
```
|
||||
|
||||
未检测到 CloakBrowser 时,使用这个提示:
|
||||
|
||||
```markdown
|
||||
当前未检测到可用 CloakBrowser 环境。
|
||||
|
||||
请确认:
|
||||
1. 你是否已经提前安装好 CloakBrowser?
|
||||
2. 如果已经安装,请提供 Python 解释器 / Node 项目目录 / CloakBrowser Chromium 二进制路径。
|
||||
3. 如果没有安装,请确认希望使用 Python 路线还是 Node.js 路线,并提供安装目录或项目目录;我会先输出安装计划,确认后再安装或预下载二进制。
|
||||
|
||||
在 CloakBrowser 检测通过前,我不会改用普通 Playwright、Puppeteer、系统 Chrome 或 CDP 取证。
|
||||
```
|
||||
|
||||
Python 路线:
|
||||
|
||||
```bash
|
||||
python -m pip install cloakbrowser playwright --upgrade
|
||||
python -m cloakbrowser install
|
||||
python -m cloakbrowser info
|
||||
python -m cloakbrowser update
|
||||
python -m cloakbrowser clear-cache
|
||||
```
|
||||
|
||||
Node.js / Playwright 路线:
|
||||
|
||||
```bash
|
||||
npm install cloakbrowser playwright-core
|
||||
npx cloakbrowser install
|
||||
npx cloakbrowser info
|
||||
npx cloakbrowser update
|
||||
npx cloakbrowser clear-cache
|
||||
```
|
||||
|
||||
Node.js / Puppeteer 路线只有用户明确要求 Puppeteer 风格 API 时才使用:
|
||||
|
||||
```bash
|
||||
npm install cloakbrowser puppeteer-core
|
||||
```
|
||||
|
||||
## 取证顺序
|
||||
|
||||
1. 确认授权范围和目标页面。
|
||||
2. 让用户确认取证模式。
|
||||
3. 检测用户选择的工具是否可用。
|
||||
4. 工具不可用时,暂停并让用户确认安装、提供路径、降级或切换模式。
|
||||
5. 按确认模式从第一次导航开始打开页面。
|
||||
6. 如需要登录、验证码、MFA 或设备验证,暂停等待用户手动完成。
|
||||
7. 如果本次取证用于后续验证方案或失败复盘,采集用户手动成功样本基线:
|
||||
- 默认同一授权目标至少 5 次成功样本。
|
||||
- 如果过程中出现新的验证码类型,每个新类型至少补到 2 次成功样本。
|
||||
- 每轮记录验证码类型、厂商、变体、成功前后截图或截图元信息、验证码区域 DOM 摘要、脚本/iframe URL、关键请求摘要、成功 UI/callback/response token 或服务端成功状态、challenge id、时间线和刷新/切题信息。
|
||||
- 动态切题时不要只保留最后一轮;每一轮都作为独立 `success_samples` 记录。
|
||||
8. 用 `scripts/evaluate_success_baseline.py --samples success_samples.json --pretty` 评估成功基线;不足时输出强提示,用户确认后仍可继续离线分析或受控验证。
|
||||
9. 只执行最少必要操作:截图、网络捕获、脚本列表、接口 initiator、页面文案和组件特征。
|
||||
10. 用采集到的 HTML、URL、文案或截图元信息运行 `scripts/classify_verify.py`。
|
||||
11. 输出类型、厂商、命中信号、置信度、推荐方案和成功基线状态。
|
||||
|
||||
## 成功样本基线格式
|
||||
|
||||
建议把用户手动完成的成功样本整理为:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization_scope": "用户确认的自有/授权测试目标",
|
||||
"provider": "custom-or-unknown",
|
||||
"success_samples": [
|
||||
{
|
||||
"sample_id": "manual-success-001",
|
||||
"success": true,
|
||||
"captcha_type": "slider",
|
||||
"captcha_variant": null,
|
||||
"evidence": {
|
||||
"screenshot_before": "<relative path or description>",
|
||||
"screenshot_after": "<relative path or description>",
|
||||
"dom_summary": "<captcha container summary>",
|
||||
"script_or_iframe_urls": ["<url without secrets>"],
|
||||
"network_summary": ["<request/response summary without secrets>"],
|
||||
"success_signal": "UI 显示验证成功 / callback 被调用 / 服务端返回成功",
|
||||
"challenge_id": "<non-secret challenge id if available>",
|
||||
"timeline": ["challenge rendered", "user solved", "success state observed"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
不要把 Cookie、Authorization、账号材料、API key 或可复用 token 写入样本文件;需要说明时只写字段名、状态和脱敏摘要。
|
||||
|
||||
## 用户确认模板
|
||||
|
||||
```markdown
|
||||
本次需要打开真实网页取证。为避免普通自动化或 CDP 特征影响验证码判断,请先确认取证模式:
|
||||
|
||||
- ruyiPage + RuyiTrace
|
||||
- 仅 ruyiPage
|
||||
- Camoufox + camoufox-reverse-mcp
|
||||
- 仅 Camoufox
|
||||
- CloakBrowser
|
||||
- 用户手动取证
|
||||
- AI 自行决定
|
||||
|
||||
在你确认前,我不会打开页面、截图、抓包、读取 Cookie/Storage 或启动浏览器工具。
|
||||
```
|
||||
@@ -0,0 +1,222 @@
|
||||
# 验证码类型
|
||||
|
||||
在 `scripts/classify_verify.py` 给出初步类型后读取本文件。这里整理的是判断依据、证据需求和方案方向,不包含未授权求解或自动提交步骤。
|
||||
|
||||
## 判断表
|
||||
|
||||
| 类型 | 主要信号 | 有用证据 | 应向用户输出 |
|
||||
| --- | --- | --- | --- |
|
||||
| `text` | “输入验证码”、验证码输入框旁的图片、短位数扭曲字符、`ImageToText` | 验证码图片 URL 或元素截图、字符集、刷新逻辑 | OCR/预处理方案、字符集约束、置信度风险 |
|
||||
| `math` | “计算结果”、算术表达式、`1+2`、`7 x 8`、算术验证码 | 图片/文本表达式、答案格式、运算符集合 | OCR 后解析表达式的方案、歧义处理 |
|
||||
| `slider` | “拖动滑块”“拼图”“缺口”、滑块手柄、背景图/滑块图 | 背景图、滑块图、canvas 状态、坐标系、轨道宽度 | 缺口/偏移识别方案、坐标校准、行为验证注意点 |
|
||||
| `click-select` | “依次点击”“点击文字”、英文 click/select/tap、词语或物体提示 | 题面文本、截图、目标坐标、坐标原点 | 题面识别和坐标返回方案 |
|
||||
| `rotate` | “旋转”“拖动使图片正向”、角度、旋转手柄 | 挑战图片、轨道宽度、角度范围、拖动映射 | 角度识别和偏移换算方案 |
|
||||
| `grid` | “九宫格”“选择所有”、3x3 图片网格、红绿灯/斑马线/自行车等提示 | 完整网格截图、题面、格子边界、多轮状态 | 目标分类和格子编号/坐标方案 |
|
||||
| `audio` | “语音验证码”“音频验证码”、听音输入、播放按钮或音频资源 | 音频文件/播放接口、题面、答案格式、视觉替代方案 | 音频转写、数字/字符约束、可访问性替代 |
|
||||
| `drag-drop` | “拖放”“拖到目标区域”、draggable/droppable/dropzone | 被拖动元素、目标区域、坐标系、释放判定 | 目标定位、拖放路径、释放点校准 |
|
||||
| `trace-draw` | “轨迹绘制”“连线”“画线”、draw/trace/connect dots | 目标路径截图、canvas 尺寸、采样点格式 | 路径提取、采样重建、轨迹格式分析 |
|
||||
| `scratch` | “刮刮卡”“刮开”、scratch card | 刮开前后状态、刮动区域、通过阈值 | 覆盖比例、轨迹采样、状态变化分析 |
|
||||
| `image-restore` | “图像复原”“图片还原”“乱序拼图”“滑动还原”“切片乱序”“分块乱序”“瓦片重排” | 分块图片、块边界、目标排列、`tileOrder`/`pieceOrder`、CSS/canvas 切片线索、滑动/拖动映射 | 先判定是否为 `tile-scramble`,再做页面顺序还原或图像边缘匹配 |
|
||||
| `area-select` | “面积验证”“框选”“圈出”“选择区域” | 完整题面截图、区域边界、坐标原点 | 目标分割、边界框/多边形坐标输出 |
|
||||
| `difference-click` | “找不同”“找茬”“点击差异” | 两张对比图、差异点、点击次数 | 图像差分、差异定位、点击坐标 |
|
||||
| `font-identify` | “字体识别”“同字体”“不同字体” | 候选文字截图、目标字体规则、顺序要求 | OCR + 字形特征/视觉模型识别 |
|
||||
| `semantic-reasoning` | “空间语义”“语义推理”、最左/最大/相邻等关系 | 题面、完整截图、目标关系、坐标系 | VLM/检测模型做关系推理并返回坐标 |
|
||||
| `game-challenge` | “小游戏”“3D”“骰子”、Arkose/FunCaptcha Enforcement | 截图/帧、题面、交互控件、challenge id | 题面理解、状态识别、授权场景人工/模型辅助 |
|
||||
| `pow-challenge` | proof-of-work、工作量证明、nonce/difficulty/solution、ALTCHA/FriendlyCaptcha/Cap.js/mCaptcha | 组件脚本、challenge/payload、difficulty、校验接口 | 识别计算挑战与校验字段,不把它当图片题 |
|
||||
| `risk-score` | “无感”“无痕”“风险评分”“invisible”“score”、reCAPTCHA v3 | sitekey/action、评分阈值、服务端校验结果 | 参数识别、评分/阈值解释、集成诊断 |
|
||||
| `one-click` | “一键验证”“checkbox”“press and hold”“点击完成验证” | 组件 HTML、按钮状态、callback、是否二次挑战 | 组件状态分析、失败时转入具体子类型 |
|
||||
| `multi-step` | “多轮”“下一题”“continue verification”、分页挑战 | 每轮截图、轮次状态、上一轮结果、最终成功标记 | 逐轮记录,优先标注每轮具体子类型 |
|
||||
| `qa-logic` | “问答验证码”“回答问题”“logic captcha” | 问题原文、答案格式、语言/知识范围 | 问题解析、确定性回答或人工复核 |
|
||||
| `biometric-liveness` | “活体检测”“人脸验证”“biometric/liveness” | SDK 标识、动作要求、授权/隐私说明 | 仅做产品识别、合规接入和人工复核建议 |
|
||||
| `token-widget` | `sitekey`、`pageurl`、`action`、`cdata`、iframe 组件、checkbox、invisible/managed 模式 | 脚本/iframe URL、sitekey、action、callback、页面 URL | 组件参数分析、token TTL、callback 和服务端校验注意点 |
|
||||
| `waf-challenge` | “Just a moment”、WAF token/cookie、PoW、sensor data、`cf_clearance`、`aws-waf-token`、`datadome`、`_abck`、`bm_sz`、`reese84`、`px-captcha`、`x-kpsdk`、`TSPD` | 响应头、challenge HTML、JS URL、cookie 名、状态码 | 反自动化产品识别和环境诊断方案 |
|
||||
| `unknown-custom` | 自研 canvas、无品牌混淆 JS、证据不足 | HTML、截图、脚本、网络接口名 | 请求补充证据并建立自定义分类 |
|
||||
|
||||
## 类型说明
|
||||
|
||||
### `text`
|
||||
|
||||
把文字验证码当作图片识别问题处理。常规流程是:
|
||||
|
||||
1. 定位精确的验证码图片或元素截图。
|
||||
2. 必要时做预处理:灰度化、二值化、降噪、纠偏、颜色过滤、去边框。
|
||||
3. 字符集简单时优先用本地 OCR:ddddocr、Tesseract 或小型自定义模型。
|
||||
4. 站点只接受数字、大写字母或固定长度时,加上字符集/长度约束。
|
||||
5. 本地 OCR 不稳定或题面包含语义要求时,再考虑 VLM 或云端识别,并先确认授权范围。
|
||||
|
||||
需要向用户追问目标字符集、期望长度、刷新接口,以及图片是否随 session 或每次尝试重新生成。
|
||||
|
||||
### `math`
|
||||
|
||||
可见题面要求计算表达式结果时,分类为 `math`。OCR 只是第一步,还要归一化运算符:`x`、`X`、`×`、`*`,以及 `÷`、`/`,再做安全解析和求值。
|
||||
|
||||
如果图片使用中文数字、噪声明显、竖排布局,或是文字应用题而不是简单表达式,要降低置信度。
|
||||
|
||||
### `slider`
|
||||
|
||||
用户需要拖动手柄、拼图块或轨道时,分类为 `slider`。分析时拆成四层:
|
||||
|
||||
- 图片识别:从背景图和滑块图中识别缺口/目标偏移。
|
||||
- 坐标校准:把图片像素映射到 DOM/CSS 像素和滑块轨道像素。
|
||||
- 行为验证:在授权 QA 场景里检查时间、速度和移动曲线是否符合产品限制。
|
||||
- 参数分析:把视觉偏移和厂商加密/绑定参数分开处理。
|
||||
|
||||
优先向用户索要背景图和滑块图。如果只有一张合成图,要判断是单图边缘/对比任务,还是 canvas 渲染的拼图。
|
||||
|
||||
### `click-select`
|
||||
|
||||
题面要求点击文字、图标、物体或坐标,尤其包含顺序要求时,分类为 `click-select`。方案需要:
|
||||
|
||||
- 提取题面:点击什么、按什么顺序点击。
|
||||
- 识别目标:OCR、目标检测、模板匹配或 VLM。
|
||||
- 坐标换算:截图像素到浏览器元素坐标。
|
||||
- 多轮复核:部分厂商会在局部成功后刷新新题。
|
||||
|
||||
中文“文字点选”必须保留原始汉字和目标顺序。
|
||||
|
||||
### `rotate`
|
||||
|
||||
题面要求把图片/物体旋转到正确方向时,分类为 `rotate`。方案需要:
|
||||
|
||||
- 通过图像匹配、边缘方向、物体正向判断或 VLM 估计角度。
|
||||
- 把角度换算为拖动距离或滑块位置。
|
||||
- 做固定偏移校准,因为很多产品存在非线性映射或厂商特定偏移。
|
||||
|
||||
需要向用户索要轨道宽度、角度范围和初始角度信息。
|
||||
|
||||
### `grid`
|
||||
|
||||
固定格子、多数为 3x3 的图片选择任务,分类为 `grid`。方案需要:
|
||||
|
||||
- 解析题面。
|
||||
- 对每个格子做目标/类别识别。
|
||||
- 输出格子编号或中心坐标。
|
||||
- 预期多轮验证,因为 reCAPTCHA、hCaptcha、AWS WAF 可能连续出题。
|
||||
|
||||
对 reCAPTCHA/hCaptcha,不要假设一轮网格选择就结束;只在授权测试中确认 response token 或 UI 成功状态。
|
||||
|
||||
### `audio`
|
||||
|
||||
页面要求播放音频并输入听到的数字、字符或短语时,分类为 `audio`。不要把短信、电话或语音 OTP 当作音频验证码;那类属于账号/MFA 边界。
|
||||
|
||||
分析时需要音频文件、播放接口、题面语言和答案格式。方案通常是音频转写、数字/字符白名单、噪声过滤和人工复核;如果站点提供视觉替代题,也要同时记录。
|
||||
|
||||
### `drag-drop`
|
||||
|
||||
拖动物体、图形、卡片或图片到指定目标区域时,分类为 `drag-drop`。它和 `slider` 的区别是目标不是单一水平滑块缺口,而是“元素到区域”的拖放关系。
|
||||
|
||||
需要记录被拖动元素边界、目标区域边界、释放点、缩放/DPR 和是否存在吸附或容错半径。
|
||||
|
||||
### `trace-draw`
|
||||
|
||||
题面要求沿路径画线、连线、描轨迹或完成轨迹绘制时,分类为 `trace-draw`。百度等产品会把轨迹绘制作为独立形态。
|
||||
|
||||
需要画布尺寸、路径参考图、采样点格式和提交字段。方案重点是路径提取、点列重采样、速度/间隔记录和坐标归一化。
|
||||
|
||||
### `scratch`
|
||||
|
||||
刮刮卡式验证要求用户用鼠标/触摸“刮开”指定区域,分类为 `scratch`。它通常不是识别答案,而是检测覆盖比例、轨迹和状态变化。
|
||||
|
||||
需要刮开前后截图、canvas 区域、通过阈值和轨迹采样格式。报告里不要把它简化为普通滑块。
|
||||
|
||||
### `image-restore`
|
||||
|
||||
图片/图像复原、乱序拼图、滑动还原或分块重排,分类为 `image-restore`。阿里云 2.0、顶象等产品可能出现这类形态。
|
||||
|
||||
需要原始分块、目标排列线索、分块边界和拖动/滑动映射。方案可以是边缘连续性匹配、特征点匹配、模板匹配或 VLM 辅助判断。
|
||||
|
||||
如果验证码是一整张图被切成多个块后顺序打乱,保持 `captcha_type: image-restore`,并补充 `captcha_variant: tile-scramble`。处理顺序必须是:
|
||||
|
||||
1. 先判断是否存在切片乱序信号:`tileOrder`、`pieceOrder`、`sliceOrder`、CSS `background-position`/sprite、canvas `drawImage`、`shuffle`/`scramble`、或截图中可见规则网格切片。
|
||||
2. 优先从页面逻辑还原顺序:接口字段、JS 数组、DOM 顺序、CSS 背景定位、canvas 源/目标坐标通常比纯视觉更可靠。
|
||||
3. 页面逻辑不足时,再用图片方法:按行列切片,比较左右/上下边缘连续性、颜色/纹理连续性、特征点匹配,输出候选顺序和置信度。
|
||||
4. 重复纹理、纯色块、随机裁剪或顺序字段加密时,降低置信度并建议人工/平台复核。
|
||||
|
||||
边界判断:
|
||||
|
||||
- 普通 `slider` 重点是找缺口或滑块目标偏移;`tile-scramble` 的目标是还原整张图或分块顺序。
|
||||
- 普通 `grid` 是按题面选择格子;`tile-scramble` 是把格子重新排列成完整图片,不是选择红绿灯/自行车等类别。
|
||||
- 如果需要拖动滑块才能触发还原,报告里同时记录滑动映射,但主类型仍按最终目标归为 `image-restore`。
|
||||
|
||||
### `area-select`
|
||||
|
||||
题面要求框选、圈选或选择图片中某个区域时,分类为 `area-select`。它和点选不同,输出通常是矩形、多边形或面积范围,而不是单点。
|
||||
|
||||
需要完整截图、题面、坐标原点和目标区域规则。方案以目标检测/分割、边界框返回和多边形坐标为主。
|
||||
|
||||
### `difference-click`
|
||||
|
||||
找不同、找茬或点击差异点,分类为 `difference-click`。这类题通常需要比较两张图或同一图中的局部差异。
|
||||
|
||||
需要两张对比图、点击次数和差异点坐标。方案可用图像配准、差分、显著性检测和人工/VLM 复核。
|
||||
|
||||
### `font-identify`
|
||||
|
||||
题面要求选择相同字体、不同字体或指定字体样式时,分类为 `font-identify`。不要只按文字内容做 OCR,因为关键在字形。
|
||||
|
||||
需要候选文字截图、目标字体规则和点击顺序。方案是 OCR 识别字符内容,再做字形特征、模板或视觉模型判断。
|
||||
|
||||
### `semantic-reasoning`
|
||||
|
||||
空间语义或视觉关系题要求理解“最左”“最大”“相邻”“被遮挡”等关系,分类为 `semantic-reasoning`。
|
||||
|
||||
需要题面原文、完整截图和坐标系。方案以目标检测 + 关系推理或 VLM 为主,并输出理由、坐标和置信度。
|
||||
|
||||
### `game-challenge`
|
||||
|
||||
小游戏、3D、骰子或 Arkose/FunCaptcha Enforcement 类交互题,分类为 `game-challenge`。这类题经常是多状态交互,不能只靠一次 OCR。
|
||||
|
||||
需要题面、初始截图/帧、交互控件、厂商 public key 或 challenge id。方案应说明状态识别、题面理解、人工接管或授权测试中的模型辅助,不给未授权自动通关步骤。
|
||||
|
||||
### `pow-challenge`
|
||||
|
||||
工作量证明、哈希难题、浏览器计算挑战,分类为 `pow-challenge`。FriendlyCaptcha、ALTCHA、Private Captcha、Cap.js、mCaptcha 等通常属于这个方向。
|
||||
|
||||
需要组件脚本、challenge/payload、difficulty、nonce/solution 字段和服务端校验接口。重点是说明这是计算挑战和服务端校验,不是图片识别题。
|
||||
|
||||
### `risk-score`
|
||||
|
||||
无感、无痕、隐形或风险评分验证,分类为 `risk-score`。典型例子包括 reCAPTCHA v3 或国内厂商的智能无感模式。
|
||||
|
||||
需要 sitekey、action、页面 URL、评分阈值、服务端校验结果和失败时是否升级到可见挑战。方案是集成诊断、评分解释、阈值/动作绑定检查。
|
||||
|
||||
### `one-click`
|
||||
|
||||
一键验证、checkbox、点击/按住完成验证,分类为 `one-click`。如果点击后升级为滑块、点选、九宫格等,应把升级后的具体类型作为主类型或补充类型记录。
|
||||
|
||||
需要组件 HTML、按钮状态、callback、二次挑战状态。注意普通表单 checkbox 不是验证码,必须有 captcha/verify/challenge 或厂商证据。
|
||||
|
||||
### `multi-step`
|
||||
|
||||
多轮、多步或分页挑战,分类为 `multi-step`。它常常是元流程;如果每轮都有具体形态,应优先输出具体子类型,并在报告中说明多轮状态。
|
||||
|
||||
需要每轮截图、轮次编号、上一轮结果和最终成功标记。方案是逐轮取证,而不是只保存最后一帧。
|
||||
|
||||
### `qa-logic`
|
||||
|
||||
问答、逻辑题或安全问题式验证码,分类为 `qa-logic`。简单算术仍优先归到 `math`。
|
||||
|
||||
需要问题原文、答案格式和语言范围。方案是文本解析、确定性知识回答或人工复核;动态题库要降低置信度。
|
||||
|
||||
### `biometric-liveness`
|
||||
|
||||
活体、人脸或生物识别验证,分类为 `biometric-liveness`。这通常涉及隐私和合规,不应提供绕过、伪造或未授权自动化方案。
|
||||
|
||||
报告只做产品/类型识别、授权接入检查、可访问性方案、人工复核和隐私合规建议。
|
||||
|
||||
### `token-widget`
|
||||
|
||||
出现 sitekey 型组件但当前没有具体图片题时,分类为 `token-widget`。记录:
|
||||
|
||||
- 厂商脚本和 iframe URL。
|
||||
- `sitekey`、`action`、`cdata`、`rqdata`、`s` 或 enterprise payload 线索。
|
||||
- callback 名和隐藏 response 字段。
|
||||
- 页面 URL 和 token 有效期。
|
||||
|
||||
报告中要说明服务端校验和绑定检查决定最终是否有效;看到 token 不等于业务动作一定会被接受。
|
||||
|
||||
### `waf-challenge`
|
||||
|
||||
Cloudflare challenge page、AWS WAF、DataDome、Akamai/Imperva/PerimeterX/Kasada 类流程、PoW 挑战等,分类为 `waf-challenge`。这不是普通图片验证码。
|
||||
|
||||
重点放在产品识别、环境诊断、浏览器/TLS/cookie 绑定,以及用户是否拥有或被授权测试目标系统。
|
||||
@@ -0,0 +1,101 @@
|
||||
# 坐标、轨迹与本地模拟
|
||||
|
||||
本文件用于生成离线坐标和轨迹。默认只输出 JSON,不直接控制真实网页。真实页面点击、拖动、提交前必须让用户再次确认。
|
||||
|
||||
## 坐标体系
|
||||
|
||||
常见坐标有三种:
|
||||
|
||||
- 图片像素:截图或验证码图片中的像素坐标。
|
||||
- 元素 CSS 像素:浏览器中验证码元素内的相对坐标。
|
||||
- 页面坐标:考虑元素位置、滚动偏移后的页面坐标。
|
||||
|
||||
换算时记录:
|
||||
|
||||
- 原图尺寸:`image_width`、`image_height`。
|
||||
- 显示尺寸:`display_width`、`display_height`。
|
||||
- DPR:`device_pixel_ratio`。
|
||||
- 元素位置:`element_left`、`element_top`。
|
||||
- 滚动:`scroll_x`、`scroll_y`。
|
||||
|
||||
使用脚本:
|
||||
|
||||
```bash
|
||||
python scripts/map_coordinates.py --image-size 300x150 --display-size 300x150 --point 120,75 --element-left 20 --element-top 80 --pretty
|
||||
```
|
||||
|
||||
## 滑块轨迹
|
||||
|
||||
轨迹应包含:
|
||||
|
||||
- 起点、终点。
|
||||
- 每个点的 `x`、`y`、`t`。
|
||||
- 轻微纵向抖动和停顿。
|
||||
- 总时长。
|
||||
|
||||
使用脚本:
|
||||
|
||||
```bash
|
||||
python scripts/generate_motion_track.py --mode slider --distance 128 --duration-ms 1100 --pretty
|
||||
```
|
||||
|
||||
输出只用于授权 QA 分析。厂商可能检查行为采集、浏览器状态和加密参数,不要假设轨迹自然就能通过。
|
||||
|
||||
## 拖放轨迹
|
||||
|
||||
拖放需要源点和目标点:
|
||||
|
||||
```bash
|
||||
python scripts/generate_motion_track.py --mode drag-drop --start 40,60 --end 220,130 --duration-ms 1400 --pretty
|
||||
```
|
||||
|
||||
注意目标区域可能有吸附、动画、释放点容错和拖拽事件差异。
|
||||
|
||||
## 刮刮卡轨迹
|
||||
|
||||
刮刮卡是覆盖区域,不是单一终点:
|
||||
|
||||
```bash
|
||||
python scripts/generate_motion_track.py --mode scratch --box 10,10,220,90 --duration-ms 1800 --pretty
|
||||
```
|
||||
|
||||
输出点列用于覆盖比例分析。移动端触摸事件和 canvas 状态变化需要真实环境复核。
|
||||
|
||||
## 轨迹绘制/连线
|
||||
|
||||
如果已有目标路径点:
|
||||
|
||||
```bash
|
||||
python scripts/generate_motion_track.py --mode trace --points "10,10 80,30 120,90" --duration-ms 1000 --pretty
|
||||
```
|
||||
|
||||
如果只有图片,需要先用 OpenCV 提取路径,再重采样。
|
||||
|
||||
## 点选坐标
|
||||
|
||||
点选/九宫格/区域选择优先输出元素相对坐标和截图坐标。多点题必须保留顺序。
|
||||
|
||||
输出格式建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"coordinate_space": "element-css",
|
||||
"points": [
|
||||
{"x": 120.0, "y": 75.0, "order": 1, "label": "天"}
|
||||
],
|
||||
"source_image_size": [300, 150],
|
||||
"display_size": [300, 150]
|
||||
}
|
||||
```
|
||||
|
||||
## 真实网页执行前检查
|
||||
|
||||
执行前必须确认:
|
||||
|
||||
- 目标是否自有/授权。
|
||||
- 浏览器模式是否按 `browser-acquisition.md`。
|
||||
- 是否需要人工完成登录或验证码。
|
||||
- 是否读取 Cookie/Storage。
|
||||
- 是否提交业务表单。
|
||||
|
||||
任何一项不明确,都停留在离线产物。
|
||||
@@ -0,0 +1,170 @@
|
||||
# 开源/本地方案示例
|
||||
|
||||
本文件只给授权场景下的离线求解和诊断示例。真实网页点击、拖动、提交前必须回到 `references/verification-workflow.md` 做确认。
|
||||
|
||||
## 通用准备
|
||||
|
||||
建议先把证据整理成文件:
|
||||
|
||||
- 图片验证码:精确裁剪后的图片。
|
||||
- 滑块:背景图、滑块图、轨道宽度、元素显示尺寸。
|
||||
- 点选/九宫格/区域:完整截图、题面文本、元素边界。
|
||||
- 音频:音频文件、语言、答案格式。
|
||||
|
||||
依赖示例:
|
||||
|
||||
```bash
|
||||
python -m pip install ddddocr opencv-python pillow numpy
|
||||
```
|
||||
|
||||
只在需要时安装对应库,不要把依赖路径写进 skill。
|
||||
|
||||
## `text`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. 用 OpenCV 做灰度、二值化、降噪、去干扰线。
|
||||
2. 用 ddddocr 或 Tesseract 识别。
|
||||
3. 用字符集、长度、大小写规则约束结果。
|
||||
|
||||
示例思路:
|
||||
|
||||
```python
|
||||
import ddddocr
|
||||
|
||||
ocr = ddddocr.DdddOcr(show_ad=False)
|
||||
with open("captcha.png", "rb") as f:
|
||||
text = ocr.classification(f.read())
|
||||
print(text)
|
||||
```
|
||||
|
||||
适合切到平台的情况:扭曲强、背景噪声强、字符粘连、样本通过率低。
|
||||
|
||||
## `math`
|
||||
|
||||
流程:
|
||||
|
||||
1. 按 `text` 识别表达式。
|
||||
2. 归一化 `x/X/×` 为 `*`,`÷` 为 `/`。
|
||||
3. 只允许数字、括号和基础运算符进入安全求值。
|
||||
|
||||
输出必须包含原始 OCR、归一化表达式和结果。遇到歧义时不要猜。
|
||||
|
||||
## `slider`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. 先确认图片模式:双图、单图、canvas。
|
||||
2. 双图优先用 ddddocr `slide_match` 或 OpenCV 模板/边缘匹配。
|
||||
3. 单图用缺口边缘、局部对比、差分或 VLM 辅助。
|
||||
4. 用 `scripts/map_coordinates.py` 把图片偏移换算成 DOM/CSS 坐标。
|
||||
5. 用 `scripts/generate_motion_track.py` 生成滑块轨迹 JSON。
|
||||
|
||||
示例思路:
|
||||
|
||||
```python
|
||||
import ddddocr
|
||||
|
||||
det = ddddocr.DdddOcr(det=False, ocr=False, show_ad=False)
|
||||
with open("target.png", "rb") as f:
|
||||
target = f.read()
|
||||
with open("background.png", "rb") as f:
|
||||
background = f.read()
|
||||
print(det.slide_match(target, background))
|
||||
```
|
||||
|
||||
注意:视觉偏移正确不等于验证一定成功;厂商可能绑定行为采集、challenge id、浏览器状态或加密参数。
|
||||
|
||||
## `click-select`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. OCR/VLM 解析题面,保留原始顺序词。
|
||||
2. 文字点选用 OCR + 模板/轮廓定位。
|
||||
3. 物体点选用 YOLO/目标检测、CLIP/VLM 或模板匹配。
|
||||
4. 输出截图坐标和元素相对坐标。
|
||||
|
||||
需要多轮时,每轮单独保存题面和截图。
|
||||
|
||||
## `rotate`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. 用 OpenCV 主方向、特征匹配或边缘方向估计角度。
|
||||
2. 用 VLM 做正向判断复核。
|
||||
3. 按轨道宽度和角度范围换算拖动距离。
|
||||
|
||||
输出角度、轨道宽度、换算公式、固定偏移和置信度。
|
||||
|
||||
## `grid`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. 分割格子,记录每格边界和中心点。
|
||||
2. 用题面选择分类器:YOLO、CLIP、VLM 或专用图片分类模型。
|
||||
3. 返回格子编号、中心坐标和每格置信度。
|
||||
|
||||
reCAPTCHA/hCaptcha/AWS WAF 可能多轮,不要只保存最后一轮。
|
||||
|
||||
## `audio`
|
||||
|
||||
开源优先:
|
||||
|
||||
1. 提取音频文件。
|
||||
2. 做降噪、音量归一化和静音裁剪。
|
||||
3. 用 Whisper/faster-whisper 转写。
|
||||
4. 用答案格式做数字/字符纠错。
|
||||
|
||||
示例依赖:
|
||||
|
||||
```bash
|
||||
python -m pip install faster-whisper
|
||||
```
|
||||
|
||||
不要处理电话语音 OTP、短信、邮箱或 MFA。
|
||||
|
||||
## 图像/坐标类组合
|
||||
|
||||
这些类型通常共享一组能力:
|
||||
|
||||
- `drag-drop`:目标检测源对象和目标区域,再生成拖放轨迹。
|
||||
- `trace-draw`:OpenCV 提取路径,骨架化后重采样成点列。
|
||||
- `scratch`:生成覆盖轨迹,观察刮开前后状态。
|
||||
- `image-restore`:先判断是否为切片乱序;页面顺序优先,图片边缘/纹理匹配次之。
|
||||
- `area-select`:检测/分割/SAM/YOLO/VLM 输出框或多边形。
|
||||
- `difference-click`:图像配准后差分定位。
|
||||
- `font-identify`:OCR 内容 + 字形特征或 VLM 字体关系判断。
|
||||
- `semantic-reasoning`:目标检测 + 关系推理或 VLM。
|
||||
|
||||
优先输出离线坐标和置信度;真实页面操作前必须确认。
|
||||
|
||||
## `image-restore` 切片乱序
|
||||
|
||||
当图片被分割成多块后顺序打乱时,保持主类型 `image-restore`,补充 `captcha_variant: tile-scramble`。第一版优先使用 `scripts/analyze_tile_restore.py`:
|
||||
|
||||
```bash
|
||||
python scripts/analyze_tile_restore.py --image scrambled.png --rows 3 --cols 3 --order-source-by-target "1,2,0,4,5,3,7,8,6" --output-image restored.png --pretty
|
||||
```
|
||||
|
||||
处理顺序:
|
||||
|
||||
1. 页面逻辑优先:从 `tileOrder`、`pieceOrder`、`sliceOrder`、CSS `background-position`、sprite、canvas `drawImage` 或接口字段还原每个目标位置对应的来源切片。
|
||||
2. 只有图片时:按 `rows/cols` 或 `tile-width/tile-height` 切片,用左右/上下边缘连续性、颜色/纹理连续性、特征点匹配或 VLM 得到候选顺序。
|
||||
3. 输出 `order_source_by_target`、`confidence`、`restore_strategy` 和还原图;低置信度时不要硬猜。
|
||||
4. 如果还原结果还要换算拖动距离或交换动作,再回到 `references/motion-and-coordinate.md` 处理坐标和轨迹。
|
||||
|
||||
边界:
|
||||
|
||||
- 普通九宫格选择题是 `grid`,不是切片乱序。
|
||||
- 普通缺口滑块是 `slider`,不是切片乱序。
|
||||
- 纯色块、重复纹理、随机裁剪或顺序字段加密时,本地边缘匹配通过率会低,应切人工/平台复核或厂商授权测试环境。
|
||||
|
||||
## `pow-challenge`
|
||||
|
||||
PoW 类不是图片识别题。对 ALTCHA、FriendlyCaptcha、Cap.js、mCaptcha 等:
|
||||
|
||||
1. 阅读官方接入方式。
|
||||
2. 检查 challenge、payload、difficulty、nonce、TTL、防重放和服务端签名。
|
||||
3. 自有系统优先修正集成和校验逻辑。
|
||||
|
||||
不要把 PoW 当成 OCR 或滑块任务。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 厂商执行注意点
|
||||
|
||||
本文件用于第二阶段的授权验证流程。厂商识别只决定注意点,不代表可以自动通过。
|
||||
|
||||
## 国内行为验证码
|
||||
|
||||
### 极验
|
||||
|
||||
- 常见类型:`slider`、`click-select`、`risk-score`、`token-widget`。
|
||||
- 常见材料:`captcha_id`、`lot_number`、`pass_token`、`gen_time`、`w`。
|
||||
- 注意点:视觉答案、行为轨迹和加密载荷分离;challenge 可能短期有效并绑定浏览器状态。
|
||||
|
||||
### 腾讯 TCaptcha
|
||||
|
||||
- 常见类型:`slider`、`click-select`、`audio`、`one-click`、`risk-score`。
|
||||
- 常见材料:`aid`/`appid`、`randstr`、`ticket`。
|
||||
- 注意点:`ticket` 和 `randstr` 往往与 session、页面和行为采集绑定。
|
||||
|
||||
### 网易易盾
|
||||
|
||||
- 常见类型:`slider`、`click-select`、`audio`、`risk-score`。
|
||||
- 常见材料:`captchaId`、`validate`、`fp`、`acToken`。
|
||||
- 注意点:指纹采集和行为采集可能影响可见题结果。
|
||||
|
||||
### 阿里云验证码
|
||||
|
||||
- 常见类型:`slider`、`image-restore`、`one-click`、`risk-score`。
|
||||
- 常见材料:`appkey`、`scene`、`sessionId`、`sig`、`token`。
|
||||
- 注意点:智能验证和风控模式可能没有可见题,优先做官方接入和服务端校验诊断。
|
||||
|
||||
### 数美、顶象、百度、京东云、云片
|
||||
|
||||
- 数美:注意 `organization`、`rid`、`pass`,可见题和无感模式分开分析。
|
||||
- 顶象:题型多,命中厂商后不要固定判滑块。
|
||||
- 百度:数字、文字、滑块、轨迹绘制都可能出现。
|
||||
- 京东云:保留 `Jcap.create`、`appId`、`sceneId` 等配置。
|
||||
- 云片:区分网页行为验证和短信/语音验证码服务。
|
||||
|
||||
## 海外 token/组件类
|
||||
|
||||
### reCAPTCHA
|
||||
|
||||
- v2 checkbox/invisible 多为 `token-widget` 或 `one-click`。
|
||||
- v3/Enterprise score/action 归 `risk-score`。
|
||||
- 注意点:服务端可能校验 hostname、action、score、session、IP。
|
||||
|
||||
### hCaptcha
|
||||
|
||||
- 常见 `token-widget`、`grid`、`audio`、语义图片题。
|
||||
- 注意点:`rqdata`、`rqtoken` 和企业载荷可能影响平台任务。
|
||||
|
||||
### Cloudflare Turnstile
|
||||
|
||||
- 常见 `token-widget`、`one-click`、`risk-score`。
|
||||
- 与 Cloudflare WAF challenge page 区分:`cf_clearance` 和 `/cdn-cgi/challenge-platform/` 是 WAF 流程。
|
||||
|
||||
### Arkose / FunCaptcha
|
||||
|
||||
- 常见 `game-challenge` 或组件初始化。
|
||||
- 注意点:public key、surl、blob、会话绑定和 3D/小游戏状态。
|
||||
- 开源方案通常只适合识别题面和状态,稳定验证常需要人工接管或授权平台对照。
|
||||
|
||||
## PoW 类
|
||||
|
||||
FriendlyCaptcha、ALTCHA、Private Captcha、Cap.js、mCaptcha 主要是 `pow-challenge`。
|
||||
|
||||
优先检查官方协议:
|
||||
|
||||
- challenge/payload 是否有效。
|
||||
- difficulty、nonce、solution 是否匹配。
|
||||
- TTL、防重放和服务端签名是否正确。
|
||||
|
||||
通常不需要打码平台;自有系统应先修复接入。
|
||||
|
||||
## WAF / Bot Management
|
||||
|
||||
Cloudflare WAF、AWS WAF、DataDome、Akamai、Imperva、PerimeterX/HUMAN、Kasada、Netacea、Radware、F5 都优先归 `waf-challenge`。
|
||||
|
||||
优先诊断:
|
||||
|
||||
- 响应头、状态码、cookie 名。
|
||||
- JS challenge 是否加载和执行。
|
||||
- 浏览器 API、Canvas/WebGL/字体/Audio 等环境。
|
||||
- TLS/HTTP 指纹、UA、IP、session 粘性。
|
||||
- WAF 服务端日志和误伤样本。
|
||||
|
||||
不要把 WAF challenge 当成普通 OCR 题。
|
||||
|
||||
## 活体/人脸
|
||||
|
||||
`biometric-liveness` 只做识别、官方 SDK 接入检查、人工审核、可访问性替代和隐私合规建议。不要生成绕过、伪造或替身方案。
|
||||
@@ -0,0 +1,188 @@
|
||||
# 厂商与产品特征
|
||||
|
||||
使用本文件把 HTML、JS、iframe、接口名和参数线索转成厂商判断。报告高置信度前,尽量要求两个以上独立信号。
|
||||
|
||||
验证码与风控产品是开放集合。本表覆盖调研到的主流公开产品、常见开源库和主要 WAF/Bot Management 产品;遇到未覆盖的新产品时,先输出 `custom-or-unknown`,并记录脚本 URL、iframe、DOM 标记、cookie/header、参数名和可见题型,后续再补充稳定信号。
|
||||
|
||||
## 厂商信号表
|
||||
|
||||
| 厂商标签 | 强信号 | 常见参数 | 常见类型 |
|
||||
| --- | --- | --- | --- |
|
||||
| `recaptcha` | `google.com/recaptcha`、`www.recaptcha.net/recaptcha`、`g-recaptcha`、`grecaptcha`、`/api2/anchor`、`/api2/reload` | `sitekey`、`k`、`s`、`action`、`enterprise`、`g-recaptcha-response` | `token-widget`、`grid`、`audio`、`risk-score`、`one-click` |
|
||||
| `hcaptcha` | `hcaptcha.com/1/api.js`、`h-captcha`、`hcaptcha.render`、`newassets.hcaptcha.com`、`/checkcaptcha/` | `sitekey`、`rqdata`、`rqtoken`、`h-captcha-response` | `token-widget`、`grid`、`audio`、`semantic-reasoning` |
|
||||
| `cloudflare-turnstile` | `challenges.cloudflare.com/turnstile`、`cf-turnstile`、`turnstile.render` | `sitekey`、`action`、`cdata`、`cf-turnstile-response` | `token-widget`、`risk-score`、`one-click` |
|
||||
| `geetest` | `geetest`、`gt=`、`challenge=`、`captcha_id`、`lot_number`、`w`、`pass_token` | `gt`、`challenge`、`captcha_id`、`lot_number`、`w`、`gen_time` | `slider`、`click-select`、`token-widget` |
|
||||
| `tencent-tcaptcha` | `captcha.gtimg.com`、`tencentcaptcha`、`TCaptcha`、`aid`、`randstr`、`ticket` | `aid`、`appid`、`randstr`、`ticket` | `slider`、`click-select`、`audio`、`one-click`、`risk-score`、`token-widget` |
|
||||
| `netease-yidun` | `captcha.yidun`、`dun.163.com`、`yidun`、`网易易盾`、`NECaptcha` | `captchaId`、`validate`、`token`、`fp`、`acToken` | `slider`、`click-select`、`audio`、`risk-score`、`token-widget` |
|
||||
| `aliyun-captcha` | `aliyun`、`aliyuncs.com`、`NoCaptcha`、`AWSC`、`nc_`、`afs` | `appkey`、`scene`、`sessionId`、`sig`、`token` | `slider`、`one-click`、`risk-score`、`image-restore`、`token-widget` |
|
||||
| `shumei-captcha` | `castatic.fengkongcloud.cn`、`smcp.min.js`、`initSMCaptcha`、`SMCaptcha`、`数美验证码` | `organization`、`appendTo`、`mode`、`rid`、`pass` | `slider`、`click-select`、`semantic-reasoning`、`risk-score` |
|
||||
| `dingxiang-captcha` | `dingxiang-inc.com`、`cdn.dingxiang-inc.com`、`dx-captcha`、`_dx.Captcha`、`captcha-ui/v5/index.js`、`顶象验证码` | `appId`、`constId`、`apiServer`、`dxToken` | `slider`、`click-select`、`rotate`、`scratch`、`semantic-reasoning`、`image-restore`、`area-select`、`difference-click`、`font-identify`、`audio`、`risk-score` |
|
||||
| `baidu-captcha` | `cloud.baidu.com/product-s/afd_s/captcha`、`console.bce.baidu.com/afd/captcha`、`验证码 Captcha-百度智能云` | 应用 ID、服务端验签参数、SDK 配置 | `slider`、`text`、`click-select`、`trace-draw` |
|
||||
| `jdcloud-captcha` | `docs.jdcloud.com/cn/captcha`、`Jcap.create`、`京东云验证码` | `appId`、`sceneId`、`productId`、回调字段 | `slider`、行为验证 |
|
||||
| `yunpian-captcha` | `yunpian.com/product/captcha`、`riddler-sdk`、`YpRiddler`、`云片行为验证` | 初始化配置、验证 token、回调字段 | `slider`、`click-select` |
|
||||
| `huawei-captcha` | `huaweicloud.com`、`OneAccess`、`图形验证码`、`captchaId`、`validateCode` | `captchaId`、`validateCode`、服务端校验参数 | `text`,也可能是自研/平台集成验证码 |
|
||||
| `tongdun-risk` | `tongdun.cn`、`tongdun.net`、`fraudmetrix`、`blackbox`、`tokenId` | `blackbox`、`tokenId`、`riskToken` | 风控/无感验证,通常归 `waf-challenge` 或 `unknown-custom` |
|
||||
| `cloudflare-waf` | `cf_clearance`、`/cdn-cgi/challenge-platform/`、`cf_chl`、`cf-mitigated`、`Just a moment` | `cf_clearance`、challenge token、Ray ID | `waf-challenge` |
|
||||
| `aws-waf` | `aws-waf-token`、`awswaf`、`aws-waf-captcha`、`challenge.js`、`waf_captcha` | `aws-waf-token`、`captcha_voucher`、`iv`、`context` | `waf-challenge`,有时展示 `grid` |
|
||||
| `datadome` | `datadome`、`x-datadome`、`ddcid`、`ddv`、`DataDome` | `datadome` cookie、`ddcid`、`cid`、`initialCid` | `waf-challenge` |
|
||||
| `arkose-funcaptcha` | `arkoselabs`、`funcaptcha`、`client-api.arkoselabs.com`、`fc-token` | `public_key`、`pkey`、`surl`、`blob`、`fc-token` | `game-challenge`、`token-widget`、图片挑战 |
|
||||
| `mtcaptcha` | `mtcaptcha.com`、`MTCaptcha`、`mtcaptchaConfig` | `sitekey`、`mtcaptcha-verifiedtoken` | `token-widget` |
|
||||
| `keycaptcha` | `keycaptcha`、`s_s_c_user_id`、`s_s_c_session_id`、`kc_cid` | `user_id`、`session_id`、`web_server_sign` | 图片/交互挑战 |
|
||||
| `friendlycaptcha` | `friendlycaptcha`、`frc-captcha`、`friendly-challenge` | `sitekey`、`solution` | `pow-challenge` |
|
||||
| `altcha` | `altcha`、`altcha-widget`、`challengeurl` | `challenge`、`payload`、`signature` | `pow-challenge` |
|
||||
| `yandex-smartcaptcha` | `smartcaptcha.yandexcloud.net`、`smart-captcha`、`window.smartCaptcha`、`SmartCaptcha` | `sitekey`、callback、invisible/checkbox 配置 | `token-widget` |
|
||||
| `captchafox` | `captchafox.com`、`docs.captchafox.com`、`CaptchaFox`、`cf-captcha` | `sitekey`、callback、response token | `token-widget` |
|
||||
| `prosopo-procaptcha` | `prosopo.io`、`procaptcha`、`@prosopo/procaptcha`、`Prosopo` | `sitekey`、challenge/token、回调字段 | `token-widget` |
|
||||
| `trustcaptcha` | `trustcaptcha.com`、`trustcomponent.com`、`TrustCaptcha` | `sitekey`、verification token、回调字段 | `token-widget` |
|
||||
| `private-captcha` | `privatecaptcha.com`、`PrivateCaptcha`、`private-captcha` | site key、challenge、PoW/token | `pow-challenge` |
|
||||
| `capjs` | `@cap.js/widget`、`cap-widget`、`data-cap-api-endpoint`、`CapWidget` | API endpoint、challenge token、PoW payload | `pow-challenge` |
|
||||
| `mcaptcha` | `mCaptcha`、`mcaptcha`、`mcaptcha-widget`、`data-mcaptcha` | site key、challenge、PoW solution | `pow-challenge` |
|
||||
| `iconcaptcha` | `IconCaptcha`、`iconcaptcha`、`icon-captcha` | widget ID、challenge token、点击坐标 | `click-select` |
|
||||
| `botdetect` | `BotDetect`、`captcha.com`、`BDC_CaptchaDiv`、`BDC_BackLink`、`captchaCode` | captcha ID、instance ID、输入字段 | `text` |
|
||||
| `securimage` | `Securimage`、`securimage_show.php`、`securimage_play.php` | 音频/图片路径、输入字段 | `text` |
|
||||
| `visualcaptcha` | `visualCaptcha`、`visualcaptcha.net` | image/audio challenge、answer 字段 | `click-select` 或 `text` |
|
||||
| `amazon-captcha` | `opfcaptcha.amazon.com`、`/errors/validateCaptcha`、`amzn-captcha`、`validateCaptcha` | captcha 图片、输入字段、`amzn` 状态参数 | `text` |
|
||||
| `cybersiara` | `CyberSiARA`、`SiARA`、`siara.js` | widget 配置、risk token、回调字段 | `waf-challenge`、`slider` |
|
||||
| `aj-captcha` | `AJ-Captcha`、`anji-plus`、`blockPuzzle`、`clickWord`、`captchaVerification` | `captchaType`、`captchaVerification`、`pointJson` | `slider`、`click-select` |
|
||||
| `tianai-captcha` | `tianai-captcha`、`TianaiCaptcha`、`TAC`、`天爱验证码` | `type`、`id`、`track`、`data` | `slider`、`rotate`、`click-select` |
|
||||
| `easycaptcha` | `EasyCaptcha`、`easy-captcha` | 图片接口、输入字段 | `text`、`math` |
|
||||
| `happycaptcha` | `HappyCaptcha`、`happy-captcha` | 图片接口、输入字段 | `text` |
|
||||
| `kaptcha` | `Kaptcha`、`com.google.code.kaptcha` | 图片接口、输入字段 | `text` |
|
||||
| `akamai-bot-manager` | `_abck`、`bm_sz`、`ak_bmsc`、`bm_sv`、`sensor_data`、Akamai Bot Manager | `_abck`、`bm_sz`、sensor payload | `waf-challenge` |
|
||||
| `imperva-incapsula` | `Imperva`、`Incapsula`、`visid_incap`、`incap_ses`、`___utmvc`、`reese84` | Incapsula cookies、challenge JS | `waf-challenge` |
|
||||
| `perimeterx-human` | `PerimeterX`、`HUMAN Security`、`px-captcha`、`_px3`、`pxvid`、`pxAppId` | `_px` cookies、collector payload、captcha token | `waf-challenge` |
|
||||
| `kasada` | `Kasada`、`x-kpsdk`、`kpsdk`、`KP_UID`、`/p.js` | `x-kpsdk-*` headers、challenge JS | `waf-challenge` |
|
||||
| `netacea` | `Netacea`、`netacea.com`、Bot Management | challenge token、bot response | `waf-challenge` |
|
||||
| `radware-bot-manager` | `Radware Bot Manager`、`rbzid`、`rbzsessionid`、`TSPD_101` | Radware cookies、challenge JS | `waf-challenge` |
|
||||
| `f5-bot-defense` | `F5 Bot Defense`、`Shape Security`、`TSPD_`、`f5_cspm`、`shape.js` | TS/TSPD cookies、sensor payload | `waf-challenge` |
|
||||
| `custom-or-unknown` | 站点自研 canvas、无品牌脚本、通用 `/captcha` 接口 | 不固定 | 未知/自研 |
|
||||
|
||||
## 置信度规则
|
||||
|
||||
- 高置信度:两个强厂商信号,或一个官方脚本/iframe URL 加上厂商特有参数名。
|
||||
- 中置信度:出现厂商特有关键词,但缺少官方脚本/iframe URL。
|
||||
- 低置信度:只有 `captcha`、`verify`、`challenge` 这类通用词,或只有视觉形态。
|
||||
|
||||
不要只凭截图过度判断厂商。滑块 UI 可能来自极验、腾讯、易盾、阿里云,也可能是自研。
|
||||
|
||||
## 产品说明
|
||||
|
||||
### reCAPTCHA
|
||||
|
||||
v2 checkbox/invisible 常见信号是 `g-recaptcha`、`api.js?render=explicit`、`/api2/anchor` 或隐藏字段 `g-recaptcha-response`。v3 常见信号是 `grecaptcha.execute(sitekey, {action: ...})`、`render=sitekey` 和 `action`。Enterprise 版本可能使用 `/recaptcha/enterprise.js` 或 enterprise payload。
|
||||
|
||||
如果没有可见图片题,v2/enterprise 组件通常报 `token-widget`;checkbox 明确可见时可报 `one-click`;出现九宫格图片题时报 `grid`;v3/score/action 证据明确时报 `risk-score`。需要提醒用户:v3 是评分/action 绑定,拿到 token 不代表一定通过服务端校验。
|
||||
|
||||
### hCaptcha
|
||||
|
||||
常见信号包括 `h-captcha`、`hcaptcha.render`、`rqdata`、`rqtoken`、`/getcaptcha/`、`/checkcaptcha/`。hCaptcha 经常在 checkbox 后进入图片网格或语义选择题。
|
||||
|
||||
证据里有 “select all images containing...” 这类题面时,分类为 `grid`;有音频替代入口时报 `audio`;如果只有站点组件、sitekey 和回调,默认 `token-widget`。
|
||||
|
||||
### Cloudflare Turnstile
|
||||
|
||||
Turnstile 使用 `challenges.cloudflare.com/turnstile` 和 `cf-turnstile`,模式可能是 managed、non-interactive、invisible 或 checkbox。记录 `sitekey`、`action`、`cdata`、callback 名。证据明确是 invisible/managed/no user interaction 时可报 `risk-score`;checkbox/一键按钮明显时可报 `one-click`;只有组件参数时报 `token-widget`。
|
||||
|
||||
不要把 Turnstile 和 Cloudflare WAF challenge page 混为一谈。Turnstile 是组件;`cf_clearance`、“Just a moment”、`/cdn-cgi/challenge-platform/` 更像 WAF/challenge 流程。
|
||||
|
||||
### GeeTest 极验
|
||||
|
||||
极验 v3 常见 `gt` 和 `challenge`。极验 v4 常见 `captcha_id`、`lot_number`、`pass_token`、`gen_time`、`w`。产品形态包括滑块、点选/icon、gobang、自适应/无感等。
|
||||
|
||||
对国内厂商,视觉识别只是其中一层。要把图片偏移/点击坐标和加密参数、浏览器绑定分开说明。
|
||||
|
||||
### 腾讯 TCaptcha
|
||||
|
||||
常见信号是 `captcha.gtimg.com`、`TCaptcha`、`TencentCaptcha`、`aid`、`appid`、`randstr`、`ticket`。公开产品形态包括滑块、图形/点选、语音、一键、无感/隐形等;证据不足时不要固定成滑块。
|
||||
|
||||
报告中保留命中的参数名,因为 `ticket` 和 `randstr` 往往与页面/session 状态绑定。
|
||||
|
||||
### 网易易盾
|
||||
|
||||
常见信号是 `captcha.yidun`、`dun.163.com`、`NECaptcha`、`captchaId`、`validate`、`fp`。产品形态包括滑块、点选和无感/风控验证。
|
||||
|
||||
易盾通常会把行为采集和指纹采集与可见题面混在一起,不要只按图片题处理。
|
||||
|
||||
### 阿里云验证码
|
||||
|
||||
常见信号是 `AWSC`、`NoCaptcha`、`nc_`、`afs`、`aliyuncs.com`、`appkey`、`scene`。产品形态包括滑块、一点即过、无痕/无感、拼图和图像复原等。
|
||||
|
||||
`sessionId`、`sig`、`token` 都可能是绑定材料,示例中可使用占位符表示。
|
||||
|
||||
### 数美验证码
|
||||
|
||||
数美 Web SDK 常见信号是 `castatic.fengkongcloud.cn/pr/.../smcp.min.js`、`initSMCaptcha`、`SMCaptcha.getResult()`、`organization`、`appendTo`、`rid`、`pass`。产品可能是滑块、点选、智能验证或无感验证。
|
||||
|
||||
报告中不要只输出“数美”。要同时说明命中的 SDK、配置项和可见题型。如果只有 `organization` 这类通用词,必须要求更多证据。
|
||||
|
||||
### 顶象验证码
|
||||
|
||||
顶象产品页强调智能无感验证和多种二次验证形态,常见信号包括 `dingxiang-inc.com`、`cdn.dingxiang-inc.com`、`captcha-ui/v5/index.js`、`dx-captcha`、`_dx.Captcha`、`appId`、`constId`、`apiServer`。
|
||||
|
||||
顶象可见形态很多:滑动拼图、文字点选、图标点选、语序点选、刮刮卡、空间语义、乱序拼图、旋转、面积验证、差异点击、语音验证、字体识别、滑动还原等。厂商判断和类型判断要分开,不能命中顶象后固定判成滑块。
|
||||
|
||||
### 百度智能云验证码
|
||||
|
||||
百度智能云验证码产品页常见信号是 `cloud.baidu.com/product-s/afd_s/captcha`、`console.bce.baidu.com/afd/captcha`、`验证码 Captcha-百度智能云`。产品形态包括滑块验证码、数字验证码、文字验证码和轨迹绘制。
|
||||
|
||||
如果只有 `bdstatic` 或 `baidu`,置信度不足;需要配合验证码产品页、控制台路径、题型文案或 SDK 接入材料。
|
||||
|
||||
### 京东云验证码
|
||||
|
||||
京东云验证码文档常见信号是 `docs.jdcloud.com/cn/captcha`、`Jcap.create`、`PC/M端 SDK接入` 和京东云验证码产品名。常见材料包括 `appId`、`sceneId`、回调结果和验证 token。
|
||||
|
||||
`jd.com`、`joya.js`、`qidian-sdk` 只是京东生态常见资源,不足以单独判断为京东云验证码。
|
||||
|
||||
### 云片行为验证
|
||||
|
||||
云片行为验证常见信号是 `yunpian.com/product/captcha`、`riddler-sdk`、`YpRiddler`、`行为式验证码`。可见产品形态包括滑动拼图和图中点选。
|
||||
|
||||
云片同时提供短信/语音验证码服务。遇到“短信验证码”“语音验证码”时,不进入网页验证码求解方案,只能说明安全边界或让用户改为人工流程。
|
||||
|
||||
### 华为/同盾类风控验证
|
||||
|
||||
华为云公开材料里更多见的是平台图形验证码或 OneAccess 集成场景;同盾更多是设备风险、无感和风控链路。它们不一定是独立可见验证码产品。
|
||||
|
||||
命中 `huaweicloud`、`OneAccess`、`tongdun`、`fraudmetrix`、`blackbox`、`tokenId` 时,优先输出“疑似平台/风控验证”,要求补充脚本 URL、接口名和可见题面。不要把它们强行归成滑块。
|
||||
|
||||
### AWS WAF
|
||||
|
||||
常见信号是 `aws-waf-token`、`awswaf`、`aws-waf-captcha`、`challenge.js`、图片分类题面。AWS WAF 可能展示类似验证码的九宫格,但本质是 WAF challenge 流程的一部分。
|
||||
|
||||
除非用户只关心可见网格题的分类,否则整体类型优先报 `waf-challenge`。
|
||||
|
||||
### DataDome
|
||||
|
||||
常见信号是 `datadome` cookie、`x-datadome` header、`ddcid`、DataDome JS。它主要是 bot mitigation 和设备/session 绑定,不是简单图片验证码。
|
||||
|
||||
重点放在环境诊断和自有/授权测试边界。
|
||||
|
||||
### Arkose / FunCaptcha
|
||||
|
||||
常见信号是 `arkoselabs`、`funcaptcha`、`client-api.arkoselabs.com`、`fc-token`、`public_key`、`pkey`、`surl`、`blob`。Arkose 可能展示 3D、小游戏或图片任务。
|
||||
|
||||
只有组件参数但命中 Arkose/FunCaptcha 时通常先报 `game-challenge`,并说明尚未看到具体题面;如果现场证据显示只是嵌入组件且未出题,可补充 `token-widget`。看到具体题面后,再按 `click-select`、`grid`、`rotate` 或其他子类型补充。
|
||||
|
||||
### 海外组件与 PoW 产品
|
||||
|
||||
Yandex SmartCaptcha、CaptchaFox、Prosopo/Procaptcha、TrustCaptcha、MTCaptcha、KeyCaptcha 更接近组件/token 类产品。FriendlyCaptcha、ALTCHA、Private Captcha、Cap.js、mCaptcha 更接近 `pow-challenge`。常见判断依据是官方脚本域名、`sitekey`、callback、隐藏 response 字段、PoW challenge 或产品专有 DOM 标记。
|
||||
|
||||
没有展示图片题时,组件类默认分类为 `token-widget`,PoW 类默认分类为 `pow-challenge`。如果后续出现具体图片网格、点选、旋转或小游戏题,再按可见题面补充或改判类型。
|
||||
|
||||
### 传统图片验证码库
|
||||
|
||||
BotDetect、Securimage、Amazon CAPTCHA、Kaptcha、EasyCaptcha、HappyCaptcha 多数是传统文字/图片验证码库。强信号包括 `BotDetect`、`BDC_CaptchaDiv`、`securimage_show.php`、`opfcaptcha.amazon.com`、`/errors/validateCaptcha`、`Kaptcha` 等库名或文件名。
|
||||
|
||||
这类通常分类为 `text` 或 `math`。重点是提取精确图片、确认字符集和刷新行为。
|
||||
|
||||
### 自托管行为验证码库
|
||||
|
||||
AJ-Captcha 和 Tianai Captcha 常见于自托管 Java/Web 项目。AJ-Captcha 常见 `blockPuzzle`、`clickWord`、`captchaVerification`;Tianai 常见 `tianai-captcha`、`TAC`、滑块/旋转/点选类型配置。
|
||||
|
||||
这类产品经常被站点二次封装。识别时保留库名、接口路径和可见题型,避免把业务自己的 `/captcha/get`、`/captcha/check` 泛化成某个具体库。
|
||||
|
||||
### Bot Management / WAF 产品
|
||||
|
||||
Cloudflare WAF、Akamai Bot Manager、Imperva/Incapsula、PerimeterX/HUMAN、Kasada、Netacea、Radware Bot Manager、F5 Bot Defense 和 DataDome 都应优先视为 `waf-challenge`。常见证据是 challenge 页面、clearance cookie、传感器脚本、专有 cookie/header、状态码和响应头。
|
||||
|
||||
这类产品即使展示可见验证码,也不等于普通图片验证码。报告应优先说明产品识别、浏览器环境、TLS/HTTP 指纹、cookie/session 绑定、JS challenge 过期和授权测试边界。
|
||||
@@ -0,0 +1,437 @@
|
||||
# 解决方案手册
|
||||
|
||||
这些方案用于授权场景下的验证码识别和验证分析。
|
||||
|
||||
## 输出原则
|
||||
|
||||
识别到厂商和类型后,按这个顺序给方案:
|
||||
|
||||
1. 先给开源/本地方案:能离线处理的优先用 ddddocr、OpenCV、Tesseract、Whisper/faster-whisper、YOLO/CLIP/VLM、规则解析或官方测试环境。
|
||||
2. 再给通过率不足时的备选:打码平台、人工接管、厂商支持或官方集成诊断。平台只作为用户自行选择的授权 QA 备选,不默认调用。
|
||||
3. 最后给切换条件:什么时候从开源方案切到平台/人工,什么时候应停在厂商集成诊断。
|
||||
4. 第二阶段不要无限沿用当前方案:同一授权目标、同一验证码类型、同一用户选择方案连续 5 次失败且没有成功,图片/坐标/轨迹/切片还原/补环境/challenge 新鲜度均无明显异常时,主动建议打码平台作为授权 QA 对照。
|
||||
5. 真实网页验证前先建立用户手动成功样本基线:同一授权目标至少 5 次成功;如出现动态切题,每个验证码类型至少 2 次成功。基线不足时必须强提示,但用户确认后可继续离线分析或受控验证。
|
||||
|
||||
常见备选平台按类型选择:
|
||||
|
||||
- 图片文字/计算/坐标/滑块/点选:云码/JFBYM、超级鹰、2Captcha、Anti-Captcha。
|
||||
- reCAPTCHA/hCaptcha/Turnstile/GeeTest/FunCaptcha/AWS WAF 等 token 或交互任务:2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha、YesCaptcha/NoCaptchaAI。
|
||||
- Arkose/FunCaptcha 小游戏:2Captcha、CapSolver、Anti-Captcha、NopeCHA,或 人工接管。
|
||||
- PoW、无感评分、WAF、活体类:优先做官方接入和环境诊断;平台只用于授权对照测试或不建议使用。
|
||||
|
||||
## 第二阶段方案切换规则
|
||||
|
||||
在用户确认方案并开始授权验证后,按 `references/verification-workflow.md` 记录 attempts JSON,并用 `scripts/evaluate_verification_attempts.py` 判断是否切换:
|
||||
|
||||
- 先用 `scripts/evaluate_success_baseline.py` 检查用户手动成功样本;缺少成功基线时,不要把失败原因过早归咎于图片识别、轨迹或平台。
|
||||
- `slider`、`image-restore/tile-scramble`、`click-select`、`grid`、`rotate`、`token-widget`:满足 5 次失败门槛且诊断均为 `ok` 时,优先推荐平台对照,输出 `recommended_next_route: platform-control`。
|
||||
- `text`、`math`、`audio`、`qa-logic`:满足 5 次失败门槛且图片/OCR/ASR/题面解析无明显异常时,可切平台或人工复核,用来判断本地识别是否低通过率。
|
||||
- `drag-drop`、`trace-draw`、`scratch`、`area-select`、`difference-click`、`font-identify`、`semantic-reasoning`、`game-challenge`、`multi-step`:先检查坐标、轨迹、多轮状态和题面变化;诊断均为 `ok` 后再推荐平台/人工接管对照。
|
||||
- `pow-challenge`、`waf-challenge`、`biometric-liveness`:不默认推荐普通打码平台;优先官方协议、环境诊断、厂商日志、人工复核或厂商支持。
|
||||
- 若失败不足 5 次、已有成功、或存在明确坐标/轨迹/图片/乱序还原/补环境/challenge 过期问题,先继续当前路线并修复阻塞项。
|
||||
|
||||
## 类型方案速查
|
||||
|
||||
| 类型 | 开源/本地优先 | 低通过率时备选 | 切换条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| `text` | ddddocr、Tesseract、OpenCV 预处理 | 云码/JFBYM、超级鹰、2Captcha ImageToText、Anti-Captcha Image | 扭曲/噪声导致 OCR 不稳定 |
|
||||
| `math` | OCR + 运算符归一化 + 安全求值 | 云码/JFBYM、超级鹰、2Captcha Normal Captcha | 表达式干扰、中文数字或应用题 |
|
||||
| `slider` | ddddocr slide-match、OpenCV 匹配、轨迹校准 | 云码/JFBYM、超级鹰、CapSolver、2Captcha GeeTest/slider | 缺口弱、背景乱序、行为评分失败;或 5 次失败且图像/坐标/轨迹/环境均正常 |
|
||||
| `click-select` | OCR/模板匹配、YOLO、VLM 坐标 | 云码/JFBYM、超级鹰、2Captcha Coordinates、CapSolver ComplexImageTask | 目标重叠、语义复杂、多轮点选;或 5 次失败且识别/坐标/环境均正常 |
|
||||
| `rotate` | OpenCV 角度估计、特征匹配、VLM | 云码/JFBYM、超级鹰、CapSolver/2Captcha 图片任务 | 对称图、角度映射非线性;或 5 次失败且角度/坐标/轨迹/环境均正常 |
|
||||
| `grid` | 分格 + 分类模型、YOLO/CLIP/VLM | 2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha、NopeCHA | 多轮、跨格、题面语义复杂;或 5 次失败且格子/坐标/环境均正常 |
|
||||
| `audio` | faster-whisper/Whisper、降噪、白名单纠错 | 2Captcha Audio、CapMonster/Anti-Captcha 音频或人工任务 | 强噪声、口音、多语言 |
|
||||
| `drag-drop` | 目标检测/VLM、DOM 坐标、释放点校准 | 云码/JFBYM、超级鹰、CapSolver ComplexImageTask、人工接管 | 吸附/动画/行为评分复杂 |
|
||||
| `trace-draw` | OpenCV 路径提取、骨架化、点列重采样 | 云码/JFBYM、超级鹰、人工标注 | 路径遮挡、采样格式未知 |
|
||||
| `scratch` | canvas 区域、覆盖轨迹、状态差分 | 人工接管、云码/JFBYM 定制、超级鹰定制 | 阈值未知、刮开后二次题 |
|
||||
| `image-restore` | 先判定切片乱序;页面 `tileOrder`/CSS/canvas 还原;OpenCV/Pillow 边缘连续性 | 云码/JFBYM、超级鹰、CapSolver/2Captcha 图片任务 | 重复纹理、随机裁剪、顺序字段加密、参数绑定;或 5 次失败且还原/坐标/轨迹/环境均正常 |
|
||||
| `area-select` | 检测/分割、SAM/YOLO、VLM 框选 | 云码/JFBYM、超级鹰、CapSolver ComplexImageTask | 边界模糊、多目标、多边形要求 |
|
||||
| `difference-click` | 图像配准 + 差分、显著性检测、VLM | 云码/JFBYM、超级鹰、人工标注 | 压缩噪声、差异细微 |
|
||||
| `font-identify` | OCR + 字形特征、模板、VLM | 云码/JFBYM、超级鹰人工/坐标 | 字体相近、缩放抗锯齿 |
|
||||
| `semantic-reasoning` | 目标检测 + 规则推理、VLM | 云码/JFBYM、超级鹰、CapSolver ComplexImageTask | 关系歧义、遮挡、VLM 低置信度 |
|
||||
| `game-challenge` | 截图状态识别、VLM 解析、拆子类型 | 2Captcha FunCaptcha、CapSolver、Anti-Captcha、NopeCHA、人工接管 | 3D 连续状态、题目频繁更新 |
|
||||
| `pow-challenge` | 官方协议、difficulty/nonce/TTL、防重放诊断 | 通常不需要平台;仅授权兼容测试考虑 ALTCHA/PoW 支持 | 服务端签名/绑定异常 |
|
||||
| `risk-score` | 官方校验日志、action/hostname/score 阈值诊断 | 2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha | 授权 QA 需要 token 对照 |
|
||||
| `one-click` | 组件状态、callback、人工点击复现 | 2Captcha/CapSolver/CapMonster token 任务、人工接管 | 升级二次挑战或状态不同步 |
|
||||
| `multi-step` | 逐轮取证,每轮按子类型选择工具 | 按子类型平台,人工接管 | 多轮混合、状态过期 |
|
||||
| `qa-logic` | 文本解析、规则库、本地 LLM/VLM、人工复核 | 云码/JFBYM、超级鹰、2Captcha Normal Captcha | 题库动态或语义歧义 |
|
||||
| `biometric-liveness` | 官方 SDK、设备权限、人工审核、可访问性方案 | 不建议平台;合规授权下联系厂商/人工审核 | 隐私合规、误识别、设备兼容 |
|
||||
| `token-widget` | 官方测试 key、sitekey/action/callback/TTL、服务端日志 | 2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha、YesCaptcha/NoCaptchaAI | 授权 QA 需要 token 对照;或 5 次失败且 sitekey/action/TTL/环境均正常 |
|
||||
| `waf-challenge` | 真实浏览器取证、WAF 日志、TLS/HTTP/JS 环境诊断 | 厂商支持、人工复核;授权对照谨慎评估平台支持 | 误伤真实用户或环境指纹异常;普通打码平台不是默认路线 |
|
||||
| `unknown-custom` | 补 HTML/脚本/截图/接口,归入相近类型 | 云码/JFBYM 定制、超级鹰定制、通用图片/坐标任务 | 证据不足或自研混淆 |
|
||||
|
||||
## `text`
|
||||
|
||||
目标:从文字、数字或字母数字混合图片验证码中识别答案。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取精确的验证码图片或元素截图,避免直接使用整页截图。
|
||||
2. 确认约束:长度、字符集、大小写、刷新行为、答案格式。
|
||||
3. 优先尝试本地 OCR:
|
||||
- ddddocr:适合常见中文网页图片验证码。
|
||||
- Tesseract:适合简单数字/字母,可配合白名单。
|
||||
- OpenCV:用于降噪、颜色过滤、纠偏、去干扰线等预处理。
|
||||
4. 本地 OCR 不稳定时,再在用户确认授权范围后考虑 VLM 或云端识别服务。
|
||||
5. 输出置信度;如果验证码每次尝试都会刷新,要求用户提供新样本复核。
|
||||
|
||||
关键风险:字体扭曲、`0/O` 和 `1/I/l` 混淆、彩色背景、服务端图片 token 单次有效。
|
||||
|
||||
## `math`
|
||||
|
||||
目标:识别并计算简单算术验证码。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取图片/文本,先按 `text` 的 OCR 流程识别表达式。
|
||||
2. 归一化运算符:`x`、`X`、`×` 转为 `*`,`÷` 转为 `/`,中文“加减乘除”转为运算符。
|
||||
3. 只允许数字、括号和基础运算符进入解析。
|
||||
4. 安全求值;遇到 OCR 歧义时不要猜测。
|
||||
5. 同时返回归一化表达式和计算结果。
|
||||
|
||||
关键风险:非整数除法、文字应用题、中文数字、`+` 与 `t` 或 `-` 与噪点混淆。
|
||||
|
||||
## `slider`
|
||||
|
||||
目标:估计滑块目标偏移,并说明授权流程中如何验证。
|
||||
|
||||
方案:
|
||||
|
||||
1. 判断图片模式:
|
||||
- 双图模式:滑块/目标图 + 背景图。
|
||||
- 单图模式:一张图内有明显缺口。
|
||||
- canvas 模式:背景由 canvas 渲染,可能需要元素截图。
|
||||
2. 识别偏移:
|
||||
- 常见双图任务可尝试 ddddocr `slide-match` 或 `slide-comparison`。
|
||||
- 可使用 OpenCV 模板匹配、边缘匹配、差分匹配。
|
||||
- 本地匹配失败时,再考虑 VLM 或云端识别。
|
||||
3. 校准坐标:
|
||||
- 图片像素映射到 CSS 像素和轨道像素。
|
||||
- 处理 DPR、缩放、裁剪偏移、轨道 padding。
|
||||
4. 在自有或授权 QA 中,用合理轨迹模型验证拖动行为和产品限制;不要表述成通用绕过。
|
||||
5. 把厂商 verify 参数和视觉偏移分开。极验、腾讯、易盾、阿里云可能把加密参数绑定到浏览器状态和行为采集。
|
||||
|
||||
关键风险:背景乱序/还原、透明滑块块、弱边缘缺口、设备像素比不一致、challenge ID 防重放、加密 `verify` payload。
|
||||
|
||||
## `click-select`
|
||||
|
||||
目标:识别题面目标,并返回用于分析的点击坐标或目标顺序。
|
||||
|
||||
方案:
|
||||
|
||||
1. 精确提取题面,尤其是“依次”“从左到右”等顺序词。
|
||||
2. 文字题用 OCR;物体/图标题用目标检测、模板匹配或 VLM。
|
||||
3. 尽量同时返回截图像素坐标和元素相对坐标。
|
||||
4. 判断厂商是否要求有序点击、多轮题面或点击后确认。
|
||||
5. 中文文字点选必须保留原始字符和目标顺序。
|
||||
|
||||
关键风险:字符重叠、干扰文字、动态缩放、多轮刷新、隐藏点击顺序评分。
|
||||
|
||||
## `rotate`
|
||||
|
||||
目标:估计图片/物体应旋转的角度,并换算成拖动偏移。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取图片:单图旋转、内外环、参考图/目标图。
|
||||
2. 用图像匹配、主方向检测、特征匹配或 VLM 判断角度。
|
||||
3. 校准角度到移动距离:
|
||||
- 确认轨道宽度和角度范围。
|
||||
- 处理固定偏移和非线性映射。
|
||||
- 在受控测试样本中校准。
|
||||
4. 返回角度、估计拖动距离和置信度。
|
||||
|
||||
关键风险:对称图片、边缘模糊、非线性滑块映射、厂商固定偏移。
|
||||
|
||||
## `grid`
|
||||
|
||||
目标:分类图片格子,并选择格子编号或中心坐标。
|
||||
|
||||
方案:
|
||||
|
||||
1. 同时截取完整网格和题面。
|
||||
2. 分割格子,记录每个格子的边界和中心点。
|
||||
3. 按题面选择模型:
|
||||
- reCAPTCHA/hCaptcha 类题面可能需要专项图片分类模型或 VLM。
|
||||
- AWS WAF 网格可能需要 WAF 专项分类。
|
||||
- 简单自研九宫格可用模板或目标匹配。
|
||||
4. 返回选中格子编号和中心坐标,并给出每格置信度。
|
||||
5. 预期多轮题面和题目变化。
|
||||
|
||||
关键风险:目标跨格、低分辨率裁剪、题面歧义、多轮验证。
|
||||
|
||||
## `audio`
|
||||
|
||||
目标:识别网页音频验证码中的数字、字符或短语。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取音频资源或播放接口,确认语言、长度、答案格式和是否有视觉替代方案。
|
||||
2. 先做降噪、音量归一化和静音裁剪。
|
||||
3. 数字/字母类题目使用 ASR 后加字符集和长度约束。
|
||||
4. 噪声强、口音明显或多语言时,使用人工复核或授权范围内的云端语音识别。
|
||||
5. 不处理短信、电话、语音 OTP 或 MFA 场景。
|
||||
|
||||
关键风险:背景噪声、重叠语音、动态音频一次性有效、语言识别错误。
|
||||
|
||||
## `drag-drop`
|
||||
|
||||
目标:定位被拖动对象和目标区域,并说明拖放验证分析路径。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取完整挑战截图,标注拖动元素、目标区域和容器边界。
|
||||
2. 用模板匹配、目标检测或 VLM 识别源对象和目标区域。
|
||||
3. 校准截图像素到 DOM/CSS 坐标,记录 DPR、缩放和滚动偏移。
|
||||
4. 在授权 QA 中分析拖放路径、释放点、停顿和吸附规则。
|
||||
5. 如果题目本质是水平拼图缺口,改归类为 `slider` 或 `image-restore`。
|
||||
|
||||
关键风险:目标区域不明显、拖放吸附、元素动画、释放点容错半径不明。
|
||||
|
||||
## `trace-draw`
|
||||
|
||||
目标:提取目标轨迹,并转换为可分析的点列。
|
||||
|
||||
方案:
|
||||
|
||||
1. 获取题面截图、canvas 尺寸和目标路径。
|
||||
2. 用边缘/颜色分割、骨架化或模板匹配提取路径。
|
||||
3. 将路径重采样为厂商接受的点列格式,记录时间间隔和坐标系。
|
||||
4. 对授权测试样本做轨迹平滑、速度和采样密度校准。
|
||||
5. 百度等轨迹绘制形态要单独标注,不要混入普通滑块。
|
||||
|
||||
关键风险:路径被遮挡、抗锯齿导致边缘不稳定、轨迹采样格式绑定浏览器状态。
|
||||
|
||||
## `scratch`
|
||||
|
||||
目标:分析刮刮卡验证的区域覆盖和状态变化。
|
||||
|
||||
方案:
|
||||
|
||||
1. 记录刮开前后截图、canvas 区域和可操作边界。
|
||||
2. 判断通过条件是覆盖比例、轨迹形态还是隐藏内容识别。
|
||||
3. 在授权 QA 中用规则化轨迹覆盖目标区域,并观察状态字段变化。
|
||||
4. 如果刮开后还要识别文字/图像,继续转入对应子类型。
|
||||
|
||||
关键风险:覆盖阈值不明、canvas 状态不可直接读取、移动端触摸事件差异。
|
||||
|
||||
## `image-restore`
|
||||
|
||||
目标:还原乱序图片、复原拼图或估计滑动还原偏移。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取分块图片、块边界、目标区域和交互方式。
|
||||
2. 先判断是否为 `tile-scramble`:图片被切片/分块后顺序打乱,常见线索是 `tileOrder`、`pieceOrder`、`sliceOrder`、`background-position`、sprite、canvas `drawImage`、`shuffle` 或 `scramble`。
|
||||
3. 优先走页面逻辑还原:
|
||||
- 从接口字段或 JS 数组读取每个目标位置对应的来源切片。
|
||||
- 从 CSS `background-position` 反推出 sprite 来源块。
|
||||
- 从 canvas `drawImage(sx, sy, sw, sh, dx, dy, dw, dh)` 反推出源块到目标块的映射。
|
||||
- 可用 `scripts/analyze_tile_restore.py` 输出 `order_source_by_target` 和还原图。
|
||||
4. 页面逻辑缺失时,再走图片匹配:
|
||||
- 按 `rows/cols` 或块宽高切片。
|
||||
- 用左右/上下边缘连续性、颜色/纹理连续性、特征点匹配或 VLM 判断候选排列。
|
||||
- 输出候选顺序、置信度和低置信度原因。
|
||||
5. 将目标排列转换为拖动、交换或滑动偏移;滑动还原类任务还要校准轨道长度、DPR 和固定偏移。
|
||||
6. 标明它和普通 `slider` 的区别:目标是复原完整图片或分块顺序,而不只是找到缺口。
|
||||
|
||||
关键风险:重复纹理、纯色块、随机裁剪、分块顺序加密、视觉复原与提交参数分离。低置信度时切换人工复核、云码/JFBYM、超级鹰、CapSolver/2Captcha 图片任务或厂商授权测试环境。
|
||||
|
||||
## `area-select`
|
||||
|
||||
目标:识别并返回需要选择的区域。
|
||||
|
||||
方案:
|
||||
|
||||
1. 精确提取完整题面和图片区域。
|
||||
2. 用目标检测、图像分割、OCR 或 VLM 找到目标区域。
|
||||
3. 返回矩形、多边形或中心点集合,并说明坐标原点。
|
||||
4. 如果产品只接受单点点击,转为 `click-select`;如果接受框选面积,保持 `area-select`。
|
||||
|
||||
关键风险:边界模糊、多个候选区域、坐标缩放、区域选择格式不一致。
|
||||
|
||||
## `difference-click`
|
||||
|
||||
目标:找出差异点并输出点击坐标。
|
||||
|
||||
方案:
|
||||
|
||||
1. 获取两张对比图或完整挑战截图。
|
||||
2. 做图像配准,消除缩放、平移和压缩差异。
|
||||
3. 用差分、显著性检测或 VLM 找出候选差异点。
|
||||
4. 输出点击点、差异描述和置信度。
|
||||
5. 多个差异点时记录点击次数和顺序要求。
|
||||
|
||||
关键风险:压缩噪声、细微差异、动态背景、差异点跨元素边界。
|
||||
|
||||
## `font-identify`
|
||||
|
||||
目标:按字体或字形规则选择目标。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取题面和候选文字截图。
|
||||
2. 先 OCR 出文字内容,再用字形特征或视觉模型判断字体关系。
|
||||
3. 区分“相同字体”“不同字体”“指定字体”三种题面。
|
||||
4. 输出目标文字、坐标和字体判断理由。
|
||||
|
||||
关键风险:文字内容识别正确但字体判断错误、缩放抗锯齿、字体样式相近。
|
||||
|
||||
## `semantic-reasoning`
|
||||
|
||||
目标:理解题面中的空间/语义关系并返回目标坐标。
|
||||
|
||||
方案:
|
||||
|
||||
1. 保留题面原文,尤其是最左、最大、相邻、遮挡、上下关系等关键词。
|
||||
2. 用目标检测或 VLM 识别候选目标。
|
||||
3. 对候选目标做关系推理,输出选择理由、坐标和置信度。
|
||||
4. 题面歧义时要求人工确认,不要猜测。
|
||||
|
||||
关键风险:关系词歧义、目标遮挡、截图裁剪、模型幻觉。
|
||||
|
||||
## `game-challenge`
|
||||
|
||||
目标:识别小游戏/3D/FunCaptcha 类交互题的题面和状态。
|
||||
|
||||
方案:
|
||||
|
||||
1. 记录厂商、public key/challenge id、题面和初始截图/帧。
|
||||
2. 判断交互目标:旋转、选择、对齐、计数或多状态操作。
|
||||
3. 优先做人工接管或授权 QA 中的模型辅助分析。
|
||||
4. 如能拆成更具体的子类型,报告中同时标注子类型。
|
||||
5. 不提供未授权自动通关脚本。
|
||||
|
||||
关键风险:题目状态连续变化、3D 渲染差异、服务端行为评分、挑战频繁更新。
|
||||
|
||||
## `pow-challenge`
|
||||
|
||||
目标:识别工作量证明类验证码/组件,并解释计算与校验约束。
|
||||
|
||||
方案:
|
||||
|
||||
1. 识别 FriendlyCaptcha、ALTCHA、Private Captcha、Cap.js、mCaptcha 等组件线索。
|
||||
2. 提取非秘密字段:challenge、payload、difficulty、nonce、salt、signature、endpoint。
|
||||
3. 区分前端计算结果、服务端签名校验和业务动作校验。
|
||||
4. 对自有系统,建议按官方协议检查难度、TTL、防重放和失败重试。
|
||||
5. 不把 PoW 组件当成 OCR 或图片验证码。
|
||||
|
||||
关键风险:计算结果短期有效、payload 绑定页面/session、难度动态变化、防重放。
|
||||
|
||||
## `risk-score`
|
||||
|
||||
目标:分析无感、无痕、隐形或风险评分验证。
|
||||
|
||||
方案:
|
||||
|
||||
1. 识别 sitekey、action、页面 URL、厂商脚本和 callback。
|
||||
2. 获取服务端校验结果中的 score、risk level、action/hostname 是否匹配。
|
||||
3. 了解失败路径:直接拒绝、升级一键、升级滑块/点选或人工复核。
|
||||
4. 对自有系统,建议调整阈值前先对照业务日志和误伤样本。
|
||||
5. 不把低分自动规避写成解决方案。
|
||||
|
||||
关键风险:score 不稳定、action 不匹配、hostname/session/IP 绑定、误伤真实用户。
|
||||
|
||||
## `one-click`
|
||||
|
||||
目标:识别一键、checkbox、点击或按住类验证。
|
||||
|
||||
方案:
|
||||
|
||||
1. 提取组件 HTML、按钮状态、callback 和 response 字段。
|
||||
2. 判断它是最终验证,还是触发后升级到滑块、点选、网格等二次挑战。
|
||||
3. 授权 QA 中记录点击/按住时长、状态变化和错误文案。
|
||||
4. 普通表单 checkbox 不归类为验证码,除非存在 captcha/verify/challenge 或厂商证据。
|
||||
|
||||
关键风险:二次挑战、行为评分、无障碍模式差异、组件状态和服务端校验不同步。
|
||||
|
||||
## `multi-step`
|
||||
|
||||
目标:记录多轮、多步或分页挑战流程。
|
||||
|
||||
方案:
|
||||
|
||||
1. 对每轮单独保存题面、截图、接口状态和结果。
|
||||
2. 每轮优先标注具体子类型,例如 `grid`、`click-select` 或 `game-challenge`。
|
||||
3. 记录轮次间的 challenge id、状态字段和刷新规则。
|
||||
4. 输出最终流程图式总结,而不是只给最后一轮结果。
|
||||
|
||||
关键风险:上一轮结果影响下一轮、状态过期、多轮截图混淆、局部成功被误判为完成。
|
||||
|
||||
## `qa-logic`
|
||||
|
||||
目标:解析问答或逻辑题验证码。
|
||||
|
||||
方案:
|
||||
|
||||
1. 保留问题原文和答案格式。
|
||||
2. 简单算术转入 `math`;常识/逻辑问题保持 `qa-logic`。
|
||||
3. 用文本解析、规则库或人工复核得到答案。
|
||||
4. 动态题库或语义模糊时输出低置信度。
|
||||
|
||||
关键风险:语言歧义、题库动态变化、看似常识但依赖上下文。
|
||||
|
||||
## `biometric-liveness`
|
||||
|
||||
目标:识别活体、人脸或生物识别验证并给出合规建议。
|
||||
|
||||
方案:
|
||||
|
||||
1. 识别 SDK/厂商标识、动作要求和业务场景。
|
||||
2. 检查用户是否在自有系统或明确授权范围内分析。
|
||||
3. 给出接入诊断、可访问性替代、人工复核和隐私合规建议。
|
||||
4. 不提供绕过、伪造、替身或未授权自动化方案。
|
||||
|
||||
关键风险:隐私合规、误识别、设备权限、不可逆生物特征处理。
|
||||
|
||||
## `token-widget`
|
||||
|
||||
目标:识别组件参数,并解释 token 校验约束。
|
||||
|
||||
方案:
|
||||
|
||||
1. 识别厂商脚本/iframe 和产品模式。
|
||||
2. 提取非秘密参数:`sitekey`、页面 URL、`action`、callback 名、enterprise 标记、`cdata`、`rqdata`,或 Arkose public key/blob、Yandex SmartCaptcha、CaptchaFox、Procaptcha、TrustCaptcha、Private Captcha、Cap.js、mCaptcha 等组件配置存在性。
|
||||
3. 只作为可选方案说明 solver 类别,不默认执行:
|
||||
- 2Captcha、CapSolver、CapMonster、Anti-Captcha 风格 token 服务。
|
||||
- QA 场景中可暂停人工验证的浏览器自动化框架。
|
||||
- 自有系统按官方文档做服务端校验。
|
||||
4. 记录 TTL 和绑定限制:
|
||||
- token 通常短期有效且单次使用。
|
||||
- 服务端可能校验 action、hostname、IP/session、score、浏览器状态。
|
||||
- callback 和隐藏 response 字段因厂商而异。
|
||||
5. 不注入 token,不操作受保护账号流程。
|
||||
|
||||
关键风险:token 过期、action/page URL 不匹配、enterprise payload 不匹配、score 过低、callback 未触发、服务端 hostname/action 校验失败。
|
||||
|
||||
## `waf-challenge`
|
||||
|
||||
目标:识别反自动化/WAF 产品,并给出诊断建议。
|
||||
|
||||
方案:
|
||||
|
||||
1. 从响应头、cookie、脚本 URL、状态码和页面文案识别产品,例如 Cloudflare WAF、AWS WAF、DataDome、Akamai、Imperva/Incapsula、PerimeterX/HUMAN、Kasada、Netacea、Radware 或 F5。
|
||||
2. 区分可见验证码和更大的 WAF 流程。图片题可能只是环境验证的一部分。
|
||||
3. 诊断证据:
|
||||
- 浏览器特性支持和 JS 运行错误。
|
||||
- cookie/token 绑定。
|
||||
- TLS/HTTP 指纹一致性。
|
||||
- IP 信誉、频率限制、session 粘性。
|
||||
- 资源被拦截或 challenge JS 过期。
|
||||
4. 对自有或授权系统,建议用干净浏览器复现、对比 accepted/rejected session,并检查服务端日志。
|
||||
5. 对未授权第三方目标,只做产品识别和高层诊断建议。
|
||||
|
||||
关键风险:clearance cookie 绑定、PoW 防重放、动态 JS 过期、误以为 OCR 能解决完整挑战。
|
||||
|
||||
## `unknown-custom`
|
||||
|
||||
目标:收集足够证据,判断自研或未知挑战。
|
||||
|
||||
方案:
|
||||
|
||||
1. 要求用户提供组件附近 HTML、脚本 URL、可见文案、截图和网络接口名。
|
||||
2. 用 `provider-products.md` 排除已知厂商。
|
||||
3. 如果可能,把可见交互先归到标准类型之一。
|
||||
4. 如果该模式会重复出现,可补充信号并运行 `scripts/classify_verify.py` 建立小型测试夹具。
|
||||
|
||||
关键风险:只凭一张截图过拟合、动态加载的厂商 JS 被漏掉、原始 HAR/cookie 中的会话材料影响判断。
|
||||
@@ -0,0 +1,114 @@
|
||||
# 打码平台与请求模板
|
||||
|
||||
打码平台在本技能中只作为“授权 QA 对照”路线:用来判断问题究竟在本地识别/坐标/轨迹链路,还是在厂商绑定、服务端校验、session/IP/UA 或 challenge TTL 链路。它不是默认替代所有本地方案,也不是未授权流程的自动通过工具。
|
||||
|
||||
## 平台选择
|
||||
|
||||
| 场景 | 常见平台 |
|
||||
| --- | --- |
|
||||
| 图片文字、计算题 | 云码/JFBYM、超级鹰、2Captcha、Anti-Captcha |
|
||||
| 坐标、点选、滑块 | 云码/JFBYM、超级鹰、2Captcha Coordinates、CapSolver ComplexImageTask |
|
||||
| reCAPTCHA、hCaptcha、Turnstile | 2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha、YesCaptcha/NoCaptchaAI |
|
||||
| GeeTest、FunCaptcha/Arkose | 2Captcha、CapSolver、CapMonster Cloud、Anti-Captcha |
|
||||
| AWS WAF、DataDome、复杂 WAF | 平台支持不稳定,优先厂商日志和环境诊断 |
|
||||
| PoW、活体、人脸 | 通常不建议平台;优先官方接入、人工审核或厂商支持 |
|
||||
|
||||
## 使用原则
|
||||
|
||||
1. 先确认授权范围和目标环境。
|
||||
2. 先用开源方案跑样本;通过率不足再考虑平台。
|
||||
3. 当同一授权目标、同一验证码类型、同一方案连续 5 次失败且图片/坐标/轨迹/切片还原/补环境/challenge 新鲜度均无明显异常时,优先建议平台作为授权 QA 对照。
|
||||
4. 平台参数只使用非秘密字段:sitekey、pageurl、action、图片 base64、题面、坐标模式等。
|
||||
5. API key 由用户在运行时提供,不写入 skill 文件,不保存,不回显,不输出到报告。
|
||||
6. token 类结果只用于授权测试和服务端集成诊断,不注入未授权流程。
|
||||
7. 未确认授权、未选择平台或未提供 API key 时,只生成模板和字段映射,不默认发送。
|
||||
|
||||
## 平台对照触发
|
||||
|
||||
满足以下条件时,输出 `recommended_next_route: platform-control`:
|
||||
|
||||
- 同一授权目标、同一验证码类型、同一用户选择方案连续 5 次验证失败。
|
||||
- 5 次中没有任何一次成功。
|
||||
- 图片识别、坐标映射、轨迹、切片乱序还原、补环境/浏览器环境和 challenge 新鲜度均未发现明显异常。
|
||||
- 当前类型适合平台对照,例如 `slider`、`image-restore/tile-scramble`、`click-select`、`grid`、`rotate`、`token-widget`。
|
||||
|
||||
不满足条件时不要急于切平台:
|
||||
|
||||
- 失败不足 5 次:继续收集样本和诊断证据。
|
||||
- 已有 1 次成功:当前方案可用但需要优化稳定性。
|
||||
- 仍有明确坐标、轨迹、图片、乱序还原、补环境或过期问题:先修复阻塞项。
|
||||
- `pow-challenge`、`waf-challenge`、`biometric-liveness`:不默认推荐普通打码平台,优先官方协议、环境诊断、人工复核或厂商支持。
|
||||
|
||||
## 请求模板脚本
|
||||
|
||||
使用 `scripts/solver_request_template.py` 生成模板:
|
||||
|
||||
```bash
|
||||
python scripts/solver_request_template.py --platform 2captcha --captcha-type token-widget --provider recaptcha --pretty
|
||||
python scripts/solver_request_template.py --platform jfbym --captcha-type click-select --pretty
|
||||
python scripts/solver_request_template.py --platform capsolver --captcha-type game-challenge --provider arkose-funcaptcha --pretty
|
||||
```
|
||||
|
||||
脚本输出 JSON 模板。
|
||||
|
||||
执行边界:
|
||||
|
||||
- 生成模板不等于发送请求。
|
||||
- 只有用户再次明确确认授权范围、选择平台、提供 API key,并要求发送时,才允许调用平台接口。
|
||||
- API key 不要写入 attempts JSON、skill 文件、报告、日志或命令历史可见位置。
|
||||
- 平台返回的 token、坐标、偏移或答案只用于用户确认的授权 QA 对照。
|
||||
|
||||
## 字段提示
|
||||
|
||||
### 图片文字/算术
|
||||
|
||||
常见字段:
|
||||
|
||||
- 图片:文件或 base64。
|
||||
- 题型:文字、数字、算术。
|
||||
- 字符约束:长度、数字/字母、大小写。
|
||||
|
||||
### 坐标/点选/九宫格
|
||||
|
||||
常见字段:
|
||||
|
||||
- 完整截图或图片 base64。
|
||||
- 题面文本。
|
||||
- 输出模式:单点、多点、有序坐标、格子编号。
|
||||
- 坐标原点和图片尺寸。
|
||||
|
||||
### 滑块/旋转
|
||||
|
||||
常见字段:
|
||||
|
||||
- 背景图、滑块图或单张挑战图。
|
||||
- 轨道宽度、显示尺寸、DPR。
|
||||
- 输出模式:偏移、角度、坐标。
|
||||
|
||||
### token widget
|
||||
|
||||
常见字段:
|
||||
|
||||
- `websiteURL` / pageurl。
|
||||
- `websiteKey` / sitekey。
|
||||
- `action`、`cdata`、`rqdata`、enterprise 标记。
|
||||
- user-agent、代理和 session 是否需要绑定。
|
||||
|
||||
### FunCaptcha/Arkose
|
||||
|
||||
常见字段:
|
||||
|
||||
- public key / pkey。
|
||||
- surl。
|
||||
- blob。
|
||||
- pageurl。
|
||||
- 题面类型和是否需要代理绑定。
|
||||
|
||||
## 平台失败排查
|
||||
|
||||
- 题型选错:平台返回无解或低通过。
|
||||
- 图片裁剪错:坐标和页面元素不一致。
|
||||
- pageurl/action/sitekey 不匹配:token 服务端校验失败。
|
||||
- token 过期:TTL 太短或提交太晚。
|
||||
- session/IP/UA 不一致:平台结果无法绑定当前浏览器。
|
||||
- 代理质量差:风控产品直接拒绝。
|
||||
@@ -0,0 +1,222 @@
|
||||
# 授权验证流程
|
||||
|
||||
本文件用于第二阶段:用户已经看到 `solution_options`,并明确选择某个方案后,再进入验证流程。默认先离线验证;真实网页操作、打码平台调用、表单提交都必须再次确认。
|
||||
|
||||
## 进入条件
|
||||
|
||||
必须同时满足:
|
||||
|
||||
1. 已有第一阶段输出:`captcha_type`、`provider`、`confidence`、`signals`、`solution_options`。
|
||||
2. 用户明确选择方案,例如“用 ddddocr + OpenCV 做滑块偏移”。
|
||||
3. 如果需要真实网页验证或失败复盘,优先已有用户手动成功样本基线;不足时必须显式提示风险。
|
||||
|
||||
## 标准流程
|
||||
|
||||
1. 复述当前识别结论:类型、厂商、置信度、关键证据。
|
||||
2. 复述用户选择的方案,并列出将使用的参考文件和脚本。
|
||||
3. 做执行前检查:
|
||||
- 证据是否足够:图片、HTML、截图、题面、脚本 URL、元素尺寸、DPR、接口名。
|
||||
- 依赖是否可用:Python、OpenCV、ddddocr、Tesseract、Whisper、平台 API key 等。
|
||||
- 是否需要真实浏览器:只要涉及打开页面、点击、拖动、提交、抓 Cookie/Storage,就需要再次确认。
|
||||
4. 评估成功样本基线:
|
||||
- 真实网页验证前,先检查 `success_samples`。
|
||||
- 默认同一授权目标至少 5 次用户手动成功样本。
|
||||
- 如果取证中观察到新的验证码类型,每个新类型至少 2 次成功样本。
|
||||
- 用 `scripts/evaluate_success_baseline.py` 输出 `success_baseline_status`、`success_baseline_summary` 和 `missing_success_samples`。
|
||||
- 基线不足时强提示:当前缺少真实成功流程对照,用户确认后仍可继续离线分析或受控验证。
|
||||
5. 离线执行:
|
||||
- 用开源工具识别答案、偏移、角度、坐标、点列或 token 诊断结果。
|
||||
- 用 `scripts/map_coordinates.py` 换算坐标。
|
||||
- 用 `scripts/generate_motion_track.py` 生成轨迹 JSON。
|
||||
- 用 `scripts/solver_request_template.py` 生成打码平台请求模板。
|
||||
6. 展示中间产物:
|
||||
- 识别结果、置信度、坐标/轨迹 JSON、缺失证据、失败原因。
|
||||
- 不要默认提交给真实页面。
|
||||
7. 每次验证后记录 attempts JSON:
|
||||
- 记录方案、验证码类型、厂商、授权目标、输入证据、识别结果、坐标/轨迹、切片还原结论、环境检查结论、challenge 新鲜度、成功/失败和失败原因。
|
||||
- 同一授权目标、同一验证码类型、同一用户选择方案连续失败时,用 `scripts/evaluate_verification_attempts.py` 做失败复盘。
|
||||
- 只有达到 5 次失败且无一次成功,且图片/坐标/轨迹/切片还原/补环境/challenge 新鲜度都无明显异常时,才主动建议切换到平台对照。
|
||||
8. 需要真实网页验证时:
|
||||
- 先读取 `references/browser-acquisition.md`。
|
||||
- 让用户确认 ruyiPage/Camoufox/CloakBrowser 模式。
|
||||
- 让用户确认具体动作:打开页面、截图、拖动、点击、提交或人工接管。
|
||||
9. 结束时输出报告:
|
||||
- 使用方案、输入证据、产物、成功/失败、失败原因、下一步建议。
|
||||
|
||||
## 成功样本基线
|
||||
|
||||
成功样本基线用于回答“真实成功流程长什么样”。没有它时,失败复盘很容易把验证码动态切题、隐藏回调、服务端绑定或成功状态漏判为本地识别失败。
|
||||
|
||||
默认门槛:
|
||||
|
||||
1. 同一授权目标至少 5 次用户手动成功样本。
|
||||
2. 如果观察到新的验证码类型,每个类型至少 2 次成功样本。
|
||||
3. 每轮成功样本必须独立记录,不要只保存最后一轮。
|
||||
|
||||
评估脚本:
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_success_baseline.py --samples success_samples.json --pretty
|
||||
```
|
||||
|
||||
脚本只做离线评估,不打开网页、不读取 Cookie/Storage、不提交验证。
|
||||
|
||||
成功样本最小结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization_scope": "用户确认的自有/授权测试目标",
|
||||
"success_samples": [
|
||||
{
|
||||
"sample_id": "manual-success-001",
|
||||
"success": true,
|
||||
"captcha_type": "slider",
|
||||
"provider": "geetest",
|
||||
"captcha_variant": null,
|
||||
"evidence": {
|
||||
"screenshot_before": "<relative path or description>",
|
||||
"screenshot_after": "<relative path or description>",
|
||||
"dom_summary": "<captcha container summary>",
|
||||
"script_or_iframe_urls": ["<url without secrets>"],
|
||||
"network_summary": ["<request/response summary without secrets>"],
|
||||
"success_signal": "UI/callback/response 显示验证成功",
|
||||
"challenge_id": "<non-secret id if available>",
|
||||
"timeline": ["rendered", "user solved", "success observed"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
基线不足时输出:
|
||||
|
||||
- `success_baseline_status: insufficient`
|
||||
- `missing_success_samples`
|
||||
- `recommended_next_route: collect-more-manual-success-samples`
|
||||
- 强提示“缺少真实成功流程对照”;用户明确确认后,仍可继续离线分析或受控验证。
|
||||
|
||||
## 5 次失败复盘门槛
|
||||
|
||||
当用户已经选择方案并确认授权后,不要无限沿用当前方案。每次尝试结束都要把结果追加到 attempts JSON,再按以下规则判断:
|
||||
|
||||
1. 先确认复盘对象是否是同一授权目标、同一验证码类型、同一用户选择方案。
|
||||
2. 如果连续失败不足 5 次,继续收集样本和诊断证据,不急于切平台。
|
||||
3. 如果同一方案已有至少 1 次成功,不触发切换;输出“当前方案可用但需优化稳定性”,并继续分析失败样本。
|
||||
4. 如果失败原因仍明确属于图片识别、坐标映射、轨迹、切片乱序未还原、补环境/浏览器环境或 challenge 过期,优先修复对应问题,不急于切平台。
|
||||
5. 如果连续 5 次失败且无成功,图片/坐标/轨迹/切片还原/补环境/challenge 新鲜度均为 `ok`,输出 `escalation_decision: recommend-platform-control`,主动建议切换到打码平台做授权 QA 对照。
|
||||
6. `pow-challenge`、`waf-challenge`、`biometric-liveness` 不默认推荐普通打码平台;优先走官方协议、浏览器环境/厂商日志诊断、人工复核或厂商支持。
|
||||
|
||||
复盘脚本示例:
|
||||
|
||||
```bash
|
||||
python scripts/evaluate_verification_attempts.py --attempts attempts.json --pretty
|
||||
```
|
||||
|
||||
脚本只做离线判断,不打开网页、不读取 API key、不发送平台请求。
|
||||
|
||||
attempts JSON 可以使用这个最小结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization_scope": "用户确认的自有/授权测试目标",
|
||||
"captcha_type": "slider",
|
||||
"provider": "geetest",
|
||||
"chosen_solution": "open-source-slider",
|
||||
"attempts": [
|
||||
{
|
||||
"success": false,
|
||||
"diagnosis_status": {
|
||||
"image": "ok",
|
||||
"coordinates": "ok",
|
||||
"track": "ok",
|
||||
"browser_env": "ok",
|
||||
"challenge_freshness": "ok"
|
||||
},
|
||||
"failure_reason": "服务端仍判定失败,视觉偏移与轨迹检查未发现异常"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 动作分级
|
||||
|
||||
| 动作 | 默认允许 | 是否需要再次确认 |
|
||||
| --- | --- | --- |
|
||||
| 离线分类、读取本地 HTML/截图 | 是 | 否 |
|
||||
| 本地 OCR、图像匹配、坐标换算、轨迹生成 | 是 | 否 |
|
||||
| 生成打码平台请求模板 | 是 | 否 |
|
||||
| 发送打码平台请求 | 否 | 是,且用户提供 API key 和授权范围 |
|
||||
| 打开真实网页 | 否 | 是,且按浏览器取证模式 |
|
||||
| 点击、拖动、提交、注入 token | 否 | 是;未授权场景拒绝 |
|
||||
| 读取 Cookie/Storage/账号材料 | 否 | 是;默认避免 |
|
||||
|
||||
## 方案路由
|
||||
|
||||
| 类型 | 首选参考 |
|
||||
| --- | --- |
|
||||
| `text`、`math`、`audio` | `references/open-source-recipes.md` |
|
||||
| `slider`、`rotate`、`image-restore` | `references/open-source-recipes.md` + `references/motion-and-coordinate.md` |
|
||||
| `click-select`、`grid`、`area-select`、`difference-click`、`font-identify`、`semantic-reasoning` | `references/open-source-recipes.md` + `references/motion-and-coordinate.md` |
|
||||
| `drag-drop`、`trace-draw`、`scratch` | `references/motion-and-coordinate.md` |
|
||||
| `token-widget`、`game-challenge`、`risk-score` | `references/provider-execution-notes.md` + `references/solver-platform-recipes.md` |
|
||||
| `pow-challenge` | `references/provider-execution-notes.md` |
|
||||
| `waf-challenge` | `references/browser-acquisition.md` + `references/provider-execution-notes.md` |
|
||||
| `biometric-liveness` | 只做合规接入和人工复核建议 |
|
||||
|
||||
## 输出模板
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "verification-flow",
|
||||
"captcha_type": "slider",
|
||||
"provider": "geetest",
|
||||
"chosen_solution": "open-source-slider",
|
||||
"authorization_scope": "用户确认的自有/授权测试目标",
|
||||
"preflight": {
|
||||
"evidence_ready": true,
|
||||
"dependencies_ready": false,
|
||||
"missing": ["背景图", "滑块图"]
|
||||
},
|
||||
"offline_steps": [
|
||||
"识别滑块偏移",
|
||||
"换算 DOM 坐标",
|
||||
"生成轨迹 JSON"
|
||||
],
|
||||
"requires_live_browser": false,
|
||||
"success_baseline_status": "insufficient",
|
||||
"success_baseline_summary": {
|
||||
"total_success_samples": 0,
|
||||
"min_total_success_samples": 5,
|
||||
"min_success_samples_per_type": 2,
|
||||
"observed_captcha_types": []
|
||||
},
|
||||
"missing_success_samples": [],
|
||||
"attempt_summary": {
|
||||
"consecutive_same_context": 0,
|
||||
"failures": 0,
|
||||
"successes": 0
|
||||
},
|
||||
"diagnosis_status": {},
|
||||
"switch_triggered": false,
|
||||
"recommended_next_route": "continue-current-route-with-diagnostics",
|
||||
"platform_control_plan": {
|
||||
"role": "授权 QA 对照,不默认替代所有本地方案",
|
||||
"send_request": false
|
||||
},
|
||||
"requires_user_confirmation": [
|
||||
"如需真实页面拖动或提交,需要再次确认"
|
||||
],
|
||||
"artifacts": []
|
||||
}
|
||||
```
|
||||
|
||||
## 失败处理
|
||||
|
||||
- 证据不足:要求补充最小证据,不猜测。
|
||||
- 成功样本基线不足:先建议用户手动完成更多成功样本;用户确认后仍可继续离线分析或受控验证,但报告必须标记风险。
|
||||
- 开源工具低通过率:先给出失败样本原因;若连续 5 次失败且图像/坐标/轨迹/还原/补环境/challenge 新鲜度均无明显异常,再建议平台作为授权 QA 对照或人工接管。
|
||||
- 失败不足 5 次:继续收集样本、运行本地诊断,不触发方案切换。
|
||||
- 已有 1 次成功:当前方案可用但稳定性不足,继续优化失败样本,不触发平台切换。
|
||||
- 图像识别、坐标映射、轨迹、切片乱序还原、补环境或 challenge 过期仍有明确异常:优先修复对应问题,不切换平台。
|
||||
- 平台任务失败:检查题型、sitekey/pageurl/action、TTL、代理/IP/session、图片坐标系。
|
||||
- 真实网页失败:优先诊断环境、浏览器模式、DPR、坐标映射、challenge 过期和厂商绑定。
|
||||
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""离线分析切片乱序图片验证码并尝试还原。
|
||||
|
||||
脚本只读取本地证据,不打开网页、不控制浏览器、不提交验证码、
|
||||
不发送网络请求。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageStat
|
||||
except ImportError: # pragma: no cover - depends on local environment
|
||||
Image = None # type: ignore[assignment]
|
||||
ImageStat = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def read_value(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
path = Path(value)
|
||||
if path.exists() and path.is_file():
|
||||
for encoding in ("utf-8", "utf-8-sig", "gb18030"):
|
||||
try:
|
||||
return path.read_text(encoding=encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return path.read_text(errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def parse_order(value: str | None) -> list[int] | None:
|
||||
if not value:
|
||||
return None
|
||||
numbers = [int(item) for item in re.findall(r"-?\d+", value)]
|
||||
return numbers or None
|
||||
|
||||
|
||||
def compact(text: str, limit: int = 100) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", text.strip())
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1] + "…"
|
||||
|
||||
|
||||
def collect_text_signals(text: str) -> list[dict[str, str]]:
|
||||
patterns = [
|
||||
("切片乱序中文文案", r"切片乱序|分块乱序|图片分割|瓦片重排|分割顺序打乱|乱序图片还原|图片被切成多块|顺序打乱"),
|
||||
("切片乱序英文文案", r"\b(?:scrambled tiles|tile scramble|scrambled image tiles|image tile restore|reorder tiles)\b"),
|
||||
("tileOrder/pieceOrder 字段", r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b"),
|
||||
("CSS 背景切片线索", r"background-position|background-size|sprite"),
|
||||
("Canvas 绘制切片线索", r"\bdrawImage\s*\("),
|
||||
("布局位置线索", r"\b(?:transform|translate3d|translate|left|top)\b.{0,80}\b(?:px|tile|piece|slice)\b"),
|
||||
("shuffle/scramble 线索", r"\b(?:shuffle|scramble|unscramble|slice|tile|piece)\b.{0,80}\b(?:captcha|verify|challenge|image)\b"),
|
||||
]
|
||||
signals: list[dict[str, str]] = []
|
||||
for label, pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
signals.append({"label": label, "evidence": compact(match.group(0)), "source": "text"})
|
||||
return signals
|
||||
|
||||
|
||||
def extract_order_arrays(text: str) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
signals: list[dict[str, str]] = []
|
||||
for pattern in (
|
||||
r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b\s*[:=]\s*\[([^\]]+)\]",
|
||||
r"[\"'](?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)[\"']\s*:\s*\[([^\]]+)\]",
|
||||
):
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if not match:
|
||||
continue
|
||||
order = [int(item) for item in re.findall(r"-?\d+", match.group(1))]
|
||||
if order:
|
||||
signals.append({"label": "抽取显式切片顺序数组", "evidence": compact(match.group(0)), "source": "html"})
|
||||
return order, signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def extract_background_position_order(
|
||||
text: str,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
return None, []
|
||||
pattern = r"background-position\s*:\s*(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)px"
|
||||
order: list[int] = []
|
||||
signals: list[dict[str, str]] = []
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
x = abs(float(match.group(1)))
|
||||
y = abs(float(match.group(2)))
|
||||
col = int(round(x / tile_width))
|
||||
row = int(round(y / tile_height))
|
||||
if 0 <= row < rows and 0 <= col < cols:
|
||||
order.append(row * cols + col)
|
||||
signals.append({"label": "抽取 background-position 切片来源", "evidence": compact(match.group(0)), "source": "html"})
|
||||
expected = rows * cols
|
||||
if len(order) == expected:
|
||||
return order, signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def extract_draw_image_order(
|
||||
text: str,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
return None, []
|
||||
pattern = r"drawImage\s*\([^,]+,\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)"
|
||||
found: dict[int, int] = {}
|
||||
signals: list[dict[str, str]] = []
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
sx = float(match.group(1))
|
||||
sy = float(match.group(2))
|
||||
dx = float(match.group(5))
|
||||
dy = float(match.group(6))
|
||||
src_col = int(round(sx / tile_width))
|
||||
src_row = int(round(sy / tile_height))
|
||||
dst_col = int(round(dx / tile_width))
|
||||
dst_row = int(round(dy / tile_height))
|
||||
if 0 <= src_row < rows and 0 <= src_col < cols and 0 <= dst_row < rows and 0 <= dst_col < cols:
|
||||
found[dst_row * cols + dst_col] = src_row * cols + src_col
|
||||
signals.append({"label": "抽取 drawImage 源/目标切片", "evidence": compact(match.group(0)), "source": "html"})
|
||||
expected = rows * cols
|
||||
if len(found) == expected:
|
||||
return [found[index] for index in range(expected)], signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def normalize_order(order: list[int], total: int) -> tuple[list[int] | None, str | None]:
|
||||
if len(order) != total:
|
||||
return None, f"顺序数组长度为 {len(order)},但网格需要 {total} 个切片"
|
||||
if sorted(order) == list(range(total)):
|
||||
return order, None
|
||||
if sorted(item - 1 for item in order) == list(range(total)):
|
||||
return [item - 1 for item in order], None
|
||||
return None, "顺序数组不是 0 基或 1 基的完整排列"
|
||||
|
||||
|
||||
def infer_grid(
|
||||
image: Any,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[int | None, int | None, int | None, int | None, list[str]]:
|
||||
warnings: list[str] = []
|
||||
width, height = image.size
|
||||
if tile_width and not cols:
|
||||
cols = width // tile_width
|
||||
if tile_height and not rows:
|
||||
rows = height // tile_height
|
||||
if rows and not tile_height:
|
||||
tile_height = height // rows
|
||||
if cols and not tile_width:
|
||||
tile_width = width // cols
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
warnings.append("需要提供 rows/cols 或 tile-width/tile-height 才能切片分析图片")
|
||||
return rows, cols, tile_width, tile_height, warnings
|
||||
if rows * tile_height > height or cols * tile_width > width:
|
||||
warnings.append("网格尺寸超过图片尺寸")
|
||||
return rows, cols, tile_width, tile_height, warnings
|
||||
|
||||
|
||||
def split_tiles(image: Any, rows: int, cols: int, tile_width: int, tile_height: int) -> list[Any]:
|
||||
tiles = []
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
left = col * tile_width
|
||||
top = row * tile_height
|
||||
tiles.append(image.crop((left, top, left + tile_width, top + tile_height)).convert("RGB"))
|
||||
return tiles
|
||||
|
||||
|
||||
def edge_cost(a: Any, b: Any, direction: str) -> float:
|
||||
if direction == "right":
|
||||
edge_a = a.crop((a.width - 1, 0, a.width, a.height))
|
||||
edge_b = b.crop((0, 0, 1, b.height))
|
||||
else:
|
||||
edge_a = a.crop((0, a.height - 1, a.width, a.height))
|
||||
edge_b = b.crop((0, 0, b.width, 1))
|
||||
diff = ImageStat.Stat(Image.eval(ImageChopsCompat.difference(edge_a, edge_b), lambda px: px)).mean
|
||||
return float(sum(diff) / len(diff))
|
||||
|
||||
|
||||
class ImageChopsCompat:
|
||||
@staticmethod
|
||||
def difference(left: Any, right: Any) -> Any:
|
||||
from PIL import ImageChops
|
||||
|
||||
return ImageChops.difference(left, right)
|
||||
|
||||
|
||||
def arrangement_cost(order: tuple[int, ...], tiles: list[Any], rows: int, cols: int) -> float:
|
||||
total = 0.0
|
||||
count = 0
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
current = tiles[order[row * cols + col]]
|
||||
if col + 1 < cols:
|
||||
total += edge_cost(current, tiles[order[row * cols + col + 1]], "right")
|
||||
count += 1
|
||||
if row + 1 < rows:
|
||||
total += edge_cost(current, tiles[order[(row + 1) * cols + col]], "down")
|
||||
count += 1
|
||||
return total / max(count, 1)
|
||||
|
||||
|
||||
def tile_diversity(tiles: list[Any]) -> float:
|
||||
means: list[float] = []
|
||||
for tile in tiles:
|
||||
stat = ImageStat.Stat(tile)
|
||||
means.extend(float(value) for value in stat.mean)
|
||||
if not means:
|
||||
return 0.0
|
||||
avg = sum(means) / len(means)
|
||||
variance = sum((value - avg) ** 2 for value in means) / len(means)
|
||||
return math.sqrt(variance)
|
||||
|
||||
|
||||
def image_edge_match_order(tiles: list[Any], rows: int, cols: int) -> tuple[list[int] | None, float, float, list[str]]:
|
||||
total = rows * cols
|
||||
warnings: list[str] = []
|
||||
if total != len(tiles):
|
||||
return None, 0.0, 0.0, ["切片数量和网格不一致"]
|
||||
if total > 9:
|
||||
warnings.append("切片超过 9 个,第一版不做全排列搜索;请优先使用页面顺序或显式 order")
|
||||
return list(range(total)), 0.25, 0.0, warnings
|
||||
|
||||
best_order: tuple[int, ...] | None = None
|
||||
best_cost = float("inf")
|
||||
second_cost = float("inf")
|
||||
for order in itertools.permutations(range(total)):
|
||||
cost = arrangement_cost(order, tiles, rows, cols)
|
||||
if cost < best_cost:
|
||||
second_cost = best_cost
|
||||
best_cost = cost
|
||||
best_order = order
|
||||
elif cost < second_cost:
|
||||
second_cost = cost
|
||||
|
||||
diversity = tile_diversity(tiles)
|
||||
separation = 0.0 if not math.isfinite(second_cost) else max(0.0, (second_cost - best_cost) / (second_cost + 1e-6))
|
||||
confidence = max(0.25, min(0.95, separation * 4.0 + min(diversity / 128.0, 0.35)))
|
||||
if separation < 0.02:
|
||||
confidence = min(confidence, 0.42)
|
||||
warnings.append("候选排列分数接近,可能是重复纹理、纯色块或边缘线索不足")
|
||||
if diversity < 6:
|
||||
confidence = min(confidence, 0.35)
|
||||
warnings.append("切片视觉差异很低,建议改用页面顺序或人工/平台复核")
|
||||
return list(best_order or range(total)), round(confidence, 2), round(best_cost, 4), warnings
|
||||
|
||||
|
||||
def restore_image(
|
||||
image: Any,
|
||||
rows: int,
|
||||
cols: int,
|
||||
tile_width: int,
|
||||
tile_height: int,
|
||||
order_source_by_target: list[int],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
tiles = split_tiles(image, rows, cols, tile_width, tile_height)
|
||||
restored = Image.new("RGB", (cols * tile_width, rows * tile_height))
|
||||
for target_index, source_index in enumerate(order_source_by_target):
|
||||
row, col = divmod(target_index, cols)
|
||||
restored.paste(tiles[source_index], (col * tile_width, row * tile_height))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
restored.save(output_path)
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
html_text = read_value(args.html)
|
||||
plain_text = read_value(args.text)
|
||||
combined_text = "\n".join(part for part in (html_text, plain_text) if part)
|
||||
signals = collect_text_signals(combined_text)
|
||||
warnings: list[str] = []
|
||||
|
||||
rows = args.rows
|
||||
cols = args.cols
|
||||
tile_width = args.tile_width
|
||||
tile_height = args.tile_height
|
||||
explicit_order = parse_order(args.order_source_by_target)
|
||||
extracted_order: list[int] | None = None
|
||||
|
||||
for extractor in (
|
||||
extract_order_arrays,
|
||||
lambda text: extract_background_position_order(text, rows, cols, tile_width, tile_height),
|
||||
lambda text: extract_draw_image_order(text, rows, cols, tile_width, tile_height),
|
||||
):
|
||||
order, found_signals = extractor(combined_text)
|
||||
signals.extend(found_signals)
|
||||
if order:
|
||||
extracted_order = order
|
||||
break
|
||||
|
||||
image = None
|
||||
if args.image:
|
||||
if Image is None:
|
||||
warnings.append("当前 Python 环境缺少 Pillow,无法读取图片;只能分析文本/HTML 线索")
|
||||
else:
|
||||
image_path = Path(args.image)
|
||||
if image_path.exists() and image_path.is_file():
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
rows, cols, tile_width, tile_height, grid_warnings = infer_grid(image, rows, cols, tile_width, tile_height)
|
||||
warnings.extend(grid_warnings)
|
||||
else:
|
||||
warnings.append("图片文件不存在")
|
||||
|
||||
total = rows * cols if rows and cols else 0
|
||||
normalized_order: list[int] | None = None
|
||||
order_warning: str | None = None
|
||||
order_source = ""
|
||||
if explicit_order and total:
|
||||
normalized_order, order_warning = normalize_order(explicit_order, total)
|
||||
order_source = "argument"
|
||||
elif extracted_order and total:
|
||||
normalized_order, order_warning = normalize_order(extracted_order, total)
|
||||
order_source = "page"
|
||||
if order_warning:
|
||||
warnings.append(order_warning)
|
||||
|
||||
confidence = 0.0
|
||||
edge_cost_value: float | None = None
|
||||
restore_strategy = "manual-or-platform"
|
||||
if normalized_order:
|
||||
confidence = 0.92 if order_source == "argument" else 0.86
|
||||
restore_strategy = "page-order"
|
||||
elif image is not None and rows and cols and tile_width and tile_height:
|
||||
tiles = split_tiles(image, rows, cols, tile_width, tile_height)
|
||||
normalized_order, confidence, edge_cost_value, edge_warnings = image_edge_match_order(tiles, rows, cols)
|
||||
warnings.extend(edge_warnings)
|
||||
restore_strategy = "image-edge-match" if confidence >= 0.55 else "manual-or-platform"
|
||||
elif signals:
|
||||
confidence = 0.58
|
||||
|
||||
restored_image: str | None = None
|
||||
if image is not None and rows and cols and tile_width and tile_height and normalized_order and args.output_image:
|
||||
if len(normalized_order) == rows * cols:
|
||||
output_path = Path(args.output_image)
|
||||
restore_image(image, rows, cols, tile_width, tile_height, normalized_order, output_path)
|
||||
restored_image = str(output_path)
|
||||
else:
|
||||
warnings.append("顺序长度不匹配,未输出还原图片")
|
||||
|
||||
is_tile_scramble = bool(signals or normalized_order or (image is not None and rows and cols))
|
||||
if not signals and normalized_order:
|
||||
signals.append({"label": "提供显式切片顺序", "evidence": ",".join(str(item) for item in normalized_order), "source": "argument"})
|
||||
if image is not None and rows and cols:
|
||||
signals.append({"label": "图片网格切片证据", "evidence": f"{rows}x{cols}", "source": "image"})
|
||||
|
||||
return {
|
||||
"is_tile_scramble": is_tile_scramble,
|
||||
"grid": {
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"tile_width": tile_width,
|
||||
"tile_height": tile_height,
|
||||
"total": total,
|
||||
},
|
||||
"order_source_by_target": normalized_order,
|
||||
"confidence": round(confidence, 2),
|
||||
"restore_strategy": restore_strategy,
|
||||
"edge_match_cost": edge_cost_value,
|
||||
"signals": signals,
|
||||
"warnings": warnings,
|
||||
"restored_image": restored_image,
|
||||
"notes": [
|
||||
"脚本只做离线切片判断和图片还原,不打开网页、不点击页面、不提交验证。",
|
||||
"优先相信页面逻辑中的顺序字段;纯图片边缘匹配低置信度时应切换人工或平台复核。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="离线分析切片乱序图片验证码")
|
||||
parser.add_argument("--image", help="待分析的乱序图片路径")
|
||||
parser.add_argument("--html", help="HTML/CSS/JS 片段或文件路径")
|
||||
parser.add_argument("--text", help="页面文案或分析备注")
|
||||
parser.add_argument("--rows", type=int, help="切片行数")
|
||||
parser.add_argument("--cols", type=int, help="切片列数")
|
||||
parser.add_argument("--tile-width", type=int, help="单块宽度")
|
||||
parser.add_argument("--tile-height", type=int, help="单块高度")
|
||||
parser.add_argument("--order-source-by-target", help="还原顺序:每个目标位置使用哪个当前切片索引,支持逗号分隔")
|
||||
parser.add_argument("--output-image", help="输出还原图片路径")
|
||||
parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = build_report(args)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""评估用户手动成功验证码样本是否足够形成成功基线。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
MIN_TOTAL_SUCCESS_SAMPLES = 5
|
||||
MIN_SUCCESS_SAMPLES_PER_TYPE = 2
|
||||
|
||||
|
||||
def load_json_source(value: str) -> Any:
|
||||
stripped = value.lstrip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
return json.loads(value)
|
||||
path = Path(value)
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def get_first(mapping: dict[str, Any], names: list[str], default: Any = None) -> Any:
|
||||
for name in names:
|
||||
if name in mapping and mapping[name] not in (None, ""):
|
||||
return mapping[name]
|
||||
return default
|
||||
|
||||
|
||||
def normalize_samples(data: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
if isinstance(data, list):
|
||||
return [item for item in data if isinstance(item, dict)], {}
|
||||
if isinstance(data, dict):
|
||||
for key in ("success_samples", "samples", "manual_success_samples"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, list):
|
||||
defaults = {name: item for name, item in data.items() if name != key}
|
||||
return [item for item in value if isinstance(item, dict)], defaults
|
||||
return [data], {}
|
||||
raise ValueError("success baseline JSON 必须是数组,或包含 success_samples/samples 数组的对象")
|
||||
|
||||
|
||||
def is_success(sample: dict[str, Any]) -> bool:
|
||||
if isinstance(sample.get("success"), bool):
|
||||
return bool(sample["success"])
|
||||
status = str(get_first(sample, ["status", "result", "outcome"], "")).strip().lower()
|
||||
return status in {"success", "succeeded", "passed", "pass", "ok", "成功", "通过"}
|
||||
|
||||
|
||||
def normalize_type(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
return text or "unknown-custom"
|
||||
|
||||
|
||||
def sample_context(sample: dict[str, Any], defaults: dict[str, Any]) -> dict[str, str]:
|
||||
return {
|
||||
"authorization_scope": str(get_first(sample, ["authorization_scope", "scope", "target"], get_first(defaults, ["authorization_scope", "scope", "target"], "未声明授权目标"))),
|
||||
"provider": str(get_first(sample, ["provider"], get_first(defaults, ["provider"], "custom-or-unknown"))),
|
||||
"captcha_type": normalize_type(get_first(sample, ["captcha_type", "type"], get_first(defaults, ["captcha_type", "type"], "unknown-custom"))),
|
||||
"captcha_variant": str(get_first(sample, ["captcha_variant", "variant"], get_first(defaults, ["captcha_variant", "variant"], ""))),
|
||||
}
|
||||
|
||||
|
||||
def evaluate(data: Any) -> dict[str, Any]:
|
||||
samples, defaults = normalize_samples(data)
|
||||
success_samples = [sample for sample in samples if is_success(sample)]
|
||||
contexts = [sample_context(sample, defaults) for sample in success_samples]
|
||||
|
||||
by_type: dict[str, int] = {}
|
||||
by_variant: dict[str, int] = {}
|
||||
scopes: dict[str, int] = {}
|
||||
providers: dict[str, int] = {}
|
||||
for context in contexts:
|
||||
captcha_type = context["captcha_type"]
|
||||
variant_key = captcha_type
|
||||
if context["captcha_variant"]:
|
||||
variant_key = f"{captcha_type}/{context['captcha_variant']}"
|
||||
by_type[captcha_type] = by_type.get(captcha_type, 0) + 1
|
||||
by_variant[variant_key] = by_variant.get(variant_key, 0) + 1
|
||||
scopes[context["authorization_scope"]] = scopes.get(context["authorization_scope"], 0) + 1
|
||||
providers[context["provider"]] = providers.get(context["provider"], 0) + 1
|
||||
|
||||
missing: list[dict[str, Any]] = []
|
||||
total_success = len(success_samples)
|
||||
if total_success < MIN_TOTAL_SUCCESS_SAMPLES:
|
||||
missing.append(
|
||||
{
|
||||
"scope": "total",
|
||||
"required": MIN_TOTAL_SUCCESS_SAMPLES,
|
||||
"actual": total_success,
|
||||
"missing": MIN_TOTAL_SUCCESS_SAMPLES - total_success,
|
||||
}
|
||||
)
|
||||
|
||||
for captcha_type, count in sorted(by_type.items()):
|
||||
if count < MIN_SUCCESS_SAMPLES_PER_TYPE:
|
||||
missing.append(
|
||||
{
|
||||
"scope": "captcha_type",
|
||||
"captcha_type": captcha_type,
|
||||
"required": MIN_SUCCESS_SAMPLES_PER_TYPE,
|
||||
"actual": count,
|
||||
"missing": MIN_SUCCESS_SAMPLES_PER_TYPE - count,
|
||||
}
|
||||
)
|
||||
|
||||
if success_samples and not by_type:
|
||||
missing.append(
|
||||
{
|
||||
"scope": "captcha_type",
|
||||
"required": MIN_SUCCESS_SAMPLES_PER_TYPE,
|
||||
"actual": 0,
|
||||
"missing": MIN_SUCCESS_SAMPLES_PER_TYPE,
|
||||
"reason": "成功样本缺少 captcha_type,无法判断动态验证码覆盖。",
|
||||
}
|
||||
)
|
||||
|
||||
status = "sufficient" if success_samples and not missing else "insufficient"
|
||||
dynamic_switch = len(by_type) > 1
|
||||
return {
|
||||
"success_baseline_status": status,
|
||||
"success_baseline_summary": {
|
||||
"total_samples": len(samples),
|
||||
"total_success_samples": total_success,
|
||||
"min_total_success_samples": MIN_TOTAL_SUCCESS_SAMPLES,
|
||||
"min_success_samples_per_type": MIN_SUCCESS_SAMPLES_PER_TYPE,
|
||||
"observed_captcha_types": sorted(by_type),
|
||||
"success_samples_by_type": dict(sorted(by_type.items())),
|
||||
"success_samples_by_type_variant": dict(sorted(by_variant.items())),
|
||||
"authorization_scopes": dict(sorted(scopes.items())),
|
||||
"providers": dict(sorted(providers.items())),
|
||||
"has_dynamic_type_switch": dynamic_switch,
|
||||
},
|
||||
"missing_success_samples": missing,
|
||||
"recommended_next_route": "ready-for-verification-flow" if status == "sufficient" else "collect-more-manual-success-samples",
|
||||
"collection_plan": {
|
||||
"role": "建立真实成功基线,用于对照本地方案、平台方案和失败复盘。",
|
||||
"default_target": "每个授权目标至少 5 次用户手动成功样本;出现新验证码类型时,该类型至少 2 次成功样本。",
|
||||
"required_evidence": [
|
||||
"成功前后截图或截图元信息",
|
||||
"验证码区域 HTML/DOM 摘要",
|
||||
"脚本/iframe URL",
|
||||
"关键请求摘要",
|
||||
"captcha_type/provider/captcha_variant",
|
||||
"成功 UI、callback、response token 或服务端成功状态",
|
||||
"challenge id、时间线和刷新/切题信息",
|
||||
],
|
||||
},
|
||||
"requires_user_confirmation": [
|
||||
"成功基线不足时,必须明确提示风险;用户确认后仍可继续离线分析或受控验证。",
|
||||
"采集真实网页成功样本前,必须确认授权目标和浏览器取证模式。",
|
||||
],
|
||||
"send_request": False,
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="评估用户手动成功验证码样本基线")
|
||||
parser.add_argument("--samples", required=True, help="成功样本 JSON 文件路径,或直接传 JSON 字符串")
|
||||
parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON")
|
||||
parser.add_argument("--json", action="store_true", help="兼容参数;脚本始终输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = evaluate(load_json_source(args.samples))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,436 @@
|
||||
#!/usr/bin/env python3
|
||||
"""评估第二阶段验证码验证尝试,并判断是否应切换到平台对照。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
MIN_FAILURES_FOR_SWITCH = 5
|
||||
NO_ORDINARY_PLATFORM_TYPES = {"pow-challenge", "waf-challenge", "biometric-liveness"}
|
||||
OK_STATUSES = {
|
||||
"ok",
|
||||
"pass",
|
||||
"passed",
|
||||
"success",
|
||||
"successful",
|
||||
"ready",
|
||||
"clean",
|
||||
"normal",
|
||||
"valid",
|
||||
"verified",
|
||||
"no_issue",
|
||||
"no-issue",
|
||||
"none",
|
||||
"无异常",
|
||||
"正常",
|
||||
"通过",
|
||||
}
|
||||
|
||||
STATUS_ALIASES = {
|
||||
"coordinate": "coordinates",
|
||||
"coord": "coordinates",
|
||||
"coords": "coordinates",
|
||||
"motion": "track",
|
||||
"trajectory": "track",
|
||||
"tile": "tile_restore",
|
||||
"restore": "tile_restore",
|
||||
"env": "browser_env",
|
||||
"environment": "browser_env",
|
||||
"browser": "browser_env",
|
||||
"freshness": "challenge_freshness",
|
||||
"ttl": "challenge_freshness",
|
||||
"challenge": "challenge_freshness",
|
||||
}
|
||||
|
||||
AXIS_LABELS = {
|
||||
"image": "图片/识别结果",
|
||||
"coordinates": "坐标映射",
|
||||
"track": "轨迹/交互行为",
|
||||
"tile_restore": "切片乱序还原",
|
||||
"browser_env": "补环境/浏览器环境",
|
||||
"challenge_freshness": "challenge 新鲜度/TTL",
|
||||
}
|
||||
|
||||
BLOCKING_ROUTE_BY_AXIS = {
|
||||
"image": "fix-image-recognition",
|
||||
"coordinates": "fix-coordinate-mapping",
|
||||
"track": "fix-motion-track",
|
||||
"tile_restore": "fix-tile-restore",
|
||||
"browser_env": "fix-browser-env",
|
||||
"challenge_freshness": "refresh-challenge",
|
||||
}
|
||||
|
||||
PLATFORM_CANDIDATES = {
|
||||
"text": ["云码/JFBYM", "超级鹰", "2Captcha ImageToText", "Anti-Captcha Image"],
|
||||
"math": ["云码/JFBYM", "超级鹰", "2Captcha Normal Captcha"],
|
||||
"slider": ["云码/JFBYM 滑块类型", "超级鹰滑块/坐标类型", "CapSolver", "2Captcha GeeTest/slider"],
|
||||
"image-restore": ["云码/JFBYM 拼图/图片任务", "超级鹰滑块/坐标类型", "CapSolver 图片任务", "2Captcha 图片任务"],
|
||||
"click-select": ["云码/JFBYM 坐标/点选类型", "超级鹰坐标类型", "2Captcha Coordinates", "CapSolver ComplexImageTask"],
|
||||
"grid": ["2Captcha Grid/reCAPTCHA", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "NopeCHA"],
|
||||
"rotate": ["云码/JFBYM 旋转类型", "超级鹰旋转/坐标类型", "CapSolver 图片任务", "2Captcha 图片任务"],
|
||||
"audio": ["2Captcha Audio", "CapMonster/Anti-Captcha 音频任务", "人工复核"],
|
||||
"drag-drop": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "CapSolver ComplexImageTask", "人工接管"],
|
||||
"trace-draw": ["云码/JFBYM 轨迹/坐标类型", "超级鹰坐标/轨迹类", "人工标注"],
|
||||
"scratch": ["云码/JFBYM 定制类型", "超级鹰定制/人工", "人工接管"],
|
||||
"area-select": ["云码/JFBYM 坐标/区域类型", "超级鹰坐标类型", "CapSolver ComplexImageTask"],
|
||||
"difference-click": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "人工标注"],
|
||||
"font-identify": ["云码/JFBYM 定制/坐标类型", "超级鹰人工/坐标类型", "人工标注"],
|
||||
"semantic-reasoning": ["云码/JFBYM 定制/坐标类型", "超级鹰人工/坐标类型", "CapSolver ComplexImageTask"],
|
||||
"game-challenge": ["2Captcha FunCaptcha/Arkose", "CapSolver FunCaptcha", "Anti-Captcha FunCaptcha", "NopeCHA", "人工接管"],
|
||||
"risk-score": ["2Captcha reCAPTCHA v3/Enterprise", "CapSolver", "CapMonster Cloud", "Anti-Captcha"],
|
||||
"one-click": ["2Captcha token 任务", "CapSolver", "CapMonster Cloud", "人工接管"],
|
||||
"multi-step": ["按每轮子类型选择平台", "人工接管多轮流程"],
|
||||
"qa-logic": ["云码/JFBYM 文字/定制类型", "超级鹰人工类型", "2Captcha Normal Captcha"],
|
||||
"token-widget": ["2Captcha", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "YesCaptcha/NoCaptchaAI"],
|
||||
"unknown-custom": ["云码/JFBYM 定制", "超级鹰人工/定制", "2Captcha/CapSolver 通用图片或坐标任务"],
|
||||
}
|
||||
|
||||
|
||||
def load_json_source(value: str) -> Any:
|
||||
stripped = value.lstrip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
return json.loads(value)
|
||||
path = Path(value)
|
||||
try:
|
||||
if path.exists() and path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def get_first(mapping: dict[str, Any], names: list[str], default: Any = None) -> Any:
|
||||
for name in names:
|
||||
if name in mapping and mapping[name] not in (None, ""):
|
||||
return mapping[name]
|
||||
return default
|
||||
|
||||
|
||||
def normalize_attempts(data: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
if isinstance(data, list):
|
||||
return [item for item in data if isinstance(item, dict)], {}
|
||||
if isinstance(data, dict):
|
||||
attempts = data.get("attempts")
|
||||
if isinstance(attempts, list):
|
||||
defaults = {key: value for key, value in data.items() if key != "attempts"}
|
||||
return [item for item in attempts if isinstance(item, dict)], defaults
|
||||
return [data], {}
|
||||
raise ValueError("attempts JSON 必须是数组,或包含 attempts 数组的对象")
|
||||
|
||||
|
||||
def context_value(attempt: dict[str, Any], defaults: dict[str, Any], names: list[str], fallback: str) -> str:
|
||||
value = get_first(attempt, names, get_first(defaults, names, fallback))
|
||||
return str(value)
|
||||
|
||||
|
||||
def attempt_context(attempt: dict[str, Any], defaults: dict[str, Any]) -> dict[str, str]:
|
||||
return {
|
||||
"authorization_scope": context_value(attempt, defaults, ["authorization_scope", "scope", "target"], "未声明授权目标"),
|
||||
"captcha_type": context_value(attempt, defaults, ["captcha_type", "type"], "unknown-custom"),
|
||||
"captcha_variant": context_value(attempt, defaults, ["captcha_variant", "variant"], ""),
|
||||
"provider": context_value(attempt, defaults, ["provider"], "custom-or-unknown"),
|
||||
"chosen_solution": context_value(attempt, defaults, ["chosen_solution", "solution", "route"], "未声明方案"),
|
||||
}
|
||||
|
||||
|
||||
def same_context(left: dict[str, str], right: dict[str, str]) -> bool:
|
||||
return (
|
||||
left["authorization_scope"] == right["authorization_scope"]
|
||||
and left["captcha_type"] == right["captcha_type"]
|
||||
and left["chosen_solution"] == right["chosen_solution"]
|
||||
)
|
||||
|
||||
|
||||
def is_success(attempt: dict[str, Any]) -> bool:
|
||||
if isinstance(attempt.get("success"), bool):
|
||||
return bool(attempt["success"])
|
||||
status = str(get_first(attempt, ["status", "result", "outcome"], "")).strip().lower()
|
||||
return status in {"success", "succeeded", "passed", "pass", "ok", "成功", "通过"}
|
||||
|
||||
|
||||
def normalize_axis(name: str) -> str:
|
||||
return STATUS_ALIASES.get(name.strip().lower(), name.strip().lower())
|
||||
|
||||
|
||||
def normalize_status(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "ok" if value else "issue"
|
||||
if value is None:
|
||||
return "unknown"
|
||||
status = str(value).strip().lower()
|
||||
if not status:
|
||||
return "unknown"
|
||||
return "ok" if status in OK_STATUSES else status
|
||||
|
||||
|
||||
def extract_diagnosis(attempt: dict[str, Any]) -> dict[str, str]:
|
||||
raw = get_first(attempt, ["diagnosis_status", "diagnosis", "checks"], {})
|
||||
diagnosis: dict[str, str] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
normalized = normalize_axis(str(key))
|
||||
if isinstance(value, dict):
|
||||
value = get_first(value, ["status", "result", "state"], "unknown")
|
||||
diagnosis[normalized] = normalize_status(value)
|
||||
|
||||
for key, axis in [
|
||||
("image_status", "image"),
|
||||
("coordinate_status", "coordinates"),
|
||||
("coordinates_status", "coordinates"),
|
||||
("track_status", "track"),
|
||||
("tile_restore_status", "tile_restore"),
|
||||
("browser_env_status", "browser_env"),
|
||||
("environment_status", "browser_env"),
|
||||
("challenge_freshness_status", "challenge_freshness"),
|
||||
("challenge_status", "challenge_freshness"),
|
||||
]:
|
||||
if key in attempt:
|
||||
diagnosis[axis] = normalize_status(attempt[key])
|
||||
return diagnosis
|
||||
|
||||
|
||||
def applicable_axes(captcha_type: str, captcha_variant: str) -> list[str]:
|
||||
if captcha_type in {"text", "math", "audio", "qa-logic"}:
|
||||
return ["image", "challenge_freshness"]
|
||||
if captcha_type == "slider":
|
||||
return ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_type == "image-restore":
|
||||
axes = ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_variant == "tile-scramble":
|
||||
axes.insert(1, "tile_restore")
|
||||
return axes
|
||||
if captcha_type in {"click-select", "grid", "area-select", "difference-click", "font-identify", "semantic-reasoning"}:
|
||||
return ["image", "coordinates", "browser_env", "challenge_freshness"]
|
||||
if captcha_type in {"rotate", "drag-drop", "trace-draw", "scratch"}:
|
||||
return ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_type in {"token-widget", "risk-score", "one-click", "game-challenge", "multi-step"}:
|
||||
return ["browser_env", "challenge_freshness"]
|
||||
if captcha_type in NO_ORDINARY_PLATFORM_TYPES:
|
||||
return ["browser_env", "challenge_freshness"]
|
||||
return ["image", "coordinates", "browser_env", "challenge_freshness"]
|
||||
|
||||
|
||||
def infer_blocking_axis_from_text(attempts: list[dict[str, Any]]) -> str | None:
|
||||
text = "\n".join(
|
||||
str(get_first(attempt, ["failure_reason", "reason", "error", "notes"], ""))
|
||||
for attempt in attempts
|
||||
).lower()
|
||||
keyword_axes = [
|
||||
("tile_restore", ["乱序", "切片", "分块", "tile", "restore", "scramble", "还原"]),
|
||||
("coordinates", ["坐标", "偏移", "映射", "dpr", "coordinate", "offset"]),
|
||||
("track", ["轨迹", "速度", "拖动", "motion", "track", "trajectory"]),
|
||||
("browser_env", ["补环境", "环境", "指纹", "cdp", "webdriver", "browser", "fingerprint"]),
|
||||
("challenge_freshness", ["过期", "ttl", "expired", "stale", "刷新", "challenge id"]),
|
||||
("image", ["图片", "识别", "ocr", "缺口", "角度", "目标", "image", "recognition"]),
|
||||
]
|
||||
for axis, keywords in keyword_axes:
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return axis
|
||||
return None
|
||||
|
||||
|
||||
def find_blocking_issue(
|
||||
recent_attempts: list[dict[str, Any]],
|
||||
context: dict[str, str],
|
||||
) -> tuple[str | None, dict[str, str]]:
|
||||
latest_diagnosis = extract_diagnosis(recent_attempts[-1]) if recent_attempts else {}
|
||||
axes = applicable_axes(context["captcha_type"], context["captcha_variant"])
|
||||
normalized = {axis: latest_diagnosis.get(axis, "unknown") for axis in axes}
|
||||
|
||||
for axis in axes:
|
||||
if normalized[axis] != "ok":
|
||||
return axis, normalized
|
||||
|
||||
inferred = infer_blocking_axis_from_text(recent_attempts)
|
||||
if inferred and inferred in axes:
|
||||
normalized[inferred] = "suspected"
|
||||
return inferred, normalized
|
||||
return None, normalized
|
||||
|
||||
|
||||
def candidates_for(context: dict[str, str]) -> list[str]:
|
||||
captcha_type = context["captcha_type"]
|
||||
if captcha_type == "image-restore" and context.get("captcha_variant") == "tile-scramble":
|
||||
return ["云码/JFBYM 拼图/图片任务", "超级鹰坐标/拼图类型", "CapSolver 图片任务", "2Captcha 图片任务", "人工复核"]
|
||||
return PLATFORM_CANDIDATES.get(captcha_type, PLATFORM_CANDIDATES["unknown-custom"])
|
||||
|
||||
|
||||
def special_route_for(captcha_type: str) -> tuple[str, list[str], str] | None:
|
||||
if captcha_type == "pow-challenge":
|
||||
return (
|
||||
"official-protocol-diagnostics",
|
||||
[],
|
||||
"PoW/工作量证明不默认推荐普通打码平台;优先检查官方协议、difficulty、nonce、TTL、防重放和服务端签名绑定。",
|
||||
)
|
||||
if captcha_type == "waf-challenge":
|
||||
return (
|
||||
"browser-env-and-vendor-diagnostics",
|
||||
[],
|
||||
"WAF challenge 不等同普通图片验证码;优先做浏览器环境、TLS/HTTP 指纹、IP/session 绑定和厂商日志诊断。",
|
||||
)
|
||||
if captcha_type == "biometric-liveness":
|
||||
return (
|
||||
"manual-review-or-vendor-support",
|
||||
[],
|
||||
"活体/人脸验证涉及隐私与合规,不默认推荐普通打码平台;优先走官方 SDK、人工复核、可访问性替代或厂商支持。",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_platform_control_plan(context: dict[str, str], candidates: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"role": "授权 QA 对照,不是默认替代所有本地方案",
|
||||
"send_request": False,
|
||||
"candidate_platforms": candidates,
|
||||
"template_command": (
|
||||
"python scripts/solver_request_template.py "
|
||||
f"--platform <platform> --captcha-type {context['captcha_type']} "
|
||||
f"--provider {context['provider']} --pretty"
|
||||
),
|
||||
"steps": [
|
||||
"先生成请求模板和字段映射,只填非秘密字段。",
|
||||
"让用户再次确认授权目标、平台、题型、代理/session 绑定要求和是否允许发送。",
|
||||
"API key 只由用户运行时提供;不要写入文件、不要保存、不要回显。",
|
||||
"平台结果只用于对照本地识别/交互链路与厂商服务端绑定问题。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def evaluate(data: Any) -> dict[str, Any]:
|
||||
attempts, defaults = normalize_attempts(data)
|
||||
if not attempts:
|
||||
return {
|
||||
"switch_triggered": False,
|
||||
"reason": "没有可评估的验证尝试记录。",
|
||||
"blocking_issue": "attempts-missing",
|
||||
"recommended_next_route": "collect-attempts",
|
||||
"platform_candidates": [],
|
||||
"attempt_summary": {"total_attempts": 0, "consecutive_same_context": 0, "failures": 0, "successes": 0},
|
||||
"diagnosis_status": {},
|
||||
"platform_control_plan": {},
|
||||
"requires_user_confirmation": ["补充 attempts JSON 后再复盘"],
|
||||
"send_request": False,
|
||||
}
|
||||
|
||||
context = attempt_context(attempts[-1], defaults)
|
||||
consecutive: list[dict[str, Any]] = []
|
||||
for attempt in reversed(attempts):
|
||||
if same_context(attempt_context(attempt, defaults), context):
|
||||
consecutive.append(attempt)
|
||||
else:
|
||||
break
|
||||
consecutive.reverse()
|
||||
|
||||
successes = sum(1 for attempt in consecutive if is_success(attempt))
|
||||
failures = len(consecutive) - successes
|
||||
special_route = special_route_for(context["captcha_type"])
|
||||
platform_candidates = candidates_for(context) if not special_route else special_route[1]
|
||||
blocking_axis, diagnosis_status = find_blocking_issue(consecutive, context)
|
||||
summary = {
|
||||
"total_attempts": len(attempts),
|
||||
"consecutive_same_context": len(consecutive),
|
||||
"failures": failures,
|
||||
"successes": successes,
|
||||
"context": context,
|
||||
"failure_threshold": MIN_FAILURES_FOR_SWITCH,
|
||||
}
|
||||
|
||||
base: dict[str, Any] = {
|
||||
"attempt_summary": summary,
|
||||
"diagnosis_status": diagnosis_status,
|
||||
"platform_candidates": platform_candidates,
|
||||
"platform_control_plan": build_platform_control_plan(context, platform_candidates) if platform_candidates else {},
|
||||
"requires_user_confirmation": [
|
||||
"实际发送打码平台请求前,必须再次确认授权范围、平台、题型和执行动作。",
|
||||
"API key 只在运行时由用户提供,不保存、不回显。",
|
||||
],
|
||||
"send_request": False,
|
||||
}
|
||||
|
||||
if special_route:
|
||||
route, candidates, reason = special_route
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "ordinary-platform-not-recommended",
|
||||
"reason": reason,
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": route,
|
||||
"platform_candidates": candidates,
|
||||
"platform_control_plan": {},
|
||||
}
|
||||
|
||||
if successes:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "keep-and-optimize",
|
||||
"reason": "同一授权目标和同一方案下已有成功记录;当前方案可用,但需要继续优化稳定性和失败样本原因。",
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "optimize-current-route",
|
||||
}
|
||||
|
||||
if failures < MIN_FAILURES_FOR_SWITCH:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "wait-for-more-evidence",
|
||||
"reason": f"连续失败 {failures} 次,未达到 {MIN_FAILURES_FOR_SWITCH} 次失败复盘门槛。",
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "continue-current-route-with-diagnostics",
|
||||
}
|
||||
|
||||
if blocking_axis:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "fix-blocking-issue-first",
|
||||
"reason": f"已达到失败次数门槛,但仍存在明确阻塞项:{AXIS_LABELS.get(blocking_axis, blocking_axis)}。",
|
||||
"blocking_issue": blocking_axis,
|
||||
"recommended_next_route": BLOCKING_ROUTE_BY_AXIS.get(blocking_axis, "fix-blocking-issue"),
|
||||
}
|
||||
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": True,
|
||||
"escalation_decision": "recommend-platform-control",
|
||||
"reason": (
|
||||
f"同一授权目标、同一验证码类型、同一方案连续 {failures} 次验证失败且无成功记录,"
|
||||
"图片/坐标/轨迹/还原/补环境/challenge 新鲜度未见明显异常;建议切换到打码平台做授权 QA 对照。"
|
||||
),
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "platform-control",
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="评估验证码验证尝试是否应切换方案")
|
||||
parser.add_argument("--attempts", required=True, help="attempts JSON 文件路径,或直接传 JSON 字符串")
|
||||
parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON")
|
||||
parser.add_argument("--json", action="store_true", help="兼容参数;脚本始终输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = evaluate(load_json_source(args.attempts))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成授权验证分析用的鼠标/触摸轨迹 JSON。
|
||||
|
||||
脚本只生成离线轨迹,不控制浏览器、不点击页面、不提交验证码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def parse_pair(value: str) -> tuple[float, float]:
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError(f"需要 x,y: {value}")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def parse_box(value: str) -> tuple[float, float, float, float]:
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise argparse.ArgumentTypeError(f"需要 left,top,right,bottom: {value}")
|
||||
left, top, right, bottom = (float(part) for part in parts)
|
||||
if right <= left or bottom <= top:
|
||||
raise argparse.ArgumentTypeError("box 的 right/bottom 必须大于 left/top")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def parse_points(value: str) -> list[tuple[float, float]]:
|
||||
points: list[tuple[float, float]] = []
|
||||
for item in value.split():
|
||||
points.append(parse_pair(item))
|
||||
if len(points) < 2:
|
||||
raise argparse.ArgumentTypeError("至少需要两个点")
|
||||
return points
|
||||
|
||||
|
||||
def ease_out_cubic(t: float) -> float:
|
||||
return 1 - (1 - t) ** 3
|
||||
|
||||
|
||||
def make_line_track(
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float],
|
||||
duration_ms: int,
|
||||
steps: int,
|
||||
jitter: float,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
if steps < 2:
|
||||
raise ValueError("steps 必须至少为 2")
|
||||
result: list[dict[str, float]] = []
|
||||
sx, sy = start
|
||||
ex, ey = end
|
||||
for index in range(steps):
|
||||
progress = index / (steps - 1)
|
||||
eased = ease_out_cubic(progress)
|
||||
x = sx + (ex - sx) * eased
|
||||
y = sy + (ey - sy) * eased
|
||||
if 0 < index < steps - 1 and jitter:
|
||||
x += rng.uniform(-jitter, jitter)
|
||||
y += rng.uniform(-jitter * 0.45, jitter * 0.45)
|
||||
t = round(duration_ms * progress)
|
||||
result.append({"x": round(x, 3), "y": round(y, 3), "t": t})
|
||||
return result
|
||||
|
||||
|
||||
def make_scratch_track(
|
||||
box: tuple[float, float, float, float],
|
||||
duration_ms: int,
|
||||
rows: int,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
left, top, right, bottom = box
|
||||
rows = max(2, rows)
|
||||
points: list[tuple[float, float]] = []
|
||||
for row in range(rows):
|
||||
y = top + (bottom - top) * row / (rows - 1)
|
||||
y += rng.uniform(-1.5, 1.5)
|
||||
if row % 2 == 0:
|
||||
points.append((left, y))
|
||||
points.append((right, y))
|
||||
else:
|
||||
points.append((right, y))
|
||||
points.append((left, y))
|
||||
return resample_polyline(points, duration_ms, max(2, rows * 8), jitter=0.8, rng=rng)
|
||||
|
||||
|
||||
def polyline_length(points: list[tuple[float, float]]) -> float:
|
||||
total = 0.0
|
||||
for first, second in zip(points, points[1:]):
|
||||
total += math.dist(first, second)
|
||||
return total
|
||||
|
||||
|
||||
def resample_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
duration_ms: int,
|
||||
steps: int,
|
||||
jitter: float,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
total_length = polyline_length(points)
|
||||
if total_length <= 0:
|
||||
raise ValueError("路径长度必须大于 0")
|
||||
result: list[dict[str, float]] = []
|
||||
segment_index = 0
|
||||
segment_start_distance = 0.0
|
||||
segment_length = math.dist(points[0], points[1])
|
||||
for index in range(steps):
|
||||
target_distance = total_length * index / (steps - 1)
|
||||
while segment_index < len(points) - 2 and segment_start_distance + segment_length < target_distance:
|
||||
segment_start_distance += segment_length
|
||||
segment_index += 1
|
||||
segment_length = math.dist(points[segment_index], points[segment_index + 1])
|
||||
local = 0.0 if segment_length == 0 else (target_distance - segment_start_distance) / segment_length
|
||||
x1, y1 = points[segment_index]
|
||||
x2, y2 = points[segment_index + 1]
|
||||
x = x1 + (x2 - x1) * local
|
||||
y = y1 + (y2 - y1) * local
|
||||
if 0 < index < steps - 1 and jitter:
|
||||
x += rng.uniform(-jitter, jitter)
|
||||
y += rng.uniform(-jitter, jitter)
|
||||
result.append({"x": round(x, 3), "y": round(y, 3), "t": round(duration_ms * index / (steps - 1))})
|
||||
return result
|
||||
|
||||
|
||||
def build_track(args: argparse.Namespace) -> dict[str, Any]:
|
||||
rng = random.Random(args.seed)
|
||||
if args.mode == "slider":
|
||||
start = args.start or (0.0, 0.0)
|
||||
end = (start[0] + args.distance, start[1] + args.vertical)
|
||||
points = make_line_track(start, end, args.duration_ms, args.steps, args.jitter, rng)
|
||||
elif args.mode == "drag-drop":
|
||||
if args.start is None or args.end is None:
|
||||
raise ValueError("drag-drop 需要 --start 和 --end")
|
||||
points = make_line_track(args.start, args.end, args.duration_ms, args.steps, args.jitter, rng)
|
||||
elif args.mode == "scratch":
|
||||
if args.box is None:
|
||||
raise ValueError("scratch 需要 --box")
|
||||
points = make_scratch_track(args.box, args.duration_ms, args.rows, rng)
|
||||
elif args.mode == "trace":
|
||||
if args.points is None:
|
||||
raise ValueError("trace 需要 --points")
|
||||
points = resample_polyline(args.points, args.duration_ms, args.steps, args.jitter, rng)
|
||||
else:
|
||||
raise ValueError(f"未知 mode: {args.mode}")
|
||||
return {
|
||||
"mode": args.mode,
|
||||
"coordinate_space": "element-css",
|
||||
"duration_ms": args.duration_ms,
|
||||
"points": points,
|
||||
"notes": [
|
||||
"轨迹仅用于授权验证分析。",
|
||||
"真实页面执行前必须再次确认授权范围和浏览器取证模式。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="生成验证码验证分析用轨迹 JSON")
|
||||
parser.add_argument("--mode", choices=["slider", "drag-drop", "scratch", "trace"], required=True)
|
||||
parser.add_argument("--distance", type=float, default=0.0, help="slider 水平距离")
|
||||
parser.add_argument("--vertical", type=float, default=0.0, help="slider 垂直偏移")
|
||||
parser.add_argument("--start", type=parse_pair, help="起点 x,y")
|
||||
parser.add_argument("--end", type=parse_pair, help="终点 x,y")
|
||||
parser.add_argument("--box", type=parse_box, help="scratch 区域 left,top,right,bottom")
|
||||
parser.add_argument("--points", type=parse_points, help='trace 点列,如 "10,10 80,30 120,90"')
|
||||
parser.add_argument("--duration-ms", type=int, default=1200)
|
||||
parser.add_argument("--steps", type=int, default=24)
|
||||
parser.add_argument("--rows", type=int, default=6)
|
||||
parser.add_argument("--jitter", type=float, default=1.2)
|
||||
parser.add_argument("--seed", type=int, default=7)
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = build_track(args)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查第二阶段验证码验证所需的离线证据是否齐备。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import imghdr
|
||||
import json
|
||||
import sys
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
REQUIRED_BY_TYPE = {
|
||||
"text": ["image"],
|
||||
"math": ["image_or_text"],
|
||||
"slider": ["background_or_screenshot", "track_width"],
|
||||
"click-select": ["screenshot", "prompt"],
|
||||
"rotate": ["image", "track_width"],
|
||||
"grid": ["screenshot", "prompt"],
|
||||
"audio": ["audio"],
|
||||
"drag-drop": ["screenshot", "source_target_bounds"],
|
||||
"trace-draw": ["screenshot_or_path_points"],
|
||||
"scratch": ["box"],
|
||||
"image-restore": ["image_or_tiles"],
|
||||
"area-select": ["screenshot", "prompt"],
|
||||
"difference-click": ["image_pair_or_screenshot"],
|
||||
"font-identify": ["screenshot", "prompt"],
|
||||
"semantic-reasoning": ["screenshot", "prompt"],
|
||||
"game-challenge": ["screenshot", "prompt_or_provider_key"],
|
||||
"pow-challenge": ["challenge_payload"],
|
||||
"risk-score": ["sitekey", "action_or_server_log"],
|
||||
"one-click": ["component_html"],
|
||||
"multi-step": ["round_screenshots"],
|
||||
"qa-logic": ["question"],
|
||||
"token-widget": ["sitekey", "pageurl"],
|
||||
"waf-challenge": ["headers_or_challenge_html"],
|
||||
}
|
||||
|
||||
|
||||
def inspect_file(path: Path) -> dict[str, Any]:
|
||||
item: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"is_file": path.is_file() if path.exists() else False,
|
||||
}
|
||||
if not item["is_file"]:
|
||||
return item
|
||||
item["size_bytes"] = path.stat().st_size
|
||||
image_type = imghdr.what(path)
|
||||
if image_type:
|
||||
item["kind"] = f"image/{image_type}"
|
||||
elif path.suffix.lower() in {".mp3", ".ogg", ".m4a", ".flac"}:
|
||||
item["kind"] = f"audio/{path.suffix.lower().lstrip('.')}"
|
||||
elif path.suffix.lower() == ".wav":
|
||||
item["kind"] = "audio/wav"
|
||||
try:
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
item["audio"] = {
|
||||
"channels": handle.getnchannels(),
|
||||
"sample_width": handle.getsampwidth(),
|
||||
"frame_rate": handle.getframerate(),
|
||||
"frames": handle.getnframes(),
|
||||
}
|
||||
except wave.Error:
|
||||
item["audio_error"] = "无法读取 wav 元信息"
|
||||
else:
|
||||
item["kind"] = "text-or-binary"
|
||||
return item
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
files = [inspect_file(Path(value)) for value in args.file or []]
|
||||
provided = set(args.provided or [])
|
||||
captcha_type = args.captcha_type
|
||||
required = REQUIRED_BY_TYPE.get(captcha_type, [])
|
||||
missing = [name for name in required if name not in provided]
|
||||
return {
|
||||
"captcha_type": captcha_type,
|
||||
"required_evidence": required,
|
||||
"provided_evidence": sorted(provided),
|
||||
"missing_evidence": missing,
|
||||
"files": files,
|
||||
"ready_for_offline_flow": not missing and all(item.get("exists") for item in files),
|
||||
"notes": [
|
||||
"只检查离线证据齐备性,不打开网页、不读取 Cookie/Storage、不提交验证。",
|
||||
"如果要真实网页执行,必须按 verification-workflow.md 再次确认。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="检查验证码验证流程所需证据")
|
||||
parser.add_argument("--captcha-type", required=True)
|
||||
parser.add_argument("--file", action="append", help="证据文件路径,可重复")
|
||||
parser.add_argument("--provided", action="append", help="已具备的证据项,如 image/sitekey/pageurl")
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = build_report(args)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""验证码截图坐标换算工具。
|
||||
|
||||
只做离线坐标换算,不打开浏览器、不点击页面、不提交验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def parse_pair(value: str, sep: str = ",") -> tuple[float, float]:
|
||||
parts = [part.strip() for part in value.lower().split(sep)]
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError(f"需要两个数值: {value}")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def parse_size(value: str) -> tuple[float, float]:
|
||||
if "x" in value.lower():
|
||||
return parse_pair(value, sep="x")
|
||||
return parse_pair(value)
|
||||
|
||||
|
||||
def map_point(
|
||||
image_size: tuple[float, float],
|
||||
display_size: tuple[float, float],
|
||||
point: tuple[float, float],
|
||||
element_left: float,
|
||||
element_top: float,
|
||||
scroll_x: float,
|
||||
scroll_y: float,
|
||||
device_pixel_ratio: float,
|
||||
point_space: str,
|
||||
) -> dict[str, Any]:
|
||||
image_width, image_height = image_size
|
||||
display_width, display_height = display_size
|
||||
if min(image_width, image_height, display_width, display_height) <= 0:
|
||||
raise ValueError("图片尺寸和显示尺寸必须大于 0")
|
||||
if device_pixel_ratio <= 0:
|
||||
raise ValueError("device_pixel_ratio 必须大于 0")
|
||||
|
||||
x, y = point
|
||||
if point_space == "image":
|
||||
element_x = x * display_width / image_width
|
||||
element_y = y * display_height / image_height
|
||||
elif point_space == "viewport-device":
|
||||
element_x = x / device_pixel_ratio - element_left
|
||||
element_y = y / device_pixel_ratio - element_top
|
||||
elif point_space == "element-css":
|
||||
element_x = x
|
||||
element_y = y
|
||||
else:
|
||||
raise ValueError(f"未知 point_space: {point_space}")
|
||||
|
||||
viewport_x = element_left + element_x
|
||||
viewport_y = element_top + element_y
|
||||
page_x = viewport_x + scroll_x
|
||||
page_y = viewport_y + scroll_y
|
||||
|
||||
return {
|
||||
"input": {
|
||||
"point": [x, y],
|
||||
"point_space": point_space,
|
||||
"image_size": [image_width, image_height],
|
||||
"display_size": [display_width, display_height],
|
||||
"element_left": element_left,
|
||||
"element_top": element_top,
|
||||
"scroll_x": scroll_x,
|
||||
"scroll_y": scroll_y,
|
||||
"device_pixel_ratio": device_pixel_ratio,
|
||||
},
|
||||
"element_css": {"x": round(element_x, 3), "y": round(element_y, 3)},
|
||||
"viewport_css": {"x": round(viewport_x, 3), "y": round(viewport_y, 3)},
|
||||
"page_css": {"x": round(page_x, 3), "y": round(page_y, 3)},
|
||||
"notes": [
|
||||
"结果仅用于授权验证分析。",
|
||||
"真实页面点击前需要再次确认浏览器模式和授权范围。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="验证码截图坐标换算工具")
|
||||
parser.add_argument("--image-size", required=True, type=parse_size, help="原图尺寸,如 300x150")
|
||||
parser.add_argument("--display-size", required=True, type=parse_size, help="页面显示尺寸,如 300x150")
|
||||
parser.add_argument("--point", required=True, type=parse_pair, help="输入点,如 120,75")
|
||||
parser.add_argument(
|
||||
"--point-space",
|
||||
choices=["image", "element-css", "viewport-device"],
|
||||
default="image",
|
||||
help="输入点所属坐标系",
|
||||
)
|
||||
parser.add_argument("--element-left", type=float, default=0.0, help="元素视口 left")
|
||||
parser.add_argument("--element-top", type=float, default=0.0, help="元素视口 top")
|
||||
parser.add_argument("--scroll-x", type=float, default=0.0, help="页面横向滚动")
|
||||
parser.add_argument("--scroll-y", type=float, default=0.0, help="页面纵向滚动")
|
||||
parser.add_argument("--device-pixel-ratio", type=float, default=1.0, help="DPR")
|
||||
parser.add_argument("--pretty", action="store_true", help="缩进输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = map_point(
|
||||
image_size=args.image_size,
|
||||
display_size=args.display_size,
|
||||
point=args.point,
|
||||
element_left=args.element_left,
|
||||
element_top=args.element_top,
|
||||
scroll_x=args.scroll_x,
|
||||
scroll_y=args.scroll_y,
|
||||
device_pixel_ratio=args.device_pixel_ratio,
|
||||
point_space=args.point_space,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成打码平台请求模板。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def template_for(platform: str, captcha_type: str, provider: str) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"platform": platform,
|
||||
"captcha_type": captcha_type,
|
||||
"provider": provider,
|
||||
"send_request": False,
|
||||
"api_key": "<由用户运行时提供,不要写入文件>",
|
||||
"authorization_required": True,
|
||||
"notes": [
|
||||
"这是请求模板,不会发送网络请求。",
|
||||
"只用于自有系统或明确授权 QA。",
|
||||
"脚本不读取 API key,不保存凭据。",
|
||||
"不要把 token 注入未授权流程。",
|
||||
],
|
||||
}
|
||||
|
||||
token_providers = {
|
||||
"recaptcha",
|
||||
"hcaptcha",
|
||||
"cloudflare-turnstile",
|
||||
"arkose-funcaptcha",
|
||||
"mtcaptcha",
|
||||
"yandex-smartcaptcha",
|
||||
"captchafox",
|
||||
"prosopo-procaptcha",
|
||||
"trustcaptcha",
|
||||
}
|
||||
|
||||
if captcha_type in {"text", "math"}:
|
||||
base["payload"] = {
|
||||
"method": "image",
|
||||
"image": "<captcha image base64 or file>",
|
||||
"constraints": {"charset": "<optional>", "length": "<optional>", "case_sensitive": False},
|
||||
}
|
||||
elif captcha_type in {
|
||||
"click-select",
|
||||
"grid",
|
||||
"area-select",
|
||||
"difference-click",
|
||||
"font-identify",
|
||||
"semantic-reasoning",
|
||||
}:
|
||||
base["payload"] = {
|
||||
"method": "coordinates",
|
||||
"image": "<challenge screenshot base64 or file>",
|
||||
"prompt": "<题面文本>",
|
||||
"coordinate_space": "image",
|
||||
"expected_output": "ordered_points_or_grid_indexes",
|
||||
}
|
||||
elif captcha_type in {"slider", "rotate", "image-restore"}:
|
||||
base["payload"] = {
|
||||
"method": "image_or_slider",
|
||||
"background": "<background image>",
|
||||
"piece": "<optional slider piece>",
|
||||
"screenshot": "<optional single challenge image>",
|
||||
"expected_output": "offset_or_angle",
|
||||
}
|
||||
elif captcha_type == "audio":
|
||||
base["payload"] = {
|
||||
"method": "audio",
|
||||
"audio": "<audio file or base64>",
|
||||
"language": "<optional>",
|
||||
"answer_format": "<digits/letters/text>",
|
||||
}
|
||||
elif captcha_type == "game-challenge" or provider == "arkose-funcaptcha":
|
||||
base["payload"] = {
|
||||
"method": "funcaptcha",
|
||||
"websiteURL": "<page url>",
|
||||
"websitePublicKey": "<arkose public key>",
|
||||
"funcaptchaApiJSSubdomain": "<optional surl>",
|
||||
"data": {"blob": "<optional blob>"},
|
||||
}
|
||||
elif captcha_type in {"token-widget", "risk-score", "one-click"} or provider in token_providers:
|
||||
base["payload"] = {
|
||||
"method": "token",
|
||||
"websiteURL": "<page url>",
|
||||
"websiteKey": "<sitekey>",
|
||||
"action": "<optional action>",
|
||||
"enterprise": "<optional bool>",
|
||||
"extra": {"cdata": "<optional>", "rqdata": "<optional>"},
|
||||
}
|
||||
elif captcha_type == "pow-challenge":
|
||||
base["payload"] = {
|
||||
"method": "pow",
|
||||
"challenge": "<challenge/payload>",
|
||||
"difficulty": "<optional>",
|
||||
"nonce": "<optional>",
|
||||
"note": "优先按官方协议在自有系统中校验,通常不需要打码平台。",
|
||||
}
|
||||
elif captcha_type == "waf-challenge":
|
||||
base["payload"] = {
|
||||
"method": "waf_diagnostic",
|
||||
"pageURL": "<page url>",
|
||||
"headers": "<response headers>",
|
||||
"challengeHTML": "<challenge html>",
|
||||
"note": "优先做 WAF/浏览器环境诊断;平台仅作授权对照。",
|
||||
}
|
||||
else:
|
||||
base["payload"] = {
|
||||
"method": "custom",
|
||||
"evidence": "<html/scripts/screenshot/prompt>",
|
||||
"expected_output": "<answer/coordinates/offset/token/diagnosis>",
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="生成打码平台请求模板")
|
||||
parser.add_argument("--platform", required=True, help="平台名,如 2captcha/capsolver/jfbym")
|
||||
parser.add_argument("--captcha-type", required=True)
|
||||
parser.add_argument("--provider", default="custom-or-unknown")
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = template_for(args.platform, args.captcha_type, args.provider)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env python3
|
||||
"""验证第二阶段辅助脚本和参考文件是否可用。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
SKILL_DIR = SCRIPT_DIR.parent
|
||||
|
||||
|
||||
def run_json(args: list[str]) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-X", "utf8", *args],
|
||||
cwd=str(SKILL_DIR),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def check_references() -> dict[str, Any]:
|
||||
required = {
|
||||
"references/verification-workflow.md": ["进入条件", "动作分级", "真实网页", "成功样本基线", "evaluate_success_baseline.py", "5 次失败复盘门槛", "evaluate_verification_attempts.py"],
|
||||
"references/browser-acquisition.md": ["用户手动成功样本", "至少 5 次成功样本", "每个新类型至少补到 2 次成功样本"],
|
||||
"references/open-source-recipes.md": ["ddddocr", "OpenCV", "Whisper", "切片乱序", "analyze_tile_restore.py"],
|
||||
"references/solver-platform-recipes.md": ["请求模板", "API key", "不默认发送", "授权 QA 对照"],
|
||||
"references/motion-and-coordinate.md": ["坐标体系", "滑块轨迹", "真实网页执行前检查"],
|
||||
"references/provider-execution-notes.md": ["极验", "Turnstile", "WAF"],
|
||||
}
|
||||
missing: list[str] = []
|
||||
for relative, snippets in required.items():
|
||||
path = SKILL_DIR / relative
|
||||
if not path.exists():
|
||||
missing.append(relative)
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for snippet in snippets:
|
||||
if snippet not in content:
|
||||
missing.append(f"{relative}:{snippet}")
|
||||
return {"passed": not missing, "missing": missing}
|
||||
|
||||
|
||||
def make_original_image(path: Path, rows: int, cols: int, tile_size: int) -> None:
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
image = Image.new("RGB", (cols * tile_size, rows * tile_size), "white")
|
||||
draw = ImageDraw.Draw(image)
|
||||
for index in range(rows * cols):
|
||||
row, col = divmod(index, cols)
|
||||
left = col * tile_size
|
||||
top = row * tile_size
|
||||
color = (
|
||||
30 + (index * 47) % 200,
|
||||
40 + (index * 83) % 190,
|
||||
50 + (index * 31) % 180,
|
||||
)
|
||||
draw.rectangle((left, top, left + tile_size - 1, top + tile_size - 1), fill=color)
|
||||
draw.line((left, top, left + tile_size - 1, top + tile_size - 1), fill=(255, 255, 255), width=2)
|
||||
draw.text((left + 3, top + 3), str(index), fill=(0, 0, 0))
|
||||
image.save(path)
|
||||
|
||||
|
||||
def scramble_image(original: Path, output: Path, rows: int, cols: int, tile_size: int, order_source_by_target: list[int]) -> None:
|
||||
from PIL import Image
|
||||
|
||||
source = Image.open(original).convert("RGB")
|
||||
tiles = []
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
left = col * tile_size
|
||||
top = row * tile_size
|
||||
tiles.append(source.crop((left, top, left + tile_size, top + tile_size)))
|
||||
|
||||
scrambled = Image.new("RGB", source.size)
|
||||
for target_index, current_index in enumerate(order_source_by_target):
|
||||
row, col = divmod(current_index, cols)
|
||||
scrambled.paste(tiles[target_index], (col * tile_size, row * tile_size))
|
||||
scrambled.save(output)
|
||||
|
||||
|
||||
def images_equal(left: Path, right: Path) -> bool:
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
left_image = Image.open(left).convert("RGB")
|
||||
right_image = Image.open(right).convert("RGB")
|
||||
return ImageChops.difference(left_image, right_image).getbbox() is None
|
||||
|
||||
|
||||
def check_tile_restore() -> dict[str, Any]:
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
return {"passed": False, "missing": ["Pillow"], "details": {}}
|
||||
|
||||
details: dict[str, Any] = {}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
|
||||
cases = [
|
||||
("3x3", 3, 3, 18, [1, 2, 0, 4, 5, 3, 7, 8, 6]),
|
||||
("4x2", 4, 2, 18, [1, 0, 3, 2, 5, 4, 7, 6]),
|
||||
]
|
||||
for name, rows, cols, tile_size, order in cases:
|
||||
original = tmp_dir / f"original-{name}.png"
|
||||
scrambled = tmp_dir / f"scrambled-{name}.png"
|
||||
restored = tmp_dir / f"restored-{name}.png"
|
||||
make_original_image(original, rows, cols, tile_size)
|
||||
scramble_image(original, scrambled, rows, cols, tile_size, order)
|
||||
report = run_json(
|
||||
[
|
||||
"scripts/analyze_tile_restore.py",
|
||||
"--image",
|
||||
str(scrambled),
|
||||
"--rows",
|
||||
str(rows),
|
||||
"--cols",
|
||||
str(cols),
|
||||
"--order-source-by-target",
|
||||
",".join(str(item) for item in order),
|
||||
"--output-image",
|
||||
str(restored),
|
||||
]
|
||||
)
|
||||
details[name] = {
|
||||
"strategy": report["restore_strategy"],
|
||||
"confidence": report["confidence"],
|
||||
"image_equal": images_equal(original, restored),
|
||||
"order": report["order_source_by_target"],
|
||||
}
|
||||
|
||||
original = tmp_dir / "original-2x2.png"
|
||||
scrambled = tmp_dir / "scrambled-2x2.png"
|
||||
make_original_image(original, 2, 2, 24)
|
||||
scramble_image(original, scrambled, 2, 2, 24, [1, 0, 3, 2])
|
||||
image_only = run_json(
|
||||
[
|
||||
"scripts/analyze_tile_restore.py",
|
||||
"--image",
|
||||
str(scrambled),
|
||||
"--rows",
|
||||
"2",
|
||||
"--cols",
|
||||
"2",
|
||||
]
|
||||
)
|
||||
details["image-only"] = {
|
||||
"strategy": image_only["restore_strategy"],
|
||||
"confidence": image_only["confidence"],
|
||||
"order_len": len(image_only["order_source_by_target"] or []),
|
||||
}
|
||||
|
||||
flat = tmp_dir / "flat.png"
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (48, 48), (120, 120, 120)).save(flat)
|
||||
flat_report = run_json(
|
||||
[
|
||||
"scripts/analyze_tile_restore.py",
|
||||
"--image",
|
||||
str(flat),
|
||||
"--rows",
|
||||
"2",
|
||||
"--cols",
|
||||
"2",
|
||||
]
|
||||
)
|
||||
details["flat"] = {
|
||||
"strategy": flat_report["restore_strategy"],
|
||||
"confidence": flat_report["confidence"],
|
||||
"warnings": flat_report["warnings"],
|
||||
}
|
||||
|
||||
passed = (
|
||||
details["3x3"]["strategy"] == "page-order"
|
||||
and details["3x3"]["image_equal"] is True
|
||||
and details["4x2"]["strategy"] == "page-order"
|
||||
and details["4x2"]["image_equal"] is True
|
||||
and details["image-only"]["order_len"] == 4
|
||||
and isinstance(details["image-only"]["confidence"], (int, float))
|
||||
and details["flat"]["strategy"] == "manual-or-platform"
|
||||
and details["flat"]["confidence"] <= 0.42
|
||||
)
|
||||
return {"passed": passed, "details": details, "missing": []}
|
||||
|
||||
|
||||
def write_attempts(path: Path, captcha_type: str, attempts: list[dict[str, Any]], captcha_variant: str = "") -> None:
|
||||
data = {
|
||||
"authorization_scope": "授权测试目标",
|
||||
"captcha_type": captcha_type,
|
||||
"captcha_variant": captcha_variant,
|
||||
"provider": "geetest" if captcha_type == "slider" else "custom-or-unknown",
|
||||
"chosen_solution": f"open-source-{captcha_type}",
|
||||
"attempts": attempts,
|
||||
}
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def failed_attempt(status_overrides: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
diagnosis = {
|
||||
"image": "ok",
|
||||
"coordinates": "ok",
|
||||
"track": "ok",
|
||||
"tile_restore": "ok",
|
||||
"browser_env": "ok",
|
||||
"challenge_freshness": "ok",
|
||||
}
|
||||
if status_overrides:
|
||||
diagnosis.update(status_overrides)
|
||||
return {
|
||||
"success": False,
|
||||
"diagnosis_status": diagnosis,
|
||||
"failure_reason": "服务端仍判定失败,离线诊断未发现明显异常",
|
||||
}
|
||||
|
||||
|
||||
def check_attempt_evaluation() -> dict[str, Any]:
|
||||
details: dict[str, Any] = {}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
|
||||
switch_path = tmp_dir / "switch.json"
|
||||
write_attempts(switch_path, "slider", [failed_attempt() for _ in range(5)])
|
||||
switch_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(switch_path)])
|
||||
details["switch"] = {
|
||||
"switch_triggered": switch_report["switch_triggered"],
|
||||
"route": switch_report["recommended_next_route"],
|
||||
"decision": switch_report["escalation_decision"],
|
||||
"send_request": switch_report["send_request"],
|
||||
}
|
||||
|
||||
below_path = tmp_dir / "below.json"
|
||||
write_attempts(below_path, "slider", [failed_attempt() for _ in range(4)])
|
||||
below_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(below_path)])
|
||||
details["below_threshold"] = {
|
||||
"switch_triggered": below_report["switch_triggered"],
|
||||
"route": below_report["recommended_next_route"],
|
||||
}
|
||||
|
||||
blocking_path = tmp_dir / "blocking.json"
|
||||
write_attempts(blocking_path, "slider", [failed_attempt() for _ in range(4)] + [failed_attempt({"coordinates": "error"})])
|
||||
blocking_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(blocking_path)])
|
||||
details["blocking"] = {
|
||||
"switch_triggered": blocking_report["switch_triggered"],
|
||||
"blocking_issue": blocking_report["blocking_issue"],
|
||||
"route": blocking_report["recommended_next_route"],
|
||||
}
|
||||
|
||||
success_path = tmp_dir / "success.json"
|
||||
success_attempts = [failed_attempt() for _ in range(4)] + [failed_attempt()]
|
||||
success_attempts[2]["success"] = True
|
||||
write_attempts(success_path, "slider", success_attempts)
|
||||
success_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(success_path)])
|
||||
details["success"] = {
|
||||
"switch_triggered": success_report["switch_triggered"],
|
||||
"route": success_report["recommended_next_route"],
|
||||
}
|
||||
|
||||
tile_path = tmp_dir / "tile.json"
|
||||
write_attempts(tile_path, "image-restore", [failed_attempt() for _ in range(5)], captcha_variant="tile-scramble")
|
||||
tile_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(tile_path)])
|
||||
details["tile_scramble"] = {
|
||||
"switch_triggered": tile_report["switch_triggered"],
|
||||
"route": tile_report["recommended_next_route"],
|
||||
"has_platform_candidates": bool(tile_report["platform_candidates"]),
|
||||
}
|
||||
|
||||
special: dict[str, Any] = {}
|
||||
for captcha_type in ["pow-challenge", "waf-challenge", "biometric-liveness"]:
|
||||
path = tmp_dir / f"{captcha_type}.json"
|
||||
write_attempts(path, captcha_type, [failed_attempt() for _ in range(5)])
|
||||
report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(path)])
|
||||
special[captcha_type] = {
|
||||
"switch_triggered": report["switch_triggered"],
|
||||
"decision": report["escalation_decision"],
|
||||
"platform_candidates": report["platform_candidates"],
|
||||
}
|
||||
details["special_types"] = special
|
||||
|
||||
passed = (
|
||||
details["switch"]["switch_triggered"] is True
|
||||
and details["switch"]["route"] == "platform-control"
|
||||
and details["switch"]["decision"] == "recommend-platform-control"
|
||||
and details["switch"]["send_request"] is False
|
||||
and details["below_threshold"]["switch_triggered"] is False
|
||||
and details["blocking"]["switch_triggered"] is False
|
||||
and details["blocking"]["blocking_issue"] == "coordinates"
|
||||
and details["success"]["switch_triggered"] is False
|
||||
and details["success"]["route"] == "optimize-current-route"
|
||||
and details["tile_scramble"]["switch_triggered"] is True
|
||||
and details["tile_scramble"]["has_platform_candidates"] is True
|
||||
and all(item["switch_triggered"] is False for item in details["special_types"].values())
|
||||
and all(item["decision"] == "ordinary-platform-not-recommended" for item in details["special_types"].values())
|
||||
and all(not item["platform_candidates"] for item in details["special_types"].values())
|
||||
)
|
||||
return {"passed": passed, "details": details}
|
||||
|
||||
|
||||
def write_success_samples(path: Path, samples: list[dict[str, Any]]) -> None:
|
||||
data = {
|
||||
"authorization_scope": "授权测试目标",
|
||||
"provider": "custom-or-unknown",
|
||||
"success_samples": samples,
|
||||
}
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def success_sample(captcha_type: str, sample_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"sample_id": sample_id,
|
||||
"success": True,
|
||||
"captcha_type": captcha_type,
|
||||
"provider": "custom-or-unknown",
|
||||
"evidence": {
|
||||
"success_signal": "UI/callback/response 显示验证成功",
|
||||
"timeline": ["rendered", "user solved", "success observed"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_success_baseline() -> dict[str, Any]:
|
||||
details: dict[str, Any] = {}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
|
||||
sufficient_path = tmp_dir / "success-sufficient.json"
|
||||
write_success_samples(
|
||||
sufficient_path,
|
||||
[
|
||||
success_sample("slider", "s1"),
|
||||
success_sample("slider", "s2"),
|
||||
success_sample("slider", "s3"),
|
||||
success_sample("click-select", "c1"),
|
||||
success_sample("click-select", "c2"),
|
||||
],
|
||||
)
|
||||
sufficient = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(sufficient_path)])
|
||||
details["sufficient"] = {
|
||||
"status": sufficient["success_baseline_status"],
|
||||
"route": sufficient["recommended_next_route"],
|
||||
"dynamic": sufficient["success_baseline_summary"]["has_dynamic_type_switch"],
|
||||
}
|
||||
|
||||
insufficient_total_path = tmp_dir / "success-insufficient-total.json"
|
||||
write_success_samples(
|
||||
insufficient_total_path,
|
||||
[
|
||||
success_sample("slider", "s1"),
|
||||
success_sample("slider", "s2"),
|
||||
success_sample("slider", "s3"),
|
||||
],
|
||||
)
|
||||
insufficient_total = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(insufficient_total_path)])
|
||||
details["insufficient_total"] = {
|
||||
"status": insufficient_total["success_baseline_status"],
|
||||
"missing_scopes": [item["scope"] for item in insufficient_total["missing_success_samples"]],
|
||||
"send_request": insufficient_total["send_request"],
|
||||
}
|
||||
|
||||
insufficient_type_path = tmp_dir / "success-insufficient-type.json"
|
||||
write_success_samples(
|
||||
insufficient_type_path,
|
||||
[
|
||||
success_sample("slider", "s1"),
|
||||
success_sample("slider", "s2"),
|
||||
success_sample("slider", "s3"),
|
||||
success_sample("slider", "s4"),
|
||||
success_sample("click-select", "c1"),
|
||||
],
|
||||
)
|
||||
insufficient_type = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(insufficient_type_path)])
|
||||
details["insufficient_type"] = {
|
||||
"status": insufficient_type["success_baseline_status"],
|
||||
"missing": insufficient_type["missing_success_samples"],
|
||||
}
|
||||
|
||||
passed = (
|
||||
details["sufficient"]["status"] == "sufficient"
|
||||
and details["sufficient"]["route"] == "ready-for-verification-flow"
|
||||
and details["sufficient"]["dynamic"] is True
|
||||
and details["insufficient_total"]["status"] == "insufficient"
|
||||
and "total" in details["insufficient_total"]["missing_scopes"]
|
||||
and details["insufficient_total"]["send_request"] is False
|
||||
and details["insufficient_type"]["status"] == "insufficient"
|
||||
and any(item.get("scope") == "captcha_type" and item.get("captcha_type") == "click-select" for item in details["insufficient_type"]["missing"])
|
||||
)
|
||||
return {"passed": passed, "details": details}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
coord = run_json(
|
||||
[
|
||||
"scripts/map_coordinates.py",
|
||||
"--image-size",
|
||||
"300x150",
|
||||
"--display-size",
|
||||
"300x150",
|
||||
"--point",
|
||||
"120,75",
|
||||
"--element-left",
|
||||
"20",
|
||||
"--element-top",
|
||||
"80",
|
||||
]
|
||||
)
|
||||
track = run_json(
|
||||
[
|
||||
"scripts/generate_motion_track.py",
|
||||
"--mode",
|
||||
"slider",
|
||||
"--distance",
|
||||
"128",
|
||||
"--duration-ms",
|
||||
"1100",
|
||||
]
|
||||
)
|
||||
template = run_json(
|
||||
[
|
||||
"scripts/solver_request_template.py",
|
||||
"--platform",
|
||||
"2captcha",
|
||||
"--captcha-type",
|
||||
"token-widget",
|
||||
"--provider",
|
||||
"recaptcha",
|
||||
]
|
||||
)
|
||||
assets = run_json(
|
||||
[
|
||||
"scripts/inspect_assets.py",
|
||||
"--captcha-type",
|
||||
"slider",
|
||||
"--provided",
|
||||
"background_or_screenshot",
|
||||
"--provided",
|
||||
"track_width",
|
||||
]
|
||||
)
|
||||
success_baseline = check_success_baseline()
|
||||
tile_restore = check_tile_restore()
|
||||
attempt_eval = check_attempt_evaluation()
|
||||
refs = check_references()
|
||||
checks = {
|
||||
"coordinate_mapping": coord["element_css"]["x"] == 120 and coord["page_css"]["x"] == 140,
|
||||
"motion_track": track["mode"] == "slider" and len(track["points"]) >= 2,
|
||||
"solver_template": template["send_request"] is False and "api_key" in template,
|
||||
"asset_inspection": assets["ready_for_offline_flow"] is True,
|
||||
"success_baseline": success_baseline["passed"],
|
||||
"tile_restore": tile_restore["passed"],
|
||||
"attempt_evaluation": attempt_eval["passed"],
|
||||
"references": refs["passed"],
|
||||
}
|
||||
report = {
|
||||
"passed": all(checks.values()),
|
||||
"checks": checks,
|
||||
"success_baseline": success_baseline,
|
||||
"tile_restore": tile_restore,
|
||||
"attempt_evaluation": attempt_eval,
|
||||
"reference_missing": refs["missing"],
|
||||
}
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0 if report["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user