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:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
"""猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块(对应 sdk/me/index.ts)。
匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。
取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)),
返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。
付费声音需 POST /login/me 绑定含 token 的 cookie。
有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.me import album, music_search
from app.sdk.platforms.me.music_info import get_sound_info
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.me import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(song_info: dict[str, Any]) -> str | None:
info = await get_sound_info(str(song_info.get("songmid") or ""))
return info.get("front_cover") or None
class _MeAlbum:
get_album_detail = staticmethod(album.get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://www.missevan.com/sound/{song_info.get('songmid') or ''}"
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断)。"""
cred = get_credentials("me") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="me",
name="猫耳FM",
music_search=music_search,
album=_MeAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+73
View File
@@ -0,0 +1,73 @@
"""猫耳FM 广播剧集列表(对应 sdk/me/album.ts,web 通道,匿名免签)。
接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name。
注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),
本接口支持翻页故用之。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
EPISODES_URL = "https://www.missevan.com/dramaapi/getdramaepisodes"
async def get_album_detail(drama_id_raw: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
# 搜索结果的剧条目带 d: 前缀(songmid="d:<id>"),这里剥掉
drama_id = drama_id_raw[2:] if str(drama_id_raw).startswith("d:") else str(drama_id_raw)
if not re.fullmatch(r"\d+", drama_id):
raise RuntimeError("剧 id 无效")
resp = await http_fetch(
f"{EPISODES_URL}?drama_id={drama_id}&season=1&page={page}&order=0&pageSize={max(30, limit)}",
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"
),
"Referer": f"https://www.missevan.com/mdrama/drama/{drama_id}",
"Accept": "application/json",
},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
raise RuntimeError(str(body.get("info") or f"获取剧集失败(HTTP {resp.status_code})"))
info = as_dict(body.get("info"))
drama = as_dict(info.get("drama"))
episodes = as_list(as_dict(info.get("episodes")).get("episode"))
songs = []
for item in episodes[:limit]:
if not isinstance(item, dict):
continue
duration = item.get("duration")
songs.append(
{
"songmid": str(item.get("sound_id")),
"name": item.get("name") or "",
"singer": drama.get("name") or "",
"albumId": str(drama_id),
"albumName": drama.get("name") or "",
"interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0,
"payPlay": int(item.get("pay_type") or 0) > 0,
"img": drama.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": drama.get("name") or str(drama_id), "total": len(episodes)},
"list": songs,
"total": len(episodes),
"limit": limit,
"allPage": 1,
"source": "me",
}
+18
View File
@@ -0,0 +1,18 @@
"""猫耳FM 曲目信息(对应 sdk/me/musicInfo.ts,App API 通道)。
接口: GET https://app.missevan.com/sound/sound?sound_id=
返回 info 直接是 sound 对象(非 web 版的 info.sound 包装):
soundstr/duration(毫秒)/front_cover/soundurl(原文件)/soundurl_64(=原文件)/
soundurl_128/-192k 变体,均为 maoercdn.com 免签名明文直链。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.me.sign import app_api_get
async def get_sound_info(sound_id: str) -> dict[str, Any]:
info = await app_api_get("/sound/sound", f"sound_id={sound_id}")
return info if isinstance(info, dict) else {}
+111
View File
@@ -0,0 +1,111 @@
"""猫耳FM 搜索(对应 sdk/me/musicSearch.ts,双通道聚合,与网页版对齐)。
广播剧(置顶): https://www.missevan.com/dramaapi/search?s=&page= → info.Datas[]
——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型
声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[]
剧条目 songmid 用 "d:<drama_id>" 前缀(取流时自动取首个免费集,见 apis/me.py);声音条目为裸 sound_id。
"""
from __future__ import annotations
import asyncio
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import encode_uri_component
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
H = {"User-Agent": UA, "Accept": "application/json", "Referer": "https://www.missevan.com/sound/search"}
async def _search_dramas(keyword: str, page: int, limit: int) -> list[dict[str, Any]]:
resp = await http_fetch(
f"https://www.missevan.com/dramaapi/search?s={encode_uri_component(keyword)}&page={page}",
headers=H,
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
return []
datas = as_list(as_dict(body.get("info")).get("Datas"))
return [
{
"songmid": f"d:{item.get('id')}",
"name": item.get("name") or "",
"singer": item.get("author") or "",
"albumId": str(item.get("id")),
"albumName": item.get("name") or "",
"interval": 0,
"payPlay": True,
"img": item.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
for item in datas[:limit]
if isinstance(item, dict)
]
async def _search_sounds(keyword: str, page: int, limit: int) -> list[dict[str, Any]]:
resp = await http_fetch(
f"https://www.missevan.com/sound/getsearch?s={encode_uri_component(keyword)}&p={page}&type=3&page_size={limit}&sort=2",
headers=H,
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
return []
datas = as_list(as_dict(body.get("info")).get("Datas"))
songs = []
for item in datas:
if not isinstance(item, dict):
continue
duration = item.get("duration")
songs.append(
{
"songmid": str(item.get("id")),
"name": item.get("soundstr") or "",
"singer": item.get("username") or "",
"albumId": "",
"albumName": "",
"interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0,
"payPlay": int(item.get("pay_type") or 0) > 0,
"img": item.get("front_cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return songs
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
"""剧优先置顶(约六成配额),零散声音补足;剧集翻页直接传 page。"""
del retry_num # 契约参数,web 搜索无重试语义
async def safe(coro: Any) -> list[dict[str, Any]]:
try:
return await coro
except Exception: # noqa: BLE001 - 单通道失败不拖垮搜索
return []
dramas, sounds = await asyncio.gather(
safe(_search_dramas(keyword, page, max(4, int(limit * 0.6)))),
safe(_search_sounds(keyword, page, limit)),
)
songs = [*dramas, *sounds][:limit]
return {
"allPage": page + (1 if len(songs) >= limit else 0),
"total": len(dramas) + len(sounds),
"limit": limit,
"source": "me",
"list": songs,
}
+98
View File
@@ -0,0 +1,98 @@
"""猫耳FM(MissEvan)App API 签名(对应 sdk/me/sign.ts)—— app.missevan.com 域鉴权。
算法(知乎 p/359692208 + zunley/gofm pkg/missevan/utils.go,2026-10-02 实测可用):
authorization = "MissEvan " + base64(HMAC-SHA256(appsecret, DATA))
DATA = METHOD\\n + url(冒号转义%3A)\\n + querystring\\n + equip_id:<id>\\n [+ token:<t>\\n]
+ x-m-date:<UTC毫秒ISO>\\n + x-m-nonce:<uuid>\\n [+ base64(sha256(body))\\n]
头:User-Agent MissEvanApp、x-m-date、x-m-nonce、Cookie(equip_id[+token])
该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
网页版 hls.m3u8 为 Widevine 加密,与此无关。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import re
import uuid
from datetime import UTC, datetime
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
APP_SECRET = "f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ"
APP_UA = "MissEvanApp/4.7.4 (iOS;15.1;iPhone11,6)"
EQUIP_ID = "52e76e9a-7592-43af-ae72-8ec755094e41"
def _utc_millis() -> str:
"""``new Date().toISOString().replace(/\\.\\d+Z$/, '.000Z')`` 的等价实现。"""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def build_auth(
method: str,
url: str,
query: str,
*,
equip_id: str,
token: str,
date: str,
nonce: str,
) -> str:
"""构造 authorization 签名(GET 便捷封装;POST 需补 body 摘要行)。"""
lines = [
method,
url.replace(":", "%3A"),
query,
f"equip_id:{equip_id}",
]
if token:
lines.append(f"token:{token}")
lines.extend([f"x-m-date:{date}", f"x-m-nonce:{nonce}"])
digest = hmac.new(APP_SECRET.encode(), ("\n".join(lines) + "\n").encode(), hashlib.sha256).digest()
return "MissEvan " + base64.b64encode(digest).decode()
def get_me_token() -> str:
"""登录 token(/login/me 绑定的 cookie 中提取;未登录为空)。"""
cookie = str((get_credentials("me") or {}).get("cookie") or "")
match = re.search(r"(?:^|;\s*)token=([^;\s]+)", cookie)
return match.group(1) if match else ""
async def app_api_get(path: str, query: str = "") -> Any:
"""App API GET 请求(自动携带登录态 token)。"""
full = f"https://app.missevan.com{path}"
date = _utc_millis()
nonce = str(uuid.uuid4())
token = get_me_token()
cookie = f"equip_id={EQUIP_ID}" + (f"; token={token}" if token else "")
resp = await http_fetch(
f"{full}?{query}" if query else full,
headers={
"User-Agent": APP_UA,
"Accept": "application/json",
"Accept-Language": "zh-Hans-CN;q=1",
"x-m-date": date,
"x-m-nonce": nonce,
"Cookie": cookie,
"Authorization": build_auth("GET", full, query, equip_id=EQUIP_ID, token=token, date=date, nonce=nonce),
},
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError(f"猫耳 App API HTTP {resp.status_code}")
body = as_dict(resp.body)
code = body.get("code")
if body.get("success") is False or (code is not None and code != 0):
raise RuntimeError(str(body.get("info") or f"猫耳 App API code {code}"))
info = body.get("info")
return info if info is not None else body
ME_UA = APP_UA