634 lines
21 KiB
Python
634 lines
21 KiB
Python
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",
|
||
]
|