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:
@@ -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))
|
||||
@@ -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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user