Files
MusicServer/app/sdk/platforms/fivesing/util.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

197 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, "")