feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 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 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,78 @@
|
||||
"""猫耳FM URL 获取(对应 sdk/apis/me.ts,App API 通道)。
|
||||
|
||||
链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
|
||||
soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
|
||||
soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
|
||||
|
||||
实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
|
||||
付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带。
|
||||
|
||||
songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
|
||||
"d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import as_dict, as_list
|
||||
from app.sdk.platforms.me.music_info import get_sound_info
|
||||
|
||||
#: 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
|
||||
QUALITY_FIELDS: dict[str, list[str]] = {
|
||||
"128k": ["soundurl_128", "soundurl", "soundurl_64", "soundurl_32"],
|
||||
"64k": ["soundurl_64", "soundurl", "soundurl_128", "soundurl_32"],
|
||||
}
|
||||
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
|
||||
|
||||
|
||||
def pick_url(info: dict[str, Any], quality: str) -> dict[str, str]:
|
||||
fields = QUALITY_FIELDS.get(quality, QUALITY_FIELDS["128k"])
|
||||
url = ""
|
||||
for field in fields:
|
||||
value = info.get(field)
|
||||
if value and str(value).startswith("http"):
|
||||
url = str(value)
|
||||
break
|
||||
if not url:
|
||||
raise RuntimeError("无可用音频资源(付费内容需 POST /login/me 绑定 token)")
|
||||
hit_128 = "-128k" in url or quality == "128k"
|
||||
return {"type": "128k" if hit_128 else "64k", "url": url}
|
||||
|
||||
|
||||
async def _resolve_drama_first_free(drama_id: str) -> str:
|
||||
"""剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[])。"""
|
||||
resp = await http_fetch(
|
||||
f"https://www.missevan.com/dramaapi/getdrama?drama_id={drama_id}",
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "application/json",
|
||||
"Referer": f"https://www.missevan.com/mdrama/{drama_id}",
|
||||
},
|
||||
timeout=15_000,
|
||||
)
|
||||
info = as_dict(resp.body).get("info") if resp.status_code == 200 else None
|
||||
episodes = as_list(as_dict(as_dict(info).get("episodes")).get("episode"))
|
||||
if not episodes:
|
||||
raise RuntimeError("剧集列表为空或已下线")
|
||||
first = next((item for item in episodes if int(as_dict(item).get("pay_type") or 0) == 0), episodes[0])
|
||||
return str(as_dict(first).get("sound_id"))
|
||||
|
||||
|
||||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
del retry_num # 契约参数,无重试语义
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
sound_id = songmid
|
||||
if songmid.startswith("d:"):
|
||||
if not re.fullmatch(r"\d+", songmid[2:]):
|
||||
raise RuntimeError("剧 id 无效")
|
||||
sound_id = await _resolve_drama_first_free(songmid[2:])
|
||||
elif not re.fullmatch(r"\d+", sound_id):
|
||||
raise RuntimeError("me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)")
|
||||
info = await get_sound_info(sound_id)
|
||||
return pick_url(info, quality)
|
||||
Reference in new issue
Block a user