feat(try): 演示采集脚本(不拖动,标注图输出 results/)

- demo_collect.py: 线上多轮触发验证码 → 本地求解 → 标注图
  (绿框目标位 + 黄色 mark 轮廓 + 蓝色拖动箭头 + 信息条)
- 每题保存原图标注 PNG 与 mark PNG,index.png 汇总长图
- 用途:给客户演示识别效果;验证码刷新换题,不消耗 verify 配额
- results/ 为演示产物目录,不纳入版本库
This commit is contained in:
杨豪
2026-09-16 13:06:29 +08:00
parent 6902be04d4
commit 581df23c97
2 changed files with 434 additions and 27 deletions
+2 -27
View File
@@ -1,29 +1,4 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
*.pyc
.venv/
venv/
# IDE
.idea/
.vscode/
*.swp
# 样本图片(不提交原始验证码)
captchas/
!captchas/.gitkeep
# 实验产物
try/out/
try/visual/
src/out/
*.png
*.jpeg
# 系统文件
.DS_Store
Thumbs.db
# CodeGraph 本地索引
.codegraph/
results/
+432
View File
@@ -0,0 +1,432 @@
"""演示采集:线上多轮触发验证码 → 本地识别 → results/ 标注图(不拖动)。
给客户演示识别效果:每轮采 1 题并求解,在大图上画出
- 绿色框 + 黄色轮廓:mark 应当重叠的目标位置
- 蓝色箭头:mark 从左侧初始位拖到目标位的偏移
- 文字:识别偏移 distance_px(原图 552 系)、缩放、旋转、置信分
用法: .venv/bin/python try/demo_collect.py [轮数=5] [每轮采题数=3]
产物: results/demo_YYYYMMDD_HHMMSS_N.{png,png} + results/index.png(汇总)
"""
import asyncio, base64, json, os, random, sys, time, urllib.request as ur
from pathlib import Path
import cv2
import numpy as np
import websockets
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
from captcha_solver import solve_slide
CDP = "http://127.0.0.1:9222"
SCALE = 340 / 552
OUT = ROOT / "results"
OUT.mkdir(exist_ok=True)
def random_phone():
return "1" + random.choice("3458") + "".join(random.choices("0123456789", k=9))
def random_password():
chars = "abcdefghijkmnpqrstuvwxyz"
caps = "ABCDEFGHJKLMNPQRSTUVWXYZ"
digits = "23456789"
syms = "!@#$%^&*"
pool = [random.choice(caps), random.choice(digits), random.choice(syms)]
pool += [random.choice(chars + caps + digits + syms) for _ in range(9)]
random.shuffle(pool)
return "".join(pool)
def _json(path):
try:
with ur.urlopen(f"{CDP}{path}", timeout=5) as r:
return json.load(r)
except (OSError, ValueError):
return None
async def demo_round(max_questions=3):
"""一轮:触发验证码 → 采 1 题求解 → 返回 (big_bytes, mark_bytes, result, tip_y) 或 None。"""
ver = _json("/json/version")
if not ver or not ver.get("webSocketDebuggerUrl"):
print("无法连接 CDP", file=sys.stderr)
return None
targets = _json("/json/list") or []
page = next((t for t in targets if t["type"] == "page" and "douyin" in t.get("url", "")), None)
if not page:
print("未找到 douyin 页面", file=sys.stderr)
return None
async with websockets.connect(ver["webSocketDebuggerUrl"], max_size=64 * 1024 * 1024) as ws:
mid = [0]
async def cdp(method, params=None, sid=None):
mid[0] += 1
m = {"id": mid[0], "method": method, "params": params or {}}
if sid:
m["sessionId"] = sid
await ws.send(json.dumps(m))
while True:
try:
r = json.loads(await ws.recv())
except (json.JSONDecodeError, asyncio.TimeoutError):
continue
if r.get("id") == mid[0]:
if "error" in r:
raise RuntimeError(f"{method}: {r['error']}")
return r.get("result", {})
psid = (await cdp("Target.attachToTarget", {"targetId": page["id"], "flatten": True}))["sessionId"]
await cdp("Runtime.enable", sid=psid)
async def js(expr):
r = await cdp("Runtime.evaluate",
{"expression": expr, "returnByValue": True, "awaitPromise": True}, psid)
return r.get("result", {}).get("value")
async def click(x, y):
for typ in ("mousePressed", "mouseReleased"):
await cdp("Input.dispatchMouseEvent",
{"type": typ, "x": x, "y": y, "button": "left",
"buttons": 1, "clickCount": 1}, psid)
async def type_text(text):
for ch in text:
await cdp("Input.dispatchKeyEvent", {"type": "keyDown", "text": ch}, psid)
await cdp("Input.dispatchKeyEvent", {"type": "keyUp"}, psid)
await asyncio.sleep(0.03)
def iframe_info():
return _json("/json/list") or []
# --- 触发验证码(已有 iframe 则复用) ---
has_iframe = any("rmc.bytedance.com" in t.get("url", "") for t in iframe_info())
if not has_iframe:
modal_raw = await js("""
(() => {
const modal = document.querySelector('[class*=login-panel], [id*=login-panel]');
const phone = [...document.querySelectorAll('input')].find(i => i.placeholder === '请输入手机号');
return JSON.stringify({open: !!modal, phoneVisible: !!(phone && phone.offsetWidth > 0)});
})()
""")
try:
modal_state = json.loads(modal_raw) if modal_raw else {}
except ValueError:
modal_state = {}
if not modal_state.get("open"):
await click(1860, 28)
await asyncio.sleep(2)
await js("""
(() => {
const other = [...document.querySelectorAll('*')].find(
e => e.children.length === 0 && /登录其他账号/.test(e.textContent || '') && e.offsetWidth > 0);
if (other) other.click();
})()
""")
await asyncio.sleep(1.5)
await js("""
(() => {
const els = [...document.querySelectorAll('*')].filter(
e => e.children.length === 0 && /密码登录/.test(e.textContent || '') && e.offsetWidth > 0);
if (els.length) els[0].click();
})()
""")
await asyncio.sleep(1.5)
captcha_ok = False
for attempt in range(6):
await js("""
(() => {
for (const ph of ['请输入手机号', '请输入密码']) {
const inp = [...document.querySelectorAll('input')].find(i => i.placeholder === ph);
if (inp) {
inp.focus();
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(inp, '');
inp.dispatchEvent(new Event('input', {bubbles: true}));
}
}
})()
""")
await asyncio.sleep(0.4)
geo_raw = await js("""
(() => {
const phone = [...document.querySelectorAll('input')].find(i => i.placeholder === '请输入手机号');
const cb = document.querySelector('span.f64a2kjb');
if (!phone) return 'null';
const pr = phone.getBoundingClientRect();
const cr = cb ? cb.getBoundingClientRect() : null;
return JSON.stringify({phone: [Math.round(pr.x + pr.width / 2), Math.round(pr.y + pr.height / 2)],
cb: cr ? [Math.round(cr.x + cr.width / 2), Math.round(cr.y + cr.height / 2)] : null});
})()
""")
try:
geo = json.loads(geo_raw) if geo_raw else None
except ValueError:
geo = None
if not geo:
print("登录弹窗没弹出")
return None
phone_no, pwd_str = random_phone(), random_password()
print(f"随机凭证: {phone_no}")
await click(*geo["phone"])
await asyncio.sleep(0.5)
await type_text(phone_no)
pwd_raw = await js("""
(() => {
const pwd = [...document.querySelectorAll('input')].find(i => i.placeholder === '请输入密码');
if (!pwd) return 'null';
const r = pwd.getBoundingClientRect();
return JSON.stringify([Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]);
})()
""")
try:
pwd_xy = json.loads(pwd_raw) if pwd_raw else None
except ValueError:
pwd_xy = None
if pwd_xy:
await click(*pwd_xy)
await asyncio.sleep(0.5)
await type_text(pwd_str)
if geo.get("cb"):
await click(*geo["cb"])
await asyncio.sleep(0.5)
await click(1165, 630)
for _ in range(25):
await asyncio.sleep(1.5)
if any("rmc.bytedance.com" in t.get("url", "") for t in iframe_info()):
captcha_ok = True
break
err_raw = await js("""
(() => {
const els = [...document.querySelectorAll('*')].filter(
e => e.children.length === 0 && e.textContent && e.offsetWidth > 0 &&
/仅支持验证码|不存在|格式有误|频繁|繁忙|错误/.test(e.textContent));
return els.length ? els[0].textContent.trim().slice(0, 60) : '';
})()
""")
if err_raw:
print(f"提交被拒: {err_raw},换号重试")
break
if captcha_ok:
break
if not captcha_ok:
print("多次换号仍未触发验证码")
return None
print("验证码已弹出")
# --- 等 iframe 渲染完成 ---
for _ in range(30):
targets = iframe_info()
it = next((t for t in targets if "rmc.bytedance.com" in t.get("url", "")), None)
if not it:
await asyncio.sleep(1.0)
continue
probe = (await cdp("Target.attachToTarget", {"targetId": it["id"], "flatten": True}))["sessionId"]
await cdp("Runtime.enable", sid=probe)
r = await cdp("Runtime.evaluate", {"expression": "document.documentElement.offsetWidth",
"returnByValue": True}, probe)
w = r.get("result", {}).get("value") or 0
await cdp("Target.detachFromTarget", {"sessionId": probe})
if w >= 380:
break
await asyncio.sleep(1.0)
targets = iframe_info()
ifr = next(t for t in targets if "rmc.bytedance.com" in t.get("url", ""))
isid = (await cdp("Target.attachToTarget", {"targetId": ifr["id"], "flatten": True}))["sessionId"]
await cdp("Runtime.enable", sid=isid)
async def jse(expr, sid=isid):
r = await cdp("Runtime.evaluate",
{"expression": expr, "returnByValue": True, "awaitPromise": True}, sid)
return r.get("result", {}).get("value")
geom_raw = await js("""
(() => {
const f = [...document.querySelectorAll('iframe')].find(f => /rmc\\.bytedance\\.com/.test(f.src));
const r = f.getBoundingClientRect();
return JSON.stringify({x: Math.round(r.x), y: Math.round(r.y), w: f.offsetWidth, h: f.offsetHeight});
})()
""")
try:
geom = json.loads(geom_raw)
except ValueError:
print("iframe 坐标解析失败")
return None
IX, IY = geom["x"], geom["y"]
print("hook:", await jse("""
(() => {
window.__lastGet = null;
const oOpen = XMLHttpRequest.prototype.open, oSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u){ this.__u = u; return oOpen.apply(this, arguments); };
XMLHttpRequest.prototype.send = function(){
this.addEventListener('load', () => {
try {
if (/captcha\\/get/.test(this.__u || '')) window.__lastGet = JSON.parse(this.responseText);
} catch (e) {}
});
return oSend.apply(this, arguments);
};
return 'ok';
})()
"""))
# --- 采题(refresh 只耗 get,不耗 verify 配额)---
got = None
for i in range(max_questions):
if i > 0 or got is None:
for typ in ("mousePressed", "mouseReleased"):
await cdp("Input.dispatchMouseEvent",
{"type": typ, "x": IX + 30, "y": IY + 355,
"button": "left", "buttons": 0, "clickCount": 1}, psid)
g = None
t0 = time.time()
while time.time() - t0 < 8 and not g:
await asyncio.sleep(0.8)
gg = await jse("JSON.stringify(window.__lastGet)")
if gg and gg != "null":
try:
g = json.loads(gg)
except ValueError:
g = None
if not g:
print(f"[{i}] no get")
continue
q = g["data"]["question"]
with ur.urlopen(q["url1"]) as r:
big = r.read()
with ur.urlopen(q["url2"]) as r:
mark = r.read()
res = solve_slide(base64.b64encode(big).decode(), base64.b64encode(mark).decode(),
tip_y=q["tip_y"])
if res.get("ok"):
got = (big, mark, res, q.get("tip_y"))
break
print(f"[{i}] 识别失败: {res.get('message')}")
await jse("window.__lastGet = null;")
# 采完关掉验证码(点右上角 ×,避免下一轮复用旧题)——不用 verify,直接清 cookie 导航
try:
await cdp("Network.enable", sid=psid)
await cdp("Network.clearBrowserCookies", sid=psid)
await cdp("Page.enable", sid=psid)
await cdp("Page.navigate", {"url": "https://www.douyin.com/jingxuan"}, sid=psid)
for _ in range(60):
try:
ev = json.loads(await asyncio.wait_for(ws.recv(), timeout=2))
except (asyncio.TimeoutError, json.JSONDecodeError, ValueError):
continue
if ev.get("method") == "Page.loadEventFired" and ev.get("sessionId") == psid:
break
await asyncio.sleep(3)
except RuntimeError:
pass
return got
def annotate(big_bytes, mark_bytes, res, tip_y):
"""标注图:绿框目标位 + 黄轮廓 + 蓝箭头拖动轨迹 + 文字。"""
arr = np.frombuffer(big_bytes, np.uint8)
big = cv2.imdecode(arr, cv2.IMREAD_COLOR)
marr = np.frombuffer(mark_bytes, np.uint8)
mark = cv2.imdecode(marr, cv2.IMREAD_UNCHANGED)
if big is None or mark is None or mark.ndim < 3 or mark.shape[2] < 4:
return None
try:
x, y = int(res["x"]), int(res["y"])
s = float(res.get("scale", 1.0))
except (KeyError, TypeError, ValueError):
return None
vis = big.copy()
h, w = mark.shape[:2]
# 目标框按识别的尺度画(mark 在大图中的实际覆盖范围)
th, tw = round(h * s), round(w * s)
cv2.rectangle(vis, (x, y), (x + tw, y + th), (0, 255, 0), 2)
# mark alpha 轮廓(黄色)贴到目标位
alpha = (mark[..., 3] > 128).astype(np.uint8) * 255
am = alpha
ys, xs = np.where(am > 0)
if len(ys):
try:
am = am[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
am_s = cv2.resize(am, (tw, th), interpolation=cv2.INTER_NEAREST)
edge = cv2.morphologyEx(am_s, cv2.MORPH_GRADIENT, np.ones((2, 2), np.uint8)) > 0
roi = vis[y:y + th, x:x + tw]
roi[edge] = (0, 255, 255)
except (ValueError, cv2.error):
pass
# 拖动轨迹箭头:mark 初始画布 x≈0,中心 y 对准目标中心
cy = y + th // 2
cv2.arrowedLine(vis, (8, cy), (x - 6, cy), (255, 128, 0), 2, tipLength=0.04)
cv2.putText(vis, f"drag {res['distance_px']}px", (8, max(14, cy - 10)),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (255, 128, 0), 1)
# 顶部信息条
bar = np.full((26, vis.shape[1], 3), 30, np.uint8)
txt = (f"x={res['x']} y={res['y']} scale={s:.3f} rot={res.get('rot', 0)} "
f"quality={res.get('quality', '-')} {res.get('confidence', '')}"
+ (f" tip_y={tip_y}" if tip_y is not None else ""))
cv2.putText(bar, txt, (6, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
vis = np.vstack([bar, vis])
return vis
async def main():
try:
rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 5
except ValueError:
rounds = 5
n_q = 3
done = 0
for i in range(rounds):
print(f"\n=== 轮 {i + 1}/{rounds} @ {time.strftime('%H:%M:%S')} ===", flush=True)
try:
got = await demo_round(n_q)
except (RuntimeError, OSError) as e:
print(f"{i + 1} 异常: {e}")
got = None
if not got:
print(f"{i + 1} 未采到题")
continue
big, mark, res, tip_y = got
ts = time.strftime("%Y%m%d_%H%M%S")
vis = annotate(big, mark, res, tip_y)
if vis is None:
print(f"{i + 1} 标注失败(图像解码/字段缺失)")
continue
cv2.imwrite(str(OUT / f"demo_{ts}_{i}.png"), vis)
(OUT / f"demo_{ts}_{i}-mark.png").write_bytes(mark)
print(f"✓ x={res['x']} y={res['y']} s={res.get('scale')} "
f"quality={res.get('quality')} {res.get('confidence')} → demo_{ts}_{i}.png")
done += 1
await asyncio.sleep(random.uniform(2, 4))
# 汇总图
sheets = []
for p in sorted(OUT.glob("demo_*_*.png")):
if p.name.endswith("-mark.png"):
continue
img = cv2.imread(str(p))
if img is None:
continue
try:
h, w = img.shape[:2]
k = 420 / h
small = cv2.resize(img, (max(1, int(w * k)), 420))
sheets.append(small)
except cv2.error:
continue
if sheets:
# 横向拼,超 4 张换行
rows = [np.hstack(sheets[i:i + 4]) for i in range(0, len(sheets), 4)]
wmax = max(r.shape[1] for r in rows)
rows = [np.hstack([r, np.full((r.shape[0], wmax - r.shape[1], 3), 20, np.uint8)])
for r in rows]
cv2.imwrite(str(OUT / "index.png"), np.vstack(rows))
print(f"\n{done} 张标注图 → {OUT}/(含 index.png 汇总)")
else:
print(f"\n{done} 张,无汇总")
if __name__ == "__main__":
asyncio.run(main())