""" 腾讯天御点选验证码 (Tencent Turing Captcha) 样本纯协议采集器 通过模拟前端两步 HTTP 接口直接拉取高清原图: 1. GET /cap_union_prehandle 获取初始会话 sess 2. POST /cap_union_new_getsig 获取题目配置与背景/提示图 URL 3. 并行下载 672x480 背景大图与 170x50 提示图标图 """ import argparse import asyncio import json import logging import os import random import re import time from pathlib import Path from typing import Optional, Tuple import httpx logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("tcaptcha_collector") class TencentCaptchaCollector: BASE_URL = "https://turing.captcha.qcloud.com" DEFAULT_AID = "2039519451" def __init__(self, aid: str = DEFAULT_AID, output_dir: str = "tcaptcha/data/raw", timeout: float = 10.0): self.aid = aid self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.index_file = self.output_dir / "index.jsonl" self.timeout = timeout self.client = httpx.Client( base_url=self.BASE_URL, trust_env=False, 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://turing.captcha.qcloud.com/", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }, timeout=self.timeout, follow_redirects=True, ) def _fetch_session(self) -> Tuple[Optional[str], Optional[str]]: """第一步:预检请求获取 sess""" cb_name = f"_aq_{random.randint(100000, 999999)}" tkid = str(random.randint(100000000, 999999999)) ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2" params = { "aid": self.aid, "protocol": "https", "accver": "1", "showtype": "popup", "ua": ua_b64, "noheader": "1", "fb": "1", "grayscale": "1", "clientype": "2", "lang": "zh-cn", "js": "/tcaptcha-frame.ba387dd1.js", "wb": "2", "tkid": tkid, "subsid": "1", "callback": cb_name, } resp = self.client.get("/cap_union_prehandle", params=params) if resp.status_code != 200: logger.error("prehandle failed with status %d", resp.status_code) return None, None match = re.search(rf"{cb_name}\((.*)\)", resp.text) if not match: logger.error("Failed to parse prehandle response: %s", resp.text[:100]) return None, None try: data = json.loads(match.group(1)) sess = data.get("sess") return sess, tkid except Exception as e: logger.error("Failed to decode JSON from prehandle: %s", e) return None, None def _fetch_sig_and_images(self, sess: str, tkid: str) -> Optional[dict]: """第二步:请求题目信息并下载图片对""" resp = self.client.post( "/cap_union_new_getsig", data={"sess": sess, "tkid": tkid}, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if resp.status_code != 200: logger.error("getsig failed with status %d", resp.status_code) return None try: res_json = resp.json() except Exception as e: logger.error("Failed to decode getsig JSON: %s", e) return None if str(res_json.get("ret")) != "0": logger.warning("getsig returned non-zero ret: %s", res_json) return None cfg = res_json.get("data", {}) instruction = cfg.get("instruction", "请依次点击:") bg_cfg = cfg.get("bg_elem_cfg", {}) img_url_bg = bg_cfg.get("img_url", "") if not img_url_bg: logger.warning("No img_url found in getsig response") return None img_url_prompt = img_url_bg.replace("img_index=1", "img_index=0") # 下载背景大图 (672x480) 与 提示图 (170x50) bg_resp = self.client.get(img_url_bg) prompt_resp = self.client.get(img_url_prompt) if bg_resp.status_code != 200 or prompt_resp.status_code != 200: logger.error("Failed to download image pairs") return None return { "instruction": instruction, "bg_bytes": bg_resp.content, "prompt_bytes": prompt_resp.content, "img_url_bg": img_url_bg, "img_url_prompt": img_url_prompt, "sess": sess, "tkid": tkid, } def fetch_one(self) -> Optional[str]: """采集单组样本并保存到本地""" sess, tkid = self._fetch_session() if not sess or not tkid: return None data = self._fetch_sig_and_images(sess, tkid) if not data: return None sample_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}" bg_filename = f"{sample_id}_bg.png" prompt_filename = f"{sample_id}_prompt.png" meta_filename = f"{sample_id}_meta.json" bg_path = self.output_dir / bg_filename prompt_path = self.output_dir / prompt_filename meta_path = self.output_dir / meta_filename bg_path.write_bytes(data["bg_bytes"]) prompt_path.write_bytes(data["prompt_bytes"]) meta_info = { "sample_id": sample_id, "timestamp": time.time(), "aid": self.aid, "instruction": data["instruction"], "bg_file": bg_filename, "prompt_file": prompt_filename, "bg_size": len(data["bg_bytes"]), "prompt_size": len(data["prompt_bytes"]), "sess": data["sess"], "tkid": data["tkid"], } meta_path.write_text(json.dumps(meta_info, ensure_ascii=False, indent=2), encoding="utf-8") # 写入 index.jsonl with open(self.index_file, "a", encoding="utf-8") as f: f.write(json.dumps(meta_info, ensure_ascii=False) + "\n") logger.info( "✓ 采集成功 [%s]: 大图 %d KB, 提示图 %d KB | 提示: %s", sample_id, len(data["bg_bytes"]) // 1024, len(data["prompt_bytes"]) // 1024, data["instruction"], ) return sample_id def collect_batch(self, count: int = 10, delay: float = 0.8) -> int: """批量采集样本""" logger.info("开始批量采集点选验证码样本,目标数量: %d, 间隔: %.2fs", count, delay) success_count = 0 for i in range(count): try: sid = self.fetch_one() if sid: success_count += 1 else: logger.warning("第 %d/%d 个样本采集失败,准备重试", i + 1, count) except Exception as e: logger.error("采集异常: %s", e) if i < count - 1 and delay > 0: # 随机微弱浮动,模拟正常采集 actual_delay = delay + random.uniform(-0.2, 0.3) time.sleep(max(0.2, actual_delay)) logger.info("批量采集完成: 成功 %d/%d (存储目录: %s)", success_count, count, self.output_dir) return success_count def close(self): self.client.close() def main(): parser = argparse.ArgumentParser(description="腾讯天御点选验证码纯协议批量采集器") parser.add_argument("--count", type=int, default=10, help="采集样本数量 (默认: 10)") parser.add_argument("--delay", type=float, default=0.8, help="每次请求间隔秒数 (默认: 0.8)") parser.add_argument("--aid", type=str, default=TencentCaptchaCollector.DEFAULT_AID, help="业务 AppID") parser.add_argument("--output-dir", type=str, default="tcaptcha/data/raw", help="保存路径") args = parser.parse_args() collector = TencentCaptchaCollector(aid=args.aid, output_dir=args.output_dir) try: collector.collect_batch(count=args.count, delay=args.delay) finally: collector.close() if __name__ == "__main__": main()