# -*- coding: utf-8 -*- """tcaptcha/tdc_values.py —— 为每个采集器**语义名**提供取值生成器(纯离线)。 背景(账本 §五 第 0 项,har/1ms_dianxuan/PROGRESS.md): 「纯离线生成 collect」的唯一阻塞点 = 不知道每个语义名该填什么值。 本文件把 2026-10-12 实测到的**同会话真值**固化成生成规则。 真值来源(不是猜的): 真 Chrome + 真 1ms 页面 -> Debugger 取**同会话** tdc.js(77,954 字节, 含 __TENCENT_CHAOS_VM)-> iframe 内 TDC.getData(true) -> 同会话 XTEA 密钥解密 -> 按「require 序 == 槽位序」对齐,38/38 槽全覆盖。 脚本: scripts/verify/align_session.py、scripts/verify/audit_names.py 会话 vs 每次采集(连续 4 次 getData 实测,这一条决定了生成器怎么分层): | 语义名 | 第1次 | 第2次 | 第3次 | 第4次 | 结论 | |---------------------------|-------------|-------------|-------------|-------------|---------------| | itoken | 641547489 | 641547489 | 641547489 | 641547489 | **会话内固定** | | struct_40_9 (#.._rand) | 1591912482 | 1591912482 | 1591912482 | 1591912482 | **会话内固定** | | window_time (#e48 委派) | 1791745224 | 1791745224 | 1791745224 | 1791745224 | **会话内固定** | | window_time (#e40) | 1791745331 | 1791745548 | 1791745813 | 1791745814 | 每次刷新 | | struct_46_9 | 1 | 2 | 3 | 4 | **每次 +1 的计数器** | | trajectory 点数 | 1 | 61 | 1 | 1 | 取完即清空 | 三类取值: * 静态画像 DEVICE_PROFILE —— UA / 屏幕 / WebGL / Canvas / 语言 / 时区 / 核数 … (已用浏览器真值交叉校验: screen.width/height/availHeight/colorDepth、 hardwareConcurrency、timezoneOffset、languages 全部一一吻合) * 会话量 Session —— itoken / 会话随机盐 / 页面加载时刻 * 每次采集 Session.next_call() —— 当前时刻、采集计数器、轨迹 """ from __future__ import annotations import random import time from typing import Any, Dict, List, Optional, Sequence # --------------------------------------------------------------------------- # # 设备画像:与真机逐项核对过(见模块 docstring 的交叉校验说明) # # --------------------------------------------------------------------------- # DEVICE_PROFILE: Dict[str, Any] = { "name": "Mac / Chrome 155 / 1440x2560", "user_agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/155.0.0.0 Safari/537.36"), "platform": "MacIntel", "languages": ["zh-CN", "zh"], "timezone_offset": "+08", # 真机 Date.getTimezoneOffset() == -480 "charset": "UTF-8", "hardware_concurrency": 10, # 真机 navigator.hardwareConcurrency # ⚠️ 方向别弄反:真机 screen.width == 1440、screen.height == 2560 # (1440x2560 竖屏;浏览器实测 screen.availHeight == 2530) "screen_width": 1440, "screen_height": 2560, "screen_color_depth": 24, # 真机 screen_fingerprint 串;字段序 = 宽-高-可用高-色深-*-*-|-* "screen_fingerprint": "1440-2560-2530-24-*-*-|-*", "webgl_vendor": "Google Inc. (Apple)", "webgl_renderer": "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)", "canvas_fingerprint": ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1" "PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg=="), "viewport_size": [360, 360], # 验证码 iframe 的可视区 "cookie_enabled": 1, "touch_detect": 2, # 真机实测值(该机 maxTouchPoints == 0) "iframe_detect": "iframe", "webrtc_ip": "", # 未取到候选时为空串 "dom_compliance": 1023, "constant_98k": "98k", "template_url": ("https://turing.captcha.gtimg.com/1/template/" "drag_ele.html?rand=1519713624347"), } # 真机里恒为这些常量的槽(连续 4 次抽样一致) CONSTANTS: Dict[str, Any] = { "time_since_load": 0, "webdriver_detect": 0, "console_detect": 0, "fp_salt": 0, "struct_48_9_call": 0, "location_href": "", "struct_40_9#empty": "", } # 真机里是空数组的槽(与轨迹区分:轨迹至少含一个点) EMPTY_LIST_NAME = "struct_76_33" DEFAULT_REFERRER = "https://1ms.run/console/personal/benefits/check-in" def _rand_int32() -> int: """[0, 2^31) 的随机整数 —— 真机观测值 1591912482 / 699129805 / 641547489 都落在这里。""" return random.randint(0, (1 << 31) - 1) def default_trajectory() -> List[List[int]]: """无鼠标输入时的轨迹(真机实测就是 [[1,1,12]],看着像个"无位移"哨兵点)。""" return [[1, 1, 12]] def _sample_dt() -> int: """采样一个事件间隔(ms)——**按 6 条真机轨迹 / 360 个非首点 dt 实测重拟合**(2026-10-12)。 旧分布(账本里记为"旧拟合")是拿**一条**样本估的,偏差很大:它给了 ≥50% 概率落在 0~16ms、约 6% 落在 41~200ms。真机实测分布是**紧密聚集在 16~17ms**: 0-4 0.28% 5-8 1.39% 9-12 6.39% 13-16 35.56% 17-20 43.61% 21-25 4.44% 26-40 2.78% 41-80 1.39% 81-200 2.22% >200 1.94% (众数 17ms×126 / 16ms×93;均值 25.2,是被极少数长停顿拉高的) 判据:真机 **79%** 落在 13~20ms,而我们旧实现只有约 36% —— "轨迹事件间隔"是服务端最便宜的一类时序特征,别再拿单条样本估分布。 """ r = random.random() if r < 0.0628: # ~6%:偏快的一档(9~12ms) return random.randint(9, 12) if r < 0.8472: # ~78%:主峰 13~20ms(真机 79%) return random.randint(13, 20) if r < 0.9000: # ~5%:21~25ms return random.randint(21, 25) if r < 0.9300: # ~3%:26~40ms return random.randint(26, 40) if r < 0.9700: # ~4%:41~200ms(偶尔的思考停顿) return random.randint(41, 200) return random.randint(201, 600) # ~3%:罕见的长时间歇 def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]: """背景图原始像素 -> iframe 显示坐标(与真机点击轨迹同一套口径)。""" scale = 320.0 / img_w return 20 + x * scale, 60 + y * scale REAL_TRAJECTORY_LEN = 61 # 真机硬不变量:6/6 条成功轨迹都是 61 点(见 _fit_points) def _fit_points(events: List[List[int]]) -> List[List[int]]: """把轨迹**补齐/截断到真机的 61 点**,末尾再钉上固定收尾 `[1,1,12]`。 为什么点数是硬约束:4 份真机抓包 / **6 条点选成功轨迹**,点数**统统是 61** (`点选.har` 里 3 次提交也一样),一条都不差 —— 这是服务端最容易查的一类 结构特征,而旧实现每次随机 57~65 点,等于每发一枪都自带一个异常值。 ⚠️ 补齐必须用 `[0,0,dt]`(驻留/微抖动),**不能**复制最后一个 `[dx,dy,dt]` —— 复制会把位移量重复计入,净位移被放大(实测 166 vs 期望 139.5)。 """ body = [e for e in events if e != [1, 1, 12]] target = REAL_TRAJECTORY_LEN - 1 if len(body) >= target: body = body[:target] else: while len(body) < target: # 驻留/微抖动:位移为 0 body.append([0, 0, _sample_dt()]) return body + [[1, 1, 12]] def trajectory_points(click_points: Sequence[Sequence[int]], img_w: int = 672) -> List[List[int]]: """按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。 形状与真机一致(真机 6 条逐条核对,2026-10-12): * 首点是**绝对坐标**(含一个"进入前"的停顿,真机实测 7~14766ms) * 之后全是**增量** `[dx,dy,dt]`,dt 分布见 `_sample_dt` * **最后一点固定是 `[1,1,12]`** * **总点数固定 61**(真机 6/6 硬不变量,见 `_fit_points`) """ scale = 320.0 / img_w targets = [(int(round(20 + x * scale)), int(round(60 + y * scale))) for x, y in click_points] cur = [random.randint(140, 220), random.randint(300, 340)] events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]] for tx, ty in targets: seg = random.randint(14, 20) for i in range(1, seg + 1): nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1) ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1) events.append([int(nx) - cur[0], int(ny) - cur[1], _sample_dt()]) cur = [int(nx), int(ny)] for _ in range(random.randint(2, 5)): # 命中后短暂驻留 events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()]) return _fit_points(events) def drag_trajectory_points(start: Sequence[float], end: Sequence[float], img_w: int = 672, steps: int = 30) -> List[List[int]]: """滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。 与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标), 同样以固定 `[1,1,12]` 收尾、dt 同一套分布。速度曲线用 ease-out: 人手拖动是先快后慢、末尾微调。 ⚠️ **拖动轨迹不套 61 点**:那个硬不变量是从**点选**轨迹量出来的(6/6), 拖动是另一种交互(按下→横移→松手),点数按 steps 走。别外推。 """ sx, sy = _to_display(float(start[0]), float(start[1]), img_w) ex, ey = _to_display(float(end[0]), float(end[1]), img_w) cur = [int(round(sx)), int(round(sy))] events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]] # 按下前停顿 for i in range(1, steps + 1): t = i / steps e = 1.0 - (1.0 - t) ** 3 nx = int(round(sx + (ex - sx) * e)) + random.randint(-1, 1) ny = int(round(sy + (ey - sy) * e)) + random.randint(-1, 1) events.append([nx - cur[0], ny - cur[1], _sample_dt()]) cur = [nx, ny] for _ in range(random.randint(2, 4)): # 落点附近的微调 events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()]) events.append([1, 1, 12]) return events def rows_from_source(js_text: str) -> List[Dict]: """分析 tdc.js **源码**(tdc_modules 的入口收的是路径,这里落临时文件再分析)。""" import os import tempfile fd, path = tempfile.mkstemp(suffix=".js", prefix="tdc_") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(js_text) return _analyze(path) finally: try: os.unlink(path) except OSError: pass def _analyze(path: str) -> List[Dict]: """惰性导入 tools/tdc_modules(它不在包里,且只做静态分析)。""" import sys from pathlib import Path as _P tools = str(_P(__file__).resolve().parents[1] / "tools") if tools not in sys.path: sys.path.insert(0, tools) from tcaptcha.telemetry import modules as tm return tm.analyze(path) def generate_collect(tdc_js_code: str, click_points: Optional[Sequence[Sequence[int]]] = None, *, key: Optional[Sequence[int]] = None, sd: Optional[Dict] = None, session: Optional[Session] = None, img_w: int = 672, referrer: Optional[str] = None, trajectory: Optional[Sequence[Sequence[int]]] = None) -> Dict[str, Any]: """**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。 流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。 密钥默认用纯 Python 静态提取(`tcaptcha.telemetry.key`),零 Node。 trajectory 直接给轨迹点(滑块用 `drag_trajectory_points`);不给则按 click_points 推。 """ from tcaptcha.telemetry.crypto import build_collect, extract_eks # 局部导入避免环形依赖 from tcaptcha.telemetry.key import extract_xtea_key_pure rows = rows_from_source(tdc_js_code) if key is None: key = extract_xtea_key_pure(tdc_js_code)["key"] sess = session or Session(referrer=referrer or DEFAULT_REFERRER) if trajectory is not None: sess.set_trajectory(trajectory) elif click_points: sess.set_trajectory(trajectory_points(click_points, img_w=img_w)) cd = generate_cd(rows, sess) plain = build_plain(cd, rows, sd) collect = build_collect(plain["chunk1"], plain["trajectory"], plain["chunk2"], plain["sd"], list(key)) return { "collect": collect, "tlg": str(len(collect)), "eks": extract_eks(tdc_js_code), "key": list(key), "source": "python_values", "cd": cd, "segments": {k: len(v) for k, v in plain.items()}, } class Session: """一次验证码会话的状态。 会话内固定的量(itoken / 随机盐 / 页面加载时刻)在 __init__ 里定下来, 每次采集会变的量(当前时刻 / 计数器 / 轨迹)由 next_call() 推进。 """ def __init__(self, now: Optional[float] = None, entry_age: int = 60, referrer: str = DEFAULT_REFERRER, profile: Optional[Dict] = None): self.t0 = int(now if now is not None else time.time()) # 页面加载时刻先于会话建立(真机两次采样差 ~107 秒) self.loaded_at = self.t0 - entry_age self.itoken = [_rand_int32(), self.loaded_at] # 会话内固定,实测 4 次完全一致 self.session_rand = _rand_int32() # struct_40_9 的"随机"槽 self.referrer = referrer self.profile = dict(profile or DEVICE_PROFILE) self.calls = 0 self.now = self.t0 self.trajectory: List[List[int]] = default_trajectory() def next_call(self, now: Optional[float] = None) -> "Session": """进入下一次 TDC.getData():计数器 +1、刷新当前时刻、轨迹复位由调用方决定。""" self.calls += 1 self.now = int(now if now is not None else time.time()) return self def set_trajectory(self, points: Sequence[Sequence[int]]) -> "Session": self.trajectory = [list(p) for p in points] return self # --------------------------------------------------------------------------- # # 语义名 -> 取值生成器 # # --------------------------------------------------------------------------- # def value_for(base: str, row: Dict, sess: Session) -> Any: """按**基础语义名**(去掉 #结构后缀)产出一个槽的值。 同一基础名在一个构建里可能出现多次(user_agent×2 / window_time×2 / struct_40_9×2),这时用行上的**结构证据**分流,而不是靠出现次序 —— 次序会随构建变,结构不会(见 tdc_modules.STRUCT_RULES)。 """ st = row.get("struct") or {} p = sess.profile # ---- 同组多成员:用结构 / 委派证据分流 ---- if base == "user_agent": # 直采的那个返回 UA 串;走委派链的那个返回 0(真机实测) return 0 if row.get("via_delegate") else p["user_agent"] if base == "struct_40_9": # 闭包最后写入 "" vs 随机 int32(账本 #47 的静态消歧依据) return "" if st.get("closure_kind") == "empty" else sess.session_rand if base == "window_time": # 委派的那个是页面加载时刻(会话内固定),直采的那个是当前时刻(每次变) return sess.loaded_at if row.get("via_delegate") else sess.now # ---- 会话量 / 每次采集量 ---- if base == "itoken": return None # 2 槽,见 generate_cd if base == "trajectory": return sess.trajectory if base == "struct_46_9": return sess.calls # 实测 1,2,3,4 —— 每次采集 +1 if base == "referrer": return sess.referrer # ---- 常量 / 画像 ---- if base in CONSTANTS: return CONSTANTS[base] if base == EMPTY_LIST_NAME: return [] if base in p: return p[base] raise KeyError("没有 %r 的取值规则(新语义名? 需要补规则)" % base) def generate_cd(rows: Sequence[Dict], sess: Optional[Session] = None, now: Optional[float] = None) -> List[Any]: """按**清单顺序**生成 cd 数组(37 项 -> 38 槽,itoken 占 2 槽)。 rows 来自 tools.tdc_modules.analyze()(每项含 name / struct / via_delegate)。 每次调用会推进 sess.next_call()(模拟一次 TDC.getData)。 """ s = sess or Session(now=now) if now is not None: s.next_call(now) elif s.calls == 0: s.next_call(s.t0) out: List[Any] = [] for r in rows: base = str(r["name"]).split("#")[0] if base == "itoken": out.extend(s.itoken) # 真机就是 2 个连续槽 continue out.append(value_for(base, r, s)) return out def trajectory_index(rows: Sequence[Dict]) -> int: """轨迹在 cd 中的下标 —— 它就是 4 段加密里第 2 段的分界点。""" k = 0 for r in rows: base = str(r["name"]).split("#")[0] if base == "trajectory": return k k += 2 if base == "itoken" else 1 raise KeyError("清单里没有 trajectory 项") def build_plain(cd: Sequence[Any], rows: Sequence[Dict], sd: Optional[Dict] = None) -> Dict[str, str]: """把 cd 组装成 4 段明文(chunk1 / trajectory / chunk2 / sd)。 与真机**逐字节一致**的分段写法(3 份样本核对,2026-10-12): chunk1 = '{"cd":[' + 轨迹之前的槽(逗号连接)+ ',' traj = '<轨迹槽的 JSON>' chunk2 = ',' + 轨迹之后的槽(逗号连接)+ '],' sd = '"sd":' + json(sd) + '}' 分隔符一律是**无空格的逗号**(真机 JSON.stringify 风格)。 """ import json def js(v: Any) -> str: return json.dumps(v, ensure_ascii=False, separators=(",", ":")) k = trajectory_index(rows) head = ",".join(js(v) for v in cd[:k]) tail = ",".join(js(v) for v in cd[k + 1:]) return { # ⚠️ 逗号是**槽与槽之间**的分隔符(真机是 JSON.stringify 语义),所以两端的空区间 # 都**不能**再补分隔逗号,否则会拼出 `{"cd":[,` 或 `...,],` 这种非法 JSON。 # · 轨迹正好是第 0 槽 -> chunk1 就是 '{"cd":['(真机成功包实测,见账本已知 #58) # · 轨迹正好是最后 1 槽 -> chunk2 就是 '],'(38 槽里 k=37 的构建实测, # 2026-10-12;此前这里恒补前导逗号 -> 拼出非法 JSON) "chunk1": '{"cd":[' + (head + "," if head else ""), "trajectory": js(cd[k]), "chunk2": ("," + tail + "],") if tail else "],", "sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}", } def main() -> int: import argparse import json import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "tools")) ap = argparse.ArgumentParser(description="按语义名生成 cd 取值(纯离线)") ap.add_argument("tdc_js", help="一份 tdc.js(用来取清单顺序与语义名)") ap.add_argument("--json", default=None) args = ap.parse_args() from tcaptcha.telemetry import modules as tm rows = tm.analyze(args.tdc_js) sess = Session() cd = generate_cd(rows, sess) print("项 语义名 取值") print("-" * 78) k = 0 for r in rows: base = str(r["name"]).split("#")[0] n = 2 if base == "itoken" else 1 val = cd[k:k + n] if n > 1 else cd[k] k += n print("%-3d %-36s %s" % (r["item"], r["name"], json.dumps(val, ensure_ascii=False)[:34])) print() print("槽数 %d / %d 项" % (len(cd), len(rows))) plain = build_plain(cd, rows) print("分段: chunk1=%d traj=%d chunk2=%d sd=%d" % (len(plain["chunk1"]), len(plain["trajectory"]), len(plain["chunk2"]), len(plain["sd"]))) if args.json: Path(args.json).write_text(json.dumps(cd, ensure_ascii=False, indent=1), encoding="utf-8") print(">>> %s" % args.json) return 0 if __name__ == "__main__": raise SystemExit(main())