ksjsb/tools/sms_login_cli.py
2026-07-30 21:38:10 +08:00

699 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""快手极速版短信登录 CLI全新设备 -> 注册 -> 发码 -> 验码 -> 会话)。
每次执行都会现场生成设备画像并在线注册,不读取或保存历史设备、抓包字段和
区域票据。公开参数只包含登录输入和 HTTP 传输选择:
uv run python -m tools.sms_login_cli --mobile 13800000000
uv run python -m tools.sms_login_cli --mobile 13800000000 --code 123456
uv run python -m tools.sms_login_cli --mobile 13800000000 --transport okhttp4-android10
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.parse
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from core.device_cookie import device_profile_cookie_fields # noqa: E402
from core.device_profile import DeviceProfileGenerator # noqa: E402
from core.http_transport import create_http_session # noqa: E402
from core.mobile_encrypt import loginhelper_encrypt_mobile # noqa: E402
from core.sig3 import Kwsg10418State, kwsg_10418_session_seed_from_time # noqa: E402
from core.sms_login import ( # noqa: E402
DEFAULT_LOGIN_HOST,
DEFAULT_REGION_HOST,
LOGIN_SMS_CODE_TYPE,
REGION_FULL_CONFIG_HOST,
build_account_security_fields,
login_api_params,
login_by_code_try_paths,
refresh_region_ticket,
request_mobile_code,
)
from core.weapon_kas import APK_DEFAULT_WEAPON_KAW, WeaponProofProvider # noqa: E402
from core.weapon_mf import generate_profile_passport_account_image # noqa: E402
from tools.new_device import run_online_bootstrap # noqa: E402
LOGIN_COUNTRY_CODE = "+86"
REQUEST_TIMEOUT_SECONDS = 20
CAPTCHA_RETRIES = 2
_CAPTCHA_RESULT_HINTS = {
350001: "参数错误:轨迹或 verifyParam 为空/无效",
350002: "缺口识别或滑动落点错误",
350005: "verifyParam 中的 captchaSn 错误",
350009: "验证时间过长captchaSn 已失效",
350014: "轨迹或环境校验未通过,需要获取新的 captchaSn",
350017: "验证参数已失效",
350029: "服务端已切换验证码类型",
}
_CAPTCHA_RETRYABLE_RESULTS = {350002, 350009, 350014, 350017}
_ACCOUNT_COOKIE_ORDER = (
"kpn",
"kpf",
"userId",
"did",
"c",
"ver",
"appver",
"language",
"countryCode",
"sys",
"mod",
"net",
"deviceName",
"earphoneMode",
"isp",
"ud",
"did_tag",
"egid",
"thermal",
"kcv",
"app",
"bottom_navigation",
"android_os",
"oDid",
"boardPlatform",
"newOc",
"androidApiLevel",
"slh",
"country_code",
"nbh",
"hotfix_ver",
"did_gt",
"keyconfig_state",
"cdid_tag",
"max_memory",
"sid",
"cold_launch_time_ms",
"oc",
"sh",
"deviceBit",
"browseType",
"ddpi",
"socName",
"is_background",
"sw",
"ftt",
"apptype",
"abi",
"cl",
"userRecoBit",
"device_abi",
"icaver",
"totalMemory",
"grant_browse_type",
"iuid",
"rdid",
"sbh",
"darkMode",
"oaid",
"client_key",
"kuaishou.api_st",
"token",
"region_ticket",
"__NSWJ",
"kuaishou.h5_st",
"os",
)
def _mobile_argument(value: str) -> str:
mobile = value.strip()
if not mobile.isdigit() or not 5 <= len(mobile) <= 20:
raise argparse.ArgumentTypeError("手机号必须是 5-20 位数字,不能使用 MOBILE 等占位符")
return mobile
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="快手极速版短信登录(每次使用全新设备)"
)
parser.add_argument(
"--mobile",
required=True,
type=_mobile_argument,
help="手机号(仅数字,不含国家码)",
)
parser.add_argument("--code", default="", help="短信验证码;不传则发码后交互输入")
parser.add_argument(
"--base-url",
default="",
help="登录 host默认 apissl.ksapisrv.com",
)
parser.add_argument(
"--transport",
choices=("requests", "okhttp4-android10"),
default="requests",
help="HTTP 传输(默认 requests",
)
return parser
def _new_sig3_state() -> Kwsg10418State:
seed = kwsg_10418_session_seed_from_time(int(time.time()))
return Kwsg10418State(session_seed=seed, counter=0x5F)
def _registrable_domain(host: str) -> str:
host = (host or "").strip().lower()
if not host:
return ""
labels = host.split(".")
return "." + ".".join(labels[-2:]) if len(labels) >= 2 else "." + host
def _set_region_cookie(session: Any, ticket: str, *, host: str = "") -> None:
if not ticket:
return
domain = _registrable_domain(host)
if domain:
session.cookies.set("region_ticket", ticket, domain=domain)
session.cookies.set("__NSWJ", "", domain=domain)
return
session.cookies.set("region_ticket", ticket)
session.cookies.set("__NSWJ", "")
def _mask_mobile(value: str) -> str:
mobile = str(value or "")
if len(mobile) == 11 and mobile.startswith("1") and mobile.isdigit():
return f"{mobile[:3]}****{mobile[-4:]}"
return mobile
def _mask_secret(value: Any, *, head: int = 8, tail: int = 6) -> str:
text = str(value or "")
if not text:
return ""
if len(text) <= head + tail + 6:
return f"<set len={len(text)}>"
return f"{text[:head]}...{text[-tail:]}(len={len(text)})"
def _is_secret_key(key: Any) -> bool:
name = str(key or "").lower()
return (
name
in {
"kuaishou.api_st",
"kuaishou.h5_st",
"kuaishou.api_client_salt",
"passtoken",
"quicklogintoken",
"region_ticket",
}
or name.endswith("_st")
or "token" in name
or "salt" in name
or name
in {"api_st", "h5_st", "pass_token", "quicklogin_token", "client_salt"}
)
def _looks_like_secret_text(value: str) -> bool:
text = value.lower()
return any(
marker in text
for marker in (
"kuaishou.api_st",
"kuaishou.h5_st",
"kuaishou.api_client_salt",
"passtoken",
"quicklogintoken",
"pass_token",
"client_salt",
)
)
def _redact_for_display(value: Any, show_secrets: bool = False) -> Any:
if show_secrets:
return value
if isinstance(value, dict):
return {
key: _mask_secret(item)
if _is_secret_key(key)
else _redact_for_display(item, show_secrets)
for key, item in value.items()
}
if isinstance(value, list):
return [_redact_for_display(item, show_secrets) for item in value]
if isinstance(value, str) and _looks_like_secret_text(value):
return _mask_secret(value, head=20, tail=12)
return value
def _json_for_display(value: Any, limit: int, *, show_secrets: bool = False) -> str:
return json.dumps(
_redact_for_display(value, show_secrets), ensure_ascii=False
)[:limit]
def _print_raw_response(
raw: dict[str, Any], *, show_secrets: bool = False, body_limit: int = 400
) -> None:
body = raw.get("body") if isinstance(raw.get("body"), dict) else {}
print(
f" status={raw.get('status')} result={body.get('result')} "
f"error_msg={body.get('error_msg')}"
)
print(
f" body: "
f"{_json_for_display(body, body_limit, show_secrets=show_secrets)}"
)
print(
f" cookies: "
f"{_json_for_display(raw.get('cookies') or {}, 500, show_secrets=show_secrets)}"
)
print(
f" set_cookie: "
f"{_json_for_display(raw.get('set_cookie') or [], 500, show_secrets=show_secrets)}"
)
if raw.get("error"):
print(f" error: {raw.get('error')}")
def _captcha_error_url(raw: dict[str, Any]) -> str:
body = raw.get("body") if isinstance(raw.get("body"), dict) else {}
if body.get("result") != 705:
return ""
error_url = str(body.get("error_url") or "").strip()
return error_url if error_url.startswith("https://") else ""
def _solve_captcha_http(
error_url: str, *, session: Any, show_secrets: bool = False
) -> str:
print("[验证] 纯 HTTP 求解ddddocr + Jose + 合成传感器)")
try:
try:
from tools.captcha_solver_http import solve_captcha
except ImportError:
from captcha_solver_http import solve_captcha
except ImportError as exc:
print(f"[验证] HTTP solver 导入失败: {exc}")
return ""
for attempt in range(CAPTCHA_RETRIES + 1):
try:
result = solve_captcha(
session=session,
error_url=error_url,
verbose=True,
)
except Exception as exc: # noqa: BLE001
print(f"[验证] HTTP solver 异常: {exc.__class__.__name__}: {exc}")
return ""
raw_result = result.get("raw") if isinstance(result.get("raw"), dict) else {}
result_code = result.get("result")
if result_code is None:
result_code = raw_result.get("result")
server_desc = result.get("desc") or raw_result.get("desc") or "-"
token = str(result.get("captcha_token") or "")
if token:
shown_token = token if show_secrets else _mask_secret(token, head=10, tail=8)
print(
f"[验证] HTTP 求解成功 result={result_code} "
f"captchaToken={shown_token}"
)
return token
hint = _CAPTCHA_RESULT_HINTS.get(result_code, "未分类响应")
print(
f"[验证] HTTP 求解未通过 result={result_code} "
f"stage={result.get('stage')} desc={server_desc}"
)
print(f" 诊断: {hint}")
if result_code == 350029:
print(
f" next_type={result.get('type') or raw_result.get('type') or '-'} "
f"config_path="
f"{result.get('configPath') or raw_result.get('configPath') or '-'}"
)
if result_code not in _CAPTCHA_RETRYABLE_RESULTS or attempt >= CAPTCHA_RETRIES:
return ""
print(
f"[验证] 重新获取 captchaSn 后重试 "
f"({attempt + 1}/{CAPTCHA_RETRIES})"
)
return ""
def _complete_captcha_handoff(
error_url: str,
*,
stage: str,
session: Any,
show_secrets: bool = False,
) -> str:
print(f"[验证] {stage} 触发 705")
print(f" 验证地址: {error_url}")
token = _solve_captcha_http(
error_url, session=session, show_secrets=show_secrets
)
if token:
return token
print("[验证] 纯 HTTP 未拿到 captchaToken停止重放不启用浏览器")
return ""
def _print_weapon_proof_gap(raw: dict[str, Any]) -> bool:
request_meta = raw.get("request_meta") if isinstance(raw.get("request_meta"), dict) else {}
if not request_meta or (
bool(request_meta.get("has_kaw")) and bool(request_meta.get("has_kas"))
):
return False
print(" 当前请求缺少动态 kaw/kas 设备证明;这是与 APP 最终请求的已确认差异")
return True
def _print_login_failure_diagnosis(
raw: dict[str, Any], *, passport_account_image: str
) -> None:
body = raw.get("body") if isinstance(raw.get("body"), dict) else {}
result = body.get("result")
if result == 705:
print("[诊断] result=705触发验证/风控,不是 result=50 签名失败")
_print_weapon_proof_gap(raw)
if not passport_account_image:
print(" 当前登录请求未带 passport_account_image")
print(" 本次为纯在线新设备路径705 多为号码风控触发交互式验证")
print(" 自动验证码求解已启用,拿到 captchaToken 后会重放原请求")
elif result == 50:
print("[诊断] result=50签名验证失败继续对比 query/body/sig/__NS_sig3/__NS_xfalcon")
def _print_session(session: Any) -> None:
print(f" api_st = {_mask_secret(session.api_st)}")
print(f" h5_st = {_mask_secret(session.h5_st)}")
print(f" client_salt = {_mask_secret(session.client_salt)}")
print(f" user_id = {session.user_id}")
if session.region_ticket:
print(f" region = {_mask_secret(session.region_ticket)}")
print(f" mobile = {_mask_mobile(session.mobile)}")
if session.pass_token:
print(f" pass_token = {_mask_secret(session.pass_token)}")
if session.quicklogin_token:
print(f" quicklogin = {_mask_secret(session.quicklogin_token)}")
def _account_cookie_fields(profile: Any, session: Any) -> dict[str, str]:
fields = {
str(key): "" if value is None else str(value)
for key, value in login_api_params(profile).items()
}
fields.update(device_profile_cookie_fields(profile))
fields.update(
{
"userId": str(session.user_id),
"ud": str(session.user_id),
"client_key": "2ac2a76d",
"kuaishou.api_st": str(session.api_st),
"token": str(session.api_st),
"region_ticket": str(session.region_ticket or ""),
"__NSWJ": "",
"kuaishou.h5_st": str(session.h5_st or ""),
"os": "android",
"cl": "",
}
)
return fields
def _account_cookie_string(profile: Any, session: Any) -> str:
fields = _account_cookie_fields(profile, session)
emitted: set[str] = set()
parts: list[str] = []
for key in _ACCOUNT_COOKIE_ORDER:
if key in fields:
parts.append(f"{key}={fields[key]}")
emitted.add(key)
for key in sorted(key for key in fields if key not in emitted):
parts.append(f"{key}={fields[key]}")
return "; ".join(parts)
def _quote_env_value(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _print_account_config(profile: Any, session: Any) -> None:
cookie = _account_cookie_string(profile, session)
value = f"账号#{cookie}#{session.client_salt}"
print("\n[配置] 复制下面一行到 .env")
print(f'ksck="{_quote_env_value(value)}"')
def _refresh_anonymous_region(profile: Any, session: Any) -> Any:
region = None
for region_host in (DEFAULT_REGION_HOST, REGION_FULL_CONFIG_HOST):
region = refresh_region_ticket(
profile,
api_st="",
client_salt="",
user_id="",
base_url=region_host,
timeout=REQUEST_TIMEOUT_SECONDS,
get_func=session.get,
)
if region.ok:
break
if region_host != REGION_FULL_CONFIG_HOST:
print(
f"[区域] keyconfig 主机未命中 host={region_host} "
f"status={region.status} error={region.error},尝试兼容主机"
)
return region
def main() -> int:
args = build_parser().parse_args()
# 设备身份只存在于本次进程;没有加载、种子复现或落盘路径。
profile = DeviceProfileGenerator().new_profile()
print(f"[设备] 在线 DFP 注册中did={profile.did}...")
try:
run_online_bootstrap(profile, timeout=REQUEST_TIMEOUT_SECONDS)
print(f"[设备] 注册完成 did={profile.did} egid={profile.egid}")
except Exception as exc: # noqa: BLE001
print(f"[设备] DFP 注册失败,改用本次本地画像继续: {exc}")
passport_account_image = generate_profile_passport_account_image(profile)
print(
"[passport] 纯 Python 现场生成 "
f"{passport_account_image[:14]}...{passport_account_image[-12:]} "
f"(len={len(passport_account_image)})"
)
weapon_header_provider = WeaponProofProvider(APK_DEFAULT_WEAPON_KAW)
print(
f"[Weapon] KAS 纯 Python 已启用 "
f"kaw={_mask_secret(APK_DEFAULT_WEAPON_KAW, head=10, tail=8)} "
f"(len={len(APK_DEFAULT_WEAPON_KAW)}, source=APK z_y_x_a fallback)"
)
sig3_state = _new_sig3_state()
seed = sig3_state.session_seed
print(
f"[sig3] 进程状态 seed=0x{seed:08x} "
f"counter_start=0x{sig3_state.counter:08x}"
)
base_url = args.base_url.rstrip("/") or None
login_host = urllib.parse.urlparse(base_url or DEFAULT_LOGIN_HOST).hostname or ""
http_session = create_http_session(args.transport)
print(f"[传输] profile={args.transport}")
region_ticket = ""
anonymous_region = _refresh_anonymous_region(profile, http_session)
if anonymous_region and anonymous_region.ok:
region_ticket = anonymous_region.ticket
_set_region_cookie(http_session, region_ticket, host=login_host)
print(
f"[区域] keyconfig 匿名票据已刷新 "
f"uid={anonymous_region.uid or '0'} name={anonymous_region.name or '-'} "
f"ticket={_mask_secret(region_ticket, head=12, tail=8)}"
)
elif anonymous_region:
print(
f"[区域] keyconfig 匿名刷新未命中,继续无票据 "
f"status={anonymous_region.status} error={anonymous_region.error}"
)
code = args.code
if not code:
effective_base = base_url or DEFAULT_LOGIN_HOST
print(
f"[发码] -> {args.mobile} host={effective_base} "
f"timeout={REQUEST_TIMEOUT_SECONDS}s type={LOGIN_SMS_CODE_TYPE}"
)
send_encrypted_mobile = loginhelper_encrypt_mobile(args.mobile)
send_captcha_token = ""
send_retry = 0
while True:
response = request_mobile_code(
profile,
args.mobile,
mobile_country_code=LOGIN_COUNTRY_CODE,
encrypted_mobile=send_encrypted_mobile,
passport_account_image=passport_account_image,
code_type=LOGIN_SMS_CODE_TYPE,
need_check=False,
prefetch_phone_number="",
request_source="1",
captcha_token=send_captcha_token,
base_url=base_url,
session_seed=seed,
sig3_state=sig3_state,
timeout=REQUEST_TIMEOUT_SECONDS,
post_func=http_session.post,
weapon_header_provider=weapon_header_provider,
)
body = response.get("body") or {}
print(
f" status={response.get('status')} "
f"body={_json_for_display(body, 300)}"
)
if response.get("error"):
print(f" error={response.get('error')}")
print(
"[发码] 请求未拿到响应;如果手机已收到短信,"
"重新执行并加 --code <验证码> 跳过发码继续登录"
)
return 2
challenge_url = _captcha_error_url({"body": body})
if body.get("result") == 1 or not (
challenge_url and send_retry < CAPTCHA_RETRIES
):
if body.get("result") != 1:
print("[发码] 未返回 result=1可能仍发了码 / 受风控);继续输入收到的验证码")
break
send_captcha_token = _complete_captcha_handoff(
challenge_url,
stage="requestMobileCode",
session=http_session,
)
if not send_captcha_token:
return 4
send_retry += 1
print(f"[发码] captcha_token 已注入,重放发码 ({send_retry}/{CAPTCHA_RETRIES})")
code = input("[输入] 收到的短信验证码: ").strip()
print(
f"[登录] mobileVerifyCode mobile={_mask_mobile(args.mobile)} "
f"code=<CODE> login_type={LOGIN_SMS_CODE_TYPE}"
)
login_encrypted_mobile = loginhelper_encrypt_mobile(args.mobile)
# 验证码重放必须复用同一组账号保护字段,保持原请求正文不变。
account_security_fields = build_account_security_fields()
login_captcha_token = ""
login_retry = 0
while True:
login_session, results = login_by_code_try_paths(
profile,
args.mobile,
code,
mobile_country_code=LOGIN_COUNTRY_CODE,
encrypted_mobile=login_encrypted_mobile,
passport_account_image=passport_account_image,
captcha_token=login_captcha_token,
prefetch_phone_number=args.mobile,
account_security_fields=account_security_fields,
include_prefetch_phone_number=True,
login_type=LOGIN_SMS_CODE_TYPE,
base_url=base_url,
session_seed=seed,
sig3_state=sig3_state,
timeout=REQUEST_TIMEOUT_SECONDS,
post_func=http_session.post,
weapon_header_provider=weapon_header_provider,
)
challenge_url = ""
for path, candidate_session in results:
raw = (
candidate_session.raw
if isinstance(candidate_session.raw, dict)
else {}
)
print(f"\n[{path}]")
_print_raw_response(raw)
_print_login_failure_diagnosis(
raw, passport_account_image=passport_account_image
)
challenge_url = challenge_url or _captcha_error_url(raw)
if login_session.ok:
break
if not (challenge_url and login_retry < CAPTCHA_RETRIES):
break
login_captcha_token = _complete_captcha_handoff(
challenge_url,
stage="mobileVerifyCode",
session=http_session,
)
if not login_captcha_token:
return 4
login_retry += 1
print(f"[登录] captcha_token 已注入,重放原接口 ({login_retry}/{CAPTCHA_RETRIES})")
if not login_session.ok:
print("\n[FAIL] mobileVerifyCode 未拿到会话(看上面 result/body")
return 1
http_session.cookies.set("kuaishou.api_st", login_session.api_st)
http_session.cookies.set("token", login_session.api_st)
if login_session.h5_st:
http_session.cookies.set("kuaishou.h5_st", login_session.h5_st)
if login_session.user_id:
http_session.cookies.set("userId", login_session.user_id)
http_session.cookies.set("ud", login_session.user_id)
account_region = refresh_region_ticket(
profile,
api_st=login_session.api_st,
client_salt=login_session.client_salt,
user_id=login_session.user_id,
base_url=DEFAULT_REGION_HOST,
timeout=REQUEST_TIMEOUT_SECONDS,
get_func=http_session.get,
)
if account_region.ok:
region_ticket = account_region.ticket
_set_region_cookie(http_session, region_ticket, host=login_host)
print(
f"[区域] keyconfig 账号票据已刷新 "
f"uid={account_region.uid or login_session.user_id} "
f"name={account_region.name or '-'} "
f"ticket={_mask_secret(region_ticket, head=12, tail=8)}"
)
else:
print(
f"[区域] keyconfig 账号刷新未命中,保留匿名票据 "
f"status={account_region.status} error={account_region.error}"
)
login_session.region_ticket = region_ticket or login_session.region_ticket
print("\n[OK] 登录成功,会话:")
_print_session(login_session)
_print_account_config(profile, login_session)
return 0
def entrypoint() -> int:
try:
return main()
except KeyboardInterrupt:
print("\n[中断] 已停止;如果短信已收到,可用 --code <验证码> 继续登录")
return 130
if __name__ == "__main__":
raise SystemExit(entrypoint())