- 真因: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。
304 lines
14 KiB
Python
304 lines
14 KiB
Python
# -*- 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())
|