feat(collect): 纯离线生成打通 1ms 签到文字点选 —— errorCode=0 收口

做了什么:
- 新增 tcaptcha/tdc_values.py: 37 个语义名的取值生成器(静态画像 / 会话量 /
  每次采集量三层)+ build_plain 四段组装 + generate_collect 纯离线出包
- crypto: 新增 generate_telemetry(mode="python_values");轨迹生成统一到
  trajectory_points()(首点绝对坐标 + 增量 + 固定收尾 [1,1,12])
- crypto.split_collect_segments 重写: pad 只有 1 个空格时旧 ` {2,}` 判据会漏段,
  且 UA 串里的普通空格会假阳性 —— 改为「模 24 算术 + 不落在 JSON 字符串内部 + 结构校验」
- tools/tdc_modules: trajectory 指纹原先指向只做 JSON.stringify 的模块,改为
  mouseMoveEvent/touchStartEvent(真轨迹模块),37/37 不变
- client 默认 telemetry_mode 改为 python_values
- 新增 scripts/verify/{align_session,audit_names,verify_values,verify_offline,send_test}.py
  与 tools/{cdp_collect,cdp_dump_scripts,tdc_profile}.py

为什么:
- 账本 #40「谁在发 HTTP 是 12 的剩余自变量」被推翻。同 aid/同客户端/同求解器、
  答案都对,只换 collect 构造方式: 本次会话构建生成 -> 0 (4/4),
  旧模板回填 -> 12 (2/2)。真因是 collect 必须与本次会话构建同源。

怎么验证:
- .venv/bin/python -m tcaptcha.selftest  全绿(含新增四段切分与纯离线闭环回归)
- scripts/verify/verify_values.py        4 份真机明文 152/152 逐槽全等
- scripts/verify/verify_offline.py       逐槽全等 + 分段几何一致
- scripts/verify/send_test.py --mode python_values  1ms 签到页文字点选 errorCode=0 x4
This commit is contained in:
yml2213
2026-10-12 03:38:18 +08:00
parent 80a122245a
commit 51dff870cf
18 changed files with 2102 additions and 91 deletions
+389
View File
@@ -0,0 +1,389 @@
# -*- 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 trajectory_points(click_points: Sequence[Sequence[int]],
img_w: int = 672) -> List[List[int]]:
"""按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。
形状与真机一致(3 份样本逐字核对):
* 首点是**绝对坐标**(含 300~600ms 初始停顿)
* 之后全是**增量** `[dx,dy,dt]`
* **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾
(没动鼠标时整条轨迹就是 `[[1,1,12]]`)
dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。
"""
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)]]
def dt() -> int:
r = random.random()
if r < 0.50:
return random.randint(0, 16)
if r < 0.75:
return random.randint(17, 24)
if r < 0.90:
return random.randint(25, 60)
if r < 0.98:
return random.randint(61, 160)
return random.randint(161, 400)
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], dt()])
cur = [int(nx), int(ny)]
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
events.append([random.randint(-1, 1), random.randint(-1, 1), 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)
import tdc_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) -> Dict[str, Any]:
"""**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。
流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。
密钥默认用纯 Python 静态提取(`tcaptcha.tdc_key`),零 Node。
"""
from tcaptcha.crypto import build_collect, extract_eks # 局部导入避免环形依赖
from tcaptcha.tdc_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 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 {
"chunk1": '{"cd":[' + head + ",",
"trajectory": js(cd[k]),
"chunk2": "," + tail + "],",
"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()
import tdc_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())