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,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,
|
||||
)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user