ksjsb/core/captcha_assist.py
2026-07-30 20:25:56 +08:00

634 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import os
import shutil
import socket
import subprocess
import tempfile
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping
from urllib.parse import urlsplit
from .device_cookie import device_profile_cookie_fields
from .device_profile import DeviceProfile
KSECRET_VERIFY_PATH = "/rest/zt/captcha/sliding/kSecretApiVerify"
CAPTCHA_BIND_PATH = "/rest/wd/captcha/verify"
def _find_captcha_token(value: Any) -> str:
if isinstance(value, Mapping):
for key, item in value.items():
if str(key).lower() in {"captchatoken", "captcha_token"}:
token = str(item or "").strip()
if token:
return token
for item in value.values():
token = _find_captcha_token(item)
if token:
return token
elif isinstance(value, (list, tuple)):
for item in value:
token = _find_captcha_token(item)
if token:
return token
return ""
def _verify_result(payload: Any) -> int | None:
if not isinstance(payload, Mapping):
return None
value = payload.get("result")
if value is None and isinstance(payload.get("data"), Mapping):
value = payload["data"].get("result")
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
@dataclass
class CaptchaVerificationState:
captcha_token: str = ""
verify_result: int | None = None
verify_status: int = 0
verified: bool = False
def observe(
self,
url: str,
payload: Any,
*,
status: int,
request_payload: Mapping[str, Any] | None = None,
) -> None:
path = urlsplit(url).path
if path == KSECRET_VERIFY_PATH and 200 <= status < 300:
token = _find_captcha_token(payload)
if token:
self.captcha_token = token
return
if path != CAPTCHA_BIND_PATH:
return
self.verify_status = int(status)
self.verify_result = _verify_result(payload)
self.verified = 200 <= status < 300 and self.verify_result == 1
if self.verified and request_payload:
bound_token = str(request_payload.get("input") or "").strip()
if bound_token:
self.captcha_token = bound_token
@dataclass
class CaptchaBrowserResult:
verified: bool = False
captcha_token: str = ""
verify_result: int | None = None
verify_status: int = 0
cookies_synced: int = 0
browser_did: str = ""
identity_matched: bool = True
error: str = ""
def build_captcha_browser_cookies(profile: DeviceProfile) -> list[dict[str, Any]]:
"""按 APP WebView 注入顺序准备验证码页所需的匿名设备身份。"""
fields = device_profile_cookie_fields(profile)
values = {
"kpn": "NEBULA",
"kpf": "ANDROID_PHONE",
"userId": "0",
"did": fields["did"],
"didv": str(profile.install_time_ms),
"c": fields["c"],
"ver": fields["ver"],
"appver": fields["appver"],
"language": "zh-cn",
"countryCode": fields["countryCode"],
"sys": fields["sys"],
"mod": fields["mod"],
"deviceName": fields["deviceName"],
"net": "WIFI",
"client_key": "2ac2a76d",
"os": "android",
}
return [
{
"name": name,
"value": value,
"domain": ".kuaishou.com",
"path": "/",
"secure": True,
"httpOnly": False,
"sameSite": "Lax",
}
for name, value in values.items()
if value
]
def sync_browser_cookies(session: Any, cookies: Iterable[Mapping[str, Any]]) -> int:
count = 0
for cookie in cookies:
name = str(cookie.get("name") or "").strip()
if not name:
continue
value = str(cookie.get("value") or "")
kwargs: dict[str, Any] = {
"path": str(cookie.get("path") or "/"),
"secure": bool(cookie.get("secure", False)),
}
domain = str(cookie.get("domain") or "").strip()
if domain:
kwargs["domain"] = domain
expires = cookie.get("expires")
if isinstance(expires, (int, float)) and expires > 0:
kwargs["expires"] = int(expires)
session.cookies.set(name, value, **kwargs)
count += 1
return count
CaptchaBrowserDriver = Callable[..., list[Mapping[str, Any]]]
def _response_request_payload(response: Any) -> Mapping[str, Any] | None:
request = getattr(response, "request", None)
if request is None:
return None
try:
payload = request.post_data_json
except Exception:
payload = None
if isinstance(payload, Mapping):
return payload
try:
raw = request.post_data
except Exception:
raw = None
if not raw:
return None
try:
import json
payload = json.loads(raw)
except (TypeError, ValueError):
return None
return payload if isinstance(payload, Mapping) else None
def _update_captcha_assets(response: Any, assets: dict[str, Any]) -> None:
"""从 bg/cut/config 响应里抽字节, 供自动求解器喂 ddddocr。"""
try:
url = str(response.url)
except Exception:
return
if "/sliding/bgPic" in url:
try:
assets["bg"] = response.body()
except Exception:
pass
elif "/sliding/cutPic" in url:
try:
assets["cut"] = response.body()
except Exception:
pass
elif "/sliding/config" in url:
try:
data = response.json()
if isinstance(data, Mapping):
assets["config"].update(data)
except Exception:
pass
def _wait_captcha_assets(page: Any, assets: dict[str, Any], *, timeout: int) -> None:
deadline = time.monotonic() + max(2, min(int(timeout), 20))
while not (assets["bg"] and assets["cut"] and assets["config"].get("bgPicWidth")):
if time.monotonic() >= deadline:
raise TimeoutError("等待验证码 bg/cut/config 图片超时")
page.wait_for_timeout(200)
def _human_drag(page: Any, x0: float, y0: float, dx: float, *, seed: int | None = None) -> None:
"""拟人拖动: 余弦缓动 + 垂直微抖 + 小幅过冲后回正。"""
import math
import random
rnd = random.Random(seed)
page.mouse.move(x0, y0)
page.wait_for_timeout(rnd.randint(120, 260))
page.mouse.down()
steps, total_ms = 44, 820
peak = dx + rnd.uniform(3.0, 9.0)
base = total_ms / steps
for i in range(1, steps + 1):
t = i / steps
ease = 0.5 * (1 - math.cos(math.pi * t))
xi = x0 + peak * ease
yi = y0 + rnd.uniform(-2.0, 2.0)
page.mouse.move(xi, yi)
page.wait_for_timeout(int(base) + rnd.randint(0, 9))
for j in range(1, 7): # 过冲后回正到 dx
t = j / 6
xi = x0 + peak + (dx - peak) * t
page.mouse.move(xi, y0 + rnd.uniform(-1.5, 1.5))
page.wait_for_timeout(rnd.randint(14, 26))
page.wait_for_timeout(rnd.randint(90, 180))
page.mouse.up()
def _auto_solve_slider(
page: Any,
assets: dict[str, Any],
*,
timeout: int = 20,
offset: int = -48,
) -> None:
"""在 captcha iframe 内: ddddocr 定缺口 + 拟人拖 slider-btn。
几何全用 bounding_box() (自动换算到外层视口坐标, 与 page.mouse 一致),
scale = 显示宽 / config.bgPicWidth(原生)。offset 为对 target_x 的原生像素修正。
offset=-48 为本验证码的经验常量偏置: ddddocr 的 target_x 系统性偏右(拼图块模板
在其图像内有固定左内缩), 实测多张新鲜图 -48 均使 kSecretApiVerify result=1。
扫描偏置时可显式传 offset 覆盖(见 tools/captcha_auto_test.py)。
"""
import ddddocr
frame = None
for fr in page.frames:
if fr is not page.main_frame and "captcha" in fr.url:
frame = fr
break
if frame is None:
raise RuntimeError("未找到 captcha iframe")
frame.locator(".slider-btn").wait_for(state="visible", timeout=timeout * 1000)
bg_box = frame.locator("img[src*='bgPic']").bounding_box()
cut_box = frame.locator("img[src*='cutPic']").bounding_box()
btn_box = frame.locator(".slider-btn").bounding_box()
if not (bg_box and cut_box and btn_box):
raise RuntimeError(
f"滑块几何缺失 bg={bool(bg_box)} cut={bool(cut_box)} btn={bool(btn_box)}"
)
native_w = int(assets["config"].get("bgPicWidth") or 686)
scale = bg_box["width"] / native_w
det = ddddocr.DdddOcr(det=False, ocr=False, show_ad=False)
res = det.slide_match(assets["cut"], assets["bg"])
target_x = res.get("target_x") or (res.get("target") or [0])[0]
# 缺口在视口里的真实 x = 背景图左边沿(bg_box.x) + 原生缺口 x * 缩放;
# 拼图块需从其 home(cut_box.x) 移到该 x, 按钮与块 1:1 联动 -> drag 即为该差值。
# (早先漏了 bg_box.x 项, 块每次都落在 bg 左内缩 ~67px 处 -> 恒 350002)
gap_viewport_x = bg_box["x"] + (target_x + offset) * scale
drag = gap_viewport_x - cut_box["x"]
print(
f"[auto-captcha] target_x={target_x} offset={offset} scale={scale:.4f} "
f"gap_vp={gap_viewport_x:.1f} cut_x={cut_box['x']:.1f} drag={drag:.1f}"
)
_human_drag(
page,
btn_box["x"] + btn_box["width"] / 2,
btn_box["y"] + btn_box["height"] / 2,
drag,
)
# mouseup 后、verify+复位前 立刻读一次拼图块真实落点(竞态窗口约 100-300ms)。
landed_x = None
try:
landed_box = frame.locator("img[src*='cutPic']").bounding_box()
if landed_box:
landed_x = landed_box["x"]
delta = landed_x - gap_viewport_x
print(
f"[auto-captcha] landed cut_x={landed_x:.1f} "
f"vs gap_vp={gap_viewport_x:.1f} (Δ={delta:+.1f}px)"
)
except Exception:
pass
return {
"target_x": target_x,
"gap_vp": gap_viewport_x,
"landed_x": landed_x,
"drag": drag,
}
def _find_system_chromium() -> str:
candidates = [os.environ.get("KS_CAPTCHA_BROWSER", "")]
for command in ("msedge", "msedge.exe", "chrome", "chrome.exe", "chromium"):
candidates.append(shutil.which(command) or "")
if os.name == "nt":
for root_name, suffix in (
("ProgramFiles(x86)", "Microsoft/Edge/Application/msedge.exe"),
("ProgramFiles", "Microsoft/Edge/Application/msedge.exe"),
("LOCALAPPDATA", "Microsoft/Edge/Application/msedge.exe"),
("ProgramFiles", "Google/Chrome/Application/chrome.exe"),
("ProgramFiles(x86)", "Google/Chrome/Application/chrome.exe"),
("LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"),
):
root = os.environ.get(root_name, "")
if root:
candidates.append(str(Path(root) / suffix))
for candidate in candidates:
if candidate and Path(candidate).is_file():
return str(Path(candidate))
raise RuntimeError("未找到系统 Edge/Chrome可用 KS_CAPTCHA_BROWSER 指定浏览器路径")
def _free_local_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_for_cdp_endpoint(port: int, *, timeout: int) -> str:
endpoint = f"http://127.0.0.1:{port}"
deadline = time.monotonic() + max(1, min(int(timeout), 15))
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(f"{endpoint}/json/version", timeout=0.5) as response:
if int(getattr(response, "status", 0) or 0) == 200:
return endpoint
except Exception:
time.sleep(0.1)
raise TimeoutError("系统浏览器 DevTools 端口启动超时")
def _run_system_browser_challenge(
error_url: str,
observer: Callable[..., bool],
*,
timeout: int,
channel: str,
initial_cookies: Iterable[Mapping[str, Any]],
auto_solve: bool = False,
) -> list[Mapping[str, Any]]:
try:
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise RuntimeError("Playwright 未安装,请先执行 uv sync") from exc
browser_path = _find_system_chromium()
port = _free_local_port()
profile_dir = tempfile.mkdtemp(prefix="ksjsb-captcha-")
process: subprocess.Popen[bytes] | None = None
browser: Any = None
done = False
assets: dict[str, Any] = {"config": {}, "bg": b"", "cut": b""}
def on_response(response: Any) -> None:
nonlocal done
_update_captcha_assets(response, assets)
try:
payload = response.json()
except Exception:
return
done = bool(
observer(
response.url,
payload,
status=int(response.status),
request_payload=_response_request_payload(response),
)
) or done
try:
process = subprocess.Popen(
[
browser_path,
f"--remote-debugging-port={port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-mode",
"--window-size=430,920",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
endpoint = _wait_for_cdp_endpoint(port, timeout=timeout)
with sync_playwright() as playwright:
browser = playwright.chromium.connect_over_cdp(
endpoint,
timeout=max(1, min(int(timeout), 30)) * 1000,
)
if not browser.contexts:
raise RuntimeError("系统浏览器没有可用上下文")
context = browser.contexts[0]
context.add_cookies(list(initial_cookies))
page = context.pages[0] if context.pages else context.new_page()
page.on("response", on_response)
page.goto(
error_url,
wait_until="domcontentloaded",
timeout=max(1, min(int(timeout), 30)) * 1000,
)
page.bring_to_front()
if auto_solve:
_wait_captcha_assets(page, assets, timeout=timeout)
_auto_solve_slider(page, assets, timeout=timeout)
deadline = time.monotonic() + max(1, int(timeout))
while not done:
if page.is_closed():
raise RuntimeError("验证页已关闭,尚未观察到绑定成功响应")
if time.monotonic() >= deadline:
raise TimeoutError(f"等待验证码绑定超时({timeout}s")
page.wait_for_timeout(250)
return list(context.cookies())
finally:
if browser is not None:
try:
browser.close()
except Exception:
pass
if process is not None and process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
shutil.rmtree(profile_dir, ignore_errors=True)
def _run_playwright_challenge(
error_url: str,
observer: Callable[..., bool],
*,
timeout: int,
channel: str,
initial_cookies: Iterable[Mapping[str, Any]],
auto_solve: bool = False,
) -> list[Mapping[str, Any]]:
try:
from playwright.sync_api import sync_playwright
except ImportError as exc:
raise RuntimeError("Playwright 未安装,请先执行 uv sync") from exc
done = False
assets: dict[str, Any] = {"config": {}, "bg": b"", "cut": b""}
def on_response(response: Any) -> None:
nonlocal done
_update_captcha_assets(response, assets)
try:
payload = response.json()
except Exception:
return
done = bool(
observer(
response.url,
payload,
status=int(response.status),
request_payload=_response_request_payload(response),
)
) or done
with sync_playwright() as playwright:
launch_args: dict[str, Any] = {"headless": False}
if channel:
launch_args["channel"] = channel
browser = playwright.chromium.launch(**launch_args)
try:
context = browser.new_context(
viewport={"width": 400, "height": 900},
screen={"width": 400, "height": 900},
device_scale_factor=2,
is_mobile=True,
locale="zh-CN",
)
context.add_cookies(list(initial_cookies))
page = context.new_page()
page.on("response", on_response)
page.goto(
error_url,
wait_until="domcontentloaded",
timeout=max(1, min(int(timeout), 30)) * 1000,
)
if auto_solve:
_wait_captcha_assets(page, assets, timeout=timeout)
_auto_solve_slider(page, assets, timeout=timeout)
deadline = time.monotonic() + max(1, int(timeout))
while not done:
if page.is_closed():
raise RuntimeError("验证页已关闭,尚未观察到绑定成功响应")
if time.monotonic() >= deadline:
raise TimeoutError(f"等待验证码绑定超时({timeout}s")
page.wait_for_timeout(250)
return list(context.cookies())
finally:
browser.close()
def complete_captcha_in_browser(
error_url: str,
session: Any,
*,
timeout: int = 180,
channel: str = "msedge",
browser_driver: CaptchaBrowserDriver | None = None,
initial_cookies: Iterable[Mapping[str, Any]] = (),
expected_did: str = "",
auto_solve: bool = False,
) -> CaptchaBrowserResult:
parsed = urlsplit(error_url)
if parsed.scheme != "https" or not parsed.netloc:
return CaptchaBrowserResult(error="验证码地址必须是有效的 HTTPS URL")
state = CaptchaVerificationState()
def observe(
url: str,
payload: Any,
*,
status: int,
request_payload: Mapping[str, Any] | None = None,
) -> bool:
state.observe(
url,
payload,
status=status,
request_payload=request_payload,
)
return state.verified
if browser_driver is not None:
driver = browser_driver
elif str(channel or "").strip().lower() == "system":
driver = _run_system_browser_challenge
else:
driver = _run_playwright_challenge
try:
cookies = driver(
error_url,
observe,
timeout=max(1, int(timeout)),
channel=str(channel or ""),
initial_cookies=list(initial_cookies),
auto_solve=bool(auto_solve),
)
except Exception as exc: # noqa: BLE001
return CaptchaBrowserResult(
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
error=f"{exc.__class__.__name__}: {exc}",
)
browser_dids = {
str(cookie.get("value") or "").strip()
for cookie in cookies
if str(cookie.get("name") or "").strip() == "did"
and str(cookie.get("value") or "").strip()
}
browser_did = (
next(iter(browser_dids), "")
if len(browser_dids) == 1
else ",".join(sorted(browser_dids))
)
identity_matched = not expected_did or browser_dids == {expected_did}
if state.verified and not identity_matched:
actual = browser_did or "<missing>"
return CaptchaBrowserResult(
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
browser_did=browser_did,
identity_matched=False,
error=f"验证码浏览器 DID 不一致: expected={expected_did} actual={actual}",
)
cookies_synced = sync_browser_cookies(session, cookies) if state.verified else 0
return CaptchaBrowserResult(
verified=state.verified,
captcha_token=state.captcha_token,
verify_result=state.verify_result,
verify_status=state.verify_status,
cookies_synced=cookies_synced,
browser_did=browser_did,
identity_matched=identity_matched,
error="" if state.verified else "未观察到验证码绑定成功响应",
)
__all__ = [
"CAPTCHA_BIND_PATH",
"KSECRET_VERIFY_PATH",
"CaptchaBrowserResult",
"CaptchaVerificationState",
"build_captcha_browser_cookies",
"complete_captcha_in_browser",
"sync_browser_cookies",
]