108 lines
3.6 KiB
Python
Executable File
108 lines
3.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Bootstrap a NetEase Cloud Game token without blocking the API server."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
TOKEN_FILE = Path(os.getenv("MAA_ONLINE_TOKEN_FILE", str(BASE_DIR / "token")))
|
|
PENDING_FILE = Path(os.getenv("MAA_ONLINE_PENDING_LOGIN_FILE", str(BASE_DIR / ".pending-login")))
|
|
API_BASE = "https://n.cg.163.com/api/v1"
|
|
|
|
|
|
def write_private(path: Path, value: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
fd = os.open(path, flags, 0o600)
|
|
try:
|
|
os.write(fd, value.encode("utf-8"))
|
|
finally:
|
|
os.close(fd)
|
|
os.chmod(path, 0o600)
|
|
|
|
|
|
def normalize_phone(phone: str, country_code: str) -> tuple[str, str]:
|
|
phone = phone.strip().replace(" ", "")
|
|
country_code = country_code.strip().lstrip("+")
|
|
if not phone.isdigit() or not country_code.isdigit():
|
|
raise ValueError("phone and country code must contain digits only")
|
|
return country_code, phone
|
|
|
|
|
|
def request_code(phone: str, country_code: str) -> None:
|
|
country_code, phone = normalize_phone(phone, country_code)
|
|
response = requests.post(
|
|
f"{API_BASE}/phone-captchas/{country_code}-{phone}",
|
|
timeout=15,
|
|
)
|
|
response.raise_for_status()
|
|
write_private(PENDING_FILE, json.dumps({"country_code": country_code, "phone": phone}))
|
|
print("SMS code requested. Run: python login.py verify <code>")
|
|
|
|
|
|
def verify_code(code: str) -> None:
|
|
if not PENDING_FILE.is_file():
|
|
raise RuntimeError("no pending login; run the request command first")
|
|
pending = json.loads(PENDING_FILE.read_text(encoding="utf-8"))
|
|
code = code.strip()
|
|
if not code:
|
|
raise ValueError("verification code must not be empty")
|
|
|
|
payload = {
|
|
"auth_method": "phone-captcha",
|
|
"ctcode": pending["country_code"],
|
|
"phone": pending["phone"],
|
|
"captcha": code,
|
|
"device_info": {
|
|
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140 Safari/537.36",
|
|
"appVersion": "5.0 (X11)",
|
|
"codecs": ["h264", "vp8", "vp9"],
|
|
},
|
|
}
|
|
response = requests.post(f"{API_BASE}/tokens", json=payload, timeout=15)
|
|
response.raise_for_status()
|
|
token = response.json().get("token")
|
|
if not token:
|
|
raise RuntimeError("login response did not contain a token")
|
|
|
|
write_private(TOKEN_FILE, token)
|
|
PENDING_FILE.unlink(missing_ok=True)
|
|
print(f"Login token saved securely to {TOKEN_FILE}")
|
|
|
|
|
|
def status() -> None:
|
|
if TOKEN_FILE.is_file() and TOKEN_FILE.stat().st_size:
|
|
print(f"token: present ({oct(TOKEN_FILE.stat().st_mode & 0o777)})")
|
|
else:
|
|
print("token: missing")
|
|
print(f"pending login: {'yes' if PENDING_FILE.is_file() else 'no'}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
request_parser = sub.add_parser("request", help="request an SMS verification code")
|
|
request_parser.add_argument("phone")
|
|
request_parser.add_argument("--country-code", default="86")
|
|
verify_parser = sub.add_parser("verify", help="exchange an SMS code for a token")
|
|
verify_parser.add_argument("code")
|
|
sub.add_parser("status", help="show credential state without revealing secrets")
|
|
args = parser.parse_args()
|
|
|
|
if args.command == "request":
|
|
request_code(args.phone, args.country_code)
|
|
elif args.command == "verify":
|
|
verify_code(args.code)
|
|
else:
|
|
status()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|