Files
Live-streaming/app/netease_qr_login.py
T

321 lines
13 KiB
Python

"""网易云音乐二维码登录会话。"""
from __future__ import annotations
import asyncio
import base64
import http.cookiejar
import http.cookies
import io
import json
import logging
import secrets
import string
import time
import urllib.parse
import urllib.request
from typing import Any, Callable
from Crypto.Cipher import AES
try:
from .netease_resolver import NeteaseResolver
except ImportError:
from netease_resolver import NeteaseResolver
_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
_WEAPI_NONCE = b"0CoJUm6Qyw8W8jud"
_WEAPI_IV = b"0102030405060708"
_WEAPI_PUBLIC_EXPONENT = 0x10001
_WEAPI_MODULUS = int(
"00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725"
"152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312"
"ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424"
"d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8"
"e7",
16,
)
_SECRET_ALPHABET = string.ascii_letters + string.digits
def _aes_encrypt_base64(content: bytes, key: bytes) -> bytes:
padding = AES.block_size - (len(content) % AES.block_size)
padded = content + bytes([padding]) * padding
encrypted = AES.new(key, AES.MODE_CBC, _WEAPI_IV).encrypt(padded)
return base64.b64encode(encrypted)
def _weapi_form(data: dict[str, Any], *, secret_key: str | None = None) -> dict[str, str]:
secret = secret_key or "".join(secrets.choice(_SECRET_ALPHABET) for _ in range(16))
if len(secret.encode("ascii")) != 16:
raise ValueError("weapi secret key must be 16 ASCII bytes")
serialized = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
first_pass = _aes_encrypt_base64(serialized, _WEAPI_NONCE)
params = _aes_encrypt_base64(first_pass, secret.encode("ascii")).decode("ascii")
reversed_secret = int.from_bytes(secret[::-1].encode("ascii"), "big")
enc_sec_key = format(
pow(reversed_secret, _WEAPI_PUBLIC_EXPONENT, _WEAPI_MODULUS),
"x",
).zfill(256)
return {"params": params, "encSecKey": enc_sec_key}
def _render_qr_png(content: str) -> bytes:
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError as exc:
raise RuntimeError("缺少 qrcode 依赖,请重新安装 requirements.txt") from exc
qr = qrcode.QRCode(
version=None,
error_correction=ERROR_CORRECT_M,
box_size=8,
border=3,
)
qr.add_data(content)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def _request_json(
url: str,
*,
opener: urllib.request.OpenerDirector | None = None,
data: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], Any]:
encoded_data = None
if data is not None:
encoded_data = urllib.parse.urlencode(data).encode("utf-8")
request = urllib.request.Request(
url,
data=encoded_data,
headers={
"User-Agent": _USER_AGENT,
"Referer": "https://music.163.com/",
"Content-Type": "application/x-www-form-urlencoded",
},
)
response = (opener or urllib.request.build_opener()).open(request, timeout=15)
payload = json.loads(response.read().decode("utf-8", errors="replace"))
if not isinstance(payload, dict):
raise RuntimeError("网易云登录接口返回格式无效")
return payload, response
def _request_weapi_json(
url: str,
data: dict[str, Any],
*,
opener: urllib.request.OpenerDirector,
secret_key: str | None = None,
) -> tuple[dict[str, Any], Any]:
return _request_json(
url,
opener=opener,
data=_weapi_form(data, secret_key=secret_key),
)
def _cookie_values(jar: http.cookiejar.CookieJar, response: Any, payload: dict[str, Any]) -> dict[str, str]:
values = {cookie.name: cookie.value for cookie in jar}
headers = getattr(response, "headers", None)
raw_headers = headers.get_all("Set-Cookie", []) if headers and hasattr(headers, "get_all") else []
for raw_header in raw_headers:
parsed = http.cookies.SimpleCookie()
try:
parsed.load(raw_header)
except http.cookies.CookieError:
continue
values.update({name: morsel.value for name, morsel in parsed.items()})
for raw_cookie in (
payload.get("cookie"),
(payload.get("data") or {}).get("cookie") if isinstance(payload.get("data"), dict) else None,
):
if not raw_cookie:
continue
parsed = http.cookies.SimpleCookie()
try:
parsed.load(str(raw_cookie))
except http.cookies.CookieError:
continue
values.update({name: morsel.value for name, morsel in parsed.items()})
return values
class NeteaseQrLogin:
"""服务端保存二维码 key,登录成功后只把 MUSIC_U 交给保存回调。"""
_STATUS_MESSAGES = {
"idle": "尚未开始扫码登录",
"awaiting_scan": "请使用网易云音乐客户端扫码",
"awaiting_confirm": "已扫码,请在手机上确认登录",
"completed": "登录成功,MUSIC_U 已自动保存",
"expired": "二维码已过期,请重新生成",
"failed": "网易云扫码登录失败",
}
def __init__(
self,
*,
api_base: Callable[[], str],
get_saved_music_u: Callable[[], str],
save_music_u: Callable[[str, dict[str, Any]], Any],
logger: logging.Logger,
account_checker: Callable[[str, str], Any] | None = None,
ttl_seconds: int = 180,
) -> None:
self.api_base = api_base
self.get_saved_music_u = get_saved_music_u
self.save_music_u = save_music_u
self.logger = logger
self.account_checker = account_checker
self.ttl_seconds = max(60, int(ttl_seconds))
self._lock = asyncio.Lock()
self._session: dict[str, Any] | None = None
def _base_url(self) -> str:
return str(self.api_base() or "https://music.163.com").rstrip("/")
def snapshot(self) -> dict[str, Any]:
session = self._session or {}
state = str(session.get("state") or "idle")
expires_at = float(session.get("expires_at") or 0)
expires_in = max(0, int(expires_at - time.time())) if expires_at else 0
return {
"success": True,
"state": state,
"message": str(session.get("message") or self._STATUS_MESSAGES.get(state, "")),
"expires_in": expires_in,
"has_qr_image": state in {"awaiting_scan", "awaiting_confirm"} and expires_in > 0,
"credential_configured": bool(str(self.get_saved_music_u() or "").strip()),
"account": session.get("account"),
}
def _start_sync(self) -> dict[str, Any]:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
payload, _ = _request_weapi_json(
f"{self._base_url()}/weapi/login/qrcode/unikey",
{"type": 1, "csrf_token": ""},
opener=opener,
)
key = str(payload.get("unikey") or (payload.get("data") or {}).get("unikey") or "").strip()
if int(payload.get("code") or 0) != 200 or not key:
raise RuntimeError(f"网易云二维码申请失败 code={payload.get('code')}")
now = time.time()
return {
"state": "awaiting_scan",
"message": self._STATUS_MESSAGES["awaiting_scan"],
"created_at": now,
"expires_at": now + self.ttl_seconds,
"key": key,
# The NetEase client currently accepts the exact HTTP URL emitted by
# the official web login page. Using HTTPS shows an unsupported-login warning.
"qr_url": f"http://music.163.com/login?codekey={key}",
"jar": jar,
"opener": opener,
"account": None,
}
async def start(self) -> dict[str, Any]:
async with self._lock:
try:
self._session = await asyncio.to_thread(self._start_sync)
except Exception as exc:
self._session = {
"state": "failed",
"message": str(exc) or type(exc).__name__,
"expires_at": 0,
}
self.logger.warning("[网易云扫码登录] 二维码申请失败: %s", type(exc).__name__)
return self.snapshot()
self.logger.info("[网易云扫码登录] 二维码已生成,等待扫码")
return self.snapshot()
def _poll_sync(self, session: dict[str, Any]) -> dict[str, Any]:
url = f"{self._base_url()}/weapi/login/qrcode/client/login"
payload, response = _request_weapi_json(
url,
{"key": session["key"], "type": 1, "csrf_token": ""},
opener=session["opener"],
)
code = int(payload.get("code") or 0)
if code == 801:
return {"state": "awaiting_scan", "message": self._STATUS_MESSAGES["awaiting_scan"]}
if code == 802:
return {"state": "awaiting_confirm", "message": self._STATUS_MESSAGES["awaiting_confirm"]}
if code == 800:
return {"state": "expired", "message": self._STATUS_MESSAGES["expired"]}
if code != 803:
return {
"state": "failed",
"message": str(payload.get("message") or f"网易云扫码状态异常 code={code}"),
}
music_u = str(_cookie_values(session["jar"], response, payload).get("MUSIC_U") or "").strip()
if not music_u:
return {"state": "failed", "message": "扫码成功但响应中缺少 MUSIC_U"}
return {"state": "authenticated", "music_u": music_u}
async def _check_account(self, music_u: str) -> dict[str, Any]:
if self.account_checker:
result = self.account_checker(self._base_url(), music_u)
return await result if asyncio.iscoroutine(result) else result
resolver = NeteaseResolver(self.logger, api_base=self._base_url(), music_u=music_u)
return await resolver.account_status()
async def poll(self) -> dict[str, Any]:
async with self._lock:
if not self._session:
return self.snapshot()
state = str(self._session.get("state") or "idle")
if state in {"completed", "expired", "failed"}:
return self.snapshot()
if time.time() >= float(self._session.get("expires_at") or 0):
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
return self.snapshot()
try:
result = await asyncio.to_thread(self._poll_sync, self._session)
if result.get("state") != "authenticated":
self._session.update(result)
return self.snapshot()
music_u = str(result.get("music_u") or "")
account = await self._check_account(music_u)
if not account.get("authenticated"):
self._session.update(state="failed", message="扫码 Cookie 登录验证失败")
return self.snapshot()
callback_result = self.save_music_u(music_u, account)
if asyncio.iscoroutine(callback_result):
await callback_result
self._session.update(
state="completed",
message=self._STATUS_MESSAGES["completed"],
account={
"user_id": str(account.get("user_id") or ""),
"nickname": str(account.get("nickname") or "网易云用户"),
"vip_type": int(account.get("vip_type") or 0),
},
)
self.logger.info("[网易云扫码登录] 登录成功,MUSIC_U 已自动保存")
return self.snapshot()
except Exception as exc:
self._session.update(
state="failed",
message=str(exc) or f"扫码状态查询异常: {type(exc).__name__}",
)
self.logger.warning("[网易云扫码登录] 状态查询或保存失败: %s", type(exc).__name__)
return self.snapshot()
async def qr_png(self) -> bytes:
async with self._lock:
if not self._session or self._session.get("state") not in {"awaiting_scan", "awaiting_confirm"}:
raise RuntimeError("当前没有可用的网易云登录二维码")
if time.time() >= float(self._session.get("expires_at") or 0):
self._session.update(state="expired", message=self._STATUS_MESSAGES["expired"])
raise RuntimeError("网易云登录二维码已过期")
content = str(self._session.get("qr_url") or "")
return await asyncio.to_thread(_render_qr_png, content)