321 lines
13 KiB
Python
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)
|