feat(tcaptcha): 完成腾讯天御点选纯协议全自动化客户端与雨云业务适配器

This commit is contained in:
yml2213
2026-09-11 23:36:23 +08:00
parent 8df4493994
commit 5220c67a99
6 changed files with 889 additions and 15 deletions
+83
View File
@@ -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)
+327
View File
@@ -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()
+115
View File
@@ -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
View File
@@ -9,12 +9,19 @@ import numpy as np
from PIL import Image 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 个字符/图标 自动识别并切割提示图中的 3 个字符/图标
返回: [(PIL_Image, (x1, y1, x2, y2)), ...] 返回: [(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) arr = np.array(img)
h, w = arr.shape 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)] 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 = [] crops = []
for seg_start, seg_end in segments[:3]: for seg_start, seg_end in segments[:3]:
+17 -12
View File
@@ -7,11 +7,12 @@
4. 全局匹配: 全局最大后验概率分配 -> 输出毫秒级精准有序点击坐标 [(x1, y1), (x2, y2), (x3, y3)] 4. 全局匹配: 全局最大后验概率分配 -> 输出毫秒级精准有序点击坐标 [(x1, y1), (x2, y2), (x3, y3)]
""" """
import io
import itertools import itertools
import logging import logging
import sys import sys
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple, Union
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path: 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) bin_t = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
self.templates.append((cid, bin_t)) 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) 自动识别并解析提示图中 3 个目标图标的真实类别 ID (0~11)
""" """
crops = slice_prompt_image(prompt_img_path) crops = slice_prompt_image(prompt_img)
target_classes = [] target_classes = []
for crop_img, _ in crops[:3]: for crop_img, _ in crops[:3]:
arr = np.array(crop_img.convert("L")) arr = np.array(crop_img.convert("L"))
@@ -92,12 +93,19 @@ class CaptchaSolver:
return target_classes 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)""" """从背景图中提取所有去重后的候选图标 (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() boxes = results[0].boxes.xyxy.cpu().numpy()
bg_pil = Image.open(bg_img_path).convert("RGB")
w_img, h_img = bg_pil.size w_img, h_img = bg_pil.size
raw_cands = [] raw_cands = []
@@ -120,22 +128,19 @@ class CaptchaSolver:
return candidates 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)] 返回 3 个目标的像素坐标: [(x1, y1), (x2, y2), (x3, y3)]
""" """
bg_path = Path(bg_img_path)
prompt_path = Path(prompt_img_path)
# 1. 确定提示图的目标序列 [T1, T2, T3] # 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: if len(target_classes) < 3:
logger.warning("提示图未成功切出 3 个目标") logger.warning("提示图未成功切出 3 个目标")
return [] return []
# 2. 定位背景图所有候选图标 # 2. 定位背景图所有候选图标
candidates = self._extract_candidates(bg_path) candidates = self._extract_candidates(bg_img)
if len(candidates) < 3: if len(candidates) < 3:
logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}") logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}")
return [] return []
+332
View File
@@ -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 };