1582 lines
58 KiB
Python
1582 lines
58 KiB
Python
"""快手极速版 短信登录(纯 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 <login_path>`` 验码登录 -> ``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",
|
||
]
|