feat(tcaptcha): 完成腾讯天御点选纯协议全自动化客户端与雨云业务适配器
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
腾讯天御点选验证码纯协议端到端实战压测基准脚本
|
||||
连续发起 N 轮真实线上验证请求,统计:
|
||||
1. 首检通过率 (Success Rate)
|
||||
2. 端到端耗时分布 (Min / Mean / Max Latency)
|
||||
3. 异常与失败分类
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tcaptcha.client import TencentCaptchaClient
|
||||
from tcaptcha.solver import CaptchaSolver
|
||||
|
||||
|
||||
def run_benchmark(rounds: int = 10, aid: str = "2039519451", interval: float = 3.0):
|
||||
print("=" * 65)
|
||||
print(f" 🚀 开始腾讯天御验证码纯协议线上压力基准测试 (AppID: {aid})")
|
||||
print(f" 测试轮数: {rounds}, 请求间隔: {interval}s")
|
||||
print("=" * 65)
|
||||
|
||||
# 预加载模型,避免把模型初始化耗时算入单次请求
|
||||
print("[*] 正在预加载 YOLO 目标检测器与 ResNet18 分类器模型...")
|
||||
solver = CaptchaSolver()
|
||||
client = TencentCaptchaClient(aid=aid, solver=solver)
|
||||
print("[*] 模型就绪,开始线上请求...\n")
|
||||
|
||||
results: List[dict] = []
|
||||
latencies: List[float] = []
|
||||
successes = 0
|
||||
|
||||
for i in range(1, rounds + 1):
|
||||
print(f"--- [Round {i}/{rounds}] ---")
|
||||
res = client.solve()
|
||||
results.append(res)
|
||||
latencies.append(res["elapsed_ms"])
|
||||
|
||||
if res["success"]:
|
||||
successes += 1
|
||||
print(f" ✅ 成功! 耗时: {res['elapsed_ms']}ms | randstr: {res['randstr']} | ticket: {res['ticket'][:24]}...")
|
||||
else:
|
||||
print(f" ❌ 失败! 耗时: {res['elapsed_ms']}ms | 原因: {res['error']}")
|
||||
|
||||
# 间隔停顿模拟真实请求,避免瞬间高频触发频控
|
||||
if i < rounds and interval > 0:
|
||||
time.sleep(interval)
|
||||
|
||||
# 汇总报表
|
||||
success_rate = (successes / rounds) * 100
|
||||
avg_latency = sum(latencies) / len(latencies) if latencies else 0
|
||||
min_latency = min(latencies) if latencies else 0
|
||||
max_latency = max(latencies) if latencies else 0
|
||||
|
||||
print("\n" + "=" * 65)
|
||||
print(" 📊 纯协议实战基准测试统计汇总")
|
||||
print("=" * 65)
|
||||
print(f"总测试轮数: {rounds}")
|
||||
print(f"成功次数: {successes}")
|
||||
print(f"失败次数: {rounds - successes}")
|
||||
print(f"综合首检通过率: {success_rate:.2f}%")
|
||||
print(f"平均单次耗时: {avg_latency:.1f} ms")
|
||||
print(f"最短单次耗时: {min_latency} ms")
|
||||
print(f"最长单次耗时: {max_latency} ms")
|
||||
print("=" * 65)
|
||||
|
||||
return success_rate, avg_latency
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Tencent Captcha Benchmark Tool")
|
||||
parser.add_argument("--rounds", type=int, default=10, help="Number of test rounds (default: 10)")
|
||||
parser.add_argument("--interval", type=float, default=3.0, help="Interval between rounds in seconds (default: 3.0)")
|
||||
parser.add_argument("--aid", type=str, default="2039519451", help="AppID (default: 2039519451)")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_benchmark(rounds=args.rounds, aid=args.aid, interval=args.interval)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
腾讯天御点选验证码工业级纯协议客户端 (Tencent Turing Captcha Protocol Client)
|
||||
实现端到端全自动纯协议闭环:
|
||||
1. GET /cap_union_prehandle 获取初始会话与 PoW 题目
|
||||
2. 毫秒级求解 Proof of Work 哈希碰撞
|
||||
3. 并发拉取动态混淆的 tdc.js 脚本
|
||||
4. POST /cap_union_new_getsig 获取当前轮次题目与图片
|
||||
5. YOLOv8n + ResNet18 模型进行视觉检测、分类与全局非冲突点选排序
|
||||
6. Node.js 沙箱环境运行 tdc.js 并拟合生成拟人化贝塞尔轨迹密文 collect 与 eks
|
||||
7. POST /cap_union_new_verify 提交验证,获取最终 ticket 与 randstr
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import httpx
|
||||
|
||||
from tcaptcha.solver import CaptchaSolver
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("tcaptcha_client")
|
||||
|
||||
|
||||
class TencentCaptchaClient:
|
||||
BASE_URL = "https://turing.captcha.qcloud.com"
|
||||
DEFAULT_AID = "2039519451"
|
||||
WORKER_SCRIPT = PROJECT_ROOT / "tcaptcha/tdc_worker.js"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
aid: str = DEFAULT_AID,
|
||||
solver: Optional[CaptchaSolver] = None,
|
||||
timeout: float = 15.0,
|
||||
user_agent: Optional[str] = None,
|
||||
):
|
||||
self.aid = aid
|
||||
self.timeout = timeout
|
||||
self.user_agent = user_agent or (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
|
||||
)
|
||||
self.solver = solver or CaptchaSolver()
|
||||
|
||||
self.http_client = httpx.Client(
|
||||
base_url=self.BASE_URL,
|
||||
trust_env=False, # 屏蔽环境代理干扰
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Referer": "https://turing.captcha.qcloud.com/",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
},
|
||||
timeout=self.timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]:
|
||||
"""求解 Proof of Work 哈希碰撞"""
|
||||
t0 = time.time()
|
||||
for i in range(2000000):
|
||||
candidate = f"{prefix}{i}"
|
||||
if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5:
|
||||
calc_time = max(1, round((time.time() - t0) * 1000))
|
||||
return candidate, calc_time
|
||||
raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}")
|
||||
|
||||
def _generate_telemetry(self, tdc_code: str, click_points: List[Tuple[int, int]]) -> Dict[str, str]:
|
||||
"""通过 Node.js 沙箱运行 tdc.js 生成真实行为轨迹遥测数据"""
|
||||
points_arg = json.dumps([[int(p[0]), int(p[1])] for p in click_points])
|
||||
proc = subprocess.run(
|
||||
["node", str(self.WORKER_SCRIPT), "-", points_arg],
|
||||
input=tdc_code,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
data = json.loads(proc.stdout.strip())
|
||||
return {
|
||||
"collect": data.get("collect", ""),
|
||||
"tlg": str(data.get("tlg", 0)),
|
||||
"eks": data.get("eks", ""),
|
||||
}
|
||||
|
||||
def solve(self, max_retries: int = 2) -> Dict:
|
||||
"""
|
||||
端到端全自动解题并提交验证
|
||||
返回格式:
|
||||
{
|
||||
"success": bool,
|
||||
"ticket": str,
|
||||
"randstr": str,
|
||||
"elapsed_ms": int,
|
||||
"points": [(x1, y1), (x2, y2), (x3, y3)],
|
||||
"raw_response": dict,
|
||||
"error": str
|
||||
}
|
||||
"""
|
||||
start_time = time.time()
|
||||
active_sess = None
|
||||
tkid = None
|
||||
tdc_code = None
|
||||
pow_answer = None
|
||||
pow_calc_time = None
|
||||
last_raw = {}
|
||||
last_error = "Max retries exceeded or verification rejected"
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# 1. 预检请求 (Prehandle) 或在会话内刷新
|
||||
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
||||
if not tkid:
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
|
||||
if not active_sess:
|
||||
# 首次进入:执行 Prehandle
|
||||
ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2"
|
||||
params = {
|
||||
"aid": self.aid,
|
||||
"protocol": "https",
|
||||
"accver": "1",
|
||||
"showtype": "popup",
|
||||
"ua": ua_b64,
|
||||
"noheader": "1",
|
||||
"fb": "1",
|
||||
"aged": "0",
|
||||
"enableAged": "0",
|
||||
"enableDarkMode": "0",
|
||||
"grayscale": "1",
|
||||
"clientype": "2",
|
||||
"lang": "zh-cn",
|
||||
"entry_url": "https://app.rainyun.com/account/reward/earn#",
|
||||
"elder_captcha": "0",
|
||||
"js": "/tcaptcha-frame.ba387dd1.js",
|
||||
"wb": "2",
|
||||
"tkid": tkid,
|
||||
"subsid": str(random.randint(1, 5)),
|
||||
"callback": cb_name,
|
||||
}
|
||||
|
||||
pre_resp = self.http_client.get(
|
||||
"/cap_union_prehandle",
|
||||
params=params,
|
||||
headers={"Referer": "https://app.rainyun.com/"},
|
||||
)
|
||||
match = re.search(rf"{cb_name}\((.*)\)", pre_resp.text)
|
||||
if not match:
|
||||
logger.warning("Failed to parse prehandle callback, retrying...")
|
||||
continue
|
||||
|
||||
pre_data = json.loads(match.group(1))
|
||||
initial_sess = pre_data.get("sess")
|
||||
comm_cfg = pre_data.get("data", {}).get("comm_captcha_cfg", {})
|
||||
|
||||
# 2. 计算 PoW
|
||||
pow_cfg = comm_cfg.get("pow_cfg", {})
|
||||
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], pow_cfg["md5"])
|
||||
|
||||
# 3. 拉取当前会话专属动态 tdc.js
|
||||
tdc_path = comm_cfg.get("tdc_path", "")
|
||||
tdc_resp = self.http_client.get(
|
||||
tdc_path,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
)
|
||||
tdc_code = tdc_resp.text
|
||||
current_sess = initial_sess
|
||||
else:
|
||||
# 会话内刷新:直接使用前一轮返回的 sess
|
||||
current_sess = active_sess
|
||||
|
||||
# 4. 获取题目信息与图片 URL (POST /cap_union_new_getsig)
|
||||
sig_resp = self.http_client.post(
|
||||
"/cap_union_new_getsig",
|
||||
data={"sess": current_sess, "tkid": tkid},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/",
|
||||
},
|
||||
)
|
||||
sig_json = sig_resp.json()
|
||||
if str(sig_json.get("ret")) != "0":
|
||||
logger.warning(f"getsig returned non-zero ret: {sig_json}")
|
||||
active_sess = None
|
||||
continue
|
||||
|
||||
dyn_info = sig_json.get("data", {})
|
||||
bg_url = dyn_info.get("bg_elem_cfg", {}).get("img_url", "")
|
||||
prompt_url = bg_url.replace("img_index=1", "img_index=0")
|
||||
|
||||
# 提取图片 URL 中的最新有效 sess
|
||||
sess_match = re.search(r"sess=([^&]+)", bg_url)
|
||||
active_sess = sess_match.group(1) if sess_match else current_sess
|
||||
|
||||
# 5. 下载背景大图与提示图标图
|
||||
bg_bytes = self.http_client.get(
|
||||
bg_url,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
).content
|
||||
prompt_bytes = self.http_client.get(
|
||||
prompt_url,
|
||||
headers={"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
).content
|
||||
|
||||
# 6. AI 模型高精度求解
|
||||
pts = self.solver.solve(bg_bytes, prompt_bytes)
|
||||
if len(pts) < 3:
|
||||
logger.warning("Solver failed to find 3 valid points, retrying...")
|
||||
continue
|
||||
|
||||
# 7. 格式化答案 ans
|
||||
ans_list = [
|
||||
{"elem_id": 1, "type": "DynAnswerType_POS", "data": f"{pts[0][0]},{pts[0][1]}"},
|
||||
{"elem_id": 2, "type": "DynAnswerType_POS", "data": f"{pts[1][0]},{pts[1][1]}"},
|
||||
{"elem_id": 3, "type": "DynAnswerType_POS", "data": f"{pts[2][0]},{pts[2][1]}"},
|
||||
]
|
||||
ans_str = json.dumps(ans_list, separators=(",", ":"))
|
||||
|
||||
# 8. Node 沙箱环境生成真实轨迹 collect 与 eks
|
||||
telemetry = self._generate_telemetry(tdc_code, pts)
|
||||
|
||||
# 9. 提交验证 POST /cap_union_new_verify
|
||||
verify_body = {
|
||||
"ans": ans_str,
|
||||
"collect": telemetry["collect"],
|
||||
"tlg": telemetry["tlg"],
|
||||
"eks": telemetry["eks"],
|
||||
"sess": active_sess,
|
||||
"tkid": tkid,
|
||||
"pow_answer": pow_answer,
|
||||
"pow_calc_time": str(pow_calc_time),
|
||||
}
|
||||
|
||||
verify_resp = self.http_client.post(
|
||||
"/cap_union_new_verify",
|
||||
data=verify_body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/",
|
||||
},
|
||||
)
|
||||
|
||||
v_json = verify_resp.json()
|
||||
total_elapsed = int((time.time() - start_time) * 1000)
|
||||
|
||||
if str(v_json.get("errorCode")) == "0":
|
||||
return {
|
||||
"success": True,
|
||||
"ticket": v_json.get("ticket", ""),
|
||||
"randstr": v_json.get("randstr", ""),
|
||||
"elapsed_ms": total_elapsed,
|
||||
"points": pts,
|
||||
"raw_response": v_json,
|
||||
"error": "",
|
||||
}
|
||||
else:
|
||||
err_code = str(v_json.get("errorCode"))
|
||||
err_msg = v_json.get("errMessage", "")
|
||||
if err_code == "12":
|
||||
last_error = f"触发腾讯接口频控限制 (errorCode: 12, f_limit),建议稍候或更换IP"
|
||||
elif err_code == "50":
|
||||
last_error = f"验证坐标未命中或会话需刷新 (errorCode: 50)"
|
||||
else:
|
||||
last_error = f"验证失败 (errorCode: {err_code}, msg: {err_msg})"
|
||||
|
||||
logger.warning(f"Attempt {attempt + 1} verify: {last_error}")
|
||||
last_raw = v_json
|
||||
# 如果接口返回了新的会话 sess,下一轮直接沿用该会话重试刷新
|
||||
new_sess = v_json.get("sess")
|
||||
if new_sess:
|
||||
active_sess = new_sess
|
||||
else:
|
||||
active_sess = None
|
||||
except Exception as e:
|
||||
last_error = f"请求异常: {e}"
|
||||
logger.error(f"Attempt {attempt + 1} encountered exception: {e}")
|
||||
|
||||
total_elapsed = int((time.time() - start_time) * 1000)
|
||||
return {
|
||||
"success": False,
|
||||
"ticket": "",
|
||||
"randstr": "",
|
||||
"elapsed_ms": total_elapsed,
|
||||
"points": [],
|
||||
"raw_response": last_raw,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client")
|
||||
parser.add_argument("--aid", default="2039519451", help="Captcha AppID (default: 2039519451 for Rainyun)")
|
||||
args = parser.parse_args()
|
||||
|
||||
client = TencentCaptchaClient(aid=args.aid)
|
||||
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid})...")
|
||||
result = client.solve()
|
||||
|
||||
if result["success"]:
|
||||
print("\n" + "=" * 50)
|
||||
print(" 🎉 验证码纯协议求解成功!")
|
||||
print("=" * 50)
|
||||
print(f"耗时: {result['elapsed_ms']} ms")
|
||||
print(f"点击坐标: {result['points']}")
|
||||
print(f"randstr: {result['randstr']}")
|
||||
print(f"ticket: {result['ticket']}")
|
||||
print("=" * 50)
|
||||
else:
|
||||
print(f"\n❌ 验证失败: {result['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
雨云 (Rainyun) 业务接口适配器 (Rainyun Captcha Adapter)
|
||||
将腾讯天御点选验证码纯协议客户端无缝注入雨云核心业务流程:
|
||||
1. 任务签到与积分领取: POST /user/reward/tasks (带 vticket 与 vrandstr)
|
||||
2. 账号密码登录: POST /user/login (带 vticket 与 vrandstr)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from tcaptcha.client import TencentCaptchaClient
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("rainyun_adapter")
|
||||
|
||||
|
||||
class RainyunAdapter:
|
||||
API_BASE = "https://api.v2.rainyun.com"
|
||||
RAINYUN_AID = "2039519451"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
cookie: Optional[str] = None,
|
||||
timeout: float = 15.0,
|
||||
):
|
||||
self.api_key = api_key
|
||||
self.cookie = cookie
|
||||
self.timeout = timeout
|
||||
self.captcha_client = TencentCaptchaClient(aid=self.RAINYUN_AID)
|
||||
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://app.rainyun.com/",
|
||||
"Origin": "https://app.rainyun.com",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
if self.api_key:
|
||||
headers["X-Api-Key"] = self.api_key
|
||||
if self.cookie:
|
||||
headers["Cookie"] = self.cookie
|
||||
|
||||
self.http_client = httpx.Client(
|
||||
base_url=self.API_BASE,
|
||||
trust_env=False,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
def get_captcha_ticket(self) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""调用纯协议验证码求解管线,获取有效 ticket 与 randstr"""
|
||||
logger.info("正在调用腾讯天御验证码求解客户端...")
|
||||
res = self.captcha_client.solve()
|
||||
if res["success"]:
|
||||
logger.info(f"验证码求解成功 (耗时: {res['elapsed_ms']}ms, randstr: {res['randstr']})")
|
||||
return res["ticket"], res["randstr"]
|
||||
else:
|
||||
logger.error(f"验证码求解失败: {res['error']}")
|
||||
return None, None
|
||||
|
||||
def claim_reward_task(self, task_name: str = "加入用户群") -> Dict:
|
||||
"""
|
||||
带验证码提交雨云积分任务
|
||||
接口: POST https://api.v2.rainyun.com/user/reward/tasks
|
||||
"""
|
||||
ticket, randstr = self.get_captcha_ticket()
|
||||
if not ticket or not randstr:
|
||||
return {"code": -1, "message": "验证码获取失败"}
|
||||
|
||||
payload = {
|
||||
"task_name": task_name,
|
||||
"verifyCode": "",
|
||||
"vticket": ticket,
|
||||
"vrandstr": randstr,
|
||||
}
|
||||
|
||||
logger.info(f"正在提交雨云任务: {task_name}...")
|
||||
resp = self.http_client.post("/user/reward/tasks", json=payload)
|
||||
try:
|
||||
res_json = resp.json()
|
||||
logger.info(f"雨云任务接口返回: {res_json}")
|
||||
return res_json
|
||||
except Exception as e:
|
||||
logger.error(f"雨云任务接口解析失败: {e}")
|
||||
return {"code": resp.status_code, "text": resp.text}
|
||||
|
||||
def login(self, field: str, password: str) -> Dict:
|
||||
"""
|
||||
雨云用户登录 (自动注入验证码凭证)
|
||||
接口: POST https://api.v2.rainyun.com/user/login
|
||||
"""
|
||||
ticket, randstr = self.get_captcha_ticket()
|
||||
if not ticket or not randstr:
|
||||
return {"code": -1, "message": "验证码获取失败"}
|
||||
|
||||
payload = {
|
||||
"field": field,
|
||||
"password": password,
|
||||
"verifyCode": "",
|
||||
"vticket": ticket,
|
||||
"vrandstr": randstr,
|
||||
}
|
||||
|
||||
logger.info(f"正在发起雨云登录: {field}...")
|
||||
resp = self.http_client.post("/user/login", json=payload)
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
return {"code": resp.status_code, "text": resp.text}
|
||||
+15
-3
@@ -9,12 +9,19 @@ import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]:
|
||||
import io
|
||||
|
||||
def slice_prompt_image(prompt_img: str | Path | bytes | Image.Image, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]:
|
||||
"""
|
||||
自动识别并切割提示图中的 3 个字符/图标
|
||||
返回: [(PIL_Image, (x1, y1, x2, y2)), ...]
|
||||
"""
|
||||
img = Image.open(prompt_img_path).convert("L")
|
||||
if isinstance(prompt_img, Image.Image):
|
||||
img = prompt_img.convert("L")
|
||||
elif isinstance(prompt_img, bytes):
|
||||
img = Image.open(io.BytesIO(prompt_img)).convert("L")
|
||||
else:
|
||||
img = Image.open(prompt_img).convert("L")
|
||||
arr = np.array(img)
|
||||
h, w = arr.shape
|
||||
|
||||
@@ -48,7 +55,12 @@ def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tu
|
||||
segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)]
|
||||
|
||||
# 原始彩色图用于切片
|
||||
orig_img = Image.open(prompt_img_path).convert("RGBA")
|
||||
if isinstance(prompt_img, Image.Image):
|
||||
orig_img = prompt_img.convert("RGBA")
|
||||
elif isinstance(prompt_img, bytes):
|
||||
orig_img = Image.open(io.BytesIO(prompt_img)).convert("RGBA")
|
||||
else:
|
||||
orig_img = Image.open(prompt_img).convert("RGBA")
|
||||
crops = []
|
||||
|
||||
for seg_start, seg_end in segments[:3]:
|
||||
|
||||
+17
-12
@@ -7,11 +7,12 @@
|
||||
4. 全局匹配: 全局最大后验概率分配 -> 输出毫秒级精准有序点击坐标 [(x1, y1), (x2, y2), (x3, y3)]
|
||||
"""
|
||||
|
||||
import io
|
||||
import itertools
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
from typing import List, Tuple, Union
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
@@ -75,11 +76,11 @@ class CaptchaSolver:
|
||||
bin_t = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
|
||||
self.templates.append((cid, bin_t))
|
||||
|
||||
def parse_prompt_targets(self, prompt_img_path: Path) -> List[int]:
|
||||
def parse_prompt_targets(self, prompt_img: str | Path | bytes | Image.Image) -> List[int]:
|
||||
"""
|
||||
自动识别并解析提示图中 3 个目标图标的真实类别 ID (0~11)
|
||||
"""
|
||||
crops = slice_prompt_image(prompt_img_path)
|
||||
crops = slice_prompt_image(prompt_img)
|
||||
target_classes = []
|
||||
for crop_img, _ in crops[:3]:
|
||||
arr = np.array(crop_img.convert("L"))
|
||||
@@ -92,12 +93,19 @@ class CaptchaSolver:
|
||||
|
||||
return target_classes
|
||||
|
||||
def _extract_candidates(self, bg_img_path: Path) -> List[Tuple[int, int, Image.Image]]:
|
||||
def _extract_candidates(self, bg_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int, Image.Image]]:
|
||||
"""从背景图中提取所有去重后的候选图标 (cx, cy, patch)"""
|
||||
results = self.detector(str(bg_img_path), conf=0.03, verbose=False)
|
||||
if isinstance(bg_img, Image.Image):
|
||||
bg_pil = bg_img.convert("RGB")
|
||||
elif isinstance(bg_img, bytes):
|
||||
bg_pil = Image.open(io.BytesIO(bg_img)).convert("RGB")
|
||||
else:
|
||||
bg_pil = Image.open(bg_img).convert("RGB")
|
||||
|
||||
# Ultralytics detector natively accepts PIL.Image
|
||||
results = self.detector(bg_pil, conf=0.03, verbose=False)
|
||||
boxes = results[0].boxes.xyxy.cpu().numpy()
|
||||
|
||||
bg_pil = Image.open(bg_img_path).convert("RGB")
|
||||
w_img, h_img = bg_pil.size
|
||||
raw_cands = []
|
||||
|
||||
@@ -120,22 +128,19 @@ class CaptchaSolver:
|
||||
|
||||
return candidates
|
||||
|
||||
def solve(self, bg_img_path: str | Path, prompt_img_path: str | Path) -> List[Tuple[int, int]]:
|
||||
def solve(self, bg_img: str | Path | bytes | Image.Image, prompt_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
端到端求解验证码点选坐标
|
||||
返回 3 个目标的像素坐标: [(x1, y1), (x2, y2), (x3, y3)]
|
||||
"""
|
||||
bg_path = Path(bg_img_path)
|
||||
prompt_path = Path(prompt_img_path)
|
||||
|
||||
# 1. 确定提示图的目标序列 [T1, T2, T3]
|
||||
target_classes = self.parse_prompt_targets(prompt_path)
|
||||
target_classes = self.parse_prompt_targets(prompt_img)
|
||||
if len(target_classes) < 3:
|
||||
logger.warning("提示图未成功切出 3 个目标")
|
||||
return []
|
||||
|
||||
# 2. 定位背景图所有候选图标
|
||||
candidates = self._extract_candidates(bg_path)
|
||||
candidates = self._extract_candidates(bg_img)
|
||||
if len(candidates) < 3:
|
||||
logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}")
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Tencent TDC.js Sandboxed Worker & Mouse Trajectory Generator
|
||||
* Runs TDC.js in an isolated mock DOM environment to generate authentic
|
||||
* encrypted telemetry (`collect`) and session encryption key (`eks`).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
|
||||
class Element {
|
||||
constructor(tagName) {
|
||||
this.tagName = (tagName || 'div').toUpperCase();
|
||||
this.children = [];
|
||||
this.style = {};
|
||||
this.attributes = {};
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
const idx = this.children.indexOf(child);
|
||||
if (idx !== -1) this.children.splice(idx, 1);
|
||||
return child;
|
||||
}
|
||||
insertBefore(newChild, refChild) {
|
||||
const idx = this.children.indexOf(refChild);
|
||||
if (idx !== -1) this.children.splice(idx, 0, newChild);
|
||||
else this.children.push(newChild);
|
||||
return newChild;
|
||||
}
|
||||
cloneNode(deep) {
|
||||
const el = new Element(this.tagName);
|
||||
el.style = { ...this.style };
|
||||
return el;
|
||||
}
|
||||
setAttribute(k, v) { this.attributes[k] = String(v); }
|
||||
getAttribute(k) { return this.attributes[k] || null; }
|
||||
hasAttribute(k) { return k in this.attributes; }
|
||||
removeAttribute(k) { delete this.attributes[k]; }
|
||||
getBoundingClientRect() {
|
||||
return { top: 0, left: 0, width: 360, height: 360, bottom: 360, right: 360 };
|
||||
}
|
||||
}
|
||||
|
||||
class CanvasElement extends Element {
|
||||
constructor() {
|
||||
super('CANVAS');
|
||||
this.width = 300;
|
||||
this.height = 150;
|
||||
}
|
||||
getContext(type) {
|
||||
if (type.includes('webgl')) {
|
||||
return {
|
||||
getSupportedExtensions: () => [
|
||||
'ANGLE_instanced_arrays', 'EXT_blend_minmax', 'EXT_texture_filter_anisotropic',
|
||||
'OES_element_index_uint', 'OES_standard_derivatives', 'OES_texture_float',
|
||||
'OES_texture_half_float', 'OES_vertex_array_object', 'WEBGL_compressed_texture_s3tc',
|
||||
'WEBGL_debug_renderer_info', 'WEBGL_depth_texture', 'WEBGL_lose_context'
|
||||
],
|
||||
getExtension: (ext) => {
|
||||
if (ext === 'WEBGL_debug_renderer_info') {
|
||||
return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getParameter: (p) => {
|
||||
if (p === 37445) return 'Apple';
|
||||
if (p === 37446) return 'Apple M1';
|
||||
if (p === 7936) return 'WebKit';
|
||||
if (p === 7937) return 'WebKit WebGL';
|
||||
return 0;
|
||||
},
|
||||
createBuffer: () => ({}),
|
||||
bindBuffer: () => {},
|
||||
bufferData: () => {},
|
||||
createProgram: () => ({}),
|
||||
createShader: () => ({}),
|
||||
shaderSource: () => {},
|
||||
compileShader: () => {},
|
||||
attachShader: () => {},
|
||||
linkProgram: () => {},
|
||||
useProgram: () => {},
|
||||
drawArrays: () => {},
|
||||
viewport: () => {},
|
||||
clearColor: () => {},
|
||||
clear: () => {}
|
||||
};
|
||||
}
|
||||
return {
|
||||
fillRect: () => {},
|
||||
fillText: () => {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||
measureText: (txt) => ({ width: txt.length * 10 }),
|
||||
beginPath: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {}
|
||||
};
|
||||
}
|
||||
toDataURL() {
|
||||
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate human-like mouse movement trajectory using cubic Bézier curves and Gaussian tremor.
|
||||
*/
|
||||
function generateBezierTrajectory(p0, p1, steps = 25) {
|
||||
// Control points with natural curve deviation
|
||||
const dx = p1.x - p0.x;
|
||||
const dy = p1.y - p0.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
|
||||
// Lateral deviation perpendicular to direct vector
|
||||
const perpX = -dy / (dist || 1);
|
||||
const perpY = dx / (dist || 1);
|
||||
const curvature = (Math.random() - 0.5) * Math.min(60, dist * 0.4);
|
||||
|
||||
const cp1 = {
|
||||
x: p0.x + dx * 0.3 + perpX * curvature + (Math.random() - 0.5) * 10,
|
||||
y: p0.y + dy * 0.3 + perpY * curvature + (Math.random() - 0.5) * 10,
|
||||
};
|
||||
const cp2 = {
|
||||
x: p0.x + dx * 0.7 + perpX * curvature * 0.6 + (Math.random() - 0.5) * 10,
|
||||
y: p0.y + dy * 0.7 + perpY * curvature * 0.6 + (Math.random() - 0.5) * 10,
|
||||
};
|
||||
|
||||
const points = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
// Ease in-out parameterization
|
||||
const easeT = t * t * (3 - 2 * t);
|
||||
const invT = 1 - easeT;
|
||||
|
||||
const bx = invT * invT * invT * p0.x +
|
||||
3 * invT * invT * easeT * cp1.x +
|
||||
3 * invT * easeT * easeT * cp2.x +
|
||||
easeT * easeT * easeT * p1.x;
|
||||
const by = invT * invT * invT * p0.y +
|
||||
3 * invT * invT * easeT * cp1.y +
|
||||
3 * invT * easeT * easeT * cp2.y +
|
||||
easeT * easeT * easeT * p1.y;
|
||||
|
||||
// Subtle tremor (except start and end)
|
||||
const tremor = (i === 0 || i === steps) ? 0 : (Math.random() - 0.5) * 1.5;
|
||||
points.push({
|
||||
x: Math.round(bx + tremor),
|
||||
y: Math.round(by + tremor)
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function runTdcCode(rawCode, clickPoints) {
|
||||
const listeners = [];
|
||||
function addL(type, fn) {
|
||||
listeners.push({ type, fn });
|
||||
}
|
||||
|
||||
const docBody = new Element('BODY');
|
||||
const docElement = new Element('HTML');
|
||||
|
||||
const baseWindow = {
|
||||
location: {
|
||||
href: 'https://turing.captcha.qcloud.com/cap_union_new_show?aid=2039519451',
|
||||
protocol: 'https:',
|
||||
host: 'turing.captcha.qcloud.com',
|
||||
hostname: 'turing.captcha.qcloud.com',
|
||||
pathname: '/cap_union_new_show',
|
||||
search: '?aid=2039519451',
|
||||
hash: ''
|
||||
},
|
||||
document: {
|
||||
createElement: (tag) => {
|
||||
if ((tag || '').toLowerCase() === 'canvas') return new CanvasElement();
|
||||
return new Element(tag);
|
||||
},
|
||||
addEventListener: addL,
|
||||
removeEventListener: () => {},
|
||||
body: docBody,
|
||||
documentElement: docElement,
|
||||
referrer: 'https://app.rainyun.com/',
|
||||
cookie: '',
|
||||
getElementsByTagName: (t) => [t === 'body' ? docBody : docElement],
|
||||
getElementById: () => null,
|
||||
title: '安全验证',
|
||||
head: new Element('HEAD')
|
||||
},
|
||||
navigator: {
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36',
|
||||
platform: 'MacIntel',
|
||||
languages: ['zh-CN', 'zh', 'en'],
|
||||
language: 'zh-CN',
|
||||
cookieEnabled: true,
|
||||
plugins: [{ name: 'PDF Viewer' }],
|
||||
mimeTypes: []
|
||||
},
|
||||
screen: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
availWidth: 1920,
|
||||
availHeight: 1055,
|
||||
colorDepth: 24,
|
||||
pixelDepth: 24
|
||||
},
|
||||
performance: {
|
||||
now: () => Date.now(),
|
||||
timing: {
|
||||
navigationStart: Date.now() - 2500,
|
||||
loadEventEnd: Date.now() - 800
|
||||
}
|
||||
},
|
||||
history: { length: 2 },
|
||||
indexedDB: {},
|
||||
sessionStorage: { getItem: () => null, setItem: () => {} },
|
||||
localStorage: { getItem: () => null, setItem: () => {} },
|
||||
Date: Date,
|
||||
Math: Math,
|
||||
setTimeout: setTimeout,
|
||||
clearTimeout: clearTimeout,
|
||||
setInterval: setInterval,
|
||||
clearInterval: clearInterval,
|
||||
console: { log: () => {}, error: () => {}, warn: () => {} },
|
||||
innerWidth: 360,
|
||||
innerHeight: 360,
|
||||
outerWidth: 1920,
|
||||
outerHeight: 1080,
|
||||
devicePixelRatio: 2,
|
||||
addEventListener: addL,
|
||||
removeEventListener: () => {}
|
||||
};
|
||||
baseWindow.window = baseWindow;
|
||||
baseWindow.self = baseWindow;
|
||||
baseWindow.top = baseWindow;
|
||||
baseWindow.parent = baseWindow;
|
||||
|
||||
// Execute TDC.js
|
||||
vm.runInNewContext(rawCode, baseWindow);
|
||||
|
||||
// Map 672x480 coordinates to 360x360 iframe display space
|
||||
const scale = 320.0 / 672.0;
|
||||
const targetPoints = clickPoints.map(p => ({
|
||||
x: Math.round(20 + p[0] * scale),
|
||||
y: Math.round(60 + p[1] * scale)
|
||||
}));
|
||||
|
||||
// Generate path: entrance -> P1 -> P2 -> P3
|
||||
let currentPoint = {
|
||||
x: Math.round(150 + (Math.random() - 0.5) * 60),
|
||||
y: Math.round(320 + (Math.random() - 0.5) * 20)
|
||||
};
|
||||
|
||||
const mmListener = listeners.find(l => l.type === 'mousemove');
|
||||
let currentTime = Date.now() - 1500;
|
||||
|
||||
if (mmListener) {
|
||||
for (const target of targetPoints) {
|
||||
// Leg trajectory
|
||||
const steps = Math.floor(18 + Math.random() * 12);
|
||||
const leg = generateBezierTrajectory(currentPoint, target, steps);
|
||||
for (const pt of leg) {
|
||||
currentTime += Math.floor(12 + Math.random() * 16);
|
||||
mmListener.fn({
|
||||
clientX: pt.x,
|
||||
clientY: pt.y,
|
||||
pageX: pt.x,
|
||||
pageY: pt.y,
|
||||
screenX: pt.x + 300,
|
||||
screenY: pt.y + 200,
|
||||
timeStamp: currentTime,
|
||||
type: 'mousemove',
|
||||
isTrusted: true
|
||||
});
|
||||
}
|
||||
currentPoint = target;
|
||||
// Dwell pause on icon (150ms ~ 350ms)
|
||||
const dwellSteps = Math.floor(4 + Math.random() * 5);
|
||||
for (let i = 0; i < dwellSteps; i++) {
|
||||
currentTime += Math.floor(25 + Math.random() * 30);
|
||||
mmListener.fn({
|
||||
clientX: currentPoint.x + Math.round((Math.random() - 0.5) * 2),
|
||||
clientY: currentPoint.y + Math.round((Math.random() - 0.5) * 2),
|
||||
pageX: currentPoint.x,
|
||||
pageY: currentPoint.y,
|
||||
screenX: currentPoint.x + 300,
|
||||
screenY: currentPoint.y + 200,
|
||||
timeStamp: currentTime,
|
||||
type: 'mousemove',
|
||||
isTrusted: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collect = baseWindow.TDC.getData();
|
||||
const info = baseWindow.TDC.getInfo();
|
||||
const eks = info ? info.info : '';
|
||||
|
||||
return {
|
||||
collect,
|
||||
tlg: collect ? collect.length : 0,
|
||||
eks
|
||||
};
|
||||
}
|
||||
|
||||
function runTdc(tdcFilePath, clickPoints) {
|
||||
const rawCode = fs.readFileSync(tdcFilePath, 'utf8');
|
||||
return runTdcCode(rawCode, clickPoints);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const tdcPath = process.argv[2];
|
||||
const pointsArg = process.argv[3];
|
||||
const points = pointsArg ? JSON.parse(pointsArg) : [[626, 253], [120, 120], [438, 33]];
|
||||
|
||||
if (tdcPath === '-') {
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
const result = runTdcCode(input, points);
|
||||
console.log(JSON.stringify(result));
|
||||
});
|
||||
} else {
|
||||
const result = runTdc(tdcPath, points);
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runTdc, runTdcCode };
|
||||
|
||||
Reference in New Issue
Block a user