"""网易云音乐二维码登录会话。""" 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)