546 lines
22 KiB
Python
546 lines
22 KiB
Python
"""纯 HTTP 快手滑块验证码求解器(无浏览器)。
|
||
|
||
设计依据(out/captcha_net/js_08...deobf2.js 还原):
|
||
- POST /rest/zt/captcha/sliding/config body={captchaSession: <key>} -> {captchaSn, bgPicUrl, cutPicUrl, bgPicWidth=686, bgPicHeight=400, cutPicWidth=122, cutPicHeight=122, disX=24, disY=122, verifyUrl2, ...}
|
||
- GET bgPic/cutPic?captchaSn=...
|
||
- 缺口: ddddocr slide_match(cut, bg) -> target_x (686 原生像素空间)
|
||
- verify payload u = {captchaSn, bgDisWidth, bgDisHeight, cutDisWidth, cutDisHeight, relativeX, relativeY, trajectory, gpuInfo, captchaExtraParam}
|
||
- verifyParam = base64(Jose.$encrypt(utf8(JSON.stringify(u)), KEY)) KEY=c7b645db-...
|
||
- POST <verifyUrl2> body={verifyParam} -> {result, captchaToken|...}
|
||
|
||
坐标关键点: 配置响应处理把所有 *Img 字段乘 scaleRatio(=containerWidth/bgPicWidth)。
|
||
服务端校验的是比例(relativeX/bgDisWidth == gap/686),比例与 scale 无关,
|
||
故取 scaleRatio=1(全原生坐标)即可:bgDisWidth=686, relativeX=target_x+offset, relativeY=disY。
|
||
|
||
relativeX/relativeY/trajectory 取自 Vue 的 sliderImgX/sliderImgY/trajectory(原生空间)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import random
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import quote
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
JOSE_JS = ROOT / "tools" / "jose_encrypt.js"
|
||
JOSE_RAW_JS = ROOT / "tools" / "jose_encrypt_raw.js" # form-encode 明文加密(不做 JSON 包装)
|
||
NODE = sys.executable if Path(sys.executable).name.lower().startswith("node") else "node"
|
||
|
||
# 三坐标空间(deobf2 JS + 浏览器 Jose.call hook 实测互证):
|
||
# NATIVE 图像 686×400(ddddocr target_x、config bgPicWidth)
|
||
# DISPLAY CSS 316×184(浏览器 bgDisWidth/relativeX;APP WebView 控件布局常量,config 不返回)
|
||
# PHYSICAL DPR=3 948×552(浏览器 trajectory 终点 ~920)
|
||
# native→physical = (DISPLAY_BG_W/NATIVE_BG_W)*DPR = (316/686)*3 = 1.382
|
||
NATIVE_BG_W = 686
|
||
DISPLAY_BG_W = 316 # 浏览器 hook 实测的 WebView 控件 CSS 宽
|
||
DISPLAY_BG_H = 184
|
||
DISPLAY_REL_Y = 70 # 浏览器 relativeY 实测
|
||
DPR = 3
|
||
NATIVE_TO_PHYS = (DISPLAY_BG_W / NATIVE_BG_W) * DPR # ≈1.382
|
||
|
||
# verifyParam 明文字段固定序(deobf2 JS: .join("=")/.join("&") on encodeURIComponent)
|
||
_VERIFY_FIELD_ORDER = (
|
||
"captchaSn", "bgDisWidth", "bgDisHeight",
|
||
"cutDisWidth", "cutDisHeight",
|
||
"relativeX", "relativeY", "trajectory",
|
||
"gpuInfo", "captchaExtraParam",
|
||
)
|
||
|
||
CAPTCHA_HOST = "https://captcha.zt.kuaishou.com"
|
||
CONFIG_URL = CAPTCHA_HOST + "/rest/zt/captcha/sliding/config"
|
||
BGPIC_URL = CAPTCHA_HOST + "/rest/zt/captcha/sliding/bgPic"
|
||
CUTPIC_URL = CAPTCHA_HOST + "/rest/zt/captcha/sliding/cutPic"
|
||
|
||
# 关键: error_url 里的整数 key 不能直接喂给 /sliding/config(会 350004 session err)。
|
||
# APP 的 captcha.html 页面先走 JS 桥 /rest/wd/captcha/get,用 {type,uri,key} 换出
|
||
# 真正的 protobuf captchaSession blob(base64,前缀 "Cgp6dC5jYXB0Y2hh" = \n\x0a zt.captcha),
|
||
# 再拿 blob 去 config。纯 HTTP 必须复刻这一步。
|
||
MINT_HOST = "https://app.m.kuaishou.com"
|
||
MINT_URL = MINT_HOST + "/rest/wd/captcha/get"
|
||
DEFAULT_CAPTCHA_TYPE = 7
|
||
DEFAULT_LOGIN_URI = "/rest/nebula/user/login/mobileVerifyCode"
|
||
|
||
KEY = "c7b645db-65e8-401f-b38c-4c07c5fff247"
|
||
|
||
# 真机 OnePlus PJZ110 / Adreno 830 的 WebView WebGL 指纹(dumpsys SurfaceFlinger 实测)
|
||
GPU_INFO = {
|
||
"glRenderer": "Adreno (TM) 830",
|
||
"glVendor": "Qualcomm",
|
||
"unmaskRenderer": "Adreno (TM) 830",
|
||
"unmaskVendor": "Qualcomm",
|
||
}
|
||
|
||
MOBILE_UA = (
|
||
"Mozilla/5.0 (Linux; Android 16; PJZ110 Build/UKQ1.230917.001; wv) "
|
||
"AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0.6478.134 "
|
||
"Mobile Safari/537.36"
|
||
)
|
||
|
||
|
||
def jose_encrypt(payload: dict[str, Any]) -> str:
|
||
"""调用 tools/jose_encrypt.js 把 payload 加密成 base64 verifyParam。"""
|
||
proc = subprocess.run(
|
||
[NODE, str(JOSE_JS), json.dumps(payload, ensure_ascii=False)],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
cwd=str(ROOT),
|
||
)
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(f"jose_encrypt 进程失败 rc={proc.returncode}: {proc.stderr.strip()}")
|
||
out = proc.stdout.strip()
|
||
if out.startswith("ERR:") or not out:
|
||
raise RuntimeError(f"jose_encrypt 错误: {out or '(空)'}")
|
||
return out
|
||
|
||
|
||
def _enc(v: Any) -> str:
|
||
"""form-encode 单值:dict/list 先 compact json.dumps,再 encodeURIComponent。
|
||
|
||
与浏览器 encodeURIComponent 一致:space→%20(非+),|→%7C,,→%2C,
|
||
"→%22,:→%3A,{→%7B。gpuInfo/captchaExtraParam 传 dict,内层引号自动变 %22。
|
||
"""
|
||
s = (
|
||
json.dumps(v, ensure_ascii=False, separators=(",", ":"))
|
||
if isinstance(v, (dict, list))
|
||
else str(v)
|
||
)
|
||
return quote(s, safe="")
|
||
|
||
|
||
def jose_encrypt_form(fields: dict[str, Any]) -> str:
|
||
"""把 verify payload 按 10 字段固定序 form-encode 后喂 jose_encrypt_raw.js。
|
||
|
||
返回 base64 verifyParam。镜像 jose_encrypt(),但不做 JSON 包装——
|
||
明文是 `captchaSn=<enc>&bgDisWidth=<enc>&...&captchaExtraParam=<enc>`。
|
||
"""
|
||
missing = [k for k in _VERIFY_FIELD_ORDER if k not in fields]
|
||
if missing:
|
||
raise KeyError(f"verifyParam 缺字段: {missing}")
|
||
plaintext = "&".join(f"{k}={_enc(fields[k])}" for k in _VERIFY_FIELD_ORDER)
|
||
proc = subprocess.run(
|
||
[NODE, str(JOSE_RAW_JS), plaintext],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60,
|
||
cwd=str(ROOT),
|
||
)
|
||
if proc.returncode != 0:
|
||
raise RuntimeError(f"jose_encrypt_raw rc={proc.returncode}: {proc.stderr.strip()}")
|
||
out = proc.stdout.strip()
|
||
if not out or out.startswith("ERR:"):
|
||
raise RuntimeError(f"jose_encrypt_raw 错误: {out or '(空)'}")
|
||
return out
|
||
|
||
|
||
def gap_target_x(bg_bytes: bytes, cut_bytes: bytes) -> int:
|
||
"""ddddocr 滑块缺口检测,返回缺口在 686 原生背景图里的 x。"""
|
||
import ddddocr
|
||
|
||
det = ddddocr.DdddOcr(det=False, ocr=False, show_ad=False)
|
||
res = det.slide_match(cut_bytes, bg_bytes)
|
||
tx = res.get("target_x")
|
||
if tx is None:
|
||
target = res.get("target") or [0]
|
||
tx = target[0] if target else 0
|
||
return int(tx)
|
||
|
||
|
||
def build_trajectory(
|
||
drag_distance: float,
|
||
*,
|
||
y: float = 0.0,
|
||
x0: float = 0.0,
|
||
seed: int | None = None,
|
||
) -> str:
|
||
"""拟人轨迹 -> "x|y|dt,x|y|dt,..."(dt 相对首样本)。
|
||
|
||
x 从 x0 到 x0+drag_distance(绝对指针 clientX 空间;浏览器实测终点 ~920 含按钮屏原点)。
|
||
余弦缓动 + 过冲回正 + 垂直抖动,对齐 captcha_assist._human_drag 的节奏。
|
||
"""
|
||
rnd = random.Random(seed)
|
||
steps = 44
|
||
total_ms = 820
|
||
peak = drag_distance + rnd.uniform(3.0, 9.0)
|
||
pts: list[list[float]] = []
|
||
t_cursor = rnd.randint(120, 260)
|
||
base = total_ms / steps
|
||
for i in range(1, steps + 1):
|
||
t = i / steps
|
||
ease = 0.5 * (1 - math.cos(math.pi * t))
|
||
x = x0 + peak * ease
|
||
yy = y + rnd.uniform(-2.0, 2.0)
|
||
t_cursor += int(base) + rnd.randint(0, 9)
|
||
pts.append([round(x, 2), round(yy, 2), t_cursor])
|
||
# 过冲后回正到 drag_distance
|
||
for j in range(1, 7):
|
||
t = j / 6
|
||
x = x0 + peak + (drag_distance - peak) * t
|
||
yy = y + rnd.uniform(-1.5, 1.5)
|
||
t_cursor += rnd.randint(14, 26)
|
||
pts.append([round(x, 2), round(yy, 2), t_cursor])
|
||
t_cursor += rnd.randint(90, 180)
|
||
pts.append([round(x0 + float(drag_distance), 2), round(y, 2), t_cursor])
|
||
|
||
base_t = pts[0][2]
|
||
return ",".join(f"{p[0]}|{p[1]}|{int(p[2] - base_t)}" for p in pts)
|
||
|
||
|
||
def captcha_extra_param() -> dict[str, Any]:
|
||
"""captchaExtraParam = merge(ua(), collectEnvInfo()) 的极简 Android 视图。
|
||
|
||
服务端对 captchaExtraParam 宽松(桌面 Chromium 也能 kSecretApiVerify result=1),
|
||
故给出与设备身份自洽的 Android WebView 环境即可。
|
||
"""
|
||
return {
|
||
"ua": MOBILE_UA,
|
||
"language": "zh-cn",
|
||
"platform": "Linux armv8l",
|
||
"devicePixelRatio": 3,
|
||
"screenWidth": 1080,
|
||
"screenHeight": 2376,
|
||
"colorDepth": 24,
|
||
"timezone": -480,
|
||
"hardwareConcurrency": 8,
|
||
"deviceMemory": 12,
|
||
"touchSupport": "1",
|
||
"mod": "OnePlus(PJZ110)",
|
||
"sys": "ANDROID_16",
|
||
"appver": "14.5.50.11631",
|
||
"kpn": "NEBULA",
|
||
}
|
||
|
||
|
||
def _new_session():
|
||
from curl_cffi import requests as cffi_requests
|
||
|
||
# captcha.zt.kuaishou.com 服务于 APP 内 WebView(Chrome/126 系 TLS),
|
||
# 不需要登录链路的 OkHttp4 指纹。impersonate=chrome120 匹配 UA 的 JA3。
|
||
sess = cffi_requests.Session(impersonate="chrome120")
|
||
sess.headers.update(
|
||
{
|
||
"User-Agent": MOBILE_UA,
|
||
"Referer": "https://app.m.kuaishou.com/",
|
||
"Origin": "https://app.m.kuaishou.com",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
}
|
||
)
|
||
return sess
|
||
|
||
|
||
def _device_session(profile_path: str | None = None, verbose: bool = False):
|
||
"""带设备 cookie 的 session(mint 需要 did/egid)。
|
||
|
||
复用 core.captcha_assist.sync_browser_cookies 把 build_captcha_browser_cookies(profile)
|
||
灌进 curl_cffi session;domain=.kuaishou.com 同时覆盖 captcha.zt 与 mint 主机 app.m。
|
||
默认加载 out/devices/device_001.json(可复现)。
|
||
"""
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
from core.captcha_assist import build_captcha_browser_cookies, sync_browser_cookies
|
||
from core.device_profile import DeviceProfileGenerator, load_device_profile
|
||
|
||
sess = _new_session()
|
||
default_profile = ROOT / "out" / "devices" / "device_001.json"
|
||
path = profile_path or str(default_profile)
|
||
profile = load_device_profile(path) if Path(path).is_file() else DeviceProfileGenerator().new_profile()
|
||
n = sync_browser_cookies(sess, build_captcha_browser_cookies(profile))
|
||
if verbose:
|
||
print(f"[solver] 注入 {n} 个设备 cookie did={getattr(profile, 'did', '?')}", flush=True)
|
||
return sess, profile
|
||
|
||
|
||
def _is_blob(s: str) -> bool:
|
||
"""protobuf captchaSession blob 的 base64 固定前缀(field1="zt.captcha")。"""
|
||
return s.startswith("Cgp6dC5jYXB0Y2hh")
|
||
|
||
|
||
def _safe_json(resp: Any) -> tuple[dict | None, str]:
|
||
"""安全解析 JSON 响应。非 dict(如耗尽 key 返字面 "result:501" 字符串)→ (None, raw)。"""
|
||
try:
|
||
obj = resp.json()
|
||
except Exception:
|
||
return None, (resp.text or "")[:300]
|
||
if not isinstance(obj, dict):
|
||
return None, repr(obj)[:300]
|
||
return obj, ""
|
||
|
||
|
||
def mint_captcha_session(
|
||
session: Any,
|
||
key: str,
|
||
*,
|
||
uri: str = DEFAULT_LOGIN_URI,
|
||
captchatype: int = DEFAULT_CAPTCHA_TYPE,
|
||
) -> tuple[str, str]:
|
||
"""把 error_url 的整数 key 换成 protobuf captchaSession blob。
|
||
|
||
复刻 APP captcha.html 页面的 `/rest/wd/captcha/get` 调用(application/json)。
|
||
返回 (captchaSession_blob, config_url)。
|
||
"""
|
||
m = session.post(
|
||
MINT_URL,
|
||
json={"type": captchatype, "uri": uri, "key": key},
|
||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||
timeout=15,
|
||
)
|
||
mj, raw = _safe_json(m)
|
||
if mj is None:
|
||
raise RuntimeError(f"mint 非 dict/无效响应: {raw}")
|
||
if mj.get("result") != 1 or not mj.get("data"):
|
||
raise RuntimeError(f"mint /rest/wd/captcha/get 失败 result={mj.get('result')} desc={mj.get('desc')}")
|
||
d_raw = mj["data"]
|
||
try:
|
||
d = json.loads(d_raw) if isinstance(d_raw, str) else d_raw
|
||
except Exception:
|
||
raise RuntimeError(f"mint data 不可解析: {repr(str(d_raw)[:200])}")
|
||
if not isinstance(d, dict) or not d.get("captchaSession"):
|
||
raise RuntimeError(f"mint 无 captchaSession: {repr(str(d)[:200])}")
|
||
return d["captchaSession"], d.get("url") or CONFIG_URL
|
||
|
||
|
||
def solve_captcha(
|
||
session: Any | None = None,
|
||
captcha_session: str = "",
|
||
*,
|
||
error_url: str = "",
|
||
uri: str = DEFAULT_LOGIN_URI,
|
||
captchatype: int = DEFAULT_CAPTCHA_TYPE,
|
||
offset: int = -48,
|
||
geom_space: str = "display",
|
||
traj_scale: float = NATIVE_TO_PHYS,
|
||
traj_y: float = 0.0,
|
||
traj_x_base: float = 0.0,
|
||
verbose: bool = True,
|
||
) -> dict[str, Any]:
|
||
"""对一个 captcha key/error_url/blob 跑完整纯 HTTP 流程,返回诊断 dict。
|
||
|
||
captcha_session 可为: error_url 里的整数 key、完整 error_url、或已 mint 出的 blob。
|
||
整数 key 会先经 /rest/wd/captcha/get 换成 blob(复刻 APP captcha.html 的桥调用)。
|
||
|
||
session 可空(自建匿名 session);正式接入登录时传入【与登录同一个】
|
||
curl_cffi session,使 captcha token 与登录设备身份(did/egid cookies)绑定。
|
||
"""
|
||
own_session = session is None
|
||
if own_session:
|
||
session, _profile = _device_session(verbose=verbose)
|
||
if error_url:
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
qs = parse_qs(urlsplit(error_url).query)
|
||
if not captcha_session:
|
||
captcha_session = (qs.get("key") or qs.get("captchaSession") or [""])[0]
|
||
if qs.get("type"):
|
||
captchatype = int(qs["type"][0])
|
||
if qs.get("uri"):
|
||
uri = qs["uri"][0]
|
||
if not captcha_session:
|
||
return {"ok": False, "stage": "input", "error": "缺少 captchaSession/key"}
|
||
|
||
def log(msg: str) -> None:
|
||
if verbose:
|
||
print(f"[solver] {msg}", flush=True)
|
||
|
||
try:
|
||
# 0) 整数 key -> protobuf blob(若已是 blob 则跳过)
|
||
if _is_blob(captcha_session):
|
||
cfg_url = CONFIG_URL
|
||
else:
|
||
log(f"mint key->blob type={captchatype} uri={uri}")
|
||
captcha_session, cfg_url = mint_captcha_session(
|
||
session, captcha_session, uri=uri, captchatype=captchatype
|
||
)
|
||
log(f"mint ok blob[:32]={captcha_session[:32]}...")
|
||
|
||
# 1) config
|
||
log(f"POST config captchaSession={captcha_session[:24]}...")
|
||
cfg_r = session.post(
|
||
cfg_url,
|
||
data={"captchaSession": captcha_session},
|
||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||
timeout=15,
|
||
)
|
||
cfg, cfg_raw = _safe_json(cfg_r)
|
||
if cfg is None:
|
||
return {"ok": False, "stage": "config", "status": cfg_r.status_code, "body": cfg_raw}
|
||
log(f"config result={cfg.get('result')} desc={cfg.get('desc')}")
|
||
if cfg.get("result") != 1:
|
||
return {"ok": False, "stage": "config", "raw": cfg}
|
||
|
||
sn = cfg["captchaSn"]
|
||
bg_w = int(cfg.get("bgPicWidth") or 686)
|
||
bg_h = int(cfg.get("bgPicHeight") or 400)
|
||
cut_w = int(cfg.get("cutPicWidth") or 122)
|
||
cut_h = int(cfg.get("cutPicHeight") or 122)
|
||
dis_x = int(cfg.get("disX") or 24)
|
||
dis_y = int(cfg.get("disY") or 122)
|
||
verify_url2 = cfg.get("verifyUrl2") or (CAPTCHA_HOST + "/rest/zt/captcha/sliding/kSecretApiVerify")
|
||
bg_url = (cfg.get("bgPicUrl") or BGPIC_URL) + f"?captchaSn={sn}"
|
||
cut_url = (cfg.get("cutPicUrl") or CUTPIC_URL) + f"?captchaSn={sn}"
|
||
|
||
# 2) bg/cut bytes
|
||
bg = session.get(bg_url, timeout=15).content
|
||
cut = session.get(cut_url, timeout=15).content
|
||
log(f"bg={len(bg)}B cut={len(cut)}B")
|
||
|
||
# 3) gap (原生 686 空间)
|
||
target_x = gap_target_x(bg, cut)
|
||
gap_native = target_x + offset # 缺口在原生图里的 x(已含 ddddocr 右偏修正)
|
||
log(f"target_x={target_x} offset={offset} gap_native={gap_native} geom={geom_space} traj_scale={traj_scale:.3f}")
|
||
|
||
# 4) 几何空间 + trajectory 空间(浏览器实测:几何 DISPLAY,trajectory PHYSICAL,混合空间)
|
||
if geom_space == "display":
|
||
scale_d = DISPLAY_BG_W / NATIVE_BG_W # 0.4606
|
||
payload_bg_w, payload_bg_h = DISPLAY_BG_W, DISPLAY_BG_H
|
||
relative_x = round(gap_native * scale_d)
|
||
relative_y = DISPLAY_REL_Y
|
||
cut_dw, cut_dh = int(cut_w * scale_d), int(cut_h * scale_d)
|
||
else: # native(control,旧全原生行为)
|
||
payload_bg_w, payload_bg_h = bg_w, bg_h
|
||
relative_x = gap_native
|
||
relative_y = dis_y
|
||
cut_dw, cut_dh = cut_w, cut_h
|
||
# trajectory 在物理像素空间:drag_native × traj_scale(默认 1.382)
|
||
drag_native = gap_native - dis_x
|
||
drag_physical = drag_native * traj_scale
|
||
trajectory = build_trajectory(drag_physical, y=traj_y, x0=traj_x_base, seed=int(time.time()) & 0xFFFF)
|
||
|
||
# 5) captchaExtraParam = key1-39, 含拖拽相关传感器(合成, 与 trajectory 时间相关)
|
||
try:
|
||
from captcha_env import build_captcha_extra_param
|
||
except ImportError:
|
||
from tools.captcha_env import build_captcha_extra_param
|
||
extra_param = build_captcha_extra_param(
|
||
drag_physical=drag_physical,
|
||
slider_x=traj_x_base,
|
||
slider_y=float(dis_y) * (NATIVE_TO_PHYS if geom_space == "display" else 1.0) + 720.0,
|
||
duration_ms=820.0,
|
||
seed=int(time.time()) & 0xFFFF,
|
||
)
|
||
|
||
# 6) payload — gpuInfo/captchaExtraParam 传 dict(jose_encrypt_form 内部 compact json + form-encode)
|
||
payload = {
|
||
"captchaSn": sn,
|
||
"bgDisWidth": payload_bg_w,
|
||
"bgDisHeight": payload_bg_h,
|
||
"cutDisWidth": cut_dw,
|
||
"cutDisHeight": cut_dh,
|
||
"relativeX": relative_x,
|
||
"relativeY": relative_y,
|
||
"trajectory": trajectory,
|
||
"gpuInfo": GPU_INFO,
|
||
"captchaExtraParam": extra_param,
|
||
}
|
||
log(f"payload geom={geom_space} relX={relative_x} drag_native={drag_native} drag_phys={drag_physical:.1f}")
|
||
|
||
# 6) encrypt(form-encode 明文,非 JSON)
|
||
verify_param = jose_encrypt_form(payload)
|
||
log(f"verifyParam len={len(verify_param)}")
|
||
|
||
# 7) verify (inner iframe: axios.post(url,{verifyParam},{Content-Type:application/json}))
|
||
verify_r = session.post(
|
||
verify_url2,
|
||
json={"verifyParam": verify_param},
|
||
headers={"Content-Type": "application/json"},
|
||
timeout=15,
|
||
)
|
||
vj, vj_raw = _safe_json(verify_r)
|
||
if vj is None:
|
||
vj = {"_text": vj_raw}
|
||
log(f"verify status={verify_r.status_code} body={str(vj)[:200]}")
|
||
|
||
# result==1 且含 captchaToken 才算成功
|
||
token = ""
|
||
if isinstance(vj, dict):
|
||
token = str(vj.get("captchaToken") or vj.get("token") or "")
|
||
return {
|
||
"ok": isinstance(vj, dict) and vj.get("result") == 1 and bool(token),
|
||
"stage": "verify",
|
||
"result": vj.get("result") if isinstance(vj, dict) else None,
|
||
"captcha_token": token,
|
||
"target_x": target_x,
|
||
"relativeX": relative_x,
|
||
"verifyParam": verify_param,
|
||
"raw": vj,
|
||
}
|
||
finally:
|
||
if own_session:
|
||
try:
|
||
session.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _default_session() -> str:
|
||
"""error_url 的整数 key(dummy-code submit 免 SMS 回收),可反复 mint。"""
|
||
return "-7707052661950021982"
|
||
|
||
|
||
def sweep_solve(
|
||
key: str,
|
||
*,
|
||
session: Any | None = None,
|
||
offsets: tuple[int, ...] = (-60, -48, -36, -24, -12, 0),
|
||
traj_scales: tuple[float, ...] = (NATIVE_TO_PHYS, 1.0, 3.0),
|
||
geom_space: str = "display",
|
||
uri: str = DEFAULT_LOGIN_URI,
|
||
captchatype: int = DEFAULT_CAPTCHA_TYPE,
|
||
stop_on_success: bool = True,
|
||
verbose: bool = True,
|
||
) -> list[dict[str, Any]]:
|
||
"""对一个 key 扫描 (offset, traj_scale) 网格,找返回 result=1 的 cell(350002 调参)。
|
||
|
||
一个设备 cookie session 复用所有 cell;每次 solve_captcha 内部重 mint 新
|
||
captchaSession(captchaSn 是否单用未知,保守重 mint;offset 是 ddddocr 系统性
|
||
右偏的每图常量修正,会跨图泛化)。
|
||
"""
|
||
own_session = session is None
|
||
if own_session:
|
||
session, _profile = _device_session(verbose=verbose)
|
||
results: list[dict[str, Any]] = []
|
||
try:
|
||
for traj_scale in traj_scales:
|
||
for off in offsets:
|
||
res = solve_captcha(
|
||
session, key,
|
||
offset=off, traj_scale=traj_scale, geom_space=geom_space,
|
||
uri=uri, captchatype=captchatype, verbose=verbose,
|
||
)
|
||
cell = {
|
||
"offset": off, "traj_scale": round(traj_scale, 3), "geom": geom_space,
|
||
"ok": res.get("ok"), "result": res.get("result"),
|
||
"stage": res.get("stage"), "target_x": res.get("target_x"),
|
||
"token": (res.get("captcha_token") or "")[:16],
|
||
}
|
||
results.append(cell)
|
||
print(f"[sweep] off={off:+d} scale={traj_scale:.3f} geom={geom_space} "
|
||
f"-> result={cell['result']} stage={cell['stage']}", flush=True)
|
||
if cell["ok"] and stop_on_success:
|
||
print(f"[sweep] ✅ 命中 off={off:+d} scale={traj_scale:.3f}", flush=True)
|
||
return results
|
||
finally:
|
||
if own_session:
|
||
try:
|
||
session.close()
|
||
except Exception:
|
||
pass
|
||
return results
|
||
|
||
|
||
if __name__ == "__main__":
|
||
args = sys.argv[1:]
|
||
sweep = "--sweep" in args
|
||
positional = [a for a in args if a != "--sweep"]
|
||
key = positional[0] if positional else _default_session()
|
||
if sweep:
|
||
rows = sweep_solve(key, verbose=True)
|
||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||
else:
|
||
res = solve_captcha(captcha_session=key, verbose=True)
|
||
print(json.dumps(res, ensure_ascii=False, indent=2))
|