feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口

- 真因:dt 分布与真机严重不符(旧 50% 落 0~16ms,真机 79% 落 13~20ms)+ 点数
  随机 57~65(真机 6/6 恒 61)。按 6 条真机轨迹重拟合 _sample_dt,新增 _fit_points
  钉死 61 点(补齐用 [0,0,dt] 驻留点,不能复制位移点——净位移会被放大)。
- A/B:1ms 图标 0×4→0×7(前 7 连 0);残余偶发 12 为连发频控形态,低频使用即可。
- errorCode 语义标定:50=答案被否(容差实测 25~40px,我们点偏仅 2~3px),
  12=包级拒答;判点位只看 50/0。
- 顺带修 build_plain:轨迹落末槽(k=37)时 chunk2 恒补前导逗号 → 非法 JSON。
- 新增 scripts/verify/send_campaign.py(批量真发+落图+统计)与 judge_icons.py
  (提示目标 vs 点击点并排判读);账本 #64~#74 + 专文 ICON2408.md。

复验:selftest 全绿;判读图 24/24 点位全对;mliev 图标 9/10=0。
This commit is contained in:
yml2213
2026-10-12 14:32:14 +08:00
parent 6a8b3b3ce4
commit 74a863c8b3
9 changed files with 752 additions and 35 deletions
+3 -3
View File
@@ -696,9 +696,9 @@ def main():
default="https://1ms.run/console/personal/benefits/check-in")
parser.add_argument("--wb", default="1")
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--telemetry", default="python_keyed",
choices=["python_keyed", "python"],
help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)")
parser.add_argument("--telemetry", default="python_values",
choices=["python_values"],
help="遥测方式: 只有 python_values(纯离线;模板回填/Node 两条老路已删除)")
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
parser.add_argument("--proxy-api", default=None,
help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)")
+21 -5
View File
@@ -219,36 +219,52 @@ def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
sd = '"sd":{...}}'
前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。
⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **JSON.stringify 语义** 消失:
k == 0 -> chunk1 = '{"cd":[' (真机成功包实测)
k == n-1 -> chunk2 = '],' (2026-10-12 实测构建)
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
"""
data = plain.encode("latin1")
cands = _candidate_pads(data)
n_struct = n_json = 0
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'],')
# 段边界一定落在 JSON 记号边界上:
# chunk1 = '{"cd":[' + 若干槽 + [',' 或 ''] —— 轨迹正好是第 0 槽时**没有那个逗号**
# (真机成功包实测:chunk1 就是 '{"cd":[',随后 17 个空格 pad)
# traj = **一个**完整 JSON 值
# chunk2 = [',' + 若干槽] + '],' —— 轨迹正好是最后 1 槽时**没有那个前导逗号**
if not (segs[0].startswith(b'{"cd":[')
and (segs[0].endswith(b',') or segs[0] == b'{"cd":[')
and segs[2].endswith(b'],')
and (segs[2].startswith(b',') or segs[2] == b'],')
and segs[3].startswith(b'"sd"')):
n_struct += 1
continue
try:
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
except Exception: # noqa: BLE001
n_struct += 1
continue
try:
obj = json.loads(b"".join(segs).decode("latin1"))
except Exception: # noqa: BLE001
n_json += 1
continue
if "cd" not in obj or "sd" not in obj:
n_json += 1
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 段格式")
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个,"
f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);"
f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON")
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
+65 -21
View File
@@ -98,17 +98,31 @@ def default_trajectory() -> List[List[int]]:
def _sample_dt() -> int:
"""采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。"""
"""采样一个事件间隔(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.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)
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]:
@@ -117,16 +131,38 @@ def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
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]`。
形状与真机一致(3 份样本逐字核对):
* 首点是**绝对坐标**(含 300~600ms 初始停顿)
* 之后全是**增量** `[dx,dy,dt]`
* **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾
(没动鼠标时整条轨迹就是 `[[1,1,12]]`)
dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。
形状与真机一致(真机 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)))
@@ -143,8 +179,7 @@ def trajectory_points(click_points: Sequence[Sequence[int]],
cur = [int(nx), int(ny)]
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()])
events.append([1, 1, 12]) # 固定收尾(真机如此)
return events
return _fit_points(events)
def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
@@ -152,7 +187,11 @@ def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
"""滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。
与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标),
同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。
同样以固定 `[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)
@@ -374,9 +413,14 @@ def build_plain(cd: Sequence[Any], rows: Sequence[Dict],
head = ",".join(js(v) for v in cd[:k])
tail = ",".join(js(v) for v in cd[k + 1:])
return {
"chunk1": '{"cd":[' + head + ",",
# ⚠️ 逗号是**槽与槽之间**的分隔符(真机是 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 + "],",
"chunk2": ("," + tail + "],") if tail else "],",
"sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}",
}
+61 -1
View File
@@ -132,14 +132,74 @@ def test_offline_generate_and_roundtrip() -> None:
len(plain["chunk2"]), len(plain["sd"]))
def test_build_plain_trajectory_first() -> None:
"""轨迹就在第 0 槽时,chunk1 里没有任何槽 —— 不能再补分隔逗号。
实测触发场景:某些构建的清单里轨迹模块排在第 0 位,那样会拼出 `{"cd":[,`
—— 非法 JSON,服务端返回 errorCode=12。
"""
import json as _json
from tcaptcha.telemetry.values import build_plain, trajectory_index
rows = [{"name": "trajectory"}, {"name": "platform"}, {"name": "constant_98k"}]
assert trajectory_index(rows) == 0
cd = [[[1, 1, 12]], "MacIntel", "98k"]
p = build_plain(cd, rows)
assert p["chunk1"] == '{"cd":[', repr(p["chunk1"])
assert _json.loads(p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"])["cd"] == cd
# 轨迹在中间时,chunk1 仍必须以 ',' 结尾(真机实测口径)
rows2 = [{"name": "platform"}, {"name": "trajectory"}, {"name": "constant_98k"}]
p2 = build_plain(["MacIntel", [[1, 1, 12]], "98k"], rows2)
assert p2["chunk1"].endswith(",") and p2["chunk2"].startswith(",")
def test_build_plain_trajectory_last() -> None:
"""轨迹就在**最后 1 槽**时,chunk2 里没有任何槽 —— 不能再补前导逗号。
实测触发场景(2026-10-12):38 槽里有构建把轨迹排在 k=37(最后),
旧写法恒拼 `,],` -> 整段变成 `{"cd":[...,],` 非法 JSON。
本题同时钉住「切分器必须接受 `],` 这种对称边界」。
"""
import base64 as _b64
import json as _json
from tcaptcha.telemetry.crypto import DEFAULT_KEY
from tcaptcha.telemetry.values import build_plain, trajectory_index
rows = [{"name": "platform"}, {"name": "constant_98k"}, {"name": "trajectory"}]
assert trajectory_index(rows) == 2
cd = ["MacIntel", "98k", [[1, 1, 12]]]
p = build_plain(cd, rows)
assert p["chunk2"] == "],", repr(p["chunk2"])
whole = p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"]
assert _json.loads(whole)["cd"] == cd
# 旧写法(恒补前导逗号)拼出来的确实是非法 JSON —— 这正是当年 12 的成因之一
bad = p["chunk1"] + p["trajectory"] + "," + p["chunk2"] + p["sd"]
try:
_json.loads(bad)
raise AssertionError("旧写法应当拼出非法 JSON")
except ValueError:
pass
# 走完整加密 -> 解密 -> 切分:切分器要吃下 `],` 这种边界
collect = build_collect(p["chunk1"], p["trajectory"], p["chunk2"], p["sd"], DEFAULT_KEY)
dec = xtea_decrypt_bytes(_b64.b64decode(collect), DEFAULT_KEY).decode("latin1").rstrip("\x00")
assert split_collect_segments(dec) == (p["chunk1"], p["trajectory"], p["chunk2"], p["sd"])
def main() -> int:
selfcheck()
test_split_single_space_pad()
test_split_ignores_space_inside_string()
test_build_collect_roundtrip_single_space_pad()
test_offline_generate_and_roundtrip()
test_build_plain_trajectory_first()
test_build_plain_trajectory_last()
print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性"
" + 四段切分回归 + 纯离线闭环)")
" + 四段切分回归 + 纯离线闭环 + 轨迹首/末槽边界)")
return 0