Files
hello_js_reverse_skill/tcaptcha/crypto.py
T
yml2213 51dff870cf 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
2026-10-12 03:38:18 +08:00

530 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
腾讯 TDC 遥测 (collect) 纯算法还原 —— 唯一路径: 明文模板回填 + 教科书 XTEA。
设计原则 (AGENTS.md §2.3 / §2.4):
* **只走纯算**: collect 的明文组装 / 轨迹 / 时间戳 / XTEA 加密 / 编码全部在 Python 内完成,
不再用伪造 DOM/WebGL/Canvas 的"补环境"沙箱去跑 tdc.js。
(踩过的坑见 har/1ms_dianxuan/ANALYSIS.md §7: 换 python/node/browser 后端对 errorCode=12
毫无差异, 说明"环境"不是自变量, 补环境路线已废弃。)
* **Node 的唯一用途**: 从服务端下发的 tdc.js 里提取本次构建的 4 个 XTEA 密钥字
(密钥由 VM 运行时算出, 不是字面量, 只能靠 opcode 插桩捕获)。Node 不参与明文生成。
* 明文结构以「真实成功样本」(models/success_collect_template.b64) 为模板,
Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。
"""
import base64
import itertools
import json
import logging
import os
import random
import re
import struct
import subprocess
import time
import urllib.parse
from pathlib import Path
from typing import Dict, List, Optional, Tuple
M32 = 0xFFFFFFFF
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
logger = logging.getLogger("tcaptcha_crypto")
# 样本构建的 XTEA 密钥字 (未加盐)。真实样本的等价密钥 (含两个轮加盐) 见
# tests/test_xtea_key.py 的 SAMPLE_KEY = [1448563793, 1850559083, 1733513813, 1364613714]。
DEFAULT_KEY = [1448563793, 1847730015, 1733184827, 1364613714]
DEFAULT_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"
)
def encrypt_string(plaintext: str, key: List[int] = DEFAULT_KEY) -> str:
"""把 ASCII/Latin1 明文用教科书 XTEA 加密后转 Base64 (不足 8 字节用 \\x00 补齐)。
本模块只有一份 xtea_encrypt_block (教科书实现), 此函数即使用它。
"""
data = plaintext.encode("latin1")
pad_len = (8 - (len(data) % 8)) % 8
data += b"\x00" * pad_len
out = bytearray()
for i in range(0, len(data), 8):
v0, v1 = struct.unpack("<II", data[i : i + 8])
ev0, ev1 = xtea_encrypt_block(v0, v1, key)
out.extend(struct.pack("<II", ev0, ev1))
return base64.b64encode(out).decode("ascii")
def extract_eks(tdc_js_code: str) -> str:
"""从 tdc.js 中提取会话密钥信封 eks (一个较长的 Base64 字符串)。
注意: tdc.js 每次构建都会重命名全局变量 (见 AGENTS.md 附录 A#6), 因此**不能**写死变量名;
这里匹配任意 `window.<随机名> = '<base64>'`, 并取长度最长的候选。
"""
cands = re.findall(r"window\.[A-Za-z_$][\w$]*\s*=\s*'([A-Za-z0-9+/=]{100,})'", tdc_js_code)
if not cands:
return ""
return max(cands, key=len)
# --------------------------------------------------------------------------- #
# 标准 (教科书) XTEA: 32 轮, delta=0x9E3779B9, sum 取 mod 2^32。
# 这是本模块**唯一**的 XTEA 实现; 需要从每份 tdc.js 里拿到的是那 4 个密钥字。
# --------------------------------------------------------------------------- #
XTEA_DELTA = 0x9E3779B9
def xtea_encrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
"""教科书 XTEA 加密 (32 轮), 全 32 位无符号运算"""
v0 &= M32
v1 &= M32
total = 0
for _ in range(32):
v0 = (v0 + (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
^ ((total + key[total & 3]) & M32))) & M32
total = (total + XTEA_DELTA) & M32
v1 = (v1 + (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
return v0, v1
def xtea_decrypt_block(v0: int, v1: int, key: List[int]) -> Tuple[int, int]:
"""教科书 XTEA 解密 (32 轮), 用于校验提取到的密钥"""
v0 &= M32
v1 &= M32
total = (XTEA_DELTA * 32) & M32
for _ in range(32):
v1 = (v1 - (((((v0 << 4) & M32) ^ (v0 >> 5)) + v0) & M32
^ ((total + key[(total >> 11) & 3]) & M32))) & M32
total = (total - XTEA_DELTA) & M32
v0 = (v0 - (((((v1 << 4) & M32) ^ (v1 >> 5)) + v1) & M32
^ ((total + key[total & 3]) & M32))) & M32
return v0, v1
def xtea_encrypt_bytes(data: bytes, key: List[int]) -> bytes:
"""XTEA 加密一段字节;不足 8 字节用 \\x00 补齐(XTEA 块对齐所需)。"""
data = data + b"\x00" * ((8 - len(data) % 8) % 8)
out = bytearray()
for i in range(0, len(data), 8):
v0, v1 = struct.unpack("<II", data[i:i + 8])
e0, e1 = xtea_encrypt_block(v0, v1, key)
out += struct.pack("<II", e0, e1)
return bytes(out)
# --------------------------------------------------------------------------- #
# collect 的「四段加密」结构(按 docs/tdc-案例.md §5.1,真机明文已判决) #
# --------------------------------------------------------------------------- #
SEG_ALIGN = 24
SEG_PAD = b" "
# 候选 pad = 一段空格。⚠️ **pad 可以只有 1 个空格**(段内容长 % 24 == 23 时),
# 2026-10-12 在真机上实测到这种样本(chunk1 = 287 字节 -> pad 1)。
# 所以**不能**用 ` {2,}` 去找 pad,那个正则会把 `{"cd":[...,0,0,0, [[1,1,12]]...` 里的
# 单空格 pad 整段漏掉,切段结果直接崩。判据必须用长度模 24 的算术。
_SpaceRun = re.compile(rb" +")
def xtea_encrypt_segment(text: str, key: List[int],
align: int = SEG_ALIGN, pad: bytes = SEG_PAD) -> bytes:
"""加密 collect 的一个**数据段**:空格 pad 到 align 字节对齐后 XTEA + Base64。
为什么要 pad 到 24(而不是 8):24 字节 -> 32 个 Base64 字符,且**不会产生 `=` 填充**。
4 段密文的 Base64 直接拼接后看起来就是一个完整的 Base64 串,没有分隔符。
实测 pad 长度落在 **1..24**(真机样本里见过 15/8/23 与 1/14/4)——
注意 **1 个空格的 pad 是真实存在的**,别用 ` {2,}` 去找它(见 split_collect_segments)。
"""
data = text.encode("latin1")
# 注意:即使 len%align==0 也要补一整个 align(真机 chunk2 内容 288 字节、
# 288%24==0,却补了 24 个空格 -> pad 长度恒为 align - len%align,落在 1..align)
data += pad * (align - len(data) % align)
return base64.b64encode(xtea_encrypt_bytes(data, key))
def xtea_encrypt_segment_raw(text: str, key: List[int]) -> bytes:
"""加密 **sd 段**:不 pad 到 24,只由 XTEA 的 8 字节块补齐(真机实测如此)。"""
data = text.encode("latin1")
return base64.b64encode(xtea_encrypt_bytes(data, key))
def build_collect(chunk1: str, trajectory: str, chunk2: str, sd: str,
key: List[int], url_encode: bool = False) -> str:
"""按案例 §5.1 组装 collect —— **4 段密文直接拼接,无分隔符**。
collect = b64(xtea(chunk1 + 空格pad到24))
+ b64(xtea(traj + 空格pad到24))
+ b64(xtea(chunk2 + 空格pad到24))
+ b64(xtea(sd)) # sd 不 pad
段的内容边界(案例 §5.5,与真机明文逐字吻合):
chunk1 = '{"cd":[' + 轨迹之前的指纹值 + ','
trajectory = '[[x,y,t],[dx,dy,dt],...,[1,1,12]]'
chunk2 = ',' + 轨迹之后的指纹值 + '],'
sd = '"sd":{"od":"C","ft":"..."}}'
"""
parts = [
xtea_encrypt_segment(chunk1, key),
xtea_encrypt_segment(trajectory, key),
xtea_encrypt_segment(chunk2, key),
xtea_encrypt_segment_raw(sd, key),
]
b64 = b"".join(parts).decode("ascii")
if url_encode:
return urllib.parse.quote(b64, safe="")
return b64
def _inside_json_string(data: bytes, pos: int) -> bool:
"""判断字节偏移 pos 是否落在 JSON 字符串字面量内部(数未转义的双引号奇偶)。"""
n = 0
i = 0
while i < pos:
c = data[i]
if c == 0x5C: # 反斜杠:跳过被转义的下一个字节
i += 2
continue
if c == 0x22: # 双引号
n += 1
i += 1
return n % 2 == 1
def _candidate_pads(data: bytes) -> List[Tuple[int, int]]:
"""候选 pad 位置:空格串 [a,b) 且满足
① b % SEG_ALIGN == 0 ② (b-a) == SEG_ALIGN - a % SEG_ALIGN ③ 不落在 JSON 字符串内部
为什么要 ③:① ② 单独用**会误报** —— 实测真机 UA 串
"Mozilla/5.0 (Macintosh…" 里偏移 47 的那个空格恰好满足
(47 % 24 == 23 -> 期望 pad 1,48 % 24 == 0),而它只是字符串内的普通空格。
段一定切在 JSON 记号边界上,所以真 pad 处引号计数必为偶数;用它把误报滤掉。
"""
out: List[Tuple[int, int]] = []
for m in _SpaceRun.finditer(data):
a, b = m.span()
if b % SEG_ALIGN or (b - a) != SEG_ALIGN - a % SEG_ALIGN:
continue
if _inside_json_string(data, a):
continue
out.append((a, b))
return out
def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
"""把一份**已解密**的 collect 明文按空格 pad 切回 4 段(去掉 pad)。
结构(3 份真机样本逐字节核对,2026-10-12):
chunk1 = '{"cd":[' + 轨迹之前的槽 + ','
traj = '<轨迹数组>'
chunk2 = ',' + 轨迹之后的槽 + '],'
sd = '"sd":{...}}'
前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
"""
data = plain.encode("latin1")
cands = _candidate_pads(data)
for (a1, b1), (a2, b2), (a3, b3) in itertools.combinations(cands, 3):
if not (b1 <= a2 and b2 <= a3):
continue
segs = [data[0:a1], data[b1:a2], data[b2:a3], data[b3:]]
# 段边界一定落在 JSON 记号边界上(3 份真机样本一致):
# chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾
if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',')
and segs[2].startswith(b',') and segs[2].endswith(b'],')
and segs[3].startswith(b'"sd"')):
continue
try:
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
except Exception: # noqa: BLE001
continue
try:
obj = json.loads(b"".join(segs).decode("latin1"))
except Exception: # noqa: BLE001
continue
if "cd" not in obj or "sd" not in obj:
continue
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
segs[2].decode("latin1"), segs[3].decode("latin1"))
raise ValueError(
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式")
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
out = bytearray()
for i in range(0, len(data) - len(data) % 8, 8):
v0, v1 = struct.unpack("<II", data[i:i + 8])
d0, d1 = xtea_decrypt_block(v0, v1, key)
out += struct.pack("<II", d0, d1)
return bytes(out)
_CAPTURE_CACHE: Dict[str, Tuple[List[List], bytes]] = {}
def _capture_key_words_cached(tdc_js_code: str, node_bin: str = "node"):
cached = _CAPTURE_CACHE.get(tdc_js_code)
if cached is None:
cached = _capture_key_words(tdc_js_code, node_bin=node_bin)
_CAPTURE_CACHE[tdc_js_code] = cached
return cached
def _capture_key_words(tdc_js_code: str, node_bin: str = "node",
timeout: float = 180.0) -> Tuple[List[List], bytes]:
"""调用 Node 插桩运行 tdc.js, 返回 (密钥候选, 该脚本产出的 collect 原始字节)"""
script = TDC_WORKER_PATH
if not script.exists():
raise FileNotFoundError(f"缺少 tdc_worker.js: {script}")
cmd = [node_bin, str(script), "-", "[[0,0],[0,0],[0,0]]",
json.dumps({"aid": "1", "captureKey": True})]
proc = subprocess.run(cmd, input=tdc_js_code, capture_output=True, text=True,
timeout=timeout, env={**os.environ, "NODE_OPTIONS": ""})
out = (proc.stdout or "").strip().splitlines()
if not out:
raise RuntimeError(f"tdc_worker 无输出 (rc={proc.returncode}): {proc.stderr[:400]}")
payload = json.loads(out[-1])
if payload.get("error"):
raise RuntimeError(f"tdc_worker 执行失败: {payload['error'][:400]}")
words = payload.get("key_words") or []
if not words:
raise RuntimeError("tdc_worker 未返回密钥候选 (captureKey 未生效?)")
raw = base64.b64decode(urllib.parse.unquote(payload["collect"]))
return words, raw
_KEY_CACHE: Dict[str, List[int]] = {}
def extract_xtea_key(tdc_js_code: str, node_bin: str = "node",
top_n: int = 8, use_cache: bool = True) -> List[int]:
"""
从服务端下发的 tdc.js 里提取本次构建的 XTEA 密钥 (4 个 32 位字)。
**主路径 = 纯 Python 静态还原** (`tcaptcha/tdc_key.py`):
反汇编字节码 -> 定位 XTEA 闭包 -> 回溯它捕获的密钥数组 -> 读出 4 个密钥字
闭包(源串 + 偏移 + 存取下标) -> 折入轮加盐。
零 Node、零 JS 运行时、零 pc 硬编码 (opcode 表按构建推导, 见 tdc_opcodes.py)。
**兜底路径 = Node 插桩** (密钥构造算法若被改动时仍可用):
1. Node 在 VM 上下文里把 ADD opcode 换成带日志版本;
XTEA 每轮的 (sum + K[j]) 是一次 ADD, 两个操作数必有一个等于 r*DELTA,
于是另一个操作数就是密钥字 —— 按频次排序得到无歧义候选。
2. Python 取候选 Top-N, 对 4! 个排列逐个用教科书 XTEA 解密
tdc.js 自己产出的 collect 前 48 字节, 与常量 cd 前缀比对 —— 命中即真密钥。
密钥按构建固定 (同一份脚本多次运行首块完全一致, 已实测), 故可缓存。
"""
import itertools
if use_cache:
cached = _KEY_CACHE.get(tdc_js_code)
if cached:
return list(cached)
# ---- 主路径: 纯 Python 静态还原 (零 Node) ----
try:
from .tdc_key import extract_xtea_key as _pure_extract
key = [int(k) & M32 for k in _pure_extract(tdc_js_code)]
if len(key) == 4:
if use_cache:
_KEY_CACHE[tdc_js_code] = key
return list(key)
except Exception as exc: # noqa: BLE001
logger.warning("纯 Python 静态提取失败, 退回 Node 插桩: %s", exc)
words, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
candidates = [int(k) & M32 for k, *_ in words[:top_n]]
# 验证用「内容特征」而非固定明文 (AGENTS.md §5.3 / 附录 A#6):
# 实测 tdc.js 至少两代明文布局 ——
# 旧版: 4 段 base64 拼接, 首段 = '{"cd":[[],"1920-1080-1055-24-*-*-|-*",0,0,"",'
# 新版: 单个 JSON, 首部 = '{"cd":[1,[],"Apple M1",24,...' / '{"cd":[1791712995,0,...'
# 字段序还随构建变化, 故只依赖跨版本稳定的结构特征: '{"cd":[' 开头 + 首块全可打印。
blocks = [struct.unpack("<II", raw[i:i + 8]) for i in range(0, 48, 8)]
for perm in itertools.permutations(candidates, 4):
key = list(perm)
head = b"".join(struct.pack("<II", *xtea_decrypt_block(v0, v1, key))
for v0, v1 in blocks)
if head[:7] == b'{"cd":[' and all(32 <= b < 127 for b in head[:48]):
if use_cache:
_KEY_CACHE[tdc_js_code] = key
return key
raise RuntimeError(
f"未能从 tdc.js 复原 XTEA 密钥 (候选 {len(candidates)} 个, 排列全试失败)")
# --------------------------------------------------------------------------- #
# §5.8 标定式交付: 运行时取一次明文模板, 之后全部由 Python 回填
# (新版 tdc.js 的明文是单个 JSON, 且**字段序随构建变化** —— 实测两份同时期脚本
# 首部一个是 '{"cd":[1,[],"Apple M1",24,...', 另一个是 '{"cd":[1791712995,0,...',
# 所以不能硬编码模板, 只能每次标定一份。)
# --------------------------------------------------------------------------- #
_TRAJ_RE = re.compile(r"\[\[\d+,-?\d+,\d+\](?:,\[-?\d+,-?\d+,\d+\])+\]")
_EPOCH_RE = re.compile(r"(?<![\d.])(1[6-9]\d{8})(?![\d.])")
def recover_payload_template(tdc_js_code: str, node_bin: str = "node"):
"""
标定: 跑一次 tdc.js, 提取本次构建的 XTEA 密钥并解密出**明文模板**。
返回 (template_text, key)。
"""
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
_, raw = _capture_key_words_cached(tdc_js_code, node_bin=node_bin)
plain = xtea_decrypt_bytes(raw, key).rstrip(b"\x00")
return plain.decode("latin1"), key
# --------------------------------------------------------------------------- #
# 纯算主路径: 以「真实成功样本的明文」为结构模板 (服务端已认可的字段序与取值),
# Python 只回填随会话变化的量: 轨迹 / 当前时间戳 / 随机盐。Node 只提供 XTEA 密钥。
# --------------------------------------------------------------------------- #
_SUCCESS_TPL_PATH = Path(__file__).resolve().parent / "models" / "success_collect_template.b64"
def _success_style_trajectory(click_points: List[Tuple[int, int]],
img_w: int = 672) -> str:
"""轨迹生成已统一到 `tcaptcha/tdc_values.trajectory_points()`(保留本函数兼容旧调用点)。
那里按真机样本把形状定死了:首点绝对坐标 + 增量点 + **固定收尾 `[1,1,12]`**。
"""
from tcaptcha.tdc_values import trajectory_points
return json.dumps(trajectory_points(click_points, img_w=img_w),
separators=(",", ":"))
def generate_collect_success(
click_points: List[Tuple[int, int]],
key: List[int],
entry_url: str = "https://1ms.run/console/personal/benefits/check-in",
raw_base64: bool = True,
) -> str:
"""成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。
raw_base64=True 返回未 URL 编码的标准 Base64 (由 httpx 表单编码时单层编码,
与真实抓包一致); False 则预先做一次 percent-encoding。
"""
if not _SUCCESS_TPL_PATH.exists():
raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}")
text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1")
text = text.replace(
'"https://1ms.run/console/personal/benefits/check-in"', '"%s"' % entry_url, 1)
text = _TRAJ_RE.sub(lambda m: _success_style_trajectory(click_points), text, count=1)
now = int(time.time())
text = text.replace("1791715501", str(now), 1) # 采集时刻
text = text.replace("1791715508", str(now + random.randint(4, 12)), 1)
text = text.replace("1791706326", str(now - random.randint(6000, 12000)), 1)
text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1)
data = text.encode("latin1")
data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充
b64 = base64.b64encode(xtea_encrypt_bytes(data, key)).decode("ascii")
if raw_base64:
return b64
return urllib.parse.quote(b64, safe="")
def generate_collect_from_template(
template: str,
click_points: List[Tuple[int, int]],
key: List[int],
refresh_time: bool = True,
) -> str:
"""用标定得到的明文模板 + Python 生成的轨迹回填出 collect (纯算)。
1. 用 Python 轨迹替换模板里的轨迹数组
2. 刷新模板里的秒级时间戳
3. 空格补齐到 8 字节倍数 -> XTEA 加密 -> base64 -> URL 编码
"""
text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template
traj = _success_style_trajectory(click_points)
m = _TRAJ_RE.search(text)
if not m:
raise RuntimeError("模板里未找到轨迹数组, 无法回填")
text = text[:m.start()] + traj + text[m.end():]
if refresh_time:
text = _EPOCH_RE.sub(str(int(time.time())), text)
data = text.encode("latin1")
data += b" " * ((8 - len(data) % 8) % 8) # 与真实 tdc.js 一致: 空格补齐
return urllib.parse.quote_from_bytes(
base64.b64encode(xtea_encrypt_bytes(data, key)), safe="")
def generate_telemetry(
tdc_js_code: str,
click_points: List[Tuple[int, int]],
aid: str = "2039519451",
user_agent: str = DEFAULT_USER_AGENT,
referrer: str = "https://app.rainyun.com/",
mode: str = "python_keyed",
node_bin: str = "node",
) -> Dict[str, str]:
"""统一遥测生成入口 —— **只走纯算**, 不再有任何伪造环境的执行后端。
mode="python_values" (推荐, 2026-10-12 新增): **纯离线**生成整颗 collect ——
tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密。
零 Node、零真机明文(密钥也由纯 Python 静态提取)。见 tcaptcha/tdc_values.py。
mode="python_keyed": 明文仍来自仓库里的**成功样本模板回填**
(generate_collect_success),Node 只负责从当前 tdc.js 提取 XTEA 密钥字。
mode="python": 完全不调用 Node, 用内置 DEFAULT_KEY + 同样的模板回填。
返回 {"collect", "tlg", "eks", "source"[, "key"]}。
关于 errorCode=12: 实测与遥测后端无关 (python / python_keyed / node / browser 全部 12,
换 IP / 加长静默同样无效), 见 har/1ms_dianxuan/ANALYSIS.md §7。**不要再用"换环境"解释它**,
把精力放在账本 §7.5 的单变量实验 (登录态/真页面上下文) 上。
"""
if mode not in ("python_values", "python_keyed", "python"):
raise ValueError(f"未知 telemetry mode: {mode!r} "
"(只支持 'python_values' / 'python_keyed' / 'python')")
if mode == "python_values":
from tcaptcha.tdc_values import generate_collect
out = generate_collect(tdc_js_code, click_points, referrer=referrer)
return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"],
"source": out["source"], "key": out["key"]}
key: Optional[List[int]] = None
source = "python"
if mode == "python_keyed":
try:
key = extract_xtea_key(tdc_js_code, node_bin=node_bin)
source = "python_keyed"
except Exception as exc: # Node 缺失/插桩失败 -> 退回固定密钥
logger.warning("XTEA 密钥提取失败, 退回内置 DEFAULT_KEY: %s", exc)
if key is None:
key = list(DEFAULT_KEY)
collect = generate_collect_success(click_points, key, entry_url=referrer, raw_base64=True)
return {
"collect": collect,
"tlg": str(len(collect)),
"eks": extract_eks(tdc_js_code),
"source": source,
"key": key,
}
def selfcheck() -> None:
"""纯算法自检: XTEA 往返 + encrypt_string 一致性。"""
for chunk in (b"hello!!", bytes(range(32)), b"x" * 24):
dec = xtea_decrypt_bytes(xtea_encrypt_bytes(chunk, DEFAULT_KEY), DEFAULT_KEY)
assert dec[:len(chunk)] == chunk, f"XTEA 往返失败: {chunk!r}"
ref = base64.b64encode(
xtea_encrypt_bytes(b"abc\x00\x00\x00\x00\x00", DEFAULT_KEY)).decode("ascii")
assert encrypt_string("abc") == ref, "encrypt_string 与 xtea_encrypt_bytes 不一致"
if __name__ == "__main__":
selfcheck()
print("crypto 自检通过: XTEA 往返 + encrypt_string 一致性")