"""快手极速版 短信登录(纯 Python 复刻)。 基于已还原算法 + 主 app Retrofit 接口 ``zvl/a.java`` 定位的端点: - ``POST {host}/rest/n/user/requestMobileCode`` 发送短信验证码 - ``POST {host}/rest/n/user/login/mobileVerifyCode`` 验码登录 -> ``LoginUserResponse`` 关键:响应是**明文 JSON**,直接含 ``kuaishou.api_st`` / ``kuaishou.h5_st`` / ``kuaishou.api_client_salt`` / ``userInfo``,无需 pfl/kwsg 解密(与运营商 一键登录的加密 ``dataRsp`` 不同)。 请求签名走已还原的 ``sig`` / ``__NS_sig3`` / ``__NS_xfalcon``。 静态定位(2026-07-24):手机号可登录时,APP 发码 type=27;验码提交走 ``mobileVerifyCode``,form 字段是 ``code`` / ``type=27`` / ``isDegraded=false``, 并补账号保护字段 ``publicKey`` / ``raw`` / ``secret``。 唯一非 Python 环节:接收短信验证码(由调用方提供)。 待实测确认(host / 精确参数放置 / userInfo 字段名): - ``host``:走 aegon 网关,默认 ``apissl.ksapisrv.com``, 可经 ``KS_LOGIN_HOST`` 覆盖。 - ``session_seed``:native 在进程启动时由 ``srand(time)`` / ``rand()+1`` 生成;同一进程的所有 10418 请求共享 seed 和递增 counter。CLI 会现场复现, 也可经 ``KS_SIG3_SESSION_SEED`` 覆盖以回放旧样本。 """ from __future__ import annotations import base64 import gzip import hashlib import json import math import os import secrets import sys import time import urllib.parse import uuid from dataclasses import dataclass, field from typing import Any, Callable, Mapping from .device_cookie import device_profile_cookie_fields from .device_profile import DeviceProfile from .sig import sig as calc_sig from .sig3 import ( KWSG_10418_DEFAULT_STATE_SOURCE, Kwsg10418State, kwsg_10418_digest24_unmix, load_kwsg_10418_tables, ) from .tokensig import tokensig as calc_tokensig from .weapon_kas import build_weapon_signature_input from .xfalcon import xfalcon_value_from_input_bytes # 旧捕获样本的进程 seed;直接调用单个端点时作为兼容回退。 # CLI 实际运行会生成并传入一份进程级 Kwsg10418State。 LOGIN_SIG3_SESSION_SEED = 0x5D7E742B DEFAULT_LOGIN_HOST = "https://apissl.ksapisrv.com" # 实测确认(2026-07-24):路由 /rest/n/user/login # 当前 APP 启动链路通过 apijsv6 更新匿名 region;apihb6 保留为完整 base # 配置兼容回退,供主机未返回 region_info 时重试。 DEFAULT_REGION_HOST = "https://apijsv6.ksapisrv.com" REGION_FULL_CONFIG_HOST = "https://apihb6.ksapisrv.com" REGION_KEYCONFIG_PATH = "/rest/nebula/system/keyconfig" REGION_KEYCONFIG_BASE_VERSION = 6 REQUEST_MOBILE_CODE_PATH = "/rest/n/user/requestMobileCode" ANONYMOUS_TOKEN_PATH = "/rest/zt/pass/refresh/anonymousToken" # APP 登录页在发码/验码前会先跑手机号预检;真实请求同样被 path 归一到 # ``/rest/nebula/user/mobile/checker`` 并带 ``passport_account_image``。 MOBILE_CHECKER_PATH = "/rest/n/user/mobile/checker" # 初始短信登录候选端点(登出态 -> 验码 -> LoginUserResponse 会话)。 LOGIN_CODE_PATH = "/rest/n/user/login/mobileVerifyCode" # 运营商一键登录(zvl.a 方法 X,返回 LoginUserResponse)。 QUICK_LOGIN_PATH = "/rest/n/user/login/quickLogin" CLIENT_KEY = "2ac2a76d" SIGNATURE_QUERY_KEYS = {"sig", "sig2", "__NS_sig3", "__NStokensig", "__NS_xfalcon"} # requestMobileCode 的 type 不是验证码位数: # 27 = 已存在手机号短信登录(swl.q0 -> Qh(27)) # 302 = 不可登录手机号注册(swl.q0 -> Qh(302) -> register/mobileV2) # 6 = 手机验证/换绑页(phoneverify/c -> verify/mobile),不是登录拿会话。 LOGIN_SMS_CODE_TYPE = 27 REGISTER_SMS_CODE_TYPE = 302 VERIFY_MOBILE_CODE_TYPE = 6 ACCOUNT_SECURITY_KEY_BITS = 2048 # 14.5.50.11631 匿名登录实测静态协议字段(设备字段由 DeviceProfile 覆盖)。 _STATIC_API_PARAMS: dict[str, str] = { "kpn": "NEBULA", "kpf": "ANDROID_PHONE", "app": "0", "apptype": "22", "kcv": "1630", "thermal": "10000", "net": "WIFI", "slh": "0", "nbh": "0", "browseType": "3", "grant_browse_type": "AUTHORIZED", "userRecoBit": "0", "iuid": "", "cdid_tag": "0", "did_tag": "0", "keyconfig_state": "2", "hotfix_ver": "", "ftt": "bd-T-T", "earphoneMode": "1", "android_os": "0", "language": "zh-cn", "ud": "0", "bottom_navigation": "true", "is_background": "0", "icaver": "1", "darkMode": "false", } LOGIN_HEADERS: dict[str, str] = { "User-Agent": "kwai-android", "Accept-Language": "zh-cn", "Content-Type": "application/x-www-form-urlencoded", "Accept-Encoding": "gzip", "Connection": "keep-alive", "x-aegon-bussiness-message": '{"is_launch_finished":true}', "page-code": "PHONE_NUMBER_LOGIN_PAGE", } WeaponHeaderProvider = Callable[[str], Mapping[str, str]] @dataclass class LoginSession: """验证码/一键登录成功后的会话。""" api_st: str = "" h5_st: str = "" client_salt: str = "" user_id: str = "" mobile: str = "" mobile_country_code: str = "" pass_token: str = "" quicklogin_token: str = "" region_ticket: str = "" raw: dict[str, Any] = field(default_factory=dict) @property def ok(self) -> bool: return bool(self.api_st) def to_dict(self) -> dict[str, Any]: return { "api_st": self.api_st, "h5_st": self.h5_st, "client_salt": self.client_salt, "user_id": self.user_id, "mobile": self.mobile, "mobile_country_code": self.mobile_country_code, "pass_token": self.pass_token, "quicklogin_token": self.quicklogin_token, "region_ticket": self.region_ticket, "raw": self.raw, } @dataclass class RegionTicket: """keyconfig 中的区域路由身份。""" uid: str = "" name: str = "" ticket: str = "" status: int = 0 error: str = "" @property def ok(self) -> bool: return bool(self.ticket) def to_dict(self) -> dict[str, Any]: return { "uid": self.uid, "name": self.name, "ticket": self.ticket, "status": self.status, "error": self.error, } @dataclass(frozen=True) class _AccountSecurityMaterial: """APP ``accountsecurity.f`` 等价的最小密钥材料。""" n: int d: int e: int key_bytes: int public_key_der: bytes _ACCOUNT_SECURITY_MATERIAL: _AccountSecurityMaterial | None = None # 先做小素数试除,减少 Miller-Rabin 轮次调用成本。 _SMALL_PRIMES = ( 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499, 503, 509, 521, 523, 541, 547, 557, 563, 569, 571, 577, 587, 593, 599, 601, 607, 613, 617, 619, 631, 641, 643, 647, 653, 659, 661, 673, 677, 683, 691, 701, 709, 719, 727, 733, 739, 743, 751, 757, 761, 769, 773, 787, 797, 809, 811, 821, 823, 827, 829, 839, 853, 857, 859, 863, 877, 881, 883, 887, 907, 911, 919, 929, 937, 941, 947, 953, 967, 971, 977, 983, 991, 997, ) def _der_len(length: int) -> bytes: if length < 0x80: return bytes([length]) raw = length.to_bytes((length.bit_length() + 7) // 8, "big") return bytes([0x80 | len(raw)]) + raw def _der(tag: int, value: bytes) -> bytes: return bytes([tag]) + _der_len(len(value)) + value def _der_integer(value: int) -> bytes: if value == 0: raw = b"\x00" else: raw = value.to_bytes((value.bit_length() + 7) // 8, "big") if raw[0] & 0x80: raw = b"\x00" + raw return _der(0x02, raw) def _rsa_public_key_der(n: int, e: int) -> bytes: """生成 Java ``PublicKey.getEncoded()`` 同形态的 X.509 SPKI DER。""" rsa_public_key = _der(0x30, _der_integer(n) + _der_integer(e)) rsa_encryption_oid = b"\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01" algorithm = _der(0x30, rsa_encryption_oid + b"\x05\x00") public_bit_string = _der(0x03, b"\x00" + rsa_public_key) return _der(0x30, algorithm + public_bit_string) def _is_probable_prime(value: int, rounds: int = 16) -> bool: if value < 2: return False if value == 2: return True if value % 2 == 0: return False for prime in _SMALL_PRIMES: if value == prime: return True if value % prime == 0: return False d = value - 1 s = 0 while d % 2 == 0: s += 1 d //= 2 for _ in range(rounds): a = secrets.randbelow(value - 3) + 2 x = pow(a, d, value) if x in (1, value - 1): continue for _ in range(s - 1): x = pow(x, 2, value) if x == value - 1: break else: return False return True def _generate_prime(bits: int, e: int) -> int: while True: candidate = secrets.randbits(bits) | (1 << (bits - 1)) | 1 # 保证 e 与 phi 分量互素,避免后续求逆失败。 if math.gcd(candidate - 1, e) != 1: continue if _is_probable_prime(candidate): return candidate def _account_security_material() -> _AccountSecurityMaterial: """懒生成/复用账号保护 RSA 密钥。 APP 优先从 AndroidKeyStore 取 ``ks_account_protect_private``;没有时生成 RSA-2048,并把 publicKey 作为 X.509 DER Base64 提交。Python 端没有 AndroidKeyStore,这里按同协议生成进程级临时密钥。 """ global _ACCOUNT_SECURITY_MATERIAL if _ACCOUNT_SECURITY_MATERIAL is not None: return _ACCOUNT_SECURITY_MATERIAL e = 65537 prime_bits = ACCOUNT_SECURITY_KEY_BITS // 2 while True: p = _generate_prime(prime_bits, e) q = _generate_prime(prime_bits, e) n = p * q if q != p and n.bit_length() == ACCOUNT_SECURITY_KEY_BITS: break lam = math.lcm(p - 1, q - 1) d = pow(e, -1, lam) key_bytes = (n.bit_length() + 7) // 8 _ACCOUNT_SECURITY_MATERIAL = _AccountSecurityMaterial( n=n, d=d, e=e, key_bytes=key_bytes, public_key_der=_rsa_public_key_der(n, e), ) return _ACCOUNT_SECURITY_MATERIAL def _sha256withrsa_b64(material: _AccountSecurityMaterial, text: str) -> str: """Java ``Signature.getInstance("SHA256withRSA")`` 的 PKCS#1 v1.5 输出。""" digest = hashlib.sha256(text.encode("utf-8")).digest() digest_info = bytes.fromhex("3031300d060960864801650304020105000420") + digest padding_len = material.key_bytes - len(digest_info) - 3 if padding_len < 8: raise ValueError("RSA key too small for SHA256withRSA") encoded = b"\x00\x01" + (b"\xff" * padding_len) + b"\x00" + digest_info signature = pow(int.from_bytes(encoded, "big"), material.d, material.n) return base64.b64encode(signature.to_bytes(material.key_bytes, "big")).decode("ascii") def build_account_security_fields(*, raw_ms: int | None = None) -> dict[str, str]: """构造 ``mobileVerifyCode`` 需要的账号保护字段。 对应 APP: - ``publicKey`` = ``Base64(PublicKey.getEncoded())`` - ``raw`` = ``String.valueOf(System.currentTimeMillis())`` - ``secret`` = ``SHA256withRSA(raw, privateKey)`` 后标准 Base64 """ material = _account_security_material() raw = str(raw_ms if raw_ms is not None else int(time.time() * 1000)) return { "publicKey": base64.b64encode(material.public_key_der).decode("ascii"), "raw": raw, "secret": _sha256withrsa_b64(material, raw), } def login_api_params(profile: DeviceProfile, *, now_ms: int | None = None) -> dict[str, str]: """构造登录请求的设备 + app 参数(query)。 与 ``main.py._api_params()`` 同源:静态协议字段 + ``device_profile_cookie_fields`` 覆盖的设备字段(did/oDid/rdid/egid/mod/sys/硬件 等)。 """ if now_ms is None: now_ms = int(time.time() * 1000) params: dict[str, str] = dict(_STATIC_API_PARAMS) profile_fields = device_profile_cookie_fields(profile) # 两份当前版本 APP 最终登录日志均不在 query 发送这些通用 Cookie 字段。 # client_key/os 由 ParamsInterceptor 放入 form body,不能在 query 重复。 for key in ("oaid", "countryCode", "sid", "deviceName"): profile_fields.pop(key, None) params.update(profile_fields) # device_profile_cookie_fields 未覆盖但登录需要的运行期字段 params.setdefault("ver", ".".join(profile.app_version.split(".")[:2])) params.setdefault("sys", f"ANDROID_{profile.android_release}") params.setdefault("androidApiLevel", "36" if profile.android_release == "16" else profile.android_release) params.setdefault("did_gt", profile.runtime_hints.did_gt or str(profile.install_time_ms)) params.setdefault("cold_launch_time_ms", str(profile.cold_launch_time_ms)) return params def keyconfig_api_params(profile: DeviceProfile, *, now_ms: int | None = None) -> dict[str, str]: """构造 ``system/keyconfig`` 的 query 设备参数。 当前 APP 的 keyconfig 请求与登录请求共用大部分设备字段,但 ``client_key`` 和 ``os`` 仅出现在 keyconfig query 中。 """ params = login_api_params(profile, now_ms=now_ms) params["client_key"] = CLIENT_KEY params["os"] = "android" return params def _merge_extra_query_params(params: dict[str, str], extra_query_params: Mapping[str, str] | None) -> None: """合并 APP 抓到的 query 参数,同时丢弃旧签名字段。""" if not extra_query_params: return params.update(_clean_extra_query_params(extra_query_params)) def _clean_extra_query_params(extra_query_params: Mapping[str, str] | None) -> dict[str, str]: """清理 APP 抓到的 query 参数,移除旧签名字段。""" out: dict[str, str] = {} if not extra_query_params: return out for key, value in extra_query_params.items(): key = str(key) if key in SIGNATURE_QUERY_KEYS or key.startswith("__NS"): continue out[key] = "" if value is None else str(value) return out def signed_login_url( path: str, params: dict[str, str], body_pairs: list[tuple[str, str]], *, state: Kwsg10418State, base_url: str, t1: bytes, t2: bytes, client_salt: str = "", ) -> str: """构造带 ``sig`` / ``__NS_sig3`` / ``__NS_xfalcon`` 的登录 URL。 若提供 ``client_salt``,额外带 ``__NStokensig = SHA256(sig + client_salt)`` (验码登录需要;发码 ``requestMobileCode`` 不需要)。 """ request_path = _sig3_path(path) signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")} signing.update(dict(body_pairs)) sig_value = calc_sig(signing) sig3_value = state.sig3_hex(request_path + sig_value, t1=t1, t2=t2) xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value) token_sig = calc_tokensig(sig_value, client_salt) if client_salt else "" signed = dict(params) signed.update({"sig": sig_value, "__NS_sig3": sig3_value, "__NS_xfalcon": xfalcon}) if token_sig: signed["__NStokensig"] = token_sig return f"{base_url}{request_path}?{urllib.parse.urlencode(signed)}" def _sig3_path(path: str) -> str: """对齐 APP ``u7a.a.a(encodedPath)`` 的 sig3 path 归一规则。 真实请求 URL 仍使用 Retrofit 注解里的 ``/rest/n/...``;只有 ``KSecurity.atlasSign(path + sig)`` 前会把部分业务 path 归一到 ``/rest/nebula/...``。 """ if ( "rest/n/sf2020" in path or "rest/n/sf21" in path or "/rest/n/livep2p" in path or "rest/n/mp/" in path ): return path if "rest/n/" in path: return path.replace("rest/n/", "rest/nebula/") if "rest/system/" in path: return path.replace("rest/system/", "rest/nebula/system/") if "rest/user/" in path: return path.replace("rest/user/", "rest/nebula/user/") if "rest/photo/" in path: return path.replace("rest/photo/", "rest/nebula/photo/") return path def _xfalcon_from_sig_pair(sig_value: str, sig3_value: str) -> str: """对齐 APP ``u0a.p.b(path, sig+sig3)`` 的 KXGS 输入。 静态实现中 path 只用于跳过列表/开关判断,真正传入 KXGS 的 byte[] 是 第二参 ``str2.getBytes()``,也就是 ``sig + __NS_sig3``。 """ return xfalcon_value_from_input_bytes((sig_value + sig3_value).encode("utf-8")) def _sig3_state( session_seed: int | None = None, shared_state: Kwsg10418State | None = None, ) -> Kwsg10418State: if shared_state is not None: return shared_state seed = session_seed if session_seed is not None else _env_int("KS_SIG3_SESSION_SEED", LOGIN_SIG3_SESSION_SEED) return Kwsg10418State(session_seed=seed, state_source=KWSG_10418_DEFAULT_STATE_SOURCE) def _env_int(name: str, default: int) -> int: raw = os.environ.get(name) if not raw: return default return int(raw, 0) def _base_url() -> str: return os.environ.get("KS_LOGIN_HOST", DEFAULT_LOGIN_HOST).rstrip("/") def _post(post_func: Callable[..., Any] | None): if post_func is None: import requests return requests.post return post_func def _requests_timeout(timeout: Any) -> Any: """把 CLI 的单个秒数转成 requests(connect, read) 超时。 线上网关偶发长时间不回 status line;拆成较短连接超时 + 明确读超时, 能避免发码阶段一直卡在 ssl.read()。 """ if isinstance(timeout, tuple): return timeout try: read_timeout = max(1, int(timeout)) except (TypeError, ValueError): return timeout connect_timeout = min(5, read_timeout) return (connect_timeout, read_timeout) def build_request_id(*, now_ms: int | None = None, suffix: int | None = None) -> str: """构造 APP 网关使用的 13 位毫秒时间戳 + 5 位随机尾号。""" timestamp = int(time.time() * 1000) if now_ms is None else int(now_ms) tail = secrets.randbelow(100_000) if suffix is None else int(suffix) if tail < 0 or tail > 99_999: raise ValueError("request id suffix must be between 0 and 99999") return f"{timestamp}{tail:05d}" def build_login_headers( base_url: str, *, request_id: str | None = None, weapon_headers: Mapping[str, str] | None = None, ) -> dict[str, str]: """构造 APP 最终 OkHttp 登录请求头。""" headers = dict(LOGIN_HEADERS) headers["Host"] = urllib.parse.urlparse(base_url).hostname or "" headers["X-REQUESTID"] = request_id or build_request_id() if weapon_headers: for name in ("kaw", "kas"): value = weapon_headers.get(name) if value: headers[name] = str(value) return headers def _prepared_request_metadata(prepared: Any) -> dict[str, Any]: """提取实际出站请求的脱敏结构,供 705 身份绑定诊断。""" actual_url = str(getattr(prepared, "url", "") or "") parsed_url = urllib.parse.urlsplit(actual_url) query_pairs = urllib.parse.parse_qsl(parsed_url.query, keep_blank_values=True) query = dict(query_pairs) headers = getattr(prepared, "headers", None) try: request_id = str(headers.get("X-REQUESTID") or "") if headers is not None else "" cookie_header = str(headers.get("Cookie") or "") if headers is not None else "" except Exception: request_id = "" cookie_header = "" raw_body = getattr(prepared, "body", b"") or b"" if isinstance(raw_body, bytes): body_text = raw_body.decode("utf-8", errors="replace") elif isinstance(raw_body, str): body_text = raw_body else: body_text = "" body_pairs = urllib.parse.parse_qsl(body_text, keep_blank_values=True) body = dict(body_pairs) cookie_names = sorted( { item.partition("=")[0].strip() for item in cookie_header.split(";") if item.partition("=")[0].strip() } ) captcha_token = str(body.get("captcha_token") or "") metadata: dict[str, Any] = { "prepared": True, "request_id": request_id, "path": parsed_url.path, "prepared_header_names": sorted(str(name) for name in headers) if headers is not None else [], "query_keys": [key for key, _ in query_pairs], "body_keys": [key for key, _ in body_pairs], "cookie_names": cookie_names, "captcha_token_length": len(captcha_token), "captcha_token_sha256": ( hashlib.sha256(captcha_token.encode("utf-8")).hexdigest()[:12] if captcha_token else "" ), } sig3_value = str(query.get("__NS_sig3") or "") if sig3_value: try: parsed_sig3 = kwsg_10418_digest24_unmix(sig3_value) except (TypeError, ValueError): pass else: metadata["sig3_session_seed"] = int(parsed_sig3["session_seed"]) metadata["sig3_counter"] = int(parsed_sig3["counter"]) return metadata def _response_http_version(response: Any) -> str: """Normalize requests/urllib3 and curl_cffi HTTP version metadata.""" raw_version = getattr(getattr(response, "raw", None), "version", None) if raw_version is not None: return { 10: "HTTP/1.0", 11: "HTTP/1.1", 20: "HTTP/2", 30: "HTTP/3", }.get(raw_version, str(raw_version)) curl_version = getattr(response, "http_version", None) if curl_version is None: return "" version_name = str(getattr(curl_version, "name", "") or "").upper() if "V1_0" in version_name: return "HTTP/1.0" if "V1_1" in version_name: return "HTTP/1.1" if "V2" in version_name: return "HTTP/2" if "V3" in version_name: return "HTTP/3" try: version_number = int(curl_version) except (TypeError, ValueError): return str(curl_version) return { 1: "HTTP/1.0", 2: "HTTP/1.1", 3: "HTTP/2", 4: "HTTP/2", 5: "HTTP/2", 30: "HTTP/3", 31: "HTTP/3", }.get(version_number, str(curl_version)) def _cookie_jar_of(post_func: Any) -> Any: """Best-effort:从 bound post 方法取底层 session 的 cookie jar。 OkHttp4Android10Session 把真实 curl jar 包在 _CurlCookieAdapter._cookies 里; requests.Session 直接暴露 .cookies。 """ owner = getattr(post_func, "__self__", None) if owner is None: return None jar = getattr(owner, "cookies", None) inner = getattr(jar, "_cookies", None) return inner or jar def _debug_print_request_cookies(post_func: Any, url: str) -> None: """``KS_DEBUG_COOKIES=1`` 时打印会随本次请求出站的 cookie。 curl_cffi 把 cookie 放在 libcurl 引擎里,不写进 ``request.headers["Cookie"]``, 所以日志里的 ``cookie_names=-`` 不可信;这里直接从 jar 侧读,并按请求 host 标注哪些会真正被发送(SEND)还是被域过滤掉(skip)。 """ if not os.environ.get("KS_DEBUG_COOKIES"): return jar = _cookie_jar_of(post_func) # curl_cffi / httpx 的 Cookies 迭代得到的是 name 字符串;真实 Cookie 对象在 .jar jar = getattr(jar, "jar", None) or jar host = urllib.parse.urlsplit(url).hostname or "" rows: list[tuple[str, str, str, str]] = [] try: for cookie in jar or []: name = str(getattr(cookie, "name", "?") or "") domain = str(getattr(cookie, "domain", "") or "") path = str(getattr(cookie, "path", "") or "/") secure = "1" if getattr(cookie, "secure", False) else "0" rows.append((name, domain, path, secure)) except Exception as exc: # noqa: BLE001 print(f" [dbg-cookie] jar 读取失败: {exc.__class__.__name__}: {exc}", file=sys.stderr) return print(f" [dbg-cookie] host={host} jar_size={len(rows)}", file=sys.stderr) for name, domain, path, secure in sorted(rows, key=lambda r: (r[1], r[0])): dom = domain.lstrip(".") will_send = not domain or host == dom or host.endswith("." + dom) or host.endswith(dom) print( f" [{'SEND' if will_send else 'skip'}] {name} " f"domain={domain or '(none)'} path={path} secure={secure}", file=sys.stderr, ) def _do_post( post_func: Callable[..., Any], url: str, body: bytes, *, base_url: str, timeout: int, weapon_header_provider: WeaponHeaderProvider | None = None, ) -> dict[str, Any]: """POST 并捕获 status / body / **cookies** / Set-Cookie。 登录会话(api_st/h5_st)可能在响应 body 或 Set-Cookie 头,故全捕获。 """ import json request_meta: dict[str, Any] = { "has_kaw": False, "has_kas": False, "header_names": [], } try: weapon_headers = weapon_header_provider(url) if weapon_header_provider else None headers = build_login_headers(base_url, weapon_headers=weapon_headers) request_meta = { "has_kaw": bool(headers.get("kaw")), "has_kas": bool(headers.get("kas")), "header_names": sorted(headers), } _debug_print_request_cookies(post_func, url) resp = post_func(url, data=body, headers=headers, timeout=_requests_timeout(timeout)) except Exception as exc: # noqa: BLE001 return { "status": 0, "error": f"{exc.__class__.__name__}: {exc}", "body": None, "text": "", "cookies": {}, "set_cookie": [], "request_meta": request_meta, } prepared = getattr(resp, "request", None) if prepared is not None: request_meta.update(_prepared_request_metadata(prepared)) http_version = _response_http_version(resp) if http_version: request_meta["http_version"] = http_version text = getattr(resp, "text", "") or "" status = int(getattr(resp, "status_code", 0) or 0) body_obj: Any = None try: body_obj = json.loads(text) except Exception: pass # okhttp4 传输:协议层手填 Accept-Encoding: gzip 头,但 curl_cffi 的 # accept_encoding=None 不会自动解压响应。大响应(如 mobileVerifyCode 登录体) # 会被服务端 gzip,resp.text 即压缩字节 -> JSON 解析失败 -> body 误判为空、 # 会话 token 被丢弃。这里补 gunzip(及 b2a.h 的 XOR 0x2B)兜底解码。 if body_obj is None: content = getattr(resp, "content", None) if isinstance(content, str): content = content.encode("utf-8", "replace") if content: xor_raw = bytes(b ^ 0x2B for b in content) candidates: list[bytes] = [] for base in (content, xor_raw): candidates.append(base) try: candidates.append(gzip.decompress(base)) except (OSError, EOFError): pass for cand in candidates: try: body_obj = json.loads(cand.decode("utf-8", "replace")) text = cand.decode("utf-8", "replace") break except (UnicodeDecodeError, ValueError): continue if body_obj is None: ce = "" try: ce = resp.headers.get("Content-Encoding") if hasattr(resp.headers, "get") else "" except Exception: ce = "" sys.stderr.write( f"[_do_post] body 解析失败 status={status} " f"content_encoding={ce!r} len={len(content)} " f"head_hex={content[:48].hex()}\n" ) # cookies(requests: RequestsCookieJar;兼容 dict-like / 迭代) cookies: dict[str, str] = {} jar = getattr(resp, "cookies", None) if jar is not None: try: cookies = dict(jar) except Exception: try: cookies = {c.name: c.value for c in jar} # type: ignore[attr-defined] except Exception: pass # Set-Cookie 原始头 set_cookie: list[str] = [] hdrs = getattr(resp, "headers", None) if hdrs is not None: try: if hasattr(hdrs, "get_list"): set_cookie = list(hdrs.get_list("Set-Cookie")) except Exception: pass if not set_cookie: try: v = hdrs.get("Set-Cookie") if hasattr(hdrs, "get") else None except Exception: v = None if v: set_cookie = [v] return { "status": status, "error": None, "body": body_obj, "text": text, "cookies": cookies, "set_cookie": set_cookie, "request_meta": request_meta, } def decode_keyconfig_payload(content: bytes | bytearray | str) -> dict[str, Any]: """解析 keyconfig 响应:明文 JSON 或 APP 使用的逐字节 XOR ``0x2B``。""" raw = content.encode("utf-8") if isinstance(content, str) else bytes(content) candidates: list[bytes] = [raw] try: candidates.append(gzip.decompress(raw)) except (OSError, EOFError): pass xor_raw = bytes(value ^ 0x2B for value in raw) candidates.append(xor_raw) try: candidates.append(gzip.decompress(xor_raw)) except (OSError, EOFError): pass # curl_cffi okhttp4 profile 不会自动解 ``Content-Encoding: gzip``,而 APP 是 # OkHttp 透明解 gzip 后再由 b2a.h 拦截器做 XOR 0x2B。所以服务端实际发的是 # ``gzip(xor_0x2B(json))``:必须先 gunzip 再 XOR,上面的候选缺少这一顺序。 try: gunzipped = gzip.decompress(raw) except (OSError, EOFError): gunzipped = b"" if gunzipped: candidates.append(gunzipped) candidates.append(bytes(value ^ 0x2B for value in gunzipped)) for candidate in candidates: try: decoded = json.loads(candidate.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): continue if isinstance(decoded, dict): return decoded if os.environ.get("KS_DEBUG_KEYCONFIG"): head_hex = raw[:64].hex() head_ascii = "".join(chr(b) if 32 <= b < 127 else "." for b in raw[:64]) sys.stderr.write( f"[dbg-keyconfig] decode failed len={len(raw)} " f"head_hex={head_hex} head_ascii={head_ascii!r}\n" ) raise ValueError("keyconfig response is neither JSON nor XOR-0x2B JSON") def _extract_region_mapping(payload: Any) -> Mapping[str, Any] | None: if not isinstance(payload, (dict, list)): return None if isinstance(payload, dict): base = payload.get("base") if isinstance(base, dict): schedule = base.get("schedule") if isinstance(schedule, dict): region_info = schedule.get("region_info") if isinstance(region_info, dict) and isinstance(region_info.get("region"), dict): return region_info["region"] region = payload.get("region") if isinstance(region, dict) and str(region.get("ticket") or "").startswith("RT_"): return region for value in payload.values(): found = _extract_region_mapping(value) if found is not None: return found return None for value in payload: found = _extract_region_mapping(value) if found is not None: return found return None def extract_region_ticket(payload: Any, *, status: int = 200) -> RegionTicket: """从完整或差量 keyconfig JSON 中提取 ``region_info.region``。""" region = _extract_region_mapping(payload) if region is None: return RegionTicket(status=status, error="keyconfig response has no region ticket") ticket = str(region.get("ticket") or "") if not ticket.startswith("RT_"): return RegionTicket(status=status, error="keyconfig region ticket has invalid format") return RegionTicket( uid=str(region.get("uid") if region.get("uid") is not None else ""), name=str(region.get("name") or ""), ticket=ticket, status=status, ) def refresh_region_ticket( profile: DeviceProfile, *, api_st: str = "", client_salt: str = "", user_id: str = "", base_url: str | None = None, keyconfig_version: int = REGION_KEYCONFIG_BASE_VERSION, extra_query_params: Mapping[str, str] | None = None, timeout: int = 20, get_func: Callable[..., Any] | None = None, ) -> RegionTicket: """从 keyconfig 在线获取当前设备/账号对应的 ``region_ticket``。 使用完整 ``base`` 配置路径,响应按 APP ``b2a.h`` 的 XOR ``0x2B`` 拦截器解码。该端点在实测请求中只带 ``sig``;登录态额外带 ``__NStokensig``,不带 ``__NS_sig3``。 """ region_base = (base_url or os.environ.get("KS_REGION_HOST") or DEFAULT_REGION_HOST).rstrip("/") params = keyconfig_api_params(profile) _merge_extra_query_params(params, extra_query_params) params.update( { "ud": str(user_id or "0"), "kcv": str(keyconfig_version), "keyconfig_state": "1", "keyConfigVersion": str(keyconfig_version), "updatedKeyConfigKey": "base", "diffInfo": "", "ts": "0", "apiInvokeTiming": "LOGIN" if api_st else "COLD_START", "is_background": "0", "cs": "false", "language": "zh-cn", } ) for stale_auth_key in ("userId", "pUid", "kuaishou.api_st", "kuaishou.h5_st", "token"): params.pop(stale_auth_key, None) if api_st: params["kuaishou.api_st"] = api_st sig_value = calc_sig(params) params["sig"] = sig_value # keyconfig 在 APP 的签名跳过列表中,抓包表现为保留空 xfalcon 且无 sig3。 params["__NS_xfalcon"] = "" if api_st and client_salt: params["__NStokensig"] = calc_tokensig(sig_value, client_salt) url = f"{region_base}{REGION_KEYCONFIG_PATH}?{urllib.parse.urlencode(params)}" headers = { "Host": urllib.parse.urlparse(region_base).hostname or "", "User-Agent": LOGIN_HEADERS["User-Agent"], "Accept-Language": LOGIN_HEADERS["Accept-Language"], "Accept-Encoding": "gzip", "Connection": "close", } if get_func is None: import requests get_func = requests.get try: response = get_func(url, headers=headers, timeout=_requests_timeout(timeout)) except Exception as exc: # noqa: BLE001 return RegionTicket(error=f"{exc.__class__.__name__}: {exc}") status = int(getattr(response, "status_code", 0) or 0) content = getattr(response, "content", None) if content is None: content = (getattr(response, "text", "") or "").encode("utf-8") if status != 200: return RegionTicket(status=status, error=f"keyconfig HTTP {status}") try: payload = decode_keyconfig_payload(content) except (TypeError, ValueError) as exc: return RegionTicket(status=status, error=str(exc)) return extract_region_ticket(payload, status=status) def request_mobile_code( profile: DeviceProfile, mobile: str, *, mobile_country_code: str = "86", encrypted_mobile: str = "", passport_account_image: str = "", code_type: int = LOGIN_SMS_CODE_TYPE, use_voice: bool = False, need_check: bool = True, prefetch_phone_number: str = "", request_source: str = "login", captcha_token: str = "", base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, extra_query_params: Mapping[str, str] | None = None, exact_query_params: bool = False, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, weapon_header_provider: WeaponHeaderProvider | None = None, ) -> dict[str, Any]: """``POST /rest/n/user/requestMobileCode`` 发送短信验证码。 返回原始响应 JSON(``RequestVerifyCodeResponse``:``{result, isCheck, phone[]}``)。 """ base_url = (base_url or _base_url()).rstrip("/") state = _sig3_state(session_seed, sig3_state) if t1 is None or t2 is None: t1, t2 = load_kwsg_10418_tables() if exact_query_params and extra_query_params: params = _clean_extra_query_params(extra_query_params) else: params = login_api_params(profile) _merge_extra_query_params(params, extra_query_params) effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "") body_pairs = [ ("mobileCountryCode", mobile_country_code), ("mobile", effective_mobile), ("type", str(code_type)), ("useVoice", "true" if use_voice else "false"), ("needCheck", "true" if need_check else "false"), ("prefetchPhoneNumber", prefetch_phone_number), ("requestSource", request_source), ] # jlm.a 在 705 重放时改写原始 FieldMap 追加 token,与 mobile_checker/login_by_code 同路径。 if captcha_token: body_pairs.append(("captcha_token", captcha_token)) if encrypted_mobile or passport_image: body_pairs.extend( [ ("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")), ("os", "android"), ] ) if passport_image: body_pairs.append(("passport_account_image", passport_image)) body_pairs.extend( [ ("cs", "false"), ("client_key", CLIENT_KEY), ("uQaTag", os.environ.get("KS_UQA_TAG", "")), ] ) url = signed_login_url(REQUEST_MOBILE_CODE_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2) body = urllib.parse.urlencode(body_pairs).encode("utf-8") return _do_post( _post(post_func), url, body, base_url=base_url, timeout=timeout, weapon_header_provider=weapon_header_provider, ) def mobile_checker( profile: DeviceProfile, mobile: str, *, mobile_country_code: str = "86", encrypted_mobile: str = "", passport_account_image: str = "", captcha_token: str = "", base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, extra_query_params: Mapping[str, str] | None = None, exact_query_params: bool = False, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, weapon_header_provider: WeaponHeaderProvider | None = None, ) -> dict[str, Any]: """``POST /rest/n/user/mobile/checker`` 手机号登录前预检。 APP 在手机号输入/发码前会先走该请求。实测最终形态: ``mobileCountryCode`` / ``mobile`` / ``cs`` / ``client_key`` / ``videoModelCrowdTag`` / ``os`` / ``uQaTag`` / ``passport_account_image`` 其中 ``mobile`` 是 ``LoginHelper.b(phone)`` 的密文; ``passport_account_image`` 来自 ``WeaponHI.dd(21)``。 """ base_url = (base_url or _base_url()).rstrip("/") state = _sig3_state(session_seed, sig3_state) if t1 is None or t2 is None: t1, t2 = load_kwsg_10418_tables() if exact_query_params and extra_query_params: params = _clean_extra_query_params(extra_query_params) else: params = login_api_params(profile) _merge_extra_query_params(params, extra_query_params) effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "") body_pairs = [ ("mobileCountryCode", mobile_country_code), ("mobile", effective_mobile), ] # jlm.a 在 Retrofit 原始表单上追加 token,公共参数拦截器随后才补其余字段。 if captcha_token: body_pairs.append(("captcha_token", captcha_token)) body_pairs.extend( [ ("cs", "false"), ("client_key", CLIENT_KEY), ("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")), ("os", "android"), ("uQaTag", os.environ.get("KS_UQA_TAG", "")), ] ) if passport_image: body_pairs.append(("passport_account_image", passport_image)) url = signed_login_url(MOBILE_CHECKER_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2) body = urllib.parse.urlencode(body_pairs).encode("utf-8") return _do_post( _post(post_func), url, body, base_url=base_url, timeout=timeout, weapon_header_provider=weapon_header_provider, ) def parse_login_user_response(data: Any) -> LoginSession: """解析登录响应。会话字段可来自 body(``LoginUserResponse.data``)或 Set-Cookie。 ``data`` 是 ``_do_post`` 返回的 rich dict:``{status, body, text, cookies, set_cookie}``。 """ sess = LoginSession() if not isinstance(data, dict): sess.raw = {"_raw": data} return sess sess.raw = data body = data.get("body") if isinstance(data.get("body"), dict) else {} cookies = data.get("cookies") or {} payload = body.get("data") if isinstance(body.get("data"), dict) else body # api_st / h5_st / client_salt:先 body 字段,再 cookie sess.api_st = str(payload.get("kuaishou.api_st") or cookies.get("kuaishou.api_st") or "") sess.h5_st = str(payload.get("kuaishou.h5_st") or cookies.get("kuaishou.h5_st") or "") sess.client_salt = str(payload.get("kuaishou.api_client_salt") or cookies.get("kuaishou.api_client_salt") or "") sess.mobile = str(payload.get("mobile") or cookies.get("mobile") or "") sess.mobile_country_code = str(payload.get("mobileCountryCode") or cookies.get("mobileCountryCode") or "") sess.pass_token = str(payload.get("passToken") or cookies.get("passToken") or "") sess.quicklogin_token = str(payload.get("quickloginToken") or "") user_info = payload.get("userInfo") or payload.get("user") or payload.get("multiUserInfo") or {} if isinstance(user_info, list) and user_info: user_info = user_info[0] if isinstance(user_info, dict): sess.user_id = str( user_info.get("user_id") or user_info.get("userId") or user_info.get("eid") or user_info.get("uid") or "" ) # 没 user_id 时,从 cookie 的 userId 兜底 if not sess.user_id: sess.user_id = str(payload.get("userId") or payload.get("user_id") or cookies.get("userId") or cookies.get("ud") or "") response_region = extract_region_ticket(body, status=int(data.get("status") or 0)) if response_region.ok: sess.region_ticket = response_region.ticket return sess def fetch_anonymous_token( profile: DeviceProfile, *, base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, ) -> dict[str, str]: """``POST /rest/zt/pass/refresh/anonymousToken`` -> 访客 token(登出态用)。 返回 ``{"visitor_st": ..., "ssecurity": ..., "user_id": ...}``。 该接口保留给 RE-LOGIN/实验路径;当前短信验证码登录分支不再依赖它。 """ base_url = (base_url or _base_url()).rstrip("/") state = _sig3_state(session_seed, sig3_state) if t1 is None or t2 is None: t1, t2 = load_kwsg_10418_tables() params = login_api_params(profile) body_pairs = [ ("kuaishou.api.visitor_st", ""), ("cs", "false"), ("client_key", CLIENT_KEY), ("os", "android"), ] url = signed_login_url(ANONYMOUS_TOKEN_PATH, params, body_pairs, state=state, base_url=base_url, t1=t1, t2=t2) body = urllib.parse.urlencode(body_pairs).encode("utf-8") data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout) body_obj = data.get("body") if isinstance(data, dict) else None payload = body_obj.get("data") if isinstance(body_obj, dict) and isinstance(body_obj.get("data"), dict) else (body_obj or {}) return { "visitor_st": str(payload.get("kuaishou.api.visitor_st") or ""), "ssecurity": str(payload.get("ssecurity") or ""), "user_id": str(payload.get("userId") or ""), } def login_by_code( profile: DeviceProfile, mobile: str, code: str, *, mobile_country_code: str = "86", encrypted_mobile: str = "", passport_account_image: str = "", captcha_token: str = "", login_path: str | None = None, login_type: int = LOGIN_SMS_CODE_TYPE, is_degraded: bool = False, device_name: str | None = None, prefetch_phone_number: str | None = None, account_security_fields: Mapping[str, str] | None = None, include_prefetch_phone_number: bool = True, visitor_token: str = "", client_salt: str = "", base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, extra_query_params: Mapping[str, str] | None = None, exact_query_params: bool = False, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, weapon_header_provider: WeaponHeaderProvider | None = None, ) -> LoginSession: """``POST `` 验码登录 -> ``LoginSession``。 APP 已存在手机号短信登录分支: ``code`` / ``mobile`` / ``mobileCountryCode`` / ``type=27`` / ``isDegraded=false``,再由账号保护层补 ``deviceName`` / ``deviceMode`` / ``publicKey`` / ``raw`` / ``secret``。 实测 APP 登录链路里 ``mobile`` 可能已由 ``LoginHelper.b`` 加密; 纯 Python 路径可通过 ``encrypted_mobile`` 或 ``KS_ENCRYPTED_MOBILE`` 传入抓到/计算出的值。风险 body 参数 ``passport_account_image`` 来自 ``WeaponHI.dd(21)``,可通过同名参数 或 ``KS_PASSPORT_ACCOUNT_IMAGE`` 传入。 ``visitor_token`` / ``client_salt`` 是旧探测参数,当前分支保留入参但不写 form,避免偏离 APP 的 ``mobileVerifyCode`` FieldMap。 成功时 ``LoginSession.ok`` 为 True,含 ``api_st`` / ``h5_st`` / ``client_salt`` / ``user_id``。 """ base_url = (base_url or _base_url()).rstrip("/") path = login_path or LOGIN_CODE_PATH request_path = _sig3_path(path) state = _sig3_state(session_seed, sig3_state) if t1 is None or t2 is None: t1, t2 = load_kwsg_10418_tables() if exact_query_params and extra_query_params: params = _clean_extra_query_params(extra_query_params) else: params = login_api_params(profile) _merge_extra_query_params(params, extra_query_params) # APP 的 mobileVerifyCode FieldMap 只在 form body 中放 deviceName/deviceMode。 # 设备画像里带的 query deviceName 会和 body 同名但取值不同,导致签名明文不一致。 params.pop("deviceName", None) params.pop("deviceMode", None) device_label = device_name or os.environ.get("KS_DEVICE_NAME") or f"{profile.manufacturer}({profile.model})" effective_mobile = encrypted_mobile or os.environ.get("KS_ENCRYPTED_MOBILE") or mobile effective_prefetch_phone_number = mobile if prefetch_phone_number is None else prefetch_phone_number passport_image = passport_account_image or os.environ.get("KS_PASSPORT_ACCOUNT_IMAGE", "") account_security = ( {str(k): str(v) for k, v in account_security_fields.items()} if account_security_fields else build_account_security_fields() ) if not all(account_security.get(key) for key in ("raw", "publicKey", "secret")): raise ValueError("account_security_fields must contain raw/publicKey/secret") body_pairs = [ ("isDegraded", "true" if is_degraded else "false"), ("code", code), ("mobileCountryCode", mobile_country_code), ("deviceMode", device_label), ("mobile", effective_mobile), ("raw", account_security["raw"]), ("publicKey", account_security["publicKey"]), ("secret", account_security["secret"]), ("type", str(login_type)), ("deviceName", device_label), ] if include_prefetch_phone_number: body_pairs.insert(5, ("prefetchPhoneNumber", effective_prefetch_phone_number)) # APP 的 705 重试先由 jlm.a 改写原始 FieldMap,再经过公共参数拦截器。 if captcha_token: body_pairs.append(("captcha_token", captcha_token)) body_pairs.extend( [ ("videoModelCrowdTag", os.environ.get("KS_VIDEO_MODEL_CROWD_TAG", "")), ("os", "android"), ] ) if passport_image: body_pairs.append(("passport_account_image", passport_image)) body_pairs.extend( [ # Aegon ParamsInterceptor 会给 FormBody 追加这些公共字段后再签名。 # 一键登录/日志样例均显示它们在 body,而不是只靠 query。 ("cs", "false"), ("client_key", CLIENT_KEY), ("uQaTag", os.environ.get("KS_UQA_TAG", "")), ] ) # login/* 接口在 Retrofit 中是 @FieldMap;实测 mobileVerifyCode 最终形态: # URL path 被拦截器归一为 /rest/nebula/...,业务字段留在 form body, # sig/__NS_sig3/__NS_xfalcon 由拦截器写入 URL query。 signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")} signing.update(dict(body_pairs)) sig_value = calc_sig(signing) sig3_value = state.sig3_hex(request_path + sig_value, t1=t1, t2=t2) xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value) signed_params = dict(params) signed_params.update({"sig": sig_value, "__NS_xfalcon": xfalcon, "__NS_sig3": sig3_value}) if client_salt: signed_params["__NStokensig"] = calc_tokensig(sig_value, client_salt) url = f"{base_url}{request_path}?{urllib.parse.urlencode(signed_params)}" body = urllib.parse.urlencode(body_pairs).encode("utf-8") data = _do_post( _post(post_func), url, body, base_url=base_url, timeout=timeout, weapon_header_provider=weapon_header_provider, ) sess = parse_login_user_response(data) if sess.ok: if not sess.mobile: sess.mobile = mobile if not sess.mobile_country_code: sess.mobile_country_code = mobile_country_code return sess # 初始短信登录候选端点(登出态 -> 验码 -> LoginUserResponse 会话)。 # 一次验证码依次试,失败(result:11/109 一般不消耗码)则换下一个。 def _encode_varint(value: int) -> bytes: buf = bytearray() while value > 0x7F: buf.append((value & 0x7F) | 0x80) value >>= 7 buf.append(value & 0x7F) return bytes(buf) def build_provider_token( kpn: str = "NEBULA", did: str = "", user_id: int = 0, timestamp_ms: int | None = None, ) -> str: """构造运营商一键登录 ``provider_token``(protobuf {kpn, did, userId, ts} + base64)。 实测(2026-07-24):provider_token = protobuf{ field1(string) = kpn("NEBULA") field2(string) = did("ANDROID_...") field3(varint) = userId field4(varint) = timestamp(ms) } + base64。不含运营商 gwAuth/accessCode(那些在 pre-login 步骤用,拿 userId)。 **已知 userId 即可纯 Python 构造,不需要运营商 SDK。** """ if timestamp_ms is None: timestamp_ms = int(time.time() * 1000) buf = b"\x0a" + bytes([len(kpn)]) + kpn.encode("utf-8") buf += b"\x12" + bytes([len(did)]) + did.encode("utf-8") buf += b"\x18" + _encode_varint(int(user_id)) buf += b"\x20" + _encode_varint(int(timestamp_ms)) return base64.b64encode(buf).decode("ascii") def login_by_quick_login( profile: DeviceProfile, *, user_id: int = 0, provider_token: str = "", provider: int = 11, api_st: str = "", client_salt: str = "", base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, ) -> LoginSession: """``POST /rest/n/user/login/quickLogin`` 运营商一键登录 -> ``LoginSession``。 实测(2026-07-24 frida 抓包):签名字段(sig/sig3/tokensig/xfalcon)全部在 **body**(不在 query);body 还需 session_id/uQaTag/videoModelCrowdTag; token 字段名是 ``kuaishou.api_st``(不是 ``token``)。 """ if not provider_token and user_id: provider_token = build_provider_token("NEBULA", profile.did, user_id) if not provider_token: raise ValueError("需要 user_id 或 provider_token") base_url = (base_url or _base_url()).rstrip("/") path = QUICK_LOGIN_PATH state = _sig3_state(session_seed, sig3_state) if t1 is None or t2 is None: t1, t2 = load_kwsg_10418_tables() params = login_api_params(profile) # body 业务字段(签名前,不含 sig/__NS*) body_pairs = [ ("provider", str(provider)), ("provider_token", provider_token), ("session_id", str(uuid.uuid4())), ("cs", "false"), ("uQaTag", os.environ.get("KS_UQA_TAG", "3#33333333339999999999#DP:3hX9ONf4GgQVINru4wfjCg==#ecBl:33#ecPp:-9#cmNt:-1#cmHs:-5#cmMnsl:-0#cmAu:-3")), ("videoModelCrowdTag", "1_100"), ("os", "android"), ("client_key", CLIENT_KEY), ] # kuaishou.api_st:fresh 登录 = 空串(app 清空后无 session);RE-LOGIN = 已有 api_st body_pairs.append(("kuaishou.api_st", api_st)) # 计算签名(query 设备参数 + body 业务字段,不含 sig/__NS*) signing = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")} signing.update(dict(body_pairs)) sig_value = calc_sig(signing) sig3_value = state.sig3_hex(_sig3_path(path) + sig_value, t1=t1, t2=t2) token_sig = calc_tokensig(sig_value, client_salt) if client_salt else "" xfalcon = _xfalcon_from_sig_pair(sig_value, sig3_value) # 签名加入 body(不是 query!) body_pairs.append(("sig", sig_value)) body_pairs.append(("__NS_sig3", sig3_value)) body_pairs.append(("__NStokensig", token_sig)) # fresh=SHA256(sig+""),始终带 body_pairs.append(("__NS_xfalcon", xfalcon)) # URL 只有设备参数(无签名) url = f"{base_url}{path}?{urllib.parse.urlencode(params)}" body = urllib.parse.urlencode(body_pairs).encode("utf-8") data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout) return parse_login_user_response(data) body = urllib.parse.urlencode(body_pairs).encode("utf-8") data = _do_post(_post(post_func), url, body, base_url=base_url, timeout=timeout) return parse_login_user_response(data) LOGIN_CODE_CANDIDATES = [ LOGIN_CODE_PATH, # r0,APP 短信登录提交分支,返回 LoginUserResponse ] def login_by_code_try_paths( profile: DeviceProfile, mobile: str, code: str, *, paths: list[str] | None = None, mobile_country_code: str = "86", encrypted_mobile: str = "", passport_account_image: str = "", captcha_token: str = "", device_name: str | None = None, prefetch_phone_number: str | None = None, account_security_fields: Mapping[str, str] | None = None, include_prefetch_phone_number: bool = True, login_type: int = LOGIN_SMS_CODE_TYPE, base_url: str | None = None, session_seed: int | None = None, sig3_state: Kwsg10418State | None = None, extra_query_params: Mapping[str, str] | None = None, exact_query_params: bool = False, t1: bytes | None = None, t2: bytes | None = None, timeout: int = 20, post_func: Callable[..., Any] | None = None, weapon_header_provider: WeaponHeaderProvider | None = None, ) -> tuple[LoginSession, list[tuple[str, LoginSession]]]: """用一个验证码试登录端点,命中(拿到会话)即停。 默认只走 APP 定位到的 ``mobileVerifyCode``;``paths`` 仅用于显式实验。 返回 (最终 session, [(path, session), ...])。 """ paths = paths or LOGIN_CODE_CANDIDATES results: list[tuple[str, LoginSession]] = [] sess = LoginSession() for path in paths: sess = login_by_code( profile, mobile, code, mobile_country_code=mobile_country_code, encrypted_mobile=encrypted_mobile, passport_account_image=passport_account_image, captcha_token=captcha_token, device_name=device_name, prefetch_phone_number=prefetch_phone_number, account_security_fields=account_security_fields, include_prefetch_phone_number=include_prefetch_phone_number, login_path=path, login_type=login_type, base_url=base_url, session_seed=session_seed, sig3_state=sig3_state, extra_query_params=extra_query_params, exact_query_params=exact_query_params, t1=t1, t2=t2, timeout=timeout, post_func=post_func, weapon_header_provider=weapon_header_provider, ) results.append((path, sess)) if sess.ok: return sess, results return sess, results __all__ = [ "CLIENT_KEY", "ANONYMOUS_TOKEN_PATH", "ACCOUNT_SECURITY_KEY_BITS", "DEFAULT_REGION_HOST", "REGION_FULL_CONFIG_HOST", "MOBILE_CHECKER_PATH", "QUICK_LOGIN_PATH", "REGION_KEYCONFIG_BASE_VERSION", "REGION_KEYCONFIG_PATH", "LOGIN_SMS_CODE_TYPE", "REGISTER_SMS_CODE_TYPE", "VERIFY_MOBILE_CODE_TYPE", "build_account_security_fields", "build_login_headers", "build_provider_token", "build_request_id", "build_weapon_signature_input", "decode_keyconfig_payload", "extract_region_ticket", "fetch_anonymous_token", "login_by_quick_login", "LOGIN_CODE_PATH", "LOGIN_HEADERS", "LOGIN_SIG3_SESSION_SEED", "LoginSession", "REQUEST_MOBILE_CODE_PATH", "login_api_params", "login_by_code", "mobile_checker", "parse_login_user_response", "refresh_region_ticket", "request_mobile_code", "signed_login_url", ]