Files

1243 lines
73 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""离线网页验证码/验证类型分类器。
脚本接收 HTML、文本、URL 和截图元信息;不会发送请求、打开浏览器、
提交答案或调用任何打码/solver 服务。
"""
from __future__ import annotations
import argparse
import html as html_lib
import json
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
def configure_utf8_stdio() -> None:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8")
configure_utf8_stdio()
@dataclass(frozen=True)
class Signal:
label: str
pattern: str
weight: float = 1.0
@dataclass(frozen=True)
class Match:
label: str
weight: float
evidence: str
source: str
@dataclass(frozen=True)
class EvidenceSource:
kind: str
text: str
SOURCE_WEIGHTS = {
"url": 1.8,
"text": 1.45,
"screenshot_meta": 1.45,
"html": 0.75,
}
HTML_FOCUS_KEYWORDS = re.compile(
r"captcha|验证码|geetest|hcaptcha|recaptcha|turnstile|cf-turnstile|g-recaptcha|h-captcha|"
r"yidun|NECaptcha|dun\.163|TCaptcha|TencentCaptcha|gtimg|AWSC|NoCaptcha|nc_|"
r"TJCaptcha|turing\.captcha|aliyun|aws-waf|awswaf|DataDome|arkoselabs|FunCaptcha|"
r"shumei|ishumei|fengkongcloud|initSMCaptcha|SMCaptcha|dingxiang|dx-captcha|_dx|"
r"baidu|bdstatic|bce\.baidu|jdcloud|Jcap|yunpian|riddler-sdk|huawei|HuaWei|OneAccess|"
r"tongdun|fraudmetrix|yandex|smart-captcha|CaptchaFox|procaptcha|prosopo|trustcaptcha|"
r"privatecaptcha|cap-widget|@cap\.js|mcaptcha|IconCaptcha|BotDetect|BDC_|securimage|"
r"visualcaptcha|opfcaptcha|validateCaptcha|amzn-captcha|CyberSiARA|siara|akamai|_abck|bm_sz|imperva|incapsula|reese84|"
r"perimeterx|px-captcha|_px3|kasada|kpsdk|x-kpsdk|netacea|radware|f5|TSPD|"
r"AJ-Captcha|aj-captcha|blockPuzzle|clickWord|tianai|TAC|happy-captcha|easy-captcha|"
r"mtcaptcha|friendlycaptcha|slider|slide|jigsaw|picture-click|click|rotate|grid|"
r"audio|voice|one-click|checkbox|press|hold|invisible|frictionless|risk[- ]?score|"
r"proof-of-work|pow|hashcash|drag|drop|trace|draw|scratch|restore|unscramble|area|"
r"difference|spot[- ]the[- ]difference|font|dice|game|3d|paged|multi[- ]step|logic|qa|"
r"liveness|biometric|face|"
r"语音|一键|一点即过|无感|无痕|评分|拖放|轨迹|绘制|连线|刮刮卡|还原|复原|乱序|"
r"面积|区域|框选|差异|找茬|字体|空间语义|语义|小游戏|多轮|分页|问答|逻辑|活体|人脸|"
r"sitekey|challenge",
re.IGNORECASE,
)
PROVIDER_SIGNALS: dict[str, list[Signal]] = {
"recaptcha": [
Signal("命中 Google reCAPTCHA 脚本", r"(?:google\.com|recaptcha\.net)/recaptcha", 3),
Signal("命中 g-recaptcha 标记", r"\bg-recaptcha\b", 2.5),
Signal("命中 grecaptcha API", r"\bgrecaptcha\b", 2),
Signal("命中 reCAPTCHA api2 iframe", r"/api2/(?:anchor|reload|bframe)", 2.5),
Signal("命中 reCAPTCHA Enterprise", r"recaptcha/(?:enterprise|enterprise\.js)", 2.5),
Signal("命中 g-recaptcha-response 字段", r"g-recaptcha-response", 2),
],
"hcaptcha": [
Signal("命中 hCaptcha 脚本", r"hcaptcha\.com/(?:1/)?api\.js|newassets\.hcaptcha\.com", 3),
Signal("命中 h-captcha 标记", r"\bh-captcha\b", 2.5),
Signal("命中 hcaptcha API", r"\bhcaptcha\b", 2),
Signal("命中 hCaptcha get/check 接口", r"/(?:getcaptcha|checkcaptcha)/", 2),
Signal("命中 h-captcha-response 字段", r"h-captcha-response", 2),
Signal("命中 hCaptcha rqdata/rqtoken", r"\brq(?:data|token)\b", 1.5),
],
"cloudflare-turnstile": [
Signal("命中 Turnstile 脚本", r"challenges\.cloudflare\.com/turnstile", 3),
Signal("命中 cf-turnstile 标记", r"\bcf-turnstile\b", 3),
Signal("命中 turnstile API", r"\bturnstile\.(?:render|execute|reset)\b", 2),
Signal("命中 cf-turnstile-response 字段", r"cf-turnstile-response", 2.5),
Signal("命中 Turnstile cdata/action", r"\b(?:cdata|data-cdata|data-action)\b", 1),
],
"geetest": [
Signal("命中极验关键词", r"\bgeetest\b|极验", 2.5),
Signal("命中极验 gt/challenge", r"\bgt\s*[:=]|challenge\s*[:=]", 1.5),
Signal("命中极验 captcha_id", r"\bcaptcha_id\b", 2),
Signal("命中极验 v4 lot/pass/gen 参数", r"\b(?:lot_number|pass_token|gen_time)\b", 2),
Signal("命中极验 w 载荷", r"(?<![a-z0-9_])w\s*[:=]\s*[\"'][A-Za-z0-9_-]{20,}", 1),
],
"tencent-tcaptcha": [
Signal("命中腾讯验证码文档/产品 URL", r"cloud\.tencent\.com/(?:document/product/1110|product/1110)|tencentcloud\.com/products/captcha|007\.qq\.com", 3),
Signal("命中腾讯验证码域名", r"captcha\.gtimg\.com|ssl\.captcha\.qq\.com|turing\.captcha\.qcloud\.com", 3),
Signal("命中腾讯验证码 API", r"\b(?:TCaptcha|TJCaptcha|TencentCaptcha|tencentcaptcha)\b", 2.5),
Signal("命中腾讯 aid/appid", r"\b(?:aid|appid|CaptchaAppId)\s*[:=]", 1),
Signal("命中腾讯 randstr/ticket", r"\brandstr\b|\brandstr\b.{0,80}\bticket\b|\bticket\b.{0,80}\brandstr\b", 2),
Signal("命中腾讯验证码文案", r"腾讯(?:验证码|防水墙)", 2),
],
"netease-yidun": [
Signal("命中易盾域名", r"captcha\.yidun|dun\.163\.com", 3),
Signal("命中易盾关键词", r"\byidun\b|网易易盾|易盾|NECaptcha", 2.5),
Signal("命中易盾 captchaId", r"(?:NECaptcha|yidun|易盾|dun\.163\.com|captcha\.yidun).{0,120}\bcaptchaId\b|\bcaptchaId\b.{0,120}(?:NECaptcha|yidun|易盾|dun\.163\.com|captcha\.yidun)", 1.5),
Signal("命中易盾 validate/fp", r"(?:NECaptcha|yidun|易盾|dun\.163\.com|captcha\.yidun).{0,120}\b(?:validate|acToken|fp)\b|\b(?:validate|acToken|fp)\b.{0,120}(?:NECaptcha|yidun|易盾|dun\.163\.com|captcha\.yidun)", 0.8),
],
"aliyun-captcha": [
Signal("命中阿里云域名", r"aliyun\.com|aliyuncs\.com|g\.alicdn\.com/(?:AWSC|nc)", 1.5),
Signal("命中阿里云 AWSC/NoCaptcha", r"g\.alicdn\.com/AWSC|AWSC\.use|NoCaptcha|\bnc_", 2.5),
Signal("命中阿里云 afs/文案", r"\bafs\b|阿里云(?:验证码|滑块|智能验证)", 2),
Signal("命中阿里云 appkey/scene", r"\b(?:appkey|scene|sessionId|sig)\b", 1),
],
"shumei-captcha": [
Signal("命中数美验证码文档/域名", r"help\.ishumei\.com/docs/tw/captcha|ishumei\.com/.{0,80}(?:captcha|验证码)", 3),
Signal("命中数美验证码静态资源", r"castatic(?:-[a-z0-9]+)?\.fengkongcloud\.cn/pr/.{0,80}smcp\.min\.js|fengkongcloud\.cn/.{0,80}smcp", 3),
Signal("命中数美验证码 API", r"\b(?:initSMCaptcha|SMCaptcha|smCaptchaCallback)\b", 2.8),
Signal("命中数美组织/结果参数", r"\b(?:organization|appendTo|rid|pass)\b.{0,120}(?:SMCaptcha|initSMCaptcha|fengkongcloud|数美)|(?:SMCaptcha|initSMCaptcha|fengkongcloud|数美).{0,120}\b(?:organization|appendTo|rid|pass)\b", 1.5),
Signal("命中数美中文名称", r"数美(?:智能)?验证码|数美", 1.5),
],
"dingxiang-captcha": [
Signal("命中顶象官网/资源", r"dingxiang-inc\.com/.{0,120}(?:captcha|验证码)|cdn\.dingxiang-inc\.com/.{0,120}(?:captcha|ctu-group)", 3),
Signal("命中顶象验证码脚本", r"(?:dx-captcha|dxCaptcha|DXCaptcha|_dx\.Captcha|dx\.Captcha|captcha-ui/v\d+/index\.js)", 3),
Signal("命中顶象参数", r"\b(?:appId|constId|apiServer|dxToken|dx_captcha)\b.{0,120}(?:dingxiang|顶象|dx-captcha|_dx)|(?:dingxiang|顶象|dx-captcha|_dx).{0,120}\b(?:appId|constId|apiServer|dxToken|dx_captcha)\b", 1.8),
Signal("命中顶象中文名称", r"顶象(?:验证码|无感验证|第五代验证码)|智能无感验证", 2.2),
],
"baidu-captcha": [
Signal("命中百度智能云验证码产品页", r"cloud\.baidu\.com/(?:product-s/afd_s/captcha|doc/AFD/)", 3),
Signal("命中百度验证码资源/控制台", r"bce\.bdstatic\.com/.{0,120}(?:captcha|验证码)|console\.bce\.baidu\.com/afd/captcha", 2.5),
Signal("命中百度验证码中文名称", r"百度(?:智能云)?(?:智能安全)?验证码|验证码 Captcha-百度智能云", 2.5),
Signal("命中百度验证码形态", r"(?:滑块验证码|数字验证码|文字验证码|轨迹绘制).{0,120}百度", 1.5),
],
"jdcloud-captcha": [
Signal("命中京东云验证码文档/域名", r"docs\.jdcloud\.com/(?:cn/)?captcha|jdcloud\.com/.{0,80}验证码", 3),
Signal("命中京东云 Jcap SDK", r"\b(?:Jcap|jcap)\.(?:create|init|verify|destroy)\b|Jcap\.create", 3),
Signal("命中京东云验证码名称", r"京东云(?:验证码|行为空间验证)|PC/M端 SDK接入.{0,40}验证码", 2),
Signal("命中京东云参数", r"\b(?:appId|sceneId|productId)\b.{0,120}(?:Jcap|jdcloud|京东云)|(?:Jcap|jdcloud|京东云).{0,120}\b(?:appId|sceneId|productId)\b", 1.5),
],
"yunpian-captcha": [
Signal("命中云片行为验证产品页", r"yunpian\.com/(?:product/captcha|official/.{0,80}captcha)", 3),
Signal("命中云片 Riddler SDK", r"riddler-sdk|YpRiddler|Riddler", 3),
Signal("命中云片中文名称", r"云片(?:行为验证|行为式验证码|验证码)", 2.5),
Signal("命中云片验证形态", r"(?:滑动拼图|图中点选|触发式|嵌入式).{0,120}云片", 1.5),
],
"huawei-captcha": [
Signal("命中华为云/OneAccess 验证码文档", r"support\.huaweicloud\.com/.{0,120}(?:captcha|验证码)|huaweicloud\.com/.{0,120}(?:captcha|验证码|图形验证码)", 2.8),
Signal("命中华为图形验证码接口", r"(?:HuaWei|Huawei|huaweicloud).{0,120}(?:captcha|图形验证码|captchaId|validateCode)|(?:captchaId|validateCode).{0,120}(?:HuaWei|Huawei|huaweicloud|华为)", 2),
Signal("命中华为中文名称", r"华为云.{0,40}(?:验证码|图形验证码|人机验证)|OneAccess.{0,80}(?:验证码|图形验证码)", 2),
],
"tongdun-risk": [
Signal("命中同盾/数科风控域名", r"tongdun\.cn|tongdun\.net|fraudmetrix\.cn|fraudmetrix\.com", 2.8),
Signal("命中同盾设备/风控参数", r"\b(?:blackbox|tokenId|riskToken)\b.{0,120}(?:tongdun|fraudmetrix|同盾)|(?:tongdun|fraudmetrix|同盾).{0,120}\b(?:blackbox|tokenId|riskToken)\b", 1.8),
Signal("命中同盾中文名称", r"同盾(?:科技|数科)?.{0,40}(?:验证码|人机|无感|风控验证)", 2),
],
"cloudflare-waf": [
Signal("命中 Cloudflare challenge 平台", r"/cdn-cgi/challenge-platform|cf_chl|cf-mitigated", 3),
Signal("命中 Cloudflare clearance", r"\bcf_clearance\b", 3),
Signal("命中 Cloudflare challenge 文案", r"Just a moment|Checking if the site connection is secure|Checking your browser", 2.5),
Signal("命中 Cloudflare Ray ID", r"Cloudflare Ray ID|cf-ray", 1.5),
],
"aws-waf": [
Signal("命中 AWS WAF 文档/域名", r"docs\.aws\.amazon\.com/waf|AWS WAF", 3),
Signal("命中 AWS WAF token", r"aws-waf-token", 3),
Signal("命中 AWS WAF 关键词", r"\bawswaf\b|aws-waf-captcha|waf_captcha", 2.5),
Signal("命中 AWS WAF challenge.js", r"challenge\.js.*aws|aws.*challenge\.js", 2),
Signal("命中 AWS WAF captcha_voucher", r"\bcaptcha_voucher\b", 2),
],
"datadome": [
Signal("命中 DataDome 关键词", r"\bdatadome\b|DataDome", 3),
Signal("命中 x-datadome header", r"x-datadome", 2.5),
Signal("命中 DataDome 标识参数", r"\b(?:ddcid|ddv|initialCid|cid)\b", 1.5),
],
"arkose-funcaptcha": [
Signal("命中 Arkose 域名", r"arkoselabs\.com|client-api\.arkoselabs", 3),
Signal("命中 FunCaptcha 关键词", r"\bfuncaptcha\b|FunCaptcha", 2.5),
Signal("命中 Arkose fc-token", r"\bfc-token\b", 2),
Signal("命中 Arkose public key/blob", r"\b(?:public_key|pkey|surl|blob)\b", 1.5),
],
"mtcaptcha": [
Signal("命中 MTCaptcha 域名", r"mtcaptcha\.com", 3),
Signal("命中 MTCaptcha 配置", r"\bMTCaptcha\b|\bmtcaptchaConfig\b|mtcaptcha-verifiedtoken", 2),
],
"keycaptcha": [
Signal("命中 KeyCaptcha 关键词", r"\bkeycaptcha\b|\bKeyCaptcha\b", 3),
Signal("命中 KeyCaptcha 参数", r"\bs_s_c_(?:user_id|session_id|web_server_sign)\b|\bkc_cid\b", 2),
],
"friendlycaptcha": [
Signal("命中 FriendlyCaptcha 关键词", r"friendlycaptcha|friendly-challenge|frc-captcha", 3),
],
"altcha": [
Signal("命中 ALTCHA 关键词", r"\baltcha\b|altcha-widget|challengeurl", 3),
],
"yandex-smartcaptcha": [
Signal("命中 Yandex SmartCaptcha 脚本/域名", r"smartcaptcha\.yandexcloud\.net|yandex\.cloud/.{0,80}smartcaptcha", 3),
Signal("命中 SmartCaptcha 标记/API", r"smart-captcha|window\.smartCaptcha|smartCaptcha\.(?:render|execute|reset)", 3),
Signal("命中 Yandex SmartCaptcha 名称", r"Yandex SmartCaptcha|SmartCaptcha", 2.5),
],
"captchafox": [
Signal("命中 CaptchaFox 域名", r"captchafox\.com|docs\.captchafox\.com|api\.captchafox\.com", 3),
Signal("命中 CaptchaFox API/标记", r"\bCaptchaFox\b|captchafox\.(?:render|execute|reset)|cf-captcha", 2.5),
],
"prosopo-procaptcha": [
Signal("命中 Prosopo/Procaptcha 域名", r"prosopo\.io|procaptcha\.com|portal\.prosopo\.io", 3),
Signal("命中 Procaptcha 资源/API", r"\b(?:procaptcha|Prosopo)\b|@prosopo/(?:procaptcha|captcha)|prosopo-procaptcha", 2.8),
],
"trustcaptcha": [
Signal("命中 TrustCaptcha 域名", r"trustcaptcha\.com|trustcomponent\.com", 3),
Signal("命中 TrustCaptcha API/组件", r"\bTrustCaptcha\b|trustcaptcha(?:Init|\.render)?|trustcaptcha-container", 2.5),
],
"private-captcha": [
Signal("命中 Private Captcha 域名", r"privatecaptcha\.com|api\.privatecaptcha\.com", 3),
Signal("命中 PrivateCaptcha 组件", r"\bPrivateCaptcha\b|private-captcha|privatecaptcha(?:\.render)?", 2.5),
],
"capjs": [
Signal("命中 Cap.js 包/站点", r"@cap\.js/(?:widget|server)|capjs\.js\.org|trycap\.dev", 3),
Signal("命中 Cap.js 组件", r"\bcap-widget\b|data-cap-api-endpoint|\bCapWidget\b", 2.8),
],
"mcaptcha": [
Signal("命中 mCaptcha 域名/名称", r"mcaptcha\.org|\bmCaptcha\b|\bmcaptcha\b", 3),
Signal("命中 mCaptcha PoW/组件", r"proof[- ]of[- ]work.{0,80}mcaptcha|mcaptcha-widget|data-mcaptcha", 2),
],
"iconcaptcha": [
Signal("命中 IconCaptcha 组件", r"\bIconCaptcha\b|iconcaptcha(?:\.render|\.init)?|icon-captcha", 3),
Signal("命中 IconCaptcha 资源", r"iconcaptcha\.com|fabianwennink/IconCaptcha", 2.5),
],
"botdetect": [
Signal("命中 BotDetect 域名/名称", r"captcha\.com|BotDetect|BotDetectCaptcha", 3),
Signal("命中 BotDetect DOM/字段", r"\bBDC_(?:CaptchaDiv|BackLink|ReloadIcon|SoundIcon)\b|captchaCode", 2.5),
],
"securimage": [
Signal("命中 Securimage 名称", r"\bSecurimage\b|securimage\.com", 3),
Signal("命中 Securimage 文件", r"securimage_(?:show|play)\.php|securimage\.php", 2.8),
],
"visualcaptcha": [
Signal("命中 visualCaptcha 名称/资源", r"\bvisualCaptcha\b|visualcaptcha\.net|emotionLoop/visualCaptcha", 3),
Signal("命中 visualCaptcha 图片选择", r"visualcaptcha.{0,120}(?:image|audio|accessibility)|(?:image|audio).{0,120}visualcaptcha", 1.8),
],
"amazon-captcha": [
Signal("命中 Amazon CAPTCHA 资源", r"opfcaptcha\.amazon\.com|/errors/validateCaptcha", 3),
Signal("命中 Amazon CAPTCHA 表单", r"\b(?:amzn-captcha|validateCaptcha|cvf_captcha)\b", 2.5),
Signal("命中 Amazon 图片验证码文案", r"Enter the characters you see below|Type the characters you see in this image", 2),
],
"cybersiara": [
Signal("命中 CyberSiARA 域名/名称", r"cybersiara\.com|\bCyberSiARA\b|\bSiARA\b", 3),
Signal("命中 CyberSiARA 组件/脚本", r"siara\.js|siara(?:Captcha|Protection|SDK)", 2.2),
],
"aj-captcha": [
Signal("命中 AJ-Captcha 名称", r"\bAJ-Captcha\b|anji-plus|ajcaptcha", 3),
Signal("命中 AJ-Captcha 常见接口/类型", r"\b(?:blockPuzzle|clickWord|captchaVerification)\b.{0,120}(?:AJ-Captcha|ajcaptcha|anji)|(?:AJ-Captcha|ajcaptcha|anji).{0,120}\b(?:blockPuzzle|clickWord|captchaVerification)\b", 2.2),
],
"tianai-captcha": [
Signal("命中 Tianai Captcha 名称", r"tianai-captcha|TianaiCaptcha|天爱验证码", 3),
Signal("命中 Tianai 前端标记", r"\bTAC\b.{0,120}(?:captcha|slider|rotate)|(?:captcha|slider|rotate).{0,120}\bTAC\b", 1.8),
],
"easycaptcha": [
Signal("命中 EasyCaptcha 名称", r"EasyCaptcha|easy-captcha|ele-admin/EasyCaptcha", 2.8),
],
"happycaptcha": [
Signal("命中 HappyCaptcha 名称", r"HappyCaptcha|happy-captcha", 2.8),
],
"kaptcha": [
Signal("命中 Kaptcha 名称", r"\bKaptcha\b|google kaptcha|com\.google\.code\.kaptcha", 2.5),
],
"akamai-bot-manager": [
Signal("命中 Akamai Bot Manager 名称", r"Akamai Bot Manager|Akamai Bot|Akamai.*(?:sensor|challenge)", 3),
Signal("命中 Akamai Bot cookie/参数", r"\b(?:_abck|bm_sz|ak_bmsc|bm_sv|sensor_data)\b", 2.8),
Signal("命中 Akamai challenge 资源", r"akamai|abck|bmak|_bm", 1.5),
],
"imperva-incapsula": [
Signal("命中 Imperva/Incapsula 名称", r"Imperva|Incapsula", 3),
Signal("命中 Imperva cookie/脚本", r"\b(?:visid_incap|incap_ses|___utmvc|reese84|utmvc)\b", 2.8),
Signal("命中 Imperva challenge 文案", r"Request unsuccessful\. Incapsula|Generated by cloudfront.*Incapsula", 1.8),
],
"perimeterx-human": [
Signal("命中 HUMAN/PerimeterX 名称", r"PerimeterX|HUMAN Security|Human Bot Defender", 3),
Signal("命中 PerimeterX cookie/字段", r"\b(?:px-captcha|_px(?:2|3)?|pxvid|pxhd|pxcts)\b", 2.8),
Signal("命中 PerimeterX 脚本/接口", r"collector-\w+\.px-cloud\.net|/api/v\d+/collector|pxAppId", 2),
],
"kasada": [
Signal("命中 Kasada 名称/域名", r"Kasada|kasada\.io", 3),
Signal("命中 Kasada kpsdk", r"\b(?:x-kpsdk|kpsdk|_kpsdk|KP_UIDz|KP_UID)\b", 2.8),
Signal("命中 Kasada 脚本", r"/(?:p|ips)\.js|/fp\?x-kpsdk", 1.8),
],
"netacea": [
Signal("命中 Netacea 名称", r"Netacea|netacea\.com", 3),
Signal("命中 Netacea Bot Management", r"netacea.{0,80}(?:bot|challenge|captcha)|(?:bot|challenge|captcha).{0,80}netacea", 2),
],
"radware-bot-manager": [
Signal("命中 Radware Bot Manager 名称", r"Radware Bot Manager|radware\.com/.{0,80}bot", 3),
Signal("命中 Radware challenge/cookie", r"\b(?:rbzid|rbzsessionid|RWD|TSPD_101)\b|radware.{0,80}challenge", 2.2),
],
"f5-bot-defense": [
Signal("命中 F5 Bot Defense 名称", r"F5 Bot Defense|Shape Security|F5 Distributed Cloud Bot Defense", 3),
Signal("命中 F5/Shape cookie/脚本", r"\b(?:_imp_apg_r_|TS[0-9a-f]{3,}|TSPD_\d+)\b|shape(?:security)?\.js|f5_cspm", 2.3),
],
}
TYPE_SIGNALS: dict[str, list[Signal]] = {
"audio": [
Signal("命中语音验证码中文文案", r"语音验证码|音频验证码|听音验证|听到的(?:数字|字符|内容)|播放(?:语音|音频).*验证码", 3),
Signal("命中语音验证码英文文案", r"\b(?:audio captcha|voice captcha|audio challenge|listen to the audio|play audio)\b", 3),
Signal("命中音频播放资源", r"(?:captcha|challenge).{0,80}\.(?:mp3|wav|ogg)|(?:audio|voice)[-_]?(?:captcha|challenge)", 1.8),
],
"drag-drop": [
Signal("命中拖放中文文案", r"拖放|拖拽到|拖动(?:图形|图片|物体|方块|卡片|元素).{0,40}(?:目标|指定位置|区域|框内)|把.{0,20}拖到.{0,20}(?:目标|位置|区域)", 3),
Signal("命中拖放英文文案", r"\b(?:drag[- ]and[- ]drop|drag the (?:item|object|shape|image)|drop (?:it|the item) (?:on|into|to)|drag to the target)\b", 3),
Signal("命中拖放组件线索", r"\b(?:draggable|droppable|dropzone|drag-target)\b.{0,120}(?:captcha|verify|challenge)|(?:captcha|verify|challenge).{0,120}\b(?:draggable|droppable|dropzone|drag-target)\b", 1.8),
],
"trace-draw": [
Signal("命中轨迹绘制中文文案", r"轨迹绘制|绘制轨迹|画出轨迹|描绘轨迹|按轨迹滑动|连线验证|划线验证|画线验证", 3),
Signal("命中轨迹绘制英文文案", r"\b(?:trace captcha|draw the path|draw a line|connect the dots|trace the pattern|draw captcha)\b", 3),
Signal("命中轨迹字段", r"\b(?:track|trace|drawPath|trajectory|pathPoints)\b.{0,120}(?:captcha|verify|challenge)|(?:captcha|verify|challenge).{0,120}\b(?:track|trace|drawPath|trajectory|pathPoints)\b", 1.5),
],
"scratch": [
Signal("命中刮刮卡中文文案", r"刮刮卡|刮开|刮动|刮出验证码|刮开验证", 3),
Signal("命中刮刮卡英文文案", r"\b(?:scratch card|scratch captcha|scratch to reveal|scratch verification)\b", 3),
],
"image-restore": [
Signal("命中图像还原中文文案", r"图像复原|图片复原|图片还原|图像还原|滑动还原|拼图还原|乱序拼图|拼图复原|还原图片|还原图像|切片乱序|分块乱序|图片分割|瓦片重排|分割顺序打乱|乱序图片还原|图片被切成多块", 3),
Signal("命中图像还原英文文案", r"\b(?:image restore|image restoration|restore the image|unscramble|scrambled image|scrambled tiles|tile scramble|reorder the puzzle|reorder tiles|puzzle restoration)\b", 3),
Signal("命中还原字段", r"\b(?:restore|restoreImage|scramble|unscramble|tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder|background-position|drawImage|sprite|shuffle)\b.{0,120}(?:captcha|verify|challenge|image)|(?:captcha|verify|challenge|image).{0,120}\b(?:restore|restoreImage|scramble|unscramble|tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder|background-position|drawImage|sprite|shuffle)\b", 1.8),
],
"area-select": [
Signal("命中区域选择中文文案", r"面积验证|区域选择|框选|圈出|选中区域|请选择区域|点击并拖拽选择|选择图片中.{0,20}区域", 3),
Signal("命中区域选择英文文案", r"\b(?:area select|select the area|mark the area|draw a box|bounding box|region selection)\b", 3),
],
"difference-click": [
Signal("命中差异点击中文文案", r"差异点击|找不同|找茬|点击不同|点击差异|找出不同之处", 3),
Signal("命中差异点击英文文案", r"\b(?:spot the difference|find the difference|difference click|click the difference)\b", 3),
],
"font-identify": [
Signal("命中字体识别中文文案", r"字体识别|同字体|不同字体|相同字体|字体相同|字体不同|选择.*字体", 3),
Signal("命中字体识别英文文案", r"\b(?:font recognition|same font|different font|select the font|font captcha)\b", 3),
],
"semantic-reasoning": [
Signal("命中空间语义中文文案", r"空间语义|语义验证|语义推理|逻辑推理|点击(?:最左|最右|上方|下方|最大|最小|相邻|重叠|被遮挡).{0,30}(?:目标|物体|图形)", 3),
Signal("命中空间语义英文文案", r"\b(?:semantic captcha|visual reasoning|spatial reasoning|select the (?:leftmost|rightmost|largest|smallest)|logic image challenge)\b", 3),
],
"game-challenge": [
Signal("命中小游戏中文文案", r"小游戏验证|游戏式验证|3D验证|三维验证|骰子|转动骰子|Arkose Enforcement|FunCaptcha", 3),
Signal("命中小游戏英文文案", r"\b(?:game challenge|mini[- ]game|3d challenge|dice challenge|arkose enforcement|funcaptcha)\b", 3),
Signal("命中 Arkose 游戏线索", r"\b(?:client-api\.arkoselabs|arkoselabs|fc-token)\b.{0,160}\b(?:game|3d|dice|enforcement)\b|\b(?:game|3d|dice|enforcement)\b.{0,160}\b(?:client-api\.arkoselabs|arkoselabs|fc-token)\b", 2),
],
"pow-challenge": [
Signal("命中 PoW 中文文案", r"工作量证明|算力证明|哈希难题|本地计算挑战|浏览器计算验证", 3),
Signal("命中 PoW 英文文案", r"\b(?:proof[- ]of[- ]work|pow challenge|hashcash|computational puzzle|cryptographic puzzle)\b", 3),
Signal("命中 PoW 组件", r"\b(?:friendlycaptcha|friendly-challenge|frc-captcha|altcha|privatecaptcha|private-captcha|cap-widget|@cap\.js|mcaptcha|mcaptcha-widget)\b", 2.5),
Signal("命中 PoW 结果字段", r"\b(?:challengeurl|payload|signature|nonce|solution|difficulty|salt)\b.{0,120}(?:captcha|pow|challenge)|(?:captcha|pow|challenge).{0,120}\b(?:challengeurl|payload|signature|nonce|solution|difficulty|salt)\b", 1.6),
],
"risk-score": [
Signal("命中无感/评分中文文案", r"无感验证|无痕验证|智能无感|风险评分|评分验证|静默验证|后台评分|行为评分|无需用户操作", 3),
Signal("命中无感/评分英文文案", r"\b(?:frictionless|invisible captcha|invisible challenge|risk score|score-based|passive captcha|behavior score|background verification|recaptcha v3)\b", 3),
Signal("命中 reCAPTCHA v3", r"recaptcha/(?:api\.js|enterprise\.js).{0,120}\brender=.*?6L|\bgrecaptcha\.execute\b.{0,120}\baction\b|reCAPTCHA v3", 2.5),
],
"one-click": [
Signal("命中一键/点击中文文案", r"一键验证|一键通过|一点即过|点击完成验证|点击按钮完成验证|点此验证|按住完成验证|长按完成验证", 3),
Signal("命中一键/点击英文文案", r"\b(?:one[- ]click captcha|one[- ]click verification|click to verify|press and hold|hold to verify|checkbox captcha|i am not a robot)\b", 3),
Signal("命中 checkbox 组件线索", r"\b(?:checkbox|check-box)\b.{0,80}(?:captcha|verify|challenge)|(?:captcha|verify|challenge).{0,80}\b(?:checkbox|check-box)\b", 1.6),
],
"multi-step": [
Signal("命中多轮中文文案", r"多轮验证|多步验证|分页验证|下一题|继续验证|第\s*\d+\s*轮", 3),
Signal("命中多轮英文文案", r"\b(?:multi[- ]step challenge|multi[- ]round captcha|paged challenge|next challenge|continue verification)\b", 3),
],
"qa-logic": [
Signal("命中问答中文文案", r"问答验证码|问题验证码|回答问题|请回答|安全问题验证|逻辑题验证码", 3),
Signal("命中问答英文文案", r"\b(?:question captcha|qa captcha|answer the question|logic captcha|security question challenge)\b", 3),
],
"biometric-liveness": [
Signal("命中活体/生物识别中文文案", r"活体检测|人脸验证|刷脸验证|真人检测|眨眼|张嘴|摇头|点头", 3),
Signal("命中活体/生物识别英文文案", r"\b(?:liveness detection|face verification|biometric verification|face captcha|selfie verification)\b", 3),
],
"math": [
Signal("命中算术文案", r"算术|请计算|计算(?:结果|答案)|math captcha|arithmetic", 3),
Signal("命中算术表达式", r"\b\d{1,3}\s*(?:[+\-*/xX×÷])\s*\d{1,3}\b", 3),
Signal("命中中文运算符", r"\d+\s*(?:加|减|乘以?|除以?)\s*\d+", 2.5),
],
"slider": [
Signal("命中滑块中文文案", r"滑块|拖动(?:滑块|拼图|手柄|滑条|滑动)|拖拽(?:滑块|拼图|手柄|滑条|滑动)|拼图|缺口|向右滑动|完成拼图|滑动验证|滑动式验证|滑动式|滑动拼图", 3),
Signal("命中滑块英文文案", r"\b(?:slider|slide|jigsaw|puzzle|drag the slider|drag to complete|slider-captcha|slide verification)\b", 2.5),
Signal("命中滑块图片命名", r"(?:bg|background|slice|piece|jigsaw)[-_]?(?:img|image|url)?", 1),
Signal("命中滑块 class 名", r"class=[\"'][^\"']*(?:slider|slide|jigsaw|puzzle)", 1.5),
],
"click-select": [
Signal("命中点选中文文案", r"点选|文字点选|依次点击|请点击|按顺序点击|点击下图|点击图中|图中点选|点击.*(?:文字|数字|图标|目标)", 3),
Signal("命中点选英文文案", r"picture-click|click-select|click captcha|icon captcha|\b(?:click|select|tap)\b.{0,40}\b(?:word|icon|object|target|in order|picture|image)\b", 2.5),
Signal("命中坐标文案", r"坐标|目标点|click coordinates|target coordinates", 2),
],
"rotate": [
Signal("命中旋转中文文案", r"旋转|转正|拖动.*(?:角度|旋转)|调整.*方向", 3),
Signal("命中旋转英文文案", r"\b(?:rotate|rotation|angle|upright|orientation)\b", 2.5),
Signal("命中旋转 class 名", r"class=[\"'][^\"']*(?:rotate|rotation|angle)", 1.5),
],
"grid": [
Signal("命中九宫格中文文案", r"九宫格|9宫格|选择所有|请选择所有|宫格", 3),
Signal("命中九宫格英文文案", r"\b(?:image grid|grid captcha|select all images|traffic lights|crosswalks|bicycles|bus|motorcycles)\b", 3),
Signal("命中网格布局线索", r"\b(?:3x3|3 x 3|nine-grid|tile|tiles|cell|cells)\b", 1.5),
Signal("命中 reCAPTCHA/hCaptcha 图片题线索", r"/recaptcha/api/fallback|\b(?:rc-imageselect|task-image|challenge-view|prompt-text)\b", 2),
],
"token-widget": [
Signal("命中 sitekey", r"\b(?:sitekey|data-sitekey|site-key)\b", 2.5),
Signal("命中 callback/action", r"\b(?:callback|data-callback|action|data-action|enterprise)\b", 1),
Signal("命中 response 字段", r"(?:g-recaptcha|h-captcha|cf-turnstile)-response", 2),
Signal("命中组件模式文案", r"\b(?:managed|non-interactive|widget)\b", 1),
],
"waf-challenge": [
Signal("命中 Cloudflare challenge 线索", r"Just a moment|cf_clearance|cf_chl|/cdn-cgi/challenge-platform|cf-mitigated", 3),
Signal("命中 WAF/反自动化文案", r"\b(?:WAF|web application firewall|bot mitigation|anti-bot|security check|checking your browser)\b", 2),
Signal("命中 WAF token/cookie", r"aws-waf-token|datadome|akamai|imperva|incapsula|perimeterx|kasada|netacea|radware|px-captcha|_abck|bm_sz|x-kpsdk|reese84|cf_clearance|TSPD", 2.5),
],
"text": [
Signal("命中文字验证码中文文案", r"图形验证码|图片验证码|文字验证码|字符验证码|输入.*验证码|请输入.*验证码|看不清|换一张", 2),
Signal("命中文字验证码英文文案", r"\b(?:text captcha|image captcha|verification code|enter the code|type the characters|image to text|retype the characters)\b", 1.5),
Signal("命中验证码图片线索", r"<img[^>]+(?:captcha|verify|code)|(?:captcha|verify|code)[-_]?(?:img|image|url)", 1.5),
],
}
TILE_SCRAMBLE_SIGNALS = [
Signal("命中切片乱序中文文案", r"切片乱序|分块乱序|图片分割|瓦片重排|分割顺序打乱|乱序图片还原|图片被切成多块|切成多块顺序打乱", 3),
Signal("命中切片乱序英文文案", r"\b(?:scrambled tiles|tile scramble|scrambled image tiles|image tile restore|reorder tiles|slice order|piece order)\b", 3),
Signal("命中切片顺序字段", r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b", 2.5),
Signal("命中 CSS sprite/背景定位切片", r"background-position|background-size|sprite", 2),
Signal("命中 Canvas drawImage 切片绘制", r"\bdrawImage\s*\(", 2),
Signal("命中 shuffle/scramble 切片逻辑", r"\b(?:shuffle|scramble|unscramble)\b.{0,100}\b(?:tile|piece|slice|image|captcha)\b|\b(?:tile|piece|slice|image|captcha)\b.{0,100}\b(?:shuffle|scramble|unscramble)\b", 1.8),
]
PLAYBOOKS = {
"audio": "references/solution-playbooks.md#audio",
"drag-drop": "references/solution-playbooks.md#drag-drop",
"trace-draw": "references/solution-playbooks.md#trace-draw",
"scratch": "references/solution-playbooks.md#scratch",
"image-restore": "references/solution-playbooks.md#image-restore",
"area-select": "references/solution-playbooks.md#area-select",
"difference-click": "references/solution-playbooks.md#difference-click",
"font-identify": "references/solution-playbooks.md#font-identify",
"semantic-reasoning": "references/solution-playbooks.md#semantic-reasoning",
"game-challenge": "references/solution-playbooks.md#game-challenge",
"pow-challenge": "references/solution-playbooks.md#pow-challenge",
"risk-score": "references/solution-playbooks.md#risk-score",
"one-click": "references/solution-playbooks.md#one-click",
"multi-step": "references/solution-playbooks.md#multi-step",
"qa-logic": "references/solution-playbooks.md#qa-logic",
"biometric-liveness": "references/solution-playbooks.md#biometric-liveness",
"text": "references/solution-playbooks.md#text",
"math": "references/solution-playbooks.md#math",
"slider": "references/solution-playbooks.md#slider",
"click-select": "references/solution-playbooks.md#click-select",
"rotate": "references/solution-playbooks.md#rotate",
"grid": "references/solution-playbooks.md#grid",
"token-widget": "references/solution-playbooks.md#token-widget",
"waf-challenge": "references/solution-playbooks.md#waf-challenge",
"unknown-custom": "references/solution-playbooks.md#unknown-custom",
}
NEXT_EVIDENCE = {
"audio": ["音频文件或播放接口", "题面文本", "答案格式", "是否有视觉替代方案"],
"drag-drop": ["完整挑战截图", "被拖动元素和目标区域边界", "坐标系", "释放判定接口"],
"trace-draw": ["轨迹图/题面截图", "目标路径坐标", "画布尺寸", "轨迹采样格式"],
"scratch": ["刮刮卡区域截图", "刮开前后状态", "画布尺寸", "通过阈值"],
"image-restore": ["乱序/待还原图片", "分块边界", "目标排列规则", "tileOrder/pieceOrder 或 CSS/canvas 切片线索", "拖动或滑动映射"],
"area-select": ["完整题面截图", "待选择区域", "元素边界", "坐标原点"],
"difference-click": ["两张对比图或完整截图", "差异点坐标", "题面", "点击次数要求"],
"font-identify": ["题面文本", "候选文字截图", "字体目标规则", "点击顺序"],
"semantic-reasoning": ["完整题面", "截图", "目标关系描述", "坐标系"],
"game-challenge": ["小游戏截图/帧", "题面", "交互控件", "厂商 public key 或 challenge id"],
"pow-challenge": ["组件脚本 URL", "challenge/payload", "difficulty/nonce 字段", "服务端校验接口"],
"risk-score": ["脚本/iframe URL", "sitekey/action", "score/阈值文档", "服务端校验结果"],
"one-click": ["组件 HTML", "按钮/checkbox 截图", "callback 名", "是否存在二次挑战"],
"multi-step": ["每一轮题面截图", "轮次状态", "上一轮结果", "最终成功标记"],
"qa-logic": ["问题原文", "答案格式", "语言/知识范围", "是否为动态题库"],
"biometric-liveness": ["SDK/厂商标识", "活体动作要求", "授权和隐私边界", "人工复核流程"],
"text": ["验证码图片或元素截图", "期望字符集/长度", "刷新接口或刷新行为"],
"math": ["表达式图片/文本", "运算符集合", "服务端接受的答案格式"],
"slider": ["背景图", "滑块/拼图块图片", "轨道宽度", "厂商 verify 接口名"],
"click-select": ["题面文本", "完整挑战截图", "元素边界框", "目标点击顺序"],
"rotate": ["挑战图片", "轨道宽度", "角度范围", "已知校准样本"],
"grid": ["题面文本", "完整网格截图", "格子边界", "轮次/状态信息"],
"token-widget": ["脚本/iframe URL", "sitekey", "页面 URL", "action/cdata/rqdata", "callback 名"],
"waf-challenge": ["响应头", "challenge HTML", "cookie 名", "状态码", "challenge JS URL"],
"unknown-custom": ["组件附近 HTML", "脚本 URL", "截图", "网络接口名"],
}
VERIFICATION_FLOW_BY_TYPE: dict[str, dict[str, list[str]]] = {
"text": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"math": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"slider": {
"references": [
"references/verification-workflow.md",
"references/open-source-recipes.md",
"references/motion-and-coordinate.md",
"references/provider-execution-notes.md",
],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/generate_motion_track.py"],
},
"click-select": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"rotate": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/generate_motion_track.py"],
},
"grid": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"audio": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"drag-drop": {
"references": ["references/verification-workflow.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/generate_motion_track.py"],
},
"trace-draw": {
"references": ["references/verification-workflow.md", "references/motion-and-coordinate.md", "references/open-source-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/generate_motion_track.py"],
},
"scratch": {
"references": ["references/verification-workflow.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/generate_motion_track.py"],
},
"image-restore": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/analyze_tile_restore.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"area-select": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"difference-click": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"font-identify": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"semantic-reasoning": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/solver_request_template.py"],
},
"game-challenge": {
"references": [
"references/verification-workflow.md",
"references/provider-execution-notes.md",
"references/solver-platform-recipes.md",
],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"pow-challenge": {
"references": ["references/verification-workflow.md", "references/provider-execution-notes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"risk-score": {
"references": [
"references/verification-workflow.md",
"references/provider-execution-notes.md",
"references/solver-platform-recipes.md",
],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"one-click": {
"references": ["references/verification-workflow.md", "references/provider-execution-notes.md", "references/solver-platform-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"multi-step": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md", "references/motion-and-coordinate.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/map_coordinates.py", "scripts/generate_motion_track.py"],
},
"qa-logic": {
"references": ["references/verification-workflow.md", "references/open-source-recipes.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"biometric-liveness": {
"references": ["references/verification-workflow.md", "references/provider-execution-notes.md"],
"scripts": ["scripts/inspect_assets.py"],
},
"token-widget": {
"references": [
"references/verification-workflow.md",
"references/provider-execution-notes.md",
"references/solver-platform-recipes.md",
],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"waf-challenge": {
"references": [
"references/verification-workflow.md",
"references/browser-acquisition.md",
"references/provider-execution-notes.md",
],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
"unknown-custom": {
"references": ["references/verification-workflow.md", "references/captcha-types.md", "references/provider-products.md"],
"scripts": ["scripts/inspect_assets.py", "scripts/solver_request_template.py"],
},
}
SOLUTION_OPTIONS: dict[str, dict[str, Any]] = {
"text": {
"open_source_first": ["ddddocr", "Tesseract + 字符白名单", "OpenCV 预处理后 OCR"],
"fallback_platforms": ["云码/JFBYM", "超级鹰", "2Captcha ImageToText", "Anti-Captcha Image"],
"when_to_switch": ["字符扭曲严重", "干扰线/背景噪声导致本地 OCR 低于目标通过率", "需要人工兜底"],
"notes": "先做图片裁剪、二值化、降噪、字符集和长度约束;平台方案只作为授权 QA 的备选。",
},
"math": {
"open_source_first": ["ddddocr/Tesseract 识别表达式", "正则归一化运算符", "安全表达式求值"],
"fallback_platforms": ["云码/JFBYM 计算题类型", "超级鹰", "2Captcha Normal Captcha"],
"when_to_switch": ["表达式被强干扰", "中文数字/竖排/应用题导致 OCR 不稳定"],
"notes": "输出归一化表达式和结果,遇到歧义不要猜。",
},
"slider": {
"open_source_first": ["ddddocr slide-match/slide-comparison", "OpenCV 模板/边缘/差分匹配", "轨迹模型与坐标校准"],
"fallback_platforms": ["云码/JFBYM 滑块类型", "超级鹰滑块/坐标类型", "CapSolver", "2Captcha GeeTest/slider 类任务"],
"when_to_switch": ["缺口弱边缘", "背景乱序/透明块", "厂商行为评分导致视觉偏移正确但验证失败"],
"notes": "视觉偏移、DOM 坐标、拖动轨迹和厂商加密参数要分开分析。",
},
"click-select": {
"open_source_first": ["OCR + 模板匹配", "YOLO/目标检测", "VLM 返回坐标与顺序"],
"fallback_platforms": ["云码/JFBYM 坐标/点选类型", "超级鹰坐标类型", "2Captcha Coordinates/ClickCaptcha", "CapSolver ComplexImageTask"],
"when_to_switch": ["目标重叠", "题面语义复杂", "多轮点选或顺序评分导致本地模型不稳定"],
"notes": "必须同时输出题面、目标顺序、截图坐标和元素相对坐标。",
},
"rotate": {
"open_source_first": ["OpenCV 特征/主方向检测", "图像匹配估计角度", "VLM 辅助判断正向"],
"fallback_platforms": ["云码/JFBYM 旋转类型", "超级鹰旋转/坐标类型", "CapSolver/2Captcha 的图片任务"],
"when_to_switch": ["图片对称", "角度映射非线性", "固定偏移需要大量样本校准"],
"notes": "返回角度、拖动距离、轨道宽度和校准偏移。",
},
"grid": {
"open_source_first": ["图像分割 + 分类模型", "YOLO/CLIP/VLM 按题面分类", "每格置信度复核"],
"fallback_platforms": ["2Captcha Grid/reCAPTCHA", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "NopeCHA"],
"when_to_switch": ["reCAPTCHA/hCaptcha 多轮题", "目标跨格或低分辨率", "题面语义超出本地模型"],
"notes": "输出格子编号和中心坐标,预期多轮验证。",
},
"audio": {
"open_source_first": ["faster-whisper/Whisper 本地转写", "降噪和静音裁剪", "数字/字符白名单纠错"],
"fallback_platforms": ["2Captcha Audio", "CapMonster/Anti-Captcha 支持的音频或人工任务", "人工复核"],
"when_to_switch": ["强噪声", "口音/多语言", "音频一次性有效且本地 ASR 不稳定"],
"notes": "只处理网页音频验证码,不处理电话语音 OTP、短信、邮箱或 MFA。",
},
"drag-drop": {
"open_source_first": ["OpenCV/目标检测定位源对象和目标区", "VLM 判断拖放关系", "DOM 坐标和释放点校准"],
"fallback_platforms": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "CapSolver ComplexImageTask", "人工接管"],
"when_to_switch": ["目标区域不明确", "动画/吸附规则复杂", "拖放路径被行为评分"],
"notes": "和水平滑块不同,重点是源对象、目标区域和释放点。",
},
"trace-draw": {
"open_source_first": ["OpenCV 边缘/颜色分割", "骨架化提取路径", "点列重采样和轨迹平滑"],
"fallback_platforms": ["云码/JFBYM 轨迹类型", "超级鹰坐标/轨迹类", "人工标注"],
"when_to_switch": ["路径被遮挡", "轨迹采样格式未知", "需要大量样本校准速度/间隔"],
"notes": "输出点列格式、采样间隔、坐标原点和画布尺寸。",
},
"scratch": {
"open_source_first": ["canvas 区域识别", "规则化覆盖轨迹", "刮开前后状态差分"],
"fallback_platforms": ["人工接管", "云码/JFBYM 其他类型定制", "超级鹰人工/定制"],
"when_to_switch": ["通过阈值未知", "移动端触摸事件差异", "刮开后还有二次文字/图像题"],
"notes": "优先分析覆盖比例和状态变化;刮开后出现题面再转入对应子类型。",
},
"image-restore": {
"open_source_first": ["先判定 tile-scramble 切片乱序", "优先从 tileOrder/pieceOrder/CSS background-position/canvas drawImage 还原顺序", "再用 OpenCV/Pillow 边缘连续性和纹理匹配"],
"fallback_platforms": ["云码/JFBYM 滑块/拼图/其他类型", "超级鹰滑块/坐标类型", "CapSolver/2Captcha 图片任务"],
"when_to_switch": ["重复纹理", "随机裁剪", "页面顺序字段被加密或缺失", "视觉复原与提交参数强绑定"],
"notes": "区别于 slider:目标是复原完整图片或分块顺序;切片乱序时先找页面逻辑,再做图片边缘匹配。",
},
"area-select": {
"open_source_first": ["目标检测/图像分割", "SAM/YOLO + 题面解析", "VLM 返回框/多边形"],
"fallback_platforms": ["云码/JFBYM 坐标/区域类型", "超级鹰坐标类型", "CapSolver ComplexImageTask"],
"when_to_switch": ["边界模糊", "多目标区域", "平台要求多边形而不是单点"],
"notes": "输出矩形、多边形或中心点集合,并说明坐标原点。",
},
"difference-click": {
"open_source_first": ["图像配准 + 差分", "显著性检测", "VLM 复核差异点"],
"fallback_platforms": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "人工标注"],
"when_to_switch": ["压缩噪声强", "差异极细微", "差异点数量/顺序不明确"],
"notes": "先配准再差分,避免把平移缩放误判为差异。",
},
"font-identify": {
"open_source_first": ["OCR 识别内容 + 字形特征比对", "模板匹配", "VLM 判断字体关系"],
"fallback_platforms": ["云码/JFBYM 坐标/定制类型", "超级鹰人工/坐标类型", "人工标注"],
"when_to_switch": ["字体相近", "抗锯齿/缩放影响字形", "题面要求复杂排序"],
"notes": "不要只看文字内容,核心是字形/字体关系。",
},
"semantic-reasoning": {
"open_source_first": ["YOLO/目标检测 + 规则推理", "CLIP/VLM 语义理解", "候选目标置信度复核"],
"fallback_platforms": ["云码/JFBYM 坐标/定制类型", "超级鹰人工/坐标类型", "CapSolver ComplexImageTask"],
"when_to_switch": ["空间关系歧义", "目标遮挡", "VLM 置信度低或输出不稳定"],
"notes": "输出选择理由和坐标,题面歧义时让用户确认。",
},
"game-challenge": {
"open_source_first": ["截图/帧状态识别", "VLM 解析题面", "必要时拆成 rotate/grid/click-select 子类型"],
"fallback_platforms": ["2Captcha FunCaptcha/Arkose", "CapSolver FunCaptcha", "Anti-Captcha FunCaptcha", "NopeCHA Arkose"],
"when_to_switch": ["3D 状态连续变化", "题目频繁更新", "纯视觉模型无法稳定控制交互"],
"notes": "优先人工接管或授权 QA 模型辅助,不提供未授权自动通关脚本。",
},
"pow-challenge": {
"open_source_first": ["按官方协议实现 challenge 校验", "检查 difficulty/nonce/TTL/防重放", "ALTCHA/Cap.js/mCaptcha 自托管校验"],
"fallback_platforms": ["通常不需要打码平台", "2Captcha/Anti-Captcha 的 ALTCHA 或 PoW 支持仅作兼容备选"],
"when_to_switch": ["第三方组件接入失败", "payload/session 绑定异常", "服务端签名校验不通过"],
"notes": "这是计算挑战,不是图片识别;自有系统应优先修正集成。",
},
"risk-score": {
"open_source_first": ["官方服务端校验日志", "action/hostname/score 阈值诊断", "误伤样本回放"],
"fallback_platforms": ["2Captcha reCAPTCHA v3/Enterprise", "CapSolver", "CapMonster Cloud", "Anti-Captcha"],
"when_to_switch": ["授权测试需要对比人工/平台 token", "评分低但无法定位 action/session/环境原因"],
"notes": "不要把低分规避写成方案;优先做集成诊断和阈值评估。",
},
"one-click": {
"open_source_first": ["组件状态和 callback 诊断", "人工点击/按住复现", "失败后识别升级出的子类型"],
"fallback_platforms": ["2Captcha/CapSolver/CapMonster 对 Turnstile/reCAPTCHA/hCaptcha 的 token 任务", "人工接管"],
"when_to_switch": ["点击后升级二次挑战", "组件状态和服务端校验不同步", "行为评分不稳定"],
"notes": "普通表单 checkbox 不是验证码,必须有验证码/厂商证据。",
},
"multi-step": {
"open_source_first": ["逐轮截图和状态记录", "每轮按具体子类型选择工具", "轮次状态机复盘"],
"fallback_platforms": ["按子类型选择平台", "人工接管多轮流程"],
"when_to_switch": ["上一轮结果影响下一轮", "局部成功被误判为完成", "多轮题型混合"],
"notes": "它是流程标签,报告里应同时列每轮的具体类型和方案。",
},
"qa-logic": {
"open_source_first": ["文本解析/规则库", "本地 LLM/VLM 解析题面", "人工复核"],
"fallback_platforms": ["云码/JFBYM 文字/定制类型", "超级鹰人工类型", "2Captcha Normal Captcha"],
"when_to_switch": ["题库动态变化", "问题依赖上下文", "自然语言歧义"],
"notes": "简单算术应转为 math;安全问题/MFA 不进入验证码求解方案。",
},
"biometric-liveness": {
"open_source_first": ["官方 SDK 接入诊断", "设备权限/摄像头/动作提示检查", "人工复核和可访问性替代"],
"fallback_platforms": ["不建议使用打码平台", "仅在合规授权下联系厂商或人工审核服务"],
"when_to_switch": ["涉及隐私/合规", "误识别/设备兼容", "需要人工审核链路"],
"notes": "不提供绕过、伪造、替身或自动化活体方案。",
},
"token-widget": {
"open_source_first": ["官方测试 key/测试环境", "sitekey/action/callback/TTL 诊断", "服务端校验日志复核"],
"fallback_platforms": ["2Captcha", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "YesCaptcha/NoCaptchaAI"],
"when_to_switch": ["授权 QA 需要对照平台 token", "本地无法稳定复现人工验证", "只需验证集成链路而非训练模型"],
"notes": "记录 pageurl、sitekey、action、callback 和 token TTL;不要注入 token 到未授权流程。",
},
"waf-challenge": {
"open_source_first": ["真实浏览器取证", "服务端/WAF 日志", "TLS/HTTP/JS 环境一致性诊断"],
"fallback_platforms": ["CapSolver/CapMonster/2Captcha 对 AWS WAF、Turnstile、DataDome 等的授权测试支持", "厂商支持/人工复核"],
"when_to_switch": ["自有系统误伤真实用户", "challenge cookie/session 绑定异常", "环境指纹诊断无法定位"],
"notes": "这不是普通图片验证码,优先定位风控产品和环境问题。",
},
"unknown-custom": {
"open_source_first": ["收集 HTML/脚本/截图/接口名", "先归入最接近的可见类型", "建立小样本测试夹具"],
"fallback_platforms": ["云码/JFBYM 定制", "超级鹰人工/定制", "2Captcha/CapSolver 通用图片或坐标任务"],
"when_to_switch": ["证据不足", "自研 canvas 混淆", "现有分类无法稳定覆盖"],
"notes": "先补证据,不要只凭一张截图过拟合。",
},
}
BOUNDARY_PATTERNS = [
r"绕过登录|绕过验证码|自动请求通过|请求通过|批量注册|薅羊毛|撞库|代过验证|破解验证码",
r"\b(?:bypass|circumvent|mass signup|bulk signup|credential stuffing|account takeover)\b",
r"短信验证码|邮箱验证码|手机验证码|动态口令|\bMFA\b|\b2FA\b|一次性密码|\bOTP\b",
]
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 read_many(values: Iterable[str] | None) -> list[str]:
if not values:
return []
return [read_value(value) for value in values if value is not None]
def focus_html(value: str) -> str:
decoded = html_lib.unescape(value)
snippets: list[str] = []
for pattern in (
r"<title[^>]*>(.*?)</title>",
r"<meta[^>]+(?:name|property)=[\"'](?:description|keywords|og:title|og:description)[\"'][^>]+content=[\"']([^\"']+)",
r"<meta[^>]+content=[\"']([^\"']+)[\"'][^>]+(?:name|property)=[\"'](?:description|keywords|og:title|og:description)[\"']",
):
for match in re.finditer(pattern, decoded, flags=re.IGNORECASE | re.DOTALL):
snippets.append(re.sub(r"<[^>]+>", " ", match.group(1)))
for match in HTML_FOCUS_KEYWORDS.finditer(decoded):
start = max(0, match.start() - 260)
end = min(len(decoded), match.end() + 260)
snippets.append(decoded[start:end])
if len(snippets) >= 80:
break
focused = "\n".join(snippets) if snippets else decoded[:20000]
focused = re.sub(r"<svg\b.*?</svg>", " ", focused, flags=re.IGNORECASE | re.DOTALL)
focused = re.sub(r"\bd=[\"'][^\"']{80,}[\"']", " ", focused)
focused = re.sub(r"[A-Za-z0-9+/=_-]{120,}", " ", focused)
return focused
def build_sources(
html: Iterable[str] | None = None,
url: Iterable[str] | None = None,
text: Iterable[str] | None = None,
screenshot_meta: Iterable[str] | None = None,
) -> list[EvidenceSource]:
sources: list[EvidenceSource] = []
for kind, values in (
("url", url),
("text", text),
("screenshot_meta", screenshot_meta),
("html", html),
):
for value in values or []:
if not value:
continue
prepared = focus_html(value) if kind == "html" else value
sources.append(EvidenceSource(kind, prepared))
return sources
def compact(text: str, limit: int = 90) -> str:
cleaned = re.sub(r"\s+", " ", text.strip())
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1] + "…"
def is_noise_match(signal: Signal, evidence: str, context: str, source_kind: str) -> bool:
if signal.label == "命中算术表达式":
expr = re.sub(r"\s+", "", evidence)
dimension = re.fullmatch(r"(\d{2,3})[xX×](\d{2,3})", expr)
if dimension and max(int(dimension.group(1)), int(dimension.group(2))) >= 20:
return True
date_like = re.fullmatch(r"(\d{1,4})-(\d{1,2})(?:-\d{1,2})?", expr)
if date_like and not re.search(r"算术|请计算|计算(?:结果|答案)|math captcha|arithmetic", context, re.IGNORECASE):
return True
if source_kind == "html" and not re.search(
r"算术|请计算|计算(?:结果|答案)|math captcha|arithmetic|captcha",
context,
re.IGNORECASE,
):
return True
return False
def collect_matches(source: EvidenceSource, signals: list[Signal]) -> list[Match]:
matches: list[Match] = []
for signal in signals:
found = re.search(signal.pattern, source.text, flags=re.IGNORECASE | re.DOTALL)
if found:
start = max(0, found.start() - 90)
end = min(len(source.text), found.end() + 90)
context = source.text[start:end]
evidence = compact(found.group(0))
if is_noise_match(signal, evidence, context, source.kind):
continue
source_weight = SOURCE_WEIGHTS.get(source.kind, 1.0)
matches.append(Match(signal.label, signal.weight * source_weight, evidence, source.kind))
return matches
def score_matches(matches: list[Match]) -> float:
return sum(match.weight for match in matches)
def confidence_from_score(score: float, provider_score: float = 0.0) -> float:
if score <= 0:
return 0.35 if provider_score else 0.2
raw = 1 - math.exp(-(score + min(provider_score, 3) * 0.25) / 4.0)
return round(max(0.45, min(0.98, raw)), 2)
def classify_provider(sources: list[EvidenceSource]) -> tuple[str, float, dict[str, list[dict[str, Any]]]]:
provider_matches: dict[str, list[dict[str, Any]]] = {}
scored: list[tuple[str, float]] = []
for provider, signals in PROVIDER_SIGNALS.items():
matches: list[Match] = []
for source in sources:
matches.extend(collect_matches(source, signals))
if matches:
score = score_matches(matches)
scored.append((provider, score))
provider_matches[provider] = [
{"label": match.label, "evidence": match.evidence, "weight": match.weight, "source": match.source}
for match in matches
]
if not scored:
return "custom-or-unknown", 0.0, provider_matches
scored.sort(key=lambda item: item[1], reverse=True)
return scored[0][0], scored[0][1], provider_matches
def classify_type(sources: list[EvidenceSource], provider: str, provider_score: float) -> tuple[str, float, dict[str, list[dict[str, Any]]]]:
type_matches: dict[str, list[dict[str, Any]]] = {}
scored: list[tuple[str, float]] = []
for captcha_type, signals in TYPE_SIGNALS.items():
matches: list[Match] = []
for source in sources:
matches.extend(collect_matches(source, signals))
if matches:
score = score_matches(matches)
scored.append((captcha_type, score))
type_matches[captcha_type] = [
{"label": match.label, "evidence": match.evidence, "weight": match.weight, "source": match.source}
for match in matches
]
provider_type_boosts: dict[str, dict[str, float]] = {
"recaptcha": {"token-widget": 2.0, "risk-score": 0.8, "one-click": 0.4, "grid": 0.2, "audio": 0.2},
"hcaptcha": {"token-widget": 2.0, "grid": 0.4, "audio": 0.2},
"cloudflare-turnstile": {"token-widget": 2.2, "risk-score": 0.8, "one-click": 0.4},
"cloudflare-waf": {"waf-challenge": 3.0},
"aws-waf": {"waf-challenge": 3.0},
"datadome": {"waf-challenge": 3.0},
"arkose-funcaptcha": {"game-challenge": 2.5, "token-widget": 1.4},
"mtcaptcha": {"token-widget": 2.5},
"keycaptcha": {"token-widget": 2.0},
"friendlycaptcha": {"pow-challenge": 3.0, "token-widget": 0.8},
"altcha": {"pow-challenge": 3.0, "token-widget": 0.8},
"yandex-smartcaptcha": {"token-widget": 3.0},
"captchafox": {"token-widget": 3.0},
"prosopo-procaptcha": {"token-widget": 3.0},
"trustcaptcha": {"token-widget": 3.0},
"private-captcha": {"pow-challenge": 3.0, "token-widget": 0.8},
"capjs": {"pow-challenge": 3.0, "token-widget": 0.8},
"mcaptcha": {"pow-challenge": 3.0, "token-widget": 0.8},
"iconcaptcha": {"click-select": 2.5},
"botdetect": {"text": 2.5, "audio": 0.4},
"securimage": {"text": 2.5, "audio": 0.4},
"visualcaptcha": {"click-select": 2.0, "audio": 0.4},
"amazon-captcha": {"text": 2.0},
"cybersiara": {"waf-challenge": 1.5, "slider": 0.6},
"aj-captcha": {"slider": 1.2, "click-select": 0.8},
"tianai-captcha": {"slider": 1.0, "rotate": 0.6, "click-select": 0.6},
"easycaptcha": {"text": 2.0},
"happycaptcha": {"text": 2.0},
"kaptcha": {"text": 2.0},
"geetest": {"slider": 1.0, "risk-score": 0.2},
"tencent-tcaptcha": {"slider": 0.8, "one-click": 0.5, "audio": 0.4, "risk-score": 0.4},
"netease-yidun": {"slider": 0.4, "click-select": 0.4, "audio": 0.2, "risk-score": 0.2},
"aliyun-captcha": {"slider": 0.7, "image-restore": 0.6, "one-click": 0.5, "risk-score": 0.5},
"shumei-captcha": {"slider": 0.7, "click-select": 0.3, "semantic-reasoning": 0.2, "risk-score": 0.2},
"dingxiang-captcha": {
"slider": 0.6,
"click-select": 0.5,
"rotate": 0.4,
"scratch": 0.3,
"area-select": 0.3,
"difference-click": 0.3,
"font-identify": 0.3,
"image-restore": 0.3,
"semantic-reasoning": 0.3,
"audio": 0.2,
"risk-score": 0.3,
},
"baidu-captcha": {"slider": 0.6, "click-select": 0.5, "trace-draw": 0.5, "text": 0.3},
"jdcloud-captcha": {"slider": 1.4},
"yunpian-captcha": {"slider": 0.7, "click-select": 0.4},
"huawei-captcha": {"text": 1.2},
"tongdun-risk": {"waf-challenge": 2.0},
"akamai-bot-manager": {"waf-challenge": 3.0},
"imperva-incapsula": {"waf-challenge": 3.0},
"perimeterx-human": {"waf-challenge": 3.0},
"kasada": {"waf-challenge": 3.0},
"netacea": {"waf-challenge": 3.0},
"radware-bot-manager": {"waf-challenge": 3.0},
"f5-bot-defense": {"waf-challenge": 3.0},
}
for boosted_type, boost in provider_type_boosts.get(provider, {}).items():
if provider_score:
scored.append((boosted_type, boost))
type_matches.setdefault(boosted_type, []).append(
{"label": f"厂商 {provider} 的默认类型倾向", "evidence": provider, "weight": boost, "source": "provider"}
)
if not scored:
return "unknown-custom", 0.0, type_matches
totals: dict[str, float] = {}
for captcha_type, score in scored:
totals[captcha_type] = totals.get(captcha_type, 0.0) + score
# 更具体的可见挑战形态应优先于通用组件信号。
specificity_order = {
"biometric-liveness": 16,
"game-challenge": 15,
"difference-click": 14,
"semantic-reasoning": 13,
"area-select": 12,
"font-identify": 12,
"image-restore": 11,
"scratch": 10,
"trace-draw": 10,
"drag-drop": 10,
"audio": 9,
"waf-challenge": 8,
"grid": 7,
"click-select": 6,
"slider": 5,
"rotate": 5,
"pow-challenge": 5,
"risk-score": 4,
"one-click": 4,
"math": 4,
"multi-step": 3,
"qa-logic": 3,
"token-widget": 2,
"text": 1,
}
best_type, best_score = sorted(
totals.items(), key=lambda item: (item[1], specificity_order.get(item[0], 0)), reverse=True
)[0]
if best_type == "text":
# 通用 captcha 文案不应覆盖强厂商组件信号。
if provider in {
"recaptcha",
"hcaptcha",
"cloudflare-turnstile",
"arkose-funcaptcha",
"mtcaptcha",
"keycaptcha",
"friendlycaptcha",
"yandex-smartcaptcha",
"captchafox",
"prosopo-procaptcha",
"trustcaptcha",
"private-captcha",
"capjs",
"mcaptcha",
}:
widget_score = totals.get("token-widget", 0)
if widget_score >= best_score:
return "token-widget", widget_score, type_matches
if provider in {
"arkose-funcaptcha",
"mtcaptcha",
"keycaptcha",
"friendlycaptcha",
"yandex-smartcaptcha",
"captchafox",
"prosopo-procaptcha",
"trustcaptcha",
"private-captcha",
"capjs",
"mcaptcha",
}:
widget_score = totals.get("token-widget", 0)
if widget_score and widget_score >= best_score - 1.0:
return "token-widget", widget_score, type_matches
return best_type, best_score, type_matches
def classify_tile_scramble_variant(sources: list[EvidenceSource], captcha_type: str) -> dict[str, Any]:
if captcha_type != "image-restore":
return {}
matches: list[Match] = []
for source in sources:
matches.extend(collect_matches(source, TILE_SCRAMBLE_SIGNALS))
if not matches:
return {}
score = score_matches(matches)
labels = {match.label for match in matches}
if any(label in labels for label in ("命中切片顺序字段", "命中 CSS sprite/背景定位切片", "命中 Canvas drawImage 切片绘制")):
strategy = "page-order"
elif score >= 4:
strategy = "image-edge-match"
else:
strategy = "manual-or-platform"
return {
"captcha_variant": "tile-scramble",
"variant_confidence": confidence_from_score(score),
"restore_strategy": strategy,
"tile_restore_evidence": [
{
"label": match.label,
"evidence": match.evidence,
"weight": match.weight,
"source": match.source,
}
for match in matches
],
}
def boundary_flags(text: str) -> list[str]:
flags: list[str] = []
for pattern in BOUNDARY_PATTERNS:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
flags.append(compact(match.group(0), 60))
return flags
def classify_sources(
html: Iterable[str] | None = None,
url: Iterable[str] | None = None,
text: Iterable[str] | None = None,
screenshot_meta: Iterable[str] | None = None,
) -> dict[str, Any]:
sources = build_sources(html=html, url=url, text=text, screenshot_meta=screenshot_meta)
combined = "\n".join(f"[{source.kind}] {source.text}" for source in sources)
provider, provider_score, provider_matches = classify_provider(sources)
captcha_type, type_score, type_matches = classify_type(sources, provider, provider_score)
confidence = confidence_from_score(type_score, provider_score)
provider_confidence = confidence_from_score(provider_score) if provider_score else 0.2
boundary = boundary_flags(combined)
tile_variant = classify_tile_scramble_variant(sources, captcha_type)
flat_signals: list[dict[str, Any]] = []
for matched_provider, matches in provider_matches.items():
for match in matches:
flat_signals.append({"category": "provider", "name": matched_provider, **match})
for matched_type, matches in type_matches.items():
for match in matches:
flat_signals.append({"category": "type", "name": matched_type, **match})
if not combined.strip():
captcha_type = "unknown-custom"
provider = "custom-or-unknown"
confidence = 0.0
provider_confidence = 0.0
result = {
"captcha_type": captcha_type,
"provider": provider,
"confidence": confidence,
"provider_confidence": provider_confidence,
"signals": flat_signals,
"recommended_playbook": PLAYBOOKS.get(captcha_type, PLAYBOOKS["unknown-custom"]),
"solution_options": SOLUTION_OPTIONS.get(captcha_type, SOLUTION_OPTIONS["unknown-custom"]),
"verification_flow": {
"enabled_after_user_confirmation": True,
"requires_authorization_scope": True,
"live_browser_requires_confirmation": True,
"default_mode": "offline-first",
"attempt_review": {
"script": "scripts/evaluate_verification_attempts.py",
"failure_threshold": 5,
"switch_route": "platform-control",
"platform_role": "授权 QA 对照",
"send_request": False,
},
"success_baseline": {
"script": "scripts/evaluate_success_baseline.py",
"min_total_success_samples": 5,
"min_success_samples_per_type": 2,
"required_before_live_verification": "strongly_recommended",
"insufficient_policy": "强提示但用户确认后可继续离线分析或受控验证",
},
**VERIFICATION_FLOW_BY_TYPE.get(captcha_type, VERIFICATION_FLOW_BY_TYPE["unknown-custom"]),
},
"next_evidence": NEXT_EVIDENCE.get(captcha_type, NEXT_EVIDENCE["unknown-custom"]),
"safety_boundary": {
"triggered": bool(boundary),
"matched_terms": boundary,
"guidance": (
"请将工作限定在类型识别、厂商判断和授权验证分析。不要自动化登录绕过、"
"批量注册、token 注入,也不要处理短信/邮箱/MFA 验证码。"
)
if boundary
else "",
},
}
result.update(tile_variant)
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="离线网页验证码/验证类型分类器")
parser.add_argument("--html", action="append", help="HTML 片段或 HTML 文件路径")
parser.add_argument("--url", action="append", help="页面/脚本/iframe URL 证据")
parser.add_argument("--text", action="append", help="页面可见挑战文案或分析备注")
parser.add_argument("--screenshot-meta", action="append", help="截图元信息或 OCR 备注")
parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON")
parser.add_argument("--compact", action="store_true", help="以紧凑格式输出 JSON")
parser.add_argument("--json", action="store_true", help="兼容参数;脚本始终输出 JSON")
return parser
def main() -> int:
args = build_parser().parse_args()
result = classify_sources(
html=read_many(args.html),
url=read_many(args.url),
text=read_many(args.text),
screenshot_meta=read_many(args.screenshot_meta),
)
indent = None if args.compact else 2
if args.pretty:
indent = 2
print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())