- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
167 lines
6.3 KiB
Python
167 lines
6.3 KiB
Python
"""网易云登录(对应 sdk/auth/wy.ts)—— 扫码 / 手机号 / cookie 绑定,weapi 加密请求。
|
||
|
||
参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)。
|
||
二维码接口只提供明文 api 形态(weapi 形态已 404);其余登录接口走 weapi。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.http.api_error import ApiError
|
||
from app.sdk.auth_store import get_wy_cookie, set_credentials
|
||
from app.sdk.common.qrcode import to_data_url
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import as_dict
|
||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||
|
||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||
|
||
|
||
async def _request_weapi(path: str, data: Any, cookie: str | None = None) -> tuple[Any, list[str]]:
|
||
ck = get_wy_cookie() if cookie is None else cookie
|
||
headers = {
|
||
"User-Agent": UA,
|
||
"origin": "https://music.163.com",
|
||
"Referer": "https://music.163.com/",
|
||
}
|
||
if ck:
|
||
headers["cookie"] = ck
|
||
resp = await http_fetch(
|
||
f"https://music.163.com/weapi{path}",
|
||
method="POST",
|
||
headers=headers,
|
||
form=weapi(data),
|
||
timeout=15_000,
|
||
)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError("网易请求失败")
|
||
return resp.body, resp.set_cookies
|
||
|
||
|
||
async def _request_api(path: str, data: Any) -> tuple[Any, list[str]]:
|
||
"""明文 api 请求(二维码接口只提供该形态)。"""
|
||
resp = await http_fetch(
|
||
f"https://music.163.com{path}",
|
||
method="POST",
|
||
headers={"User-Agent": UA, "origin": "https://music.163.com", "Referer": "https://music.163.com/"},
|
||
form=data,
|
||
timeout=15_000,
|
||
)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError("网易请求失败")
|
||
return resp.body, resp.set_cookies
|
||
|
||
|
||
def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
|
||
"""从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U。"""
|
||
joined = "; ".join(part for part in (item.split(";")[0].strip() for item in set_cookie) if part)
|
||
match = re.search(r"MUSIC_U=([^;]+)", joined)
|
||
return joined, match.group(1) if match else ""
|
||
|
||
|
||
async def _fetch_profile() -> dict[str, Any]:
|
||
"""登录成功后拉取账号资料。"""
|
||
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
|
||
body = as_dict(body)
|
||
account = as_dict(body.get("account"))
|
||
profile = as_dict(body.get("profile"))
|
||
return {
|
||
"userid": account.get("id"),
|
||
"nickname": profile.get("nickname"),
|
||
"avatar": profile.get("avatarUrl"),
|
||
"vipType": profile.get("vipType"),
|
||
}
|
||
|
||
|
||
async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
|
||
"""应用登录 cookie(成功后调用)。"""
|
||
cookie, music_u = _extract_login_cookie(set_cookie)
|
||
if not music_u:
|
||
raise RuntimeError("登录响应中缺少 MUSIC_U")
|
||
profile = await _fetch_profile()
|
||
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
|
||
return profile
|
||
|
||
|
||
async def qr_key() -> dict[str, Any]:
|
||
"""创建扫码登录 key 与二维码内容。"""
|
||
body, _ = await _request_api("/api/login/qrcode/unikey", {"type": 3})
|
||
body = as_dict(body)
|
||
if body.get("code") != 200 or not body.get("unikey"):
|
||
raise RuntimeError("获取二维码 key 失败")
|
||
key = str(body["unikey"])
|
||
qrurl = f"https://music.163.com/login?codekey={key}"
|
||
qrimg = ""
|
||
try:
|
||
qrimg = to_data_url(qrurl)
|
||
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
|
||
qrimg = ""
|
||
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
|
||
|
||
|
||
async def qr_check(key: str) -> dict[str, Any]:
|
||
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)。"""
|
||
body, set_cookie = await _request_api("/api/login/qrcode/client/login", {"key": key, "type": 3})
|
||
body = as_dict(body)
|
||
code = body.get("code")
|
||
if code == 803:
|
||
profile = await _apply_login(set_cookie)
|
||
return {"code": 803, "message": "登录成功", "profile": profile}
|
||
messages: dict[Any, str] = {800: "二维码已过期", 801: "等待扫码", 802: "已扫码,待确认"}
|
||
message = messages.get(code) or body.get("message") or "未知状态"
|
||
return {"code": code, "message": message}
|
||
|
||
|
||
async def cellphone_login(
|
||
phone: str, password: str | None = None, captcha: str | None = None, countrycode: str = "86"
|
||
) -> dict[str, Any]:
|
||
"""手机号登录(密码明文传入,本地 MD5 后提交;或直接传验证码 captcha)。"""
|
||
data: dict[str, Any] = {
|
||
"type": "1",
|
||
"https": "true",
|
||
"phone": phone,
|
||
"countrycode": countrycode,
|
||
"remember": "true",
|
||
}
|
||
if captcha:
|
||
data["captcha"] = captcha
|
||
else:
|
||
data["password"] = hashlib.md5(str(password or "").encode()).hexdigest()
|
||
body, set_cookie = await _request_weapi("/api/w/login/cellphone", data)
|
||
body = as_dict(body)
|
||
if body.get("code") != 200:
|
||
raise ApiError(401, str(body.get("message") or body.get("msg") or "登录失败"))
|
||
return await _apply_login(set_cookie)
|
||
|
||
|
||
async def login_by_cookie(cookie_input: str) -> dict[str, Any]:
|
||
"""手动绑定 cookie(须含 MUSIC_U);绑定前用 account/get 实测校验。"""
|
||
cookie = str(cookie_input or "").strip()
|
||
match = re.search(r"MUSIC_U=([^;\s]+)", cookie)
|
||
if not match:
|
||
raise ApiError(422, "cookie 需包含 MUSIC_U 字段")
|
||
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
|
||
body = as_dict(body)
|
||
account = as_dict(body.get("account"))
|
||
if not account.get("id"):
|
||
raise ApiError(401, "cookie 无效或已过期(MUSIC_U 校验未通过)")
|
||
profile_body = as_dict(body.get("profile"))
|
||
profile = {
|
||
"userid": account.get("id"),
|
||
"nickname": profile_body.get("nickname"),
|
||
"avatar": profile_body.get("avatarUrl"),
|
||
"vipType": profile_body.get("vipType"),
|
||
}
|
||
set_credentials("wy", {"cookie": cookie, "musicU": match.group(1), **profile})
|
||
return profile
|
||
|
||
|
||
async def status() -> dict[str, Any] | None:
|
||
"""当前账号资料(未登录返回 None)。"""
|
||
if not get_wy_cookie():
|
||
return None
|
||
return await _fetch_profile()
|