first commit
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
"""网易云音乐二维码登录会话。"""
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user