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
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""judge_icons.py —— 图标点选题的**目视判读图**生成器。
把 `send_campaign.py` 的一次实验目录变成一张对照图:每行一次跑,列出
[提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch] (j = 1,2,3)
这样"点得对不对 / 顺序对不对"一眼可判 —— 顺序即红框上的 #1/#2/#3。
用法:
.venv/bin/python scripts/verify/judge_icons.py /tmp/icon_campaign/<tag> [out.png]
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tcaptcha.ocr.slicer import slice_prompt_image # noqa: E402
CELL = 120
PAD = 8
ROWH = CELL + 26
def build(campaign_dir: str, out_path: str) -> str:
d = Path(campaign_dir)
runs = sorted([p.name for p in d.iterdir() if re.fullmatch(r"run\d+", p.name)],
key=lambda s: int(s[3:]))
if not runs:
raise SystemExit("目录下没有 run* 子目录: %s" % d)
width = PAD + 60 + 3 * (CELL + PAD + CELL) + PAD + 150
height = PAD + len(runs) * (ROWH + PAD)
canvas = Image.new("RGB", (width, height), (245, 245, 245))
dr = ImageDraw.Draw(canvas)
y = PAD
for rn in runs:
rd = d / rn
info = json.loads((rd / "points.json").read_text(encoding="utf-8"))
ec = info.get("errorCode")
pts = info.get("points") or []
offs = info.get("centroid_offsets_px") or []
dr.text((PAD, y + 4), "%s\nec=%s" % (rn, ec), fill=(20, 20, 20))
try:
prompt = Image.open(rd / "prompt.jpg").convert("RGB")
crops = slice_prompt_image(prompt)
except Exception: # noqa: BLE001
crops = []
try:
bg = Image.open(rd / "bg.jpg").convert("RGB")
except Exception: # noqa: BLE001
bg = None
x = PAD + 60
for j in range(3):
if j < len(crops):
pc = crops[j][0].convert("RGB").resize((CELL, CELL), Image.LANCZOS)
else:
pc = Image.new("RGB", (CELL, CELL), (255, 210, 210))
canvas.paste(pc, (x, y + 20))
dr.rectangle([x, y + 20, x + CELL, y + 20 + CELL], outline=(0, 0, 0))
x2 = x + CELL + PAD
if bg is not None and j < len(pts):
px, py = pts[j]
box = (max(0, px - 32), max(0, py - 32),
min(bg.width, px + 32) + 1, min(bg.height, py + 32) + 1)
patch = bg.crop(box).resize((CELL, CELL), Image.LANCZOS)
tag = "#%d(%d,%d)" % (j + 1, px, py)
if j < len(offs) and offs[j] is not None:
tag += " 偏%.0fpx" % offs[j]
dr.text((x2 + 2, y + 4), tag, fill=(0, 0, 0))
else:
patch = Image.new("RGB", (CELL, CELL), (232, 232, 232))
canvas.paste(patch, (x2, y + 20))
dr.rectangle([x2, y + 20, x2 + CELL, y + 20 + CELL], outline=(200, 0, 0), width=3)
x = x2 + CELL + PAD
y += ROWH + PAD
canvas.save(out_path)
return out_path
if __name__ == "__main__":
if len(sys.argv) < 2:
raise SystemExit(__doc__)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(src, "judge.png")
print("saved", build(src, dst))
+303
View File
@@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
"""send_campaign.py —— 真实发送**批量实验**(从仓库里跑,不再用 /tmp 临时脚本)。
和 `send_test.py` 的区别:这个是"打 N 发 + 落图 + 统计学"的实验脚手架,用来判定
`errorCode` 到底跟踪什么(答案对不对?点位准不准?),并把每次点击的**顺序**画在图上。
用法:
# 基线:可复现靶子上连续跑 6 次图标题
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 6
# 容差标定:把答案整体平移一个固定偏移(判定服务端命中半径有多紧)
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 30,30
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 0,60
# 负对照:故意交换前两个点(必错 -> 期望 50)
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap
产物(默认 /tmp/icon_campaign/<时间戳>/):
run{i}/bg.jpg 底图
run{i}/prompt.jpg 提示图(仅图标/文字点选题)
run{i}/annotated.jpg 底图 + **按点击顺序编号**的红圈(+ 估计的图标质心十字)
run{i}/points.json 点位 / 每点离图标质心的距离 / errorCode
summary.json 汇总(含每种 errorCode 计数)
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import tcaptcha.protocol.client as client_mod # noqa: E402
import tcaptcha.solvers.icon_click as icon_mod # noqa: E402
from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, # noqa: E402
KIND_TEXT_CLICK, TencentCaptchaClient)
# 内置靶子:aid / entry_url / referer / wb
TARGETS: Dict[str, Tuple[str, str, str, str]] = {
"1ms": ("195672344", "https://1ms.run/console/personal/benefits/check-in",
"https://1ms.run/", "1"),
"mliev": ("190758559", "https://mliev.login.wang/auth/#/oidc/authorize",
"https://mliev.login.wang/", "1"),
}
KIND_MAP = {
"text": (KIND_TEXT_CLICK,),
"icon": (KIND_ICON_CLICK,),
"slider": (KIND_SLIDER,),
"any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER),
}
# --------------------------------------------------------------------------- #
# 「点得准不准」的独立量尺:点击点 vs 该处图标的**暗色笔画质心**
# --------------------------------------------------------------------------- #
def icon_centroid_offset(bg_pil, x: int, y: int, half: int = 32) -> Optional[Tuple[float, float, float]]:
"""返回 (质心x, 质心y, 离点击点的距离px);估不出来返回 None。
原理:图标是近黑的线条画,背景是彩色的(天空/水面/木纹…)。在点击点周围截
(2*half)² 的窗口,用 Otsu 分出暗色笔画,取笔画质心当作"图标视觉中心"。
**这是独立于我求解器的量尺**(求解器用的是 YOLO box 中心),所以能回答
"我们到底点偏了多少"。
"""
w, h = bg_pil.size
box = (max(0, x - half), max(0, y - half), min(w, x + half), min(h, y + half))
patch = np.array(bg_pil.crop(box).convert("L"))
if patch.size == 0:
return None
thr, binimg = cv2.threshold(patch.astype(np.uint8), 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
ys, xs = np.nonzero(binimg)
if len(xs) < 20:
return None
cx, cy = float(xs.mean()), float(ys.mean())
lx, ly = x - box[0], y - box[1] # 点击点在窗口内的坐标
return (cx + box[0], cy + box[1], float(np.hypot(cx - lx, cy - ly)))
def annotate(bg_bytes: bytes, points: Sequence[Sequence[int]], prompt_bytes: Optional[bytes],
offsets: Sequence[Optional[float]]) -> bytes:
"""底图上画**按点击顺序编号**的红圈;右上角贴提示图;标注每点偏离质心的 px。"""
bgr = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR)
for i, (x, y) in enumerate(points, 1):
cv2.circle(bgr, (int(x), int(y)), 18, (0, 0, 255), 2)
cv2.circle(bgr, (int(x), int(y)), 2, (0, 0, 255), -1)
cv2.putText(bgr, str(i), (int(x) - 8, int(y) - 22),
cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 255, 0), 2)
if prompt_bytes:
pr = cv2.imdecode(np.frombuffer(prompt_bytes, np.uint8), cv2.IMREAD_COLOR)
if pr is not None:
ph = bgr.shape[0] // 4
pw = max(1, int(pr.shape[1] * ph / pr.shape[0]))
pr = cv2.resize(pr, (pw, ph))
y0, x0 = 4, bgr.shape[1] - pw - 4
if x0 >= 0:
bgr[y0:y0 + ph, x0:x0 + pw] = pr
cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1)
return cv2.imencode(".jpg", bgr)[1].tobytes()
def main() -> int:
ap = argparse.ArgumentParser(description="真实发送批量实验(打 N 发 + 落图 + 统计)")
ap.add_argument("--target", default="mliev", choices=list(TARGETS))
ap.add_argument("--kind", default="icon", choices=list(KIND_MAP))
ap.add_argument("--n", type=int, default=6)
ap.add_argument("--offset", default="0,0",
help="给答案整体平移 DX,DY 像素(容差标定用)")
ap.add_argument("--swap", action="store_true", help="故意交换前两个点(负对照,必错)")
ap.add_argument("--max-refreshes", type=int, default=40)
ap.add_argument("--outdir", default=None)
ap.add_argument("--max-retries", type=int, default=1)
args = ap.parse_args()
aid, entry, referer, wb = TARGETS[args.target]
dx, dy = (int(v) for v in args.offset.split(","))
tag = "%s_%s_n%d_off%s%s" % (args.target, args.kind, args.n, args.offset,
"_swap" if args.swap else "")
# 默认落在**项目内**(har/ 只跟踪 .md,所以图片不会进 git,但会留在项目树里便于复核)
outdir = Path(args.outdir or (ROOT / "har" / "1ms_dianxuan" / "icon_campaign" / tag))
outdir.mkdir(parents=True, exist_ok=True)
# ---- hook:抓每次求解的图 + 点位,并施加扰动
state: Dict[str, object] = {}
real_icon_solve = icon_mod.CaptchaSolver.solve
def _as_pil(img):
from PIL import Image
import io as _io
if isinstance(img, Image.Image):
return img.convert("RGB")
if isinstance(img, bytes):
return Image.open(_io.BytesIO(img)).convert("RGB")
return Image.open(img).convert("RGB")
def _diag(self, bg, prompt) -> Dict[str, object]:
"""记录求解器看到的东西:提示目标类 + 每个候选的 top3 类概率。
用来判"是不是把某个图标认成了别的类/挑错了同类的另一个实例"。
"""
try:
bgr, pim = _as_pil(bg), _as_pil(prompt)
targets = self.parse_prompt_targets(pim)
cands = self._extract_candidates(bgr)
blobs = np.stack([self._preprocess_patch(c[2]) for c in cands],
axis=0).astype(np.float32)
logits = self.cls_sess.run(None, {"input": blobs})[0]
exp = np.exp(logits - np.max(logits, axis=1, keepdims=True))
probs = exp / np.sum(exp, axis=1, keepdims=True)
out = []
for (cx, cy, _p), pr in zip(cands, probs):
order = np.argsort(pr[:12])[::-1][:3]
out.append({"xy": [int(cx), int(cy)],
"top": [[int(i), round(float(pr[i]), 3)] for i in order]})
return {"targets": targets, "candidates": out}
except Exception as exc: # noqa: BLE001
return {"diag_err": str(exc)[:120]}
def spy(self, bg, prompt):
pts = list(real_icon_solve(self, bg, prompt))
state["bg_raw"], state["prompt_raw"] = bg, prompt
state["diag"] = _diag(self, bg, prompt)
if pts and (dx or dy):
pts = [(x + dx, y + dy) for x, y in pts]
if pts and args.swap and len(pts) >= 2:
pts = [pts[1], pts[0]] + pts[2:]
state["points"] = pts
return pts
icon_mod.CaptchaSolver.solve = spy
real_gen = client_mod.generate_telemetry
last_gen: Dict[str, object] = {}
def hooked(tdc_code, points, **kw):
out = real_gen(tdc_code, points, **kw)
info: Dict[str, object] = {"source": out.get("source"),
"collect_len": len(out.get("collect") or "")}
try:
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes
plain = xtea_decrypt_bytes(base64.b64decode(out["collect"]),
out["key"]).decode("latin1").rstrip("\x00")
info["segments"] = [len(s) for s in split_collect_segments(plain)]
except Exception as exc: # noqa: BLE001
info["segments_err"] = str(exc)[:160]
try:
Path("/tmp/icon_campaign_last_fail.txt").write_bytes(plain.encode("latin1"))
except Exception: # noqa: BLE001
pass
last_gen.clear()
last_gen.update(info)
return out
client_mod.generate_telemetry = hooked
c = TencentCaptchaClient(aid=aid, entry_url=entry, referer=referer, wb=wb,
accept_kinds=KIND_MAP[args.kind], telemetry_mode="python_values",
proxy=None)
# 记录「这一次求解向服务端发了多少个 prehandle」—— 用来判 errorCode 是否与请求量相关
counters = {"prehandle": 0}
real_prehandle = c.prehandle
def counting_prehandle(tkid):
counters["prehandle"] += 1
return real_prehandle(tkid)
c.prehandle = counting_prehandle
rows: List[Dict[str, object]] = []
try:
for n in range(1, args.n + 1):
state.clear()
counters["prehandle"] = 0
t0 = time.time()
try:
res = c.solve(max_retries=args.max_retries, max_refreshes=args.max_refreshes)
except Exception as exc: # noqa: BLE001
rows.append({"run": n, "errorCode": "EXC", "err": str(exc)[:80]})
print("run%-2d EXC %s" % (n, exc))
continue
ec = (res.get("raw_response") or {}).get("errorCode")
pts = list(state.get("points") or [])
bg_raw, prompt_raw = state.get("bg_raw"), state.get("prompt_raw")
d = outdir / ("run%d" % n)
d.mkdir(parents=True, exist_ok=True)
offsets: List[Optional[float]] = []
centroids: List[Optional[List[float]]] = []
if bg_raw is not None:
from PIL import Image
import io as _io
bg_pil = Image.open(_io.BytesIO(bg_raw)).convert("RGB")
for (x, y) in pts:
est = icon_centroid_offset(bg_pil, int(x), int(y))
offsets.append(None if est is None else round(est[2], 1))
centroids.append(None if est is None else [round(est[0], 1), round(est[1], 1)])
(d / "bg.jpg").write_bytes(bg_raw)
(d / "annotated.jpg").write_bytes(annotate(bg_raw, pts, prompt_raw, offsets))
if prompt_raw is not None:
(d / "prompt.jpg").write_bytes(prompt_raw)
(d / "points.json").write_text(json.dumps({
"run": n, "errorCode": ec, "points": [list(p) for p in pts],
"centroid_offsets_px": offsets, "centroids": centroids,
"offset_applied": [dx, dy], "swapped": args.swap,
"segments": last_gen.get("segments") or last_gen.get("segments_err"),
"solver_diag": state.get("diag"),
"prehandles": counters["prehandle"],
}, ensure_ascii=False, indent=1), encoding="utf-8")
row = {"run": n, "errorCode": ec, "points": [list(p) for p in pts],
"off_px": offsets, "secs": round(time.time() - t0, 1),
"prehandles": counters["prehandle"]}
rows.append(row)
print("run%-2d ec=%-4s 偏离质心px=%-22s prehandle=%-3d points=%s %.0fs" % (
n, ec, str(offsets), counters["prehandle"], pts, time.time() - t0))
time.sleep(1.0)
finally:
c.close()
icon_mod.CaptchaSolver.solve = real_icon_solve
client_mod.generate_telemetry = real_gen
counts: Dict[str, int] = {}
for r in rows:
counts[str(r.get("errorCode"))] = counts.get(str(r.get("errorCode")), 0) + 1
all_off = [v for r in rows for v in (r.get("off_px") or []) if isinstance(v, (int, float))]
summary = {
"target": args.target, "kind": args.kind, "n": args.n,
"offset_applied": [dx, dy], "swapped": args.swap,
"errorCode_counts": counts,
"centroid_offset_px": {
"count": len(all_off),
"min": min(all_off) if all_off else None,
"max": max(all_off) if all_off else None,
"mean": round(float(np.mean(all_off)), 1) if all_off else None,
},
"rows": rows,
}
(outdir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1),
encoding="utf-8")
print("\n" + "=" * 74)
print("靶子=%s 题型=%s 次数=%d 答案平移=%s 交换=%s" % (args.target, args.kind, args.n,
args.offset, args.swap))
print("errorCode 计数: %s" % json.dumps(counts, ensure_ascii=False))
if all_off:
print("点击点偏离图标笔画质心: 均值 %.1fpx / 最小 %.1f / 最大 %.1f(%d 个点)"
% (np.mean(all_off), min(all_off), max(all_off), len(all_off)))
print("产物: %s" % outdir)
print("=" * 74)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12 -3
View File
@@ -12,7 +12,11 @@
用法:
.venv/bin/python scripts/verify/send_test.py
.venv/bin/python scripts/verify/send_test.py --mode python_keyed # 对照组:旧模板回填
.venv/bin/python scripts/verify/send_test.py --kind icon # 2408 图标点选
.venv/bin/python scripts/verify/send_test.py --kind slider # 2401/2402 滑块
注: `--mode` 只剩 `python_values` 一种 —— 模板回填 / Node 兜底两条老路已整体删除
(它们产生的 collect 与本次会话构建不同源,在真实 aid 上必然 errorCode=12)。
"""
import argparse
import json
@@ -42,8 +46,8 @@ KIND_MAP = {
def main() -> int:
ap = argparse.ArgumentParser(description="1ms 签到页文字点选 · 真实发送测试")
ap.add_argument("--mode", default="python_values",
choices=["python_values", "python_keyed", "python"])
ap.add_argument("--mode", default="python_values", choices=["python_values"],
help="遥测生成方式(只有 python_values:纯离线按本次会话构建生成)")
ap.add_argument("--kind", default="text", choices=list(KIND_MAP),
help="只解哪种题(默认 text=文字点选)")
ap.add_argument("--max-refreshes", type=int, default=12)
@@ -76,6 +80,11 @@ def main() -> int:
seen["traj_tail"] = json.loads(traj)[-1]
except Exception as e: # noqa: BLE001
seen["segments_err"] = str(e)[:120]
try: # 失败时落盘,便于事后解剖
open("/tmp/send_test_fail.b64", "w").write(out["collect"])
open("/tmp/send_test_fail.txt", "w", encoding="latin1").write(plain)
except Exception: # noqa: BLE001
pass
print("[hook] %s" % json.dumps(seen, ensure_ascii=False))
return out