- 新增 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 路由验证通过
159 lines
4.7 KiB
Python
159 lines
4.7 KiB
Python
"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import math
|
||
import re
|
||
import time
|
||
from collections.abc import Iterable, Mapping
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
|
||
def to_md5(text: str) -> str:
|
||
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def as_dict(value: Any) -> dict[str, Any]:
|
||
"""上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。"""
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def as_list(value: Any) -> list[Any]:
|
||
"""上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。"""
|
||
return value if isinstance(value, list) else []
|
||
|
||
|
||
def size_formate(size: float | int | None) -> str:
|
||
"""将字节数格式化为可读字符串。"""
|
||
if not size:
|
||
return "0 B"
|
||
units = ["B", "KB", "MB", "GB", "TB"]
|
||
number = math.floor(math.log(size) / math.log(1024))
|
||
number = min(number, len(units) - 1)
|
||
return f"{size / math.pow(1024, number):.2f} {units[number]}"
|
||
|
||
|
||
def format_play_time(seconds: float | int | str | None) -> str:
|
||
"""秒数 → mm:ss;0 返回 --/--。"""
|
||
try:
|
||
value = float(seconds) # type: ignore[arg-type]
|
||
except (TypeError, ValueError):
|
||
return "--/--"
|
||
if math.isnan(value) or value < 0:
|
||
value = 0
|
||
minutes = int(value // 60)
|
||
secs = int(value % 60)
|
||
if minutes == 0 and secs == 0:
|
||
return "--/--"
|
||
return f"{minutes:02d}:{secs:02d}"
|
||
|
||
|
||
def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str:
|
||
"""时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。"""
|
||
if not value:
|
||
return ""
|
||
if isinstance(value, datetime):
|
||
date = value
|
||
elif isinstance(value, (int, float)):
|
||
date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value)
|
||
elif isinstance(value, str):
|
||
raw = value
|
||
if "T" not in raw:
|
||
raw = raw.split(".")[0].replace("-", "/")
|
||
parsed: datetime | None = None
|
||
for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
|
||
try:
|
||
parsed = datetime.strptime(raw[: len(pattern) + 10], pattern)
|
||
break
|
||
except ValueError:
|
||
continue
|
||
if parsed is None:
|
||
try:
|
||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return ""
|
||
date = parsed
|
||
else:
|
||
return ""
|
||
return (
|
||
fmt.replace("Y", str(date.year))
|
||
.replace("M", f"{date.month:02d}")
|
||
.replace("D", f"{date.day:02d}")
|
||
.replace("h", f"{date.hour:02d}")
|
||
.replace("m", f"{date.minute:02d}")
|
||
.replace("s", f"{date.second:02d}")
|
||
)
|
||
|
||
|
||
def date_format2(timestamp_ms: float | int) -> str:
|
||
"""相对时间格式化(用于评论时间)。"""
|
||
differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000)
|
||
if differ < 60:
|
||
return f"{differ}秒前"
|
||
if differ < 3600:
|
||
return f"{differ // 60}分钟前"
|
||
if differ < 86400:
|
||
return f"{differ // 3600}小时前"
|
||
if differ < 2592000:
|
||
return f"{differ // 86400}天前"
|
||
return date_format(timestamp_ms)
|
||
|
||
|
||
def format_play_count(num: float | int | None) -> str:
|
||
if num is None:
|
||
return "0"
|
||
if num > 100000000:
|
||
return f"{int(num / 10000000) / 10}亿"
|
||
if num > 10000:
|
||
return f"{int(num / 1000) / 10}万"
|
||
return str(num)
|
||
|
||
|
||
_ENTITY_MAP = {
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
""": '"',
|
||
"'": "'",
|
||
"'": "'",
|
||
" ": " ",
|
||
}
|
||
|
||
|
||
def decode_name(text: Any = "") -> str:
|
||
"""解码 HTML 实体。"""
|
||
if not text:
|
||
return ""
|
||
if not isinstance(text, str):
|
||
text = str(text)
|
||
for entity, char in _ENTITY_MAP.items():
|
||
text = text.replace(entity, char)
|
||
return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text)
|
||
|
||
|
||
def format_singer_name(
|
||
singers: Iterable[Mapping[str, Any]] | str | None,
|
||
name_key: str = "name",
|
||
join: str = "、",
|
||
) -> str:
|
||
"""歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。"""
|
||
if isinstance(singers, (list, tuple)):
|
||
names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)]
|
||
return decode_name(join.join(names))
|
||
return decode_name(str(singers or ""))
|
||
|
||
|
||
def pick(data: Any, *keys: str, default: Any = None) -> Any:
|
||
"""沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。"""
|
||
cur = data
|
||
for key in keys:
|
||
if isinstance(cur, Mapping):
|
||
cur = cur.get(key)
|
||
else:
|
||
return default
|
||
if cur is None:
|
||
return default
|
||
return cur
|