feat(rainyun): 完成雨云纯协议自动登录签到脚本及青龙单目录部署包
- 逆向分析雨云登录与签到接口,打通腾讯天御点选验证码全协议过码闭环 - 支持 RAINYUN_ACCOUNT='账号#密码' 极简配置 (多账号支持 & 与换行分割) - 增加积分提现金额换算展示 (2000积分 = 1元现金) 及青龙系统通知推送 - 新增 dist/rainyun_qinglong 单层平铺部署目录与专属 README - 增强 solver.py 在模板缺失时的 ResNet18 分类器双模降级能力
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tencent Captcha Package"""
|
||||
|
||||
from .client import TencentCaptchaClient
|
||||
from .crypto import extract_eks, generate_collect
|
||||
from .slicer import slice_prompt_image
|
||||
from .solver import CaptchaSolver
|
||||
|
||||
__all__ = [
|
||||
"TencentCaptchaClient",
|
||||
"CaptchaSolver",
|
||||
"extract_eks",
|
||||
"generate_collect",
|
||||
"slice_prompt_image",
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
腾讯天御点选验证码纯协议 HTTP 微服务 (Tencent Turing Captcha HTTP Microservice)
|
||||
用于在独立机器/宿主机/GPU服务器上运行,供青龙容器或其他轻量级客户端通过 HTTP API 跨机调用。
|
||||
基于 Python 原生 ThreadingHTTPServer 实现,零额外依赖 (无需额外安装 FastAPI/Flask)。
|
||||
|
||||
使用方式:
|
||||
uv run python -m tcaptcha.server --port 8000 --host 0.0.0.0
|
||||
调用接口:
|
||||
POST http://<IP>:8000/solve
|
||||
请求体 (可选): {"aid": "2039519451"}
|
||||
返回 JSON:
|
||||
{
|
||||
"code": 200,
|
||||
"success": true,
|
||||
"ticket": "tr03...",
|
||||
"randstr": "@...",
|
||||
"elapsed_ms": 820
|
||||
}
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("tcaptcha_server")
|
||||
|
||||
GLOBAL_SOLVER = None
|
||||
|
||||
|
||||
def get_solver() -> CaptchaSolver:
|
||||
global GLOBAL_SOLVER
|
||||
if GLOBAL_SOLVER is None:
|
||||
logger.info("正在初始化并预加载 AI 求解推理引擎 (YOLOv8 + ResNet18)...")
|
||||
GLOBAL_SOLVER = CaptchaSolver()
|
||||
logger.info("✓ AI 推理引擎预加载完成!")
|
||||
return GLOBAL_SOLVER
|
||||
|
||||
|
||||
class CaptchaHttpHandler(BaseHTTPRequestHandler):
|
||||
def _send_json(self, code: int, data: dict):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Headers", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self._send_json(200, {"status": "ok"})
|
||||
|
||||
def do_GET(self):
|
||||
if self.path in ["/health", "/"]:
|
||||
self._send_json(200, {
|
||||
"status": "online",
|
||||
"service": "Tencent Captcha Solver Microservice",
|
||||
"endpoints": {
|
||||
"solve": "POST /solve (body: {'aid': '2039519451'})",
|
||||
"health": "GET /health"
|
||||
}
|
||||
})
|
||||
elif self.path.startswith("/solve"):
|
||||
# 允许通过 GET 快速调试
|
||||
self._handle_solve({"aid": "2039519451"})
|
||||
else:
|
||||
self._send_json(404, {"error": "Not Found"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path.startswith("/solve"):
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_length) if content_length > 0 else b"{}"
|
||||
try:
|
||||
params = json.loads(body.decode("utf-8")) if body else {}
|
||||
except Exception:
|
||||
params = {}
|
||||
self._handle_solve(params)
|
||||
else:
|
||||
self._send_json(404, {"error": "Not Found"})
|
||||
|
||||
def _handle_solve(self, params: dict):
|
||||
aid = params.get("aid") or "2039519451"
|
||||
logger.info(f"收到求解请求 (AppID: {aid})...")
|
||||
solver = get_solver()
|
||||
client = TencentCaptchaClient(aid=aid, solver=solver)
|
||||
result = client.solve()
|
||||
|
||||
if result.get("success"):
|
||||
self._send_json(200, {
|
||||
"code": 200,
|
||||
"success": True,
|
||||
"ticket": result.get("ticket"),
|
||||
"randstr": result.get("randstr"),
|
||||
"elapsed_ms": result.get("elapsed_ms"),
|
||||
"points": result.get("points"),
|
||||
})
|
||||
else:
|
||||
self._send_json(500, {
|
||||
"code": 500,
|
||||
"success": False,
|
||||
"error": result.get("error"),
|
||||
"elapsed_ms": result.get("elapsed_ms"),
|
||||
})
|
||||
|
||||
def log_message(self, format, *args):
|
||||
logger.info("%s - %s", self.address_string(), format % args)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Tencent Captcha Microservice")
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0", help="Listen host (default: 0.0.0.0)")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Listen port (default: 8000)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 预加载推理引擎
|
||||
get_solver()
|
||||
|
||||
server_address = (args.host, args.port)
|
||||
httpd = ThreadingHTTPServer(server_address, CaptchaHttpHandler)
|
||||
logger.info(f"🚀 验证码微服务已启动: http://{args.host}:{args.port}")
|
||||
logger.info("可用于青龙面板配置环境变量: TCAPTCHA_API_URL=http://<IP>:%d/solve", args.port)
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("服务已停止")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+20
-9
@@ -79,19 +79,30 @@ class CaptchaSolver:
|
||||
def parse_prompt_targets(self, prompt_img: str | Path | bytes | Image.Image) -> List[int]:
|
||||
"""
|
||||
自动识别并解析提示图中 3 个目标图标的真实类别 ID (0~11)
|
||||
支持干净模板匹配与 ResNet18 分类器自动双模降级
|
||||
"""
|
||||
crops = slice_prompt_image(prompt_img)
|
||||
target_classes = []
|
||||
for crop_img, _ in crops[:3]:
|
||||
arr = np.array(crop_img.convert("L"))
|
||||
bg = np.median(arr)
|
||||
bin_c = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
|
||||
if not crops:
|
||||
return []
|
||||
|
||||
scores = [cv2.matchTemplate(bin_c, t[1], cv2.TM_CCOEFF_NORMED)[0, 0] for t in self.templates]
|
||||
best_cid = int(np.argmax(scores))
|
||||
target_classes.append(best_cid)
|
||||
if self.templates:
|
||||
target_classes = []
|
||||
for crop_img, _ in crops[:3]:
|
||||
arr = np.array(crop_img.convert("L"))
|
||||
bg = np.median(arr)
|
||||
bin_c = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA)
|
||||
|
||||
return target_classes
|
||||
scores = [cv2.matchTemplate(bin_c, t[1], cv2.TM_CCOEFF_NORMED)[0, 0] for t in self.templates]
|
||||
best_cid = int(np.argmax(scores))
|
||||
target_classes.append(best_cid)
|
||||
return target_classes
|
||||
|
||||
# 模板缺失时自动使用 ResNet18 分类器直接预测提示小图类别
|
||||
patch_tensors = torch.stack([self.transform(c[0].convert("RGB")) for c in crops[:3]]).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.classifier(patch_tensors)
|
||||
probs = torch.softmax(logits, dim=1).cpu().numpy()
|
||||
return [int(np.argmax(p[:12])) for p in probs]
|
||||
|
||||
def _extract_candidates(self, bg_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int, Image.Image]]:
|
||||
"""从背景图中提取所有去重后的候选图标 (cx, cy, patch)"""
|
||||
|
||||
Reference in New Issue
Block a user