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
|
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
@@ -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 []
|
||||||
|
|||||||
@@ -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