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 "" 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", ]