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

+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
UserAgent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
)
#: 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")