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

+62
View File
@@ -0,0 +1,62 @@
"""QQ音乐(对应 sdk/tx/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析 / 个人歌单,
顶层挂 get_music_url(取流在 apis/tx.py)、get_lyric、get_pic、get_music_info /
handle_music_info(songmid → SongInfo,供酷我兜底与评论/歌词反查数字 songId)、
get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.tx import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
user_playlist,
)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口。延迟导入 apis.tx:apis 模块反向依赖 platforms.tx.utils,
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块(wy 已踩过)。"""
from app.sdk.apis import tx as tx_api
return await tx_api.get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://y.qq.com/n/yqq/song/{song_info.get('songmid')}.html"
MODULE = Platform(
id="tx",
name="QQ音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=music_info.get_music_info,
handle_music_info=music_info.handle_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+183
View File
@@ -0,0 +1,183 @@
"""QQ音乐专辑(移植自 sdk/tx/album.ts)。
三条路径:
* 详情 —— music.musichallAlbum.AlbumSongList/GetAlbumSongList(参数名必须是驼峰
`albumMid`,下划线形态会被上游以 104400 拒绝)
* 全量曲目 —— 同一接口按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
* 搜索 —— c.y.qq.com 的 search_for_qq_cp `t=8`
GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,
封面走平台固定图片 URL 规则。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.filter import album_img, music_info_item
from app.sdk.platforms.tx.utils import create_musicu_fetch
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
ALBUM_BATCH = 100
SEARCH_URL = "http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p={page}&n={limit}&w={query}&t=8"
_ALBUM_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"albumDetail/([A-Za-z0-9]+)"),
re.compile(r"/album/([A-Za-z0-9]+)"),
re.compile(r"albummid=([A-Za-z0-9]+)"),
)
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
"""GetAlbumSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
def album_link(mid: str) -> str:
return f"https://y.qq.com/n/ryqq/albumDetail/{mid}"
async def fetch_album_songs(album_id: str) -> dict[str, Any]:
"""专辑曲目全量(GetAlbumSongList 100 一批翻页)。
终止条件与原文一致:本批为空、本批不足一批、或已翻到 totalNum 为止。
"""
album_mid = str(album_id or "").strip()
if not album_mid:
raise RuntimeError("tx 专辑 id 为空")
songs: list[dict[str, Any]] = []
total_num = 0
begin = 0
while True:
body = await create_musicu_fetch(
{
"req": {
"module": "music.musichallAlbum.AlbumSongList",
"method": "GetAlbumSongList",
"param": {"albumMid": album_mid, "begin": begin, "num": ALBUM_BATCH, "order": 2},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get album song list faild.")
data = req.get("data") or {}
batch = data.get("songList") or []
if not total_num:
total_num = int(data.get("totalNum") or 0)
if not batch:
break
songs.extend(batch)
if len(batch) < ALBUM_BATCH:
break
if total_num > 0 and begin + len(batch) >= total_num:
break
begin += ALBUM_BATCH
first: dict[str, Any] = {}
if songs:
first = (songs[0] or {}).get("songInfo") or {}
return {
"raw": songs,
"songs": filter_song_list(songs),
"total": total_num or len(songs),
"albumMid": album_mid,
"albumName": (first.get("album") or {}).get("name"),
}
async def get_album_detail(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""专辑详情 + 曲目(路由优先走这个签名:get_album_detail(id, page, limit))。"""
body = await create_musicu_fetch(
{
"req": {
"module": "music.musichallAlbum.AlbumSongList",
"method": "GetAlbumSongList",
"param": {"albumMid": id, "begin": (page - 1) * limit, "num": limit},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get album song list faild.")
data = req.get("data") or {}
song_list = data.get("songList") or []
first: dict[str, Any] = {}
if song_list:
first = (song_list[0] or {}).get("songInfo") or {}
authors: list[str] = []
for item in song_list:
for singer in ((item or {}).get("songInfo") or {}).get("singer") or []:
name = (singer or {}).get("name")
if name and name not in authors:
authors.append(name)
return {
"source": "tx",
"info": {
"name": (first.get("album") or {}).get("name"),
"img": album_img(str(id)),
"desc": None,
"author": "、".join(authors) or None,
},
"list": filter_song_list(song_list),
"page": page,
"limit": limit,
"total": data.get("totalNum"),
}
async def get_album_songs(id: str) -> list[dict[str, Any]]:
return (await fetch_album_songs(id))["songs"]
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8)。"""
resp = await http_fetch(
SEARCH_URL.format(page=page, limit=limit, query=quote(str(keyword or ""), safe="")),
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/portal/search.html"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
raw_data = body.get("data")
album_data: dict[str, Any] = (raw_data.get("album") if isinstance(raw_data, dict) else None) or {}
if resp.status_code != 200 or not album_data:
raise RuntimeError("tx 专辑搜索失败")
rows: list[dict[str, Any]] = []
for item in album_data.get("list") or []:
mid = str((item or {}).get("albumMID") or "").strip()
if not mid:
continue
rows.append(
{
"id": mid,
"name": str(item.get("albumName") or "").strip(),
"cover": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{mid}.jpg",
"trackCount": int(item.get("song_count") or item.get("songCount") or 0),
"creator": str(item.get("singerName") or "").strip(),
"description": "",
"link": album_link(mid),
"type": "album",
"albumId": str(item.get("albumID") or ""),
"publishTime": str(item.get("publicTime") or ""),
"source": "tx",
}
)
total = int(album_data.get("totalnum") or album_data.get("total") or len(rows)) or len(rows)
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "tx"}
async def parse_album_link(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum):命中任一形态即取详情,否则原文案报错。"""
raw = str(link or "")
for pattern in _ALBUM_PATTERNS:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的QQ音乐专辑链接")
+341
View File
@@ -0,0 +1,341 @@
"""QQ音乐评论(移植自 sdk/tx/comment.ts)。
两路数据源(与原文一致,接口不同、返回形状不同):
* 最新评论 —— c.y.qq.com 的 fcg_global_comment_h5(form 表单,cmd=8)
* 热门评论 —— musicu.fcg 的 music.globalComment.CommentRead/GetHotCommentList
原文靠 `_requestObj.cancelHttp()` 做并发去重;Python 请求层没有取消句柄(见
common/request.py 的说明),这里改用 asyncio.Task 合并同一 songmid 的并发查询。
两处刻意的降级/容错:
* `middlecommentcontent` 的父评论头像与点赞数会被搬到第一条楼中楼,父条目置 null,
随后楼中楼整体反转 —— 照抄原文,否则前端展示顺序与字段归属会和 TS 版不一致;
* 原文 `c.replynick.substring(1)` 在 replynick 缺失时会抛 TypeError 把整页打挂,
这里按空串处理(唯一的偏离,只影响脏数据)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
from app.sdk.platforms.tx.utils import MUSICU_URL
EMOJIS: dict[str, str] = {
"e400846": "😘",
"e400874": "😴",
"e400825": "😃",
"e400847": "😙",
"e400835": "😍",
"e400873": "😳",
"e400836": "😎",
"e400867": "😭",
"e400832": "😊",
"e400837": "😏",
"e400875": "😫",
"e400831": "😉",
"e400855": "😡",
"e400823": "😄",
"e400862": "😨",
"e400844": "😖",
"e400841": "😓",
"e400830": "😈",
"e400828": "😆",
"e400833": "😋",
"e400822": "😀",
"e400843": "😕",
"e400829": "😇",
"e400824": "😂",
"e400834": "😌",
"e400877": "😷",
"e400132": "🍉",
"e400181": "🍺",
"e401067": "☕️",
"e400186": "🥧",
"e400343": "🐷",
"e400116": "🌹",
"e400126": "🍃",
"e400613": "💋",
"e401236": "❤️",
"e400622": "💔",
"e400637": "💣",
"e400643": "💩",
"e400773": "🔪",
"e400102": "🌛",
"e401328": "🌞",
"e400420": "👏",
"e400914": "🙌",
"e400408": "👍",
"e400414": "👎",
"e401121": "✋",
"e400396": "👋",
"e400384": "👉",
"e401115": "✊",
"e400402": "👌",
"e400905": "🙈",
"e400906": "🙉",
"e400907": "🙊",
"e400562": "👻",
"e400932": "🙏",
"e400644": "💪",
"e400611": "💉",
"e400185": "🎁",
"e400655": "💰",
"e400325": "🐥",
"e400612": "💊",
"e400198": "🎉",
"e401685": "⚡️",
"e400631": "💝",
"e400768": "🔥",
"e400432": "👑",
}
_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_UA_HOT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0"
)
_NEW_COMMENT_URL = "http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg"
_EMOJI_RE = re.compile(r"\[em\](e\d+)\[/em\]")
# songmid → songId,以及并发查询合并用的 task 表
_song_id_map: dict[str, Any] = {}
_promises: dict[str, asyncio.Task[Any]] = {}
def replace_emoji(msg: str) -> str:
"""`[em]e400846[/em]` → emoji;未知编号替换为空串(与原文 `|| ''` 一致)。"""
if not msg or "[em]" not in msg:
return msg
return _EMOJI_RE.sub(lambda match: EMOJIS.get(match.group(1), ""), msg)
def format_time(time: Any) -> int | None:
"""原文字符串长度 < 10 视为无效时间返回 null;否则补 `000` 转毫秒。"""
text = str(time)
if len(text) < 10:
return None
try:
return int(text + "000")
except ValueError:
return None
async def get_song_id(song_info: dict[str, Any]) -> Any:
"""songId 优先;否则 songmid 反查(带缓存 + 并发合并)。"""
song_id = song_info.get("songId")
if song_id:
return song_id
songmid = str(song_info.get("songmid") or "")
if songmid in _song_id_map:
return _song_id_map[songmid]
task = _promises.get(songmid)
if task is None:
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
_promises[songmid] = task
try:
info = await asyncio.shield(task)
finally:
_promises.pop(songmid, None)
if not info:
raise RuntimeError("获取歌曲信息失败")
_song_id_map[songmid] = info.get("songId")
return info.get("songId")
def _time_pair(time: Any) -> tuple[int | None, str | None]:
parsed = format_time(time)
return parsed, (date_format2(parsed) if parsed else None)
def filter_new_comment(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
if not isinstance(item, dict):
continue
time, time_str = _time_pair(item.get("time"))
middle = item.get("middlecommentcontent")
if middle:
# 父条目头像/点赞搬到第一条楼中楼,父条目置空,楼中楼反转(照抄原文)
first = middle[0]
first["avatarurl"] = item.get("avatarurl")
first["praisenum"] = item.get("praisenum")
item["avatarurl"] = None
item["praisenum"] = None
middle.reverse()
replies: list[dict[str, Any]] = []
for reply in middle or []:
if not isinstance(reply, dict):
continue
reply_time = time if reply.get("subcommentid") == item.get("commentid") else None
reply_time_str = time_str if reply.get("subcommentid") == item.get("commentid") else None
replies.append(
{
"id": f"sub_{item.get('rootcommentid')}_{reply.get('subcommentid')}",
"text": replace_emoji(str(reply.get("subcommentcontent") or "")).replace("\\n", "\n"),
"time": reply_time,
"timeStr": reply_time_str,
"userName": str(reply.get("replynick") or "")[1:],
"avatar": reply.get("avatarurl"),
"userId": reply.get("encrypt_replyuin"),
"likedCount": reply.get("praisenum"),
}
)
same_root = item.get("rootcommentid") == item.get("commentid")
rows.append(
{
"id": f"{item.get('rootcommentid')}_{item.get('commentid')}",
"rootId": item.get("rootcommentid"),
"text": replace_emoji(str(item.get("rootcommentcontent") or "")).replace("\\n", "\n")
if item.get("rootcommentcontent")
else "",
"time": time if same_root else None,
"timeStr": time_str if same_root else None,
"userName": str(item.get("rootcommentnick") or "")[1:],
"avatar": item.get("avatarurl"),
"userId": item.get("encrypt_rootcommentuin"),
"likedCount": item.get("praisenum"),
"reply": replies,
}
)
return rows
def filter_hot_comment(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
if not isinstance(item, dict):
continue
time, time_str = _time_pair(item.get("PubTime")) if item.get("PubTime") else (None, None)
replies: list[dict[str, Any]] = []
for reply in item.get("SubComments") or []:
if not isinstance(reply, dict):
continue
reply_time, reply_time_str = _time_pair(reply.get("PubTime")) if reply.get("PubTime") else (None, None)
replies.append(
{
"id": f"sub_{reply.get('SeqNo')}_{reply.get('CmId')}",
"text": replace_emoji(str(reply.get("Content") or "")).replace("\\n", "\n"),
"time": reply_time,
"timeStr": reply_time_str,
"userName": reply.get("Nick") or "",
"avatar": reply.get("Avatar"),
"images": [reply["Pic"]] if reply.get("Pic") else [],
"userId": reply.get("EncryptUin"),
"likedCount": reply.get("PraiseNum"),
}
)
rows.append(
{
"id": f"{item.get('SeqNo')}_{item.get('CmId')}",
"rootId": item.get("SeqNo"),
"text": replace_emoji(str(item.get("Content") or "")).replace("\\n", "\n")
if item.get("Content")
else "",
"time": time,
"timeStr": time_str,
"userName": item.get("Nick") or "",
"images": [item["Pic"]] if item.get("Pic") else [],
"avatar": item.get("Avatar"),
"location": item.get("Location") or "",
"userId": item.get("EncryptUin"),
"likedCount": item.get("PraiseNum"),
"reply": replies,
}
)
return rows
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
"""最新评论(fcg_global_comment_h5,form 表单)。失败文案与原文一致:`获取评论失败`。"""
song_id = await get_song_id(song_info)
resp = await http_fetch(
_NEW_COMMENT_URL,
method="POST",
headers={"User-Agent": _UA_IE},
form={
"uin": "0",
"format": "json",
"cid": "205360772",
"reqtype": "2",
"biztype": "1",
"topid": song_id,
"cmd": "8",
"needmusiccrit": "1",
"pagenum": page - 1,
"pagesize": limit,
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError("获取评论失败")
comment = body.get("comment") or {}
total = comment.get("commenttotal") or 0
return {
"source": "tx",
"comments": filter_new_comment(comment.get("commentlist")),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
"""热门评论(musicu.fcg GetHotCommentList)。失败文案:`获取热门评论失败`。"""
song_id = await get_song_id(song_info)
resp = await http_fetch(
MUSICU_URL,
method="POST",
body={
"comm": {
"cv": 4747474,
"ct": 24,
"format": "json",
"inCharset": "utf-8",
"outCharset": "utf-8",
"notice": 0,
"platform": "yqq.json",
"needNewCode": 1,
"uin": 0,
},
"req": {
"module": "music.globalComment.CommentRead",
"method": "GetHotCommentList",
"param": {
"BizType": 1,
"BizId": str(song_id),
"LastCommentSeqNo": "",
"PageSize": limit,
"PageNum": page - 1,
"HotType": 1,
"WithAirborne": 0,
"PicEnable": 1,
},
},
},
headers={"User-Agent": _UA_HOT, "referer": "https://y.qq.com/", "origin": "https://y.qq.com"},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
raise RuntimeError("获取热门评论失败")
comment = (req.get("data") or {}).get("CommentList") or {}
total = comment.get("Total") or 0
return {
"source": "tx",
"comments": filter_hot_comment(comment.get("Comments")),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
+80
View File
@@ -0,0 +1,80 @@
"""QQ音乐 SongInfo 组装公共片段。
原 TS 把这套「file.size_* → types/_types」与「专辑图/歌手图」逻辑在
musicSearch / songList / leaderboard / singer / parse 里各抄了一份。迁移时合并到一处,
字段与判定顺序保持原样(顺序影响前端音质下拉的排列)。
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
# 顺序即原实现的 push 顺序:(file 字段, 对外音质名)
_SIZE_FIELDS: tuple[tuple[str, str], ...] = (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
)
def build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""file 体积字段 → (types, _types)。0/缺失表示该档不存在(与原文 `!== 0` 一致)。"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in _SIZE_FIELDS:
if file.get(key):
size = size_formate(file[key])
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
return types, _types
def song_img(album_mid: Any, singers: Any) -> str:
"""有专辑 mid 用专辑图(T002),否则退回首位歌手头像(T001)。"""
if album_mid in ("", "空", None):
if isinstance(singers, list) and singers and isinstance(singers[0], dict) and singers[0].get("mid"):
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
return ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
def album_img(album_mid: Any) -> str:
"""专辑封面(singer 专辑列表、专辑搜索用,不退化到歌手图)。"""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
def music_info_item(item: Mapping[str, Any] | None) -> dict[str, Any]:
"""原始曲目条目 → SongInfo(对应 singer.ts 的 filterMusicInfoItem)。
比 music_search/filter_list 的版本多带 lrc/otherSource 两个空字段 —— 原文
只在 filterMusicInfoItem 里带,专辑/歌手曲目沿用这个形状,不要合并掉。
"""
raw: Mapping[str, Any] = item or {}
file = raw.get("file") or {}
types, _types = build_types(file)
album = raw.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
singers = raw.get("singer") or []
return {
"source": "tx",
"singer": format_singer_name(singers, "name"),
"name": raw.get("title"),
"albumName": album_name,
"albumId": album_mid,
"albumMid": album_mid,
"interval": format_play_time(raw.get("interval")),
"songId": raw.get("id"),
"songmid": raw.get("mid"),
"strMediaMid": file.get("media_mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, singers),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
+52
View File
@@ -0,0 +1,52 @@
"""QQ音乐热搜(移植自 sdk/tx/hotSearch.ts)。
musicu.fcg 的 tencent_musicsoso_hotkey.HotkeyService/GetHotkeyForQQMusicPC。
失败文案与原文一致:`获取热搜词失败`;重试上限 3 次(try max num)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
def filter_list(raw_list: Any) -> list[str]:
return [str(item.get("query")) for item in raw_list or [] if isinstance(item, dict) and item.get("query")]
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"Referer": "https://y.qq.com/portal/player.html"},
body={
"comm": {
"ct": "19",
"cv": "1803",
"guid": "0",
"patch": "118",
"psrf_access_token_expiresAt": 0,
"psrf_qqaccess_token": "",
"psrf_qqopenid": "",
"psrf_qqunionid": "",
"tmeAppID": "qqmusic",
"tmeLoginType": 0,
"uin": "0",
"wid": "0",
},
"hotkey": {
"method": "GetHotkeyForQQMusicPC",
"module": "tencent_musicsoso_hotkey.HotkeyService",
"param": {"search_id": "", "uin": 0},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError("获取热搜词失败")
return {"source": "tx", "list": filter_list(((body.get("hotkey") or {}).get("data") or {}).get("vec_hotkey"))}
+150
View File
@@ -0,0 +1,150 @@
"""QQ音乐榜单(移植自 sdk/tx/leaderboard.ts)。
榜单列表是硬编码的 25 个(boardList);详情走 musicu.fcg 的
musicToplist.ToplistInfoServer/GetDetail,period 参数(如 2026_31)从 top.html
按正则抓取并按 bangid 缓存。抓不到就传空串 —— 接口对缺省 period 容忍,
与原文 `info ? info.period : getPeriods(bangid)` 的降级一致。
上游不分页:一次返回 LIMIT 首,page 入参仅为对齐协议。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
LIMIT = 300
BOARD_LIST: list[dict[str, str]] = [
{"id": "tx__4", "name": "流行指数榜", "bangid": "4"},
{"id": "tx__26", "name": "热歌榜", "bangid": "26"},
{"id": "tx__27", "name": "新歌榜", "bangid": "27"},
{"id": "tx__62", "name": "飙升榜", "bangid": "62"},
{"id": "tx__58", "name": "说唱榜", "bangid": "58"},
{"id": "tx__57", "name": "喜力电音榜", "bangid": "57"},
{"id": "tx__28", "name": "网络歌曲榜", "bangid": "28"},
{"id": "tx__5", "name": "内地榜", "bangid": "5"},
{"id": "tx__3", "name": "欧美榜", "bangid": "3"},
{"id": "tx__59", "name": "香港地区榜", "bangid": "59"},
{"id": "tx__16", "name": "韩国榜", "bangid": "16"},
{"id": "tx__60", "name": "抖快榜", "bangid": "60"},
{"id": "tx__29", "name": "影视金曲榜", "bangid": "29"},
{"id": "tx__17", "name": "日本榜", "bangid": "17"},
{"id": "tx__52", "name": "腾讯音乐人原创榜", "bangid": "52"},
{"id": "tx__36", "name": "K歌金曲榜", "bangid": "36"},
{"id": "tx__61", "name": "台湾地区榜", "bangid": "61"},
{"id": "tx__63", "name": "DJ舞曲榜", "bangid": "63"},
{"id": "tx__64", "name": "综艺新歌榜", "bangid": "64"},
{"id": "tx__65", "name": "国风热歌榜", "bangid": "65"},
{"id": "tx__67", "name": "听歌识曲榜", "bangid": "67"},
{"id": "tx__72", "name": "动漫音乐榜", "bangid": "72"},
{"id": "tx__73", "name": "游戏音乐榜", "bangid": "73"},
{"id": "tx__75", "name": "有声榜", "bangid": "75"},
{"id": "tx__131", "name": "校园音乐人排行榜", "bangid": "131"},
]
_PERIOD_LIST_RXP = re.compile(
r'<i class="play_cover__btn c_tx_link js_icon_play" data-listkey=".+?" data-listname=".+?" '
r'data-tid=".+?" data-date=".+?" .+?</i>'
)
_PERIOD_RXP = re.compile(r'data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?</i>')
PERIOD_URL = "https://c.y.qq.com/node/pc/wk_v15/top.html"
# bangid → {"name", "bangid", "period"}(原 TS 的 periods 对象,模块级缓存)
_periods: dict[str, dict[str, str]] = {}
def filter_data(raw_list: Any) -> list[dict[str, Any]]:
songs: list[dict[str, Any]] = []
for item in raw_list or []:
file = (item or {}).get("file") or {}
types, _types = build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
singers = item.get("singer") or []
songs.append(
{
"singer": format_singer_name(singers, "name"),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, singers),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def _get_periods(bangid: str) -> str:
"""抓 top.html 填缓存,返回该榜单的 period;抓不到返回空串(不抛错,接口容忍)。"""
resp = await http_fetch(PERIOD_URL)
html = resp.body if isinstance(resp.body, str) else ""
for tag_html in _PERIOD_LIST_RXP.findall(html):
result = _PERIOD_RXP.search(tag_html)
if not result:
continue
_periods[result.group(2)] = {"name": result.group(1), "bangid": result.group(2), "period": result.group(3)}
info = _periods.get(bangid)
return info["period"] if info else ""
async def _list_detail_request(bangid: str, period: str, limit: int) -> dict[str, Any]:
# topid 必须发数字:传字符串 "4" 服务端会回 toplist.code 10006(无数据),传 4 才返回榜单。
topid: Any = int(bangid) if str(bangid).isdigit() else bangid
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": UA_MUSICU},
body={
"toplist": {
"module": "musicToplist.ToplistInfoServer",
"method": "GetDetail",
"param": {"topid": topid, "num": limit, "period": period},
},
"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859},
},
)
return resp.body if isinstance(resp.body, dict) else {}
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "tx"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
retry_num += 1
if retry_num > 3:
raise RuntimeError("try max num")
bare = str(bangid)
info = _periods.get(bare)
period = info["period"] if info else await _get_periods(bare)
body = await _list_detail_request(bare, period, LIMIT)
if body.get("code") != 0:
return await get_list(bangid, page, retry_num)
info_list = ((body.get("toplist") or {}).get("data") or {}).get("songInfoList") or []
return {
"total": len(info_list),
"list": filter_data(info_list),
"limit": LIMIT,
"page": 1,
"source": "tx",
}
+276
View File
@@ -0,0 +1,276 @@
"""QQ音乐歌词(移植自 sdk/tx/lyric.ts)。
上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式:
[起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)...
同一份数据要拆成两路:
lyric —— 标准 LRC(把逐字标记剥掉,只留行时间)
yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字)
翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Callable
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
from app.sdk.platforms.tx.qrc_decode import decode_qrc
from app.sdk.platforms.tx.utils import MUSICU_URL
SUCCESS_CODE = 0
_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]")
_WORD_TIME = re.compile(r"\(\d+,\d+\)")
_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))")
_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)")
_TIME_SPLIT = re.compile(r"[:.]")
_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="')
_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$')
# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情)
_song_id_map: dict[str, Any] = {}
_promises: dict[str, asyncio.Task[Any]] = {}
def ms_format(time_ms: int) -> str:
"""毫秒 → `[mm:ss.SSS]`。"""
ms = time_ms % 1000
total = time_ms // 1000
minute = total // 60
second = total % 60
return f"[{minute:02d}:{second:02d}.{ms:03d}]"
def parse_lyric(lrc: str) -> dict[str, str]:
"""逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。"""
text = lrc.strip().replace("\r", "")
if not text:
return {"lyric": "", "yrc": ""}
yrc_lines: list[str] = []
lrc_lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
if line.startswith("[offset"):
yrc_lines.append(line)
lrc_lines.append(line)
if _LINE_TIME2.match(line):
lrc_lines.append(line)
continue
start_ms = int(result.group(1))
start_str = ms_format(start_ms)
words = _LINE_TIME.sub("", line, count=1)
lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
times = _WORD_TIME_ALL.findall(words)
if not times:
continue
marks = []
for time in times:
parts = _WORD_TIME_PARTS.fullmatch(time)
if parts is None:
continue
marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>")
word_arr = _WORD_TIME.split(words)
new_words = "".join(
f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks)
)
yrc_lines.append(f"{start_str}{new_words}")
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
def parse_rlyric(lrc: str) -> str:
"""罗马音(逐字格式)→ 标准 LRC 行。"""
text = lrc.strip().replace("\r", "")
if not text:
return ""
lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
continue
start_str = ms_format(int(result.group(1)))
words = _LINE_TIME.sub("", line, count=1)
lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
return "\n".join(lines)
def remove_tag(text: str) -> str:
"""剥离 `<LyricContent="..." />` 外层标签,只留正文。"""
return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text))
def get_intv(interval: str) -> int:
"""`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。"""
if not interval:
return 0
if "." not in interval:
interval += ".0"
parts = _TIME_SPLIT.split(interval)
while len(parts) < 3:
parts.insert(0, "0")
minute, second, ms = parts[0], parts[1], parts[2]
def _int(value: str) -> int:
try:
return int(value)
except ValueError:
return 0
return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms)
def _align_times(raw_lyric: str, lrc: str) -> str:
"""把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。
与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。
"""
out: list[str] = []
lrc_lines = lrc.split("\n")
for line in raw_lyric.split("\n"):
result = _LINE_TIME2.match(line)
if not result:
continue
if not _LINE_TIME2.sub("", line, count=1).strip():
continue
t1 = get_intv(result.group(1))
while lrc_lines:
lrc_line = lrc_lines.pop(0)
lrc_result = _LINE_TIME2.match(lrc_line)
if not lrc_result:
continue
if abs(t1 - get_intv(lrc_result.group(1))) < 100:
out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1))
break
return "\n".join(out)
def _literal(text: str) -> Callable[[re.Match[str]], str]:
r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。"""
return lambda _match: text
def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str:
return _align_times(rlrc, lrc)
def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str:
"""翻译行对齐。
原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是
`time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。
这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。
"""
return _align_times(tlrc, lrc)
def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
if lrc:
parsed = parse_lyric(remove_tag(lrc))
info["lyric"] = parsed["lyric"]
info["yrc"] = parsed["yrc"]
if rlrc:
info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"])
if tlrc:
info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"])
return info
async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
"""三路 QRC hex 解密后解析。"""
lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc)
return parse(lyric, tlyric, rlyric)
async def get_song_id(song_info: dict[str, Any]) -> Any:
"""songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。"""
song_id = song_info.get("songId")
if song_id:
return song_id
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取歌曲信息失败")
if songmid in _song_id_map:
return _song_id_map[songmid]
task = _promises.get(songmid)
if task is None:
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
_promises[songmid] = task
try:
info = await asyncio.shield(task)
finally:
_promises.pop(songmid, None)
if not info:
raise RuntimeError("获取歌曲信息失败")
_song_id_map[songmid] = info.get("songId")
return info.get("songId")
async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"referer": "https://y.qq.com", "user-agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"method": "GetPlayLyricInfo",
"module": "music.musichallSong.PlayLyricInfo",
"param": {
"format": "json",
"crypt": 1,
"ct": 19,
"cv": 1873,
"interval": 0,
"lrc_t": 0,
"qrc": 1,
"qrc_t": 0,
"roma": 1,
"roma_t": 0,
"songID": song_id,
"trans": 1,
"trans_t": 0,
"type": -1,
},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
data = req.get("data") or {}
if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
return await _retry(song_id, retry_num)
return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma"))
async def _retry(song_id: Any, retry_num: int) -> dict[str, str]:
"""失败重试(上限 3 次,文案与原文一致:Get lyric failed)。"""
retry_num += 1
if retry_num > 3:
raise RuntimeError("Get lyric failed")
# 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败),
# 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。
return await _fetch(song_id, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
"""Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。"""
song_id = await get_song_id(song_info)
return await _fetch(song_id, 0)
+108
View File
@@ -0,0 +1,108 @@
"""QQ音乐歌曲信息(移植自 sdk/tx/musicInfo.ts)。
走 musicu.fcg 的 music.pf_song_detail_svr/get_song_detail_yqq:songmid → track_info。
歌词与评论模块靠它把 songmid 换成数字 songID(接口只认数字 id)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, size_formate
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
def _build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""file.size_* → types/_types(0 表示该档不存在,与原文一致)。"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
):
if file.get(key):
size = size_formate(file[key])
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
return types, _types
def _song_img(album_id: Any, singers: Any) -> str:
if album_id in ("", "空", None):
if isinstance(singers, list) and singers and singers[0].get("mid"):
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
return ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
async def get_music_info_by_mid(songmid: str) -> dict[str, Any] | None:
"""songmid → SongInfo;无 media_mid 时返回 None(原文同样返回 null)。"""
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": UA_MUSICU},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if body.get("code") != 0 or req.get("code") != 0:
raise RuntimeError("获取歌曲信息失败")
item = (req.get("data") or {}).get("track_info") or {}
file = item.get("file") or {}
if not file.get("media_mid"):
return None
types, _types = _build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_id = album.get("mid") or ""
singers = item.get("singer") or []
return {
"singer": "、".join(str(singer.get("name")) for singer in singers if isinstance(singer, dict)),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_id,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album.get("mid") or "",
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": _song_img(album_id, singers),
"types": types,
"_types": _types,
"typeUrl": {},
}
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""Platform.get_music_info 契约:song_info 里取 songmid。"""
songmid = song_info.get("songmid") or song_info.get("hash") or ""
if not songmid:
return None
return await get_music_info_by_mid(str(songmid))
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""补全缺失的歌名/歌手/封面;取不到时原样返回(不抛错)。"""
if song_info.get("name") and song_info.get("singer"):
return song_info
try:
info = await get_music_info(song_info)
except (RuntimeError, OSError, TimeoutError):
return song_info
if not info:
return song_info
merged = {**info, **{k: v for k, v in song_info.items() if v}}
return merged
+139
View File
@@ -0,0 +1,139 @@
"""QQ音乐搜索(移植自 sdk/tx/musicSearch.ts)。
走 musics.fcg 签名通道(zzc 签名 + SearchCgiService/DoSearchForQQMusicMobile)。
item_song → SongInfo;无 media_mid 的条目直接丢弃(原文同样 skip)。
"""
from __future__ import annotations
import random
from typing import Any
from app.sdk.common.utils import format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
from app.sdk.platforms.tx.utils import sign_request
DEFAULT_LIMIT = 50
SUCCESS_CODE = 0
# 客户端指纹参数,原样照抄(少一项上游会拒)
_SEARCH_COMM: dict[str, str] = {
"ct": "11",
"cv": "14090508",
"v": "14090508",
"tmeAppID": "qqmusic",
"phonetype": "EBG-AN10",
"deviceScore": "553.47",
"devicelevel": "50",
"newdevicelevel": "20",
"rom": "HuaWei/EMOTION/EmotionUI_14.2.0",
"os_ver": "12",
"OpenUDID": "0",
"OpenUDID2": "0",
"QIMEI36": "0",
"udid": "0",
"chid": "0",
"aid": "0",
"oaid": "0",
"taid": "0",
"tid": "0",
"wid": "0",
"uid": "0",
"sid": "0",
"modeSwitch": "6",
"teenMode": "0",
"ui_mode": "2",
"nettype": "1020",
"v4ip": "",
}
def _search_id() -> str:
"""对应原实现的 `Math.random().toString().slice(2)`:去掉 `0.` 前缀的随机数字串。"""
return str(random.random()).split(".", 1)[-1]
def handle_result(raw_list: Any) -> list[dict[str, Any]]:
if not raw_list or not isinstance(raw_list, list):
return []
songs: list[dict[str, Any]] = []
for item in raw_list:
file = (item or {}).get("file") or {}
if not file.get("media_mid"):
continue
types, _types = build_types(file)
album = item.get("album") or {}
album_mid = album.get("mid") or ""
singers = item.get("singer") or []
songs.append(
{
"singer": format_singer_name(singers, "name"),
"name": item.get("title"),
"albumName": album.get("name") or "",
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img(album_mid, singers),
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def _music_search(keyword: str, page: int, limit: int, retry_num: int) -> dict[str, Any]:
"""签名通道搜索。重试上限 5 次(文案与原文一致:搜索失败)。"""
if retry_num > 5:
raise RuntimeError("搜索失败")
resp = await sign_request(
{
"comm": _SEARCH_COMM,
"req": {
"module": "music.search.SearchCgiService",
"method": "DoSearchForQQMusicMobile",
"param": {
"search_type": 0,
"searchid": _search_id(),
"query": keyword,
"page_num": page,
"num_per_page": limit,
"highlight": 0,
"nqc_flag": 0,
"multi_zhida": 0,
"cat": 2,
"grp": 1,
"sin": 0,
"sem": 0,
},
},
}
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if not req or body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
return await _music_search(keyword, page, limit, retry_num + 1)
return req.get("data") or {}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
if not limit:
limit = DEFAULT_LIMIT
data = await _music_search(keyword, page, limit, retry_num)
# 响应结构:req.data.{body, meta}。歌曲数组在 data.body.item_song,总数在 data.meta.estimate_sum
# (TS 侧写的是 body.item_song / meta.estimate_sum,其中 body 即此处的 data)。
inner = data.get("body")
meta = data.get("meta")
songs = handle_result(inner.get("item_song") if isinstance(inner, dict) else None)
total = (meta.get("estimate_sum") or 0) if isinstance(meta, dict) else 0
return {
"list": songs,
"allPage": -(-int(total) // limit) if total else 0, # ceil 除法
"limit": limit,
"total": total,
"source": "tx",
}
+153
View File
@@ -0,0 +1,153 @@
"""QQ音乐链接解析(移植自 sdk/tx/parse.ts)。
歌曲链接形态:
* https://y.qq.com/n/ryqq/songDetail/<songmid>
* https://y.qq.com/n/yqq/song/<songmid>.html
* https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
* ...?songmid=<songmid>
元数据走 fcg_play_single_song(顺带拿 file 体积信息)。直链 url/type 是尽力附带:
取流失败只返回元数据,不影响解析结果(与上游一致,VIP/下架曲目即如此)。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
from app.sdk.platforms.tx.filter import album_img
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
_SONG_DETAIL_RE = re.compile(r"songDetail/([A-Za-z0-9]+)")
_SONG_PATH_RE = re.compile(r"/song/([A-Za-z0-9]+)")
_SONG_MID_QUERY_RE = re.compile(r"[?&]songmid=([A-Za-z0-9]+)")
_SONG_ID_QUERY_RE = re.compile(r"[?&]songid=(\d+)")
_DETAIL_URL = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg"
async def _fetch_single_song(query: str) -> dict[str, Any] | None:
"""fcg_play_single_song 首条(query 形如 songmid=xxx / songid=123)。"""
resp = await http_fetch(
f"{_DETAIL_URL}?{query}&format=json",
headers={"User-Agent": _UA, "Referer": "http://m.y.qq.com"},
timeout=15000,
)
if resp.status_code != 200:
return None
body = resp.body if isinstance(resp.body, dict) else {}
data = body.get("data")
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
return data[0]
async def fetch_mid_by_song_id(songid: str) -> str:
"""上游 fetchSongDetailByID:数字 songid → mid。"""
item = await _fetch_single_song(f"songid={quote(str(songid), safe='')}")
mid = str((item or {}).get("mid") or "")
if not mid:
raise RuntimeError("QQ音乐歌曲不存在")
return mid
async def fetch_song_detail_raw(songmid: str) -> dict[str, Any]:
"""上游 fetchSongDetail:songmid → 原始条目。"""
item = await _fetch_single_song(f"songmid={quote(str(songmid), safe='')}")
if not item or not item.get("mid"):
raise RuntimeError("QQ音乐歌曲不存在")
return item
def convert_song(item: dict[str, Any]) -> dict[str, Any]:
"""原始条目 → MusicServer Song 形状。
注意体积字段的判定与 filter.build_types 不同:这里要求 `isinstance(number)` 且 > 0
(原文是 `typeof size === 'number' && size > 0`),因此字符串型体积会被跳过。
"""
file = item.get("file") or {}
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
):
size = file.get(key)
if isinstance(size, (int, float)) and not isinstance(size, bool) and size > 0:
formatted = size_formate(size)
types.append({"type": quality, "size": formatted})
_types[quality] = {"size": formatted}
album = item.get("album") or {}
album_mid = album.get("mid") or ""
return {
"source": "tx",
"songmid": str(item.get("mid")),
"songId": item.get("id"),
"name": item.get("title") or item.get("name") or "",
"singer": format_singer_name(item.get("singer"), "name"),
"albumId": album_mid,
"albumMid": album_mid,
"albumName": album.get("name") or "",
"interval": format_play_time(item.get("interval") or 0),
"img": album_img(album_mid) if album_mid else "",
"lrc": None,
"strMediaMid": file.get("media_mid") or item.get("mid"),
"types": types,
"_types": _types,
"typeUrl": {},
}
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(尽力附带直链 url/type)。"""
raw = str(link or "")
match = _SONG_DETAIL_RE.search(raw) or _SONG_PATH_RE.search(raw)
songmid = match.group(1) if match else ""
songid = ""
if not songmid:
id_match = _SONG_ID_QUERY_RE.search(raw)
songid = id_match.group(1) if id_match else ""
mid_match = _SONG_MID_QUERY_RE.search(raw)
songmid = mid_match.group(1) if mid_match else ""
if not songmid and songid:
songmid = await fetch_mid_by_song_id(songid)
if not songmid:
raise RuntimeError("无效的QQ音乐歌曲链接")
item = await fetch_song_detail_raw(songmid)
song = convert_song(item)
if songid:
song["songId"] = int(songid)
try:
# 延迟导入:apis.tx 反向依赖 platforms.tx.utils,模块级互 import 会拿到半初始化模块
from app.sdk.apis import tx as tx_api
hit = await tx_api.get_music_url(song, "320k")
song["url"] = hit.get("url")
song["type"] = hit.get("type")
except (RuntimeError, OSError, TimeoutError):
# 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
from app.sdk.platforms.tx.album import parse_album_link
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
from app.sdk.platforms.tx.playlist import parse_playlist_link
return await parse_playlist_link(link)
+14
View File
@@ -0,0 +1,14 @@
"""QQ音乐封面(移植自 sdk/tx/index.ts 的 getPic)。
封面是固定 URL 规则:T002 = 专辑图,按 albumId/albumMid 拼。路由总是传 albumId(缺省空串),
这里对 None 也按空串处理(原 JS 会把 undefined 拼成字面量 "undefined",无实际意义)。
"""
from __future__ import annotations
from typing import Any
async def get_pic(song_info: dict[str, Any]) -> str:
album_id = song_info.get("albumId") or ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
+299
View File
@@ -0,0 +1,299 @@
"""QQ音乐歌单能力位(移植自 sdk/tx/playlist.ts)。
复用 song_list 的搜索/分类/详情,另补三块上游独有的逻辑:
* 分类歌单按入参 limit 重新切片(上游固定 36 条一页,需多次拉取拼够)
* 推荐歌单(musicu.fcg playlist.HotRecommendServer/get_hot_recommend)
* 歌单链接解析(extract_playlist_id 支持现代 ryqq 路由 / 老版 id、disstid 查询,
并显式拒绝歌曲/专辑链接,避免把其中的数字 id 误判成歌单)
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import parse_qs, urlparse
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
from app.sdk.platforms.tx import song_list
from app.sdk.platforms.tx.utils import MUSICU_URL
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
_API_LIMIT = song_list.LIMIT_LIST
_DIGITS = re.compile(r"^\d+$")
# 歌曲/专辑页特征:出现即拒绝(歌单链接可能带 ?id=,故先看是否含 playlist)
_NON_PLAYLIST = re.compile(r"(albumDetail|/album/|albummid=|songDetail|/song/|songmid=|[?&]songid=)")
_BARE_PLAYLIST_PATH = re.compile(r"(?:^|/)playlist/(\d+)(?:[/?#]|$)")
def _playlist_link(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"
def _to_summary(item: Any) -> dict[str, Any] | None:
"""歌单摘要:兼容 song_list 的既有字段并补 spec 形状。"""
if not isinstance(item, dict):
return None
playlist_id = str(item.get("id") or "").strip()
if not playlist_id:
return None
cover = str(item.get("img") or item.get("cover") or "")
track_count = item.get("total")
return {
**item,
"id": playlist_id,
"name": item.get("name") or "",
"cover": cover,
"trackCount": int(track_count) if isinstance(track_count, (int, float)) else 0,
"creator": item.get("author") or item.get("creator") or "",
"description": item.get("desc") or item.get("description") or "",
"link": _playlist_link(playlist_id),
"source": "tx",
}
def _normalize_playlist_id(value: str) -> str:
trimmed = str(value or "").strip()
return trimmed if trimmed and _DIGITS.match(trimmed) else ""
def _filter_category_content(raw: Any) -> list[dict[str, Any]]:
"""分类内容 → 摘要(补 song_cnt/play_cnt,song_list.filter_list2 未映射这两个字段)。"""
rows: list[dict[str, Any]] = []
for item in raw or []:
basic = (item or {}).get("basic") or {}
playlist_id = str(basic.get("tid") or "").strip()
name = str(basic.get("title") or "").strip()
if not playlist_id or not name:
continue
play_count = basic.get("play_cnt") or 0
cover = str((basic.get("cover") or {}).get("medium_url") or (basic.get("cover") or {}).get("default_url") or "")
desc = str(basic.get("desc") or "").replace("<br>", "\n")
track_count = basic.get("song_cnt") or 0
rows.append(
{
"id": playlist_id,
"name": name,
"img": cover,
"cover": cover,
"author": (basic.get("creator") or {}).get("nick") or "",
"creator": (basic.get("creator") or {}).get("nick") or "",
"play_count": format_play_count(play_count),
"playCount": int(play_count),
"trackCount": int(track_count),
"total": int(track_count),
"desc": desc,
"description": desc,
"source": "tx",
}
)
return rows
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(复用 song_list.search)。"""
result = await song_list.search(str(keyword or ""), page, limit)
rows = [summary for summary in (_to_summary(item) for item in result.get("list") or []) if summary]
total = result.get("total")
return {"list": rows, "page": page, "limit": limit, "total": total or len(rows), "source": "tx"}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单详情:详情接口返回全量曲目,这里按 page/limit 切片。"""
detail = await song_list.get_list_detail(str(playlist_id or ""))
songs = detail.get("list") or []
start = max(0, (page - 1) * limit)
total = detail.get("total")
return {
"source": "tx",
"info": detail.get("info"),
"list": songs[start : start + limit],
"page": page,
"limit": limit,
"total": total if total is not None else len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
detail = await song_list.get_list_detail(str(playlist_id or ""))
return detail.get("list") or []
async def get_categories() -> dict[str, Any]:
"""歌单分类:分组标签 + 热门标记(单个接口失败不阻塞整体)。"""
tags = await song_list.get_tags()
groups = tags.get("tags") or []
hot_tags = tags.get("hotTag") or []
hot_set = {str(item.get("id")) for item in hot_tags if isinstance(item, dict)}
rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
seen = {""}
for group in groups:
for item in group.get("list") or []:
category_id = str(item.get("id") or "").strip()
name = str(item.get("name") or "").strip()
if not category_id or not name or category_id in seen:
continue
seen.add(category_id)
rows.append(
{"id": category_id, "name": name, "group": group.get("name") or "", "hot": category_id in hot_set}
)
return {"source": "tx", "list": rows}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类歌单。上游固定 36 条一页,按 (start+limit) 推算需要拉几页再切片。
categoryId 为空或 '10000000'(上游「全部」默认值)时走 get_playlist_by_tag 广场分支。
"""
category = str(category_id or "").strip()
is_all = category in ("", "10000000")
start = max(0, (page - 1) * limit)
api_pages = max(1, -(-(start + limit) // _API_LIMIT)) # ceil 除法
items: list[dict[str, Any]] = []
total = 0
for api_page in range(1, api_pages + 1):
url = song_list.get_list_url("5", "" if is_all else category, api_page)
resp = await http_fetch(url, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError(f"tx 分类歌单获取失败 (code {body.get('code') or resp.status_code})")
data = (body.get("playlist") or {}).get("data") or {}
if is_all:
total = int(data.get("total") or total)
batch = []
for item in data.get("v_playlist") or []:
play_count = item.get("access_num") or 0
playlist_id = str(item.get("tid") or "").strip()
name = str(item.get("title") or "").strip()
if not playlist_id or not name:
continue
batch.append(
{
"id": playlist_id,
"name": name,
"cover": str(item.get("cover_url_medium") or item.get("cover_url_big") or ""),
"creator": (item.get("creator_info") or {}).get("nick") or "",
"trackCount": len(item.get("song_ids") or []),
"playCount": int(play_count),
"play_count": format_play_count(play_count),
"description": str(item.get("desc") or "").replace("<br>", "\n"),
}
)
items.extend(batch)
else:
content = data.get("content") or {}
total = int(content.get("total_cnt") or total)
batch = _filter_category_content(content.get("v_item"))
items.extend(batch)
if len(batch) < _API_LIMIT:
break
rows = [summary for summary in (_to_summary(item) for item in items[start : start + limit]) if summary]
return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "tx"}
async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]:
"""推荐歌单(playlist.HotRecommendServer/get_hot_recommend)。"""
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Content-Type": "application/json"},
body={
"comm": {"ct": 24},
"recomPlaylist": {
"method": "get_hot_recommend",
"module": "playlist.HotRecommendServer",
"param": {"async": 1, "cmd": 2},
},
},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError(f"tx 推荐歌单获取失败 (code {body.get('code') or resp.status_code})")
rows: list[dict[str, Any]] = []
for item in ((body.get("recomPlaylist") or {}).get("data") or {}).get("v_hot") or []:
playlist_id = str(
item.get("content_id") or item.get("dissid") or item.get("id") or item.get("tid") or ""
).strip()
name = str(item.get("title") or item.get("dissname") or item.get("name") or "").strip()
if not playlist_id or not name:
continue
cover = str(item.get("cover") or item.get("imgurl") or "")
play_count = item.get("listen_num") or item.get("listennum") or 0
rows.append(
{
"id": playlist_id,
"name": name,
"cover": cover.replace("http://", "https://", 1) if cover.startswith("http://") else cover,
"trackCount": int(item.get("song_cnt") or item.get("song_count") or item.get("songnum") or 0),
"playCount": int(play_count),
"play_count": format_play_count(play_count),
"creator": str(item.get("username") or (item.get("creator") or {}).get("nick") or "").strip(),
"description": "",
"link": _playlist_link(playlist_id),
"source": "tx",
}
)
if not rows:
raise RuntimeError("no recommended playlists found")
start = max(0, (page - 1) * limit)
return {"list": rows[start : start + limit], "page": page, "limit": limit, "total": len(rows), "source": "tx"}
async def extract_playlist_id(link: str) -> str | None:
"""链接 → 数字歌单 id;非歌单链接返回 None。"""
raw = str(link or "").strip()
if not raw:
return None
try:
parsed = urlparse(raw)
except ValueError:
parsed = None
if parsed is not None and parsed.scheme:
path = parsed.path.lower()
if "playlist" in path:
query = parse_qs(parsed.query)
for key in ("id", "disstid", "dissid"):
for value in query.get(key) or []:
found = _normalize_playlist_id(value)
if found:
return found
parts = [part for part in parsed.path.strip("/").split("/") if part]
for index, part in enumerate(parts):
if part.lower() == "playlist" and index + 1 < len(parts):
found = _normalize_playlist_id(parts[index + 1])
if found:
return found
match = _BARE_PLAYLIST_PATH.search(raw)
return match.group(1) if match else None
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""歌单链接解析:提取 disstid 后取详情;短链走 song_list.get_list_id 兜底。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的QQ音乐歌单链接")
if not re.search(r"playlist", raw, re.IGNORECASE) and _NON_PLAYLIST.search(raw):
raise RuntimeError("无效的QQ音乐歌单链接")
playlist_id = await extract_playlist_id(raw)
if not playlist_id and not re.search(r"\d", raw):
raise RuntimeError("无效的QQ音乐歌单链接")
if not playlist_id:
try:
playlist_id = await song_list.get_list_id(raw)
except (RuntimeError, OSError, TimeoutError):
playlist_id = ""
if not playlist_id:
raise RuntimeError("无效的QQ音乐歌单链接")
return await get_playlist_detail(playlist_id)
get_recommended_playlists = get_recommended
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
"""QQ音乐歌手(移植自 sdk/tx/singer.ts)。
上游歌手详情接口(musichallSinger.GetSingerDetail 等)已停用(104403),这里按原文的
合成路径取信息:
* 歌手名 —— 从 GetSingerSongList 第一条曲目的 singer 数组里按 mid 反查(找不到取首位)
* 作品数 —— GetSingerSongList 的 totalNum
* 专辑数 —— GetAlbumList 的 total
* 头像/性别 —— 头像走固定 URL 规则(T001),性别接口已无可信来源,返回 null
"""
from __future__ import annotations
import asyncio
from typing import Any
from app.sdk.platforms.tx.filter import album_img, music_info_item
from app.sdk.platforms.tx.utils import create_musicu_fetch
SONG_LIST_MODULE = "musichall.song_list_server"
ALBUM_LIST_MODULE = "music.musichallAlbum.AlbumListServer"
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
"""GetSingerSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
def filter_album_list(raw: Any) -> list[dict[str, Any]]:
return [
{
"id": item.get("albumID"),
"mid": item.get("albumMid"),
"count": item.get("totalNum"),
"info": {
"name": item.get("albumName"),
"author": item.get("singerName"),
"img": album_img(item.get("albumMid")),
"desc": None,
},
}
for item in raw or []
if isinstance(item, dict)
]
async def get_info(id: str) -> dict[str, Any]:
"""歌手信息:歌曲数与专辑数并发取,任一失败整体失败(与原文 Promise.all 一致)。"""
song_body, album_body = await asyncio.gather(
create_musicu_fetch(
{
"req": {
"module": SONG_LIST_MODULE,
"method": "GetSingerSongList",
"param": {"singerMid": id, "order": 1, "begin": 0, "num": 1},
}
}
),
create_musicu_fetch(
{
"req": {
"module": ALBUM_LIST_MODULE,
"method": "GetAlbumList",
"param": {"singerMid": id, "order": 0, "begin": 0, "num": 1, "songNumTag": 0, "singerID": 0},
}
}
),
)
song_req = song_body.get("req") or {}
album_req = album_body.get("req") or {}
if song_req.get("code") != 0 or album_req.get("code") != 0:
raise RuntimeError("get singer info faild.")
song_data = song_req.get("data") or {}
album_data = album_req.get("data") or {}
# 详情接口停用:歌手名从歌曲条目反查
first_song = (song_data.get("songList") or [{}])[0] or {}
singers = (first_song.get("songInfo") or {}).get("singer") or []
self_info = next((s for s in singers if isinstance(s, dict) and s.get("mid") == id), None)
if self_info is None:
self_info = singers[0] if singers and isinstance(singers[0], dict) else {}
return {
"source": "tx",
"id": id,
"info": {
"name": self_info.get("name") or "",
"desc": None,
"avatar": f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{id}.jpg",
"gender": None,
},
"count": {
"music": song_data.get("totalNum"),
"album": album_data.get("total"),
},
}
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
body = await create_musicu_fetch(
{
"req": {
"module": SONG_LIST_MODULE,
"method": "GetSingerSongList",
"param": {"singerMid": id, "order": 1, "begin": (page - 1) * limit, "num": limit},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get singer song list faild.")
data = req.get("data") or {}
return {
"source": "tx",
"list": filter_song_list(data.get("songList")),
"limit": limit,
"page": page,
"total": data.get("totalNum"),
}
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
body = await create_musicu_fetch(
{
"req": {
"module": ALBUM_LIST_MODULE,
"method": "GetAlbumList",
"param": {
"singerMid": id,
"order": 0,
"begin": (page - 1) * limit,
"num": limit,
"songNumTag": 0,
"singerID": 0,
},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get singer album faild.")
data = req.get("data") or {}
return {
"source": "tx",
"list": filter_album_list(data.get("albumList")),
"limit": limit,
"page": page,
"total": data.get("total"),
}
+348
View File
@@ -0,0 +1,348 @@
"""QQ音乐歌单广场 + 歌单详情(移植自 sdk/tx/songList.ts)。
一个模块承载三件事(原文也是这样,路由按能力位分别用):
* song_list —— 分类标签与分类歌单列表(get_tag / get_hot_tag / get_list)
* playlist —— 歌单详情(get_list_detail)与歌单搜索(search)
* parse —— 歌单链接 → 数字 id 归一(get_list_id,含 302 短链兜底)
分类歌单固定 36 条一页(limit_list),调用方要的 limit 在 playlist 层再切片。
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
LIMIT_LIST = 36
SUCCESS_CODE = 0
SORT_LIST: list[dict[str, Any]] = [
{"name": "最热", "id": 5},
{"name": "最新", "id": 2},
]
_HOT_TAG_HTML = re.compile(r'class="c_bg_link js_tag_item" data-id="\w+">.+?</a>')
_HOT_TAG = re.compile(r'data-id="(\w+)">(.+?)</a>')
# https://y.qq.com/n/yqq/playlist/7217720898.html
# https://i.y.qq.com/n2/m/share/details/taoge.html?...&id=7217720898&ADTAG=qfshare
_LIST_DETAIL_LINK = re.compile(r"/playlist/(\d+)")
_LIST_DETAIL_LINK2 = re.compile(r"id=(\d+)")
_BASE_QUERY = (
"loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0"
)
TAGS_URL = f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(json.dumps({'tags': {'method': 'get_all_categories', 'param': {'qq': ''}, 'module': 'playlist.PlaylistAllCategoriesServer'}}, ensure_ascii=False, separators=(',', ':')), safe='')}"
HOT_TAG_URL = "https://c.y.qq.com/node/pc/wk_v15/category_playlist.html"
LIST_DETAIL_URL = (
"https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
"?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid={playlist_id}"
"&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&notice=0"
"&platform=yqq.json&needNewCode=0"
)
SEARCH_URL = (
"http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist"
"?page_no={page}&num_per_page={limit}&format=json&query={query}"
"&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8"
)
_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
def get_list_url(sort_id: Any, tag_id: Any, page: int) -> str:
"""分类歌单列表 URL:tagId 非空走分类内容接口,否则走「全部」广场接口。"""
if tag_id:
data: dict[str, Any] = {
"comm": {"cv": 1602, "ct": 20},
"playlist": {
"method": "get_category_content",
"param": {
"titleid": int(tag_id),
"caller": "0",
"category_id": int(tag_id),
"size": LIMIT_LIST,
"page": page - 1,
"use_page": 1,
},
"module": "playlist.PlayListCategoryServer",
},
}
else:
data = {
"comm": {"cv": 1602, "ct": 20},
"playlist": {
"method": "get_playlist_by_tag",
"param": {
"id": 10000000,
"sin": LIMIT_LIST * (page - 1),
"size": LIMIT_LIST,
"order": sort_id,
"cur_page": page,
},
"module": "playlist.PlayListPlazaServer",
},
}
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(payload, safe='')}"
def filter_tag_info(raw_list: Any) -> list[dict[str, Any]]:
return [
{
"name": group.get("group_name"),
"list": [
{
"parent_id": group.get("group_id"),
"parent_name": group.get("group_name"),
"id": item.get("id"),
"name": item.get("name"),
"source": "tx",
}
for item in group.get("v_item") or []
],
}
for group in raw_list or []
]
def filter_info_hot_tag(html: Any) -> list[dict[str, Any]]:
tags: list[dict[str, Any]] = []
if not isinstance(html, str):
return tags
for tag_html in _HOT_TAG_HTML.findall(html):
result = _HOT_TAG.search(tag_html)
if not result:
continue
try:
tag_id = int(result.group(1))
except ValueError:
continue
tags.append({"id": tag_id, "name": result.group(2), "source": "tx"})
return tags
def filter_list(data: dict[str, Any], page: int) -> dict[str, Any]:
"""「全部」广场分支(get_playlist_by_tag)。"""
return {
"list": [
{
"play_count": format_play_count(item.get("access_num")),
"id": str(item.get("tid")),
"author": (item.get("creator_info") or {}).get("nick"),
"name": item.get("title"),
"time": date_format(item["modify_time"] * 1000, "Y-M-D") if item.get("modify_time") else "",
"img": item.get("cover_url_medium"),
"total": len(item.get("song_ids") or []),
"desc": decode_name(item.get("desc")).replace("<br>", "\n"),
"source": "tx",
}
for item in data.get("v_playlist") or []
],
"total": data.get("total"),
"page": page,
"limit": LIMIT_LIST,
"source": "tx",
}
def filter_list2(data: dict[str, Any], page: int) -> dict[str, Any]:
"""分类内容分支(get_category_content)。"""
content = data.get("content") or {}
rows: list[dict[str, Any]] = []
for item in content.get("v_item") or []:
basic = item.get("basic") or {}
cover = basic.get("cover") or {}
rows.append(
{
"play_count": format_play_count(basic.get("play_cnt")),
"id": str(basic.get("tid")),
"author": (basic.get("creator") or {}).get("nick"),
"name": basic.get("title"),
"img": cover.get("medium_url") or cover.get("default_url"),
"desc": decode_name(basic.get("desc")).replace("<br>", "\n"),
"source": "tx",
}
)
return {
"list": rows,
"total": content.get("total_cnt"),
"page": page,
"limit": LIMIT_LIST,
"source": "tx",
}
def filter_list_detail(raw_list: Any) -> list[dict[str, Any]]:
"""歌单曲目 → SongInfo(比搜索结果多带 lrc/otherSource 两个空字段,与原文一致)。"""
songs: list[dict[str, Any]] = []
for item in raw_list or []:
file = (item or {}).get("file") or {}
types, _types = build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
songs.append(
{
"singer": format_singer_name(item.get("singer"), "name"),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, item.get("singer")),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(TAGS_URL)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(retry_num + 1)
return filter_tag_info(((body.get("tags") or {}).get("data") or {}).get("v_group"))
async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_TAG_URL)
if resp.status_code != 200:
return await get_hot_tag(retry_num + 1)
return filter_info_hot_tag(resp.body)
async def get_tags() -> dict[str, Any]:
"""分类标签 + 热门标签。原 TS 用 Promise.all(任一失败整体失败),
这里改为独立兜底:分类页少一个分组比整页 404 更有用。"""
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag(), return_exceptions=True)
return {
"tags": [] if isinstance(tags, BaseException) else tags,
"hotTag": [] if isinstance(hot_tag, BaseException) else hot_tag,
"source": "tx",
}
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_list_url(sort_id, tag_id, page))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, retry_num + 1)
data = (body.get("playlist") or {}).get("data") or {}
return filter_list2(data, page) if tag_id else filter_list(data, page)
async def handle_parse_id(link: str, retry_num: int = 0) -> str:
"""短链跟随:请求层不自动跟随重定向,手动读 Location(文案与原文一致:link try max num)。"""
if retry_num > 2:
raise RuntimeError("link try max num")
resp = await http_fetch(link)
if resp.status_code > 400:
return await handle_parse_id(link, retry_num + 1)
return resp.header("location") or link
async def get_list_id(playlist_id: str) -> str:
"""链接/裸 id → 纯数字歌单 id。"""
if not re.search(r"[?&:/]", playlist_id):
return playlist_id
target = playlist_id
if not _LIST_DETAIL_LINK.search(target):
target = await handle_parse_id(target)
result = _LIST_DETAIL_LINK.search(target)
if not result:
result = _LIST_DETAIL_LINK2.search(target)
if not result:
raise RuntimeError("failed")
return result.group(1)
async def get_list_detail(playlist_id: str, retry_num: int = 0) -> dict[str, Any]:
"""歌单详情(含全部曲目,不分页)。重试文案与原文一致:try max num。"""
if retry_num > 2:
raise RuntimeError("try max num")
bare = await get_list_id(playlist_id)
resp = await http_fetch(
LIST_DETAIL_URL.format(playlist_id=quote(bare, safe="")),
headers={"Origin": "https://y.qq.com", "Referer": f"https://y.qq.com/n/yqq/playsquare/{bare}.html"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list_detail(bare, retry_num + 1)
cdlist = (body.get("cdlist") or [{}])[0]
songlist = cdlist.get("songlist") or []
return {
"list": filter_list_detail(songlist),
"page": 1,
"limit": len(songlist) + 1,
"total": len(songlist),
"source": "tx",
"info": {
"name": cdlist.get("dissname"),
"img": cdlist.get("logo"),
"desc": decode_name(cdlist.get("desc")).replace("<br>", "\n"),
"author": cdlist.get("nickname"),
"play_count": format_play_count(cdlist.get("visitnum")),
},
}
async def search(text: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
"""歌单搜索。重试上限 5 次(文案与原文一致:max retry)。"""
if retry_num > 5:
raise RuntimeError("max retry")
resp = await http_fetch(
SEARCH_URL.format(page=page - 1, limit=limit, query=quote(text, safe="")),
headers={"User-Agent": _UA_IE, "Referer": "http://y.qq.com/portal/search.html"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 0:
return await search(text, page, limit, retry_num + 1)
data = body.get("data") or {}
return {
"list": [
{
"play_count": format_play_count(item.get("listennum")),
"id": str(item.get("dissid")),
"author": decode_name((item.get("creator") or {}).get("name")),
"name": decode_name(item.get("dissname")),
"time": date_format(item.get("createtime"), "Y-M-D"),
"img": item.get("imgurl"),
"total": item.get("song_count"),
"desc": decode_name(decode_name(item.get("introduction"))).replace("<br>", "\n"),
"source": "tx",
}
for item in data.get("list") or []
],
"limit": limit,
"total": data.get("sum"),
"source": "tx",
}
async def get_detail_page_url(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{await get_list_id(playlist_id)}"
sort_list = SORT_LIST
+292
View File
@@ -0,0 +1,292 @@
"""QQ音乐个人歌单(移植自 sdk/tx/userPlaylist.ts)。
需登录 cookie(auth_store.get_tx_auth 返回的 cookie,须含 uin 类字段):
未登录 / cookie 里取不到 uin 时**抛出清晰错误**,不静默返回空列表(与原文一致)。
三路数据源,结果按 id 去重:
1) fcg_user_created_diss → 自建歌单
2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
虚拟条目 id 固定为 `profile:favorites`(与原文一致),其歌曲需凭 cookie 走同一接口拉取。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
FAVORITE_ID = "profile:favorites"
FAVORITE_NAME = "我喜欢的歌曲"
_CREATED_URL = "https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss"
_PROFILE_ASSET_URL = "https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg"
# 上游 normalizeQQUIN 的候选键,按序取第一个非空
_UIN_KEYS = (
"wxuin",
"uin",
"ptui_loginuin",
"luin",
"pt2gguin",
"superuin",
"p_uin",
"musicid",
"userid",
)
def cookie_value(cookie: str, key: str) -> str:
for part in cookie.split(";"):
name, sep, value = part.strip().partition("=")
if sep and name.strip() == key:
return value.strip()
return ""
def normalize_qq_uin(cookie: str) -> str:
"""cookie → uin(去前导 `o` 与 `0`,与原文 normalizeQQUIN 一致)。"""
raw = ""
for key in _UIN_KEYS:
value = cookie_value(cookie, key)
if value:
raw = value
break
return raw.removeprefix("o").lstrip("0").strip()
def _playlist_link(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"
def _normalize_cover(cover: Any) -> str:
value = str(cover or "").strip()
if value.startswith("//"):
return f"https:{value}"
if value.startswith("http://"):
return value.replace("http://", "https://", 1)
return value
def _make_summary(
playlist_id: str,
name: str,
*,
cover: Any = "",
track_count: Any = 0,
play_count: Any = 0,
creator: Any = "",
description: Any = "",
link: str | None = None,
) -> dict[str, Any]:
count = int(play_count or 0)
return {
"id": playlist_id,
"name": name,
"cover": _normalize_cover(cover),
"trackCount": int(track_count or 0),
"playCount": count,
"play_count": format_play_count(count),
"creator": str(creator or ""),
"description": str(description or ""),
"link": link or _playlist_link(playlist_id),
"source": "tx",
}
def _profile_asset_url(uin: str, reqtype: str, page: int, limit: int) -> str:
offset = (max(1, page) - 1) * limit
query = urlencode(
{
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
"platform": "yqq.json",
"needNewCode": "0",
"loginUin": uin,
"hostUin": "0",
"notice": "0",
"g_tk": "5381",
"ct": "20",
"cid": "205360956",
"userid": uin,
"reqtype": reqtype,
"sin": str(offset),
"ein": str(offset + limit - 1),
}
)
return f"{_PROFILE_ASSET_URL}?{query}"
async def _fetch_json(url: str, cookie: str) -> dict[str, Any]:
resp = await http_fetch(
url,
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Cookie": cookie},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or body is None:
raise RuntimeError("tx 个人歌单接口请求失败")
return body
async def _fetch_created(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""自建歌单(fcg_user_created_diss)。id 优先级:dissid > tid > profile:dir:<dirid>。"""
query = urlencode(
{
"hostuin": uin,
"sin": str((page - 1) * limit),
"size": str(limit),
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
}
)
body = await _fetch_json(f"{_CREATED_URL}?{query}", cookie)
if body.get("code") != 0:
raise RuntimeError(f"tx 自建歌单获取失败: {body.get('message') or ''} (code {body.get('code')})")
data = body.get("data") or {}
rows: list[dict[str, Any]] = []
for item in data.get("disslist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
tid = int(item.get("tid") or 0)
dir_id = int(item.get("dirid") or 0)
if diss_id > 0:
playlist_id = str(diss_id)
elif tid > 0:
playlist_id = str(tid)
elif dir_id > 0:
playlist_id = f"profile:dir:{dir_id}"
else:
playlist_id = ""
name = str(item.get("diss_name") or item.get("title") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("diss_cover") or item.get("cover"),
track_count=item.get("song_count") or item.get("song_num") or item.get("song_cnt") or 0,
play_count=item.get("listen_num") or item.get("visitnum") or 0,
creator=uin,
description=item.get("diss_desc") or item.get("desc") or "",
)
)
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
playlist_id = str(item.get("dissid") or "").strip()
name = str(item.get("dissname") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("imgurl"),
track_count=item.get("song_count") or item.get("song_num") or 0,
play_count=item.get("listennum") or 0,
creator=uin,
description=item.get("introduction") or "",
)
)
return rows
async def _fetch_collected(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""收藏歌单(fcg_get_profile_order_asset reqtype=3)。code 非 0 时返回空列表(同原文)。"""
body = await _fetch_json(_profile_asset_url(uin, "3", page, limit), cookie)
if body.get("code") != 0:
return []
rows: list[dict[str, Any]] = []
for item in (body.get("data") or {}).get("cdlist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
name = str(item.get("dissname") or "").strip()
if diss_id <= 0 or not name:
continue
rows.append(
_make_summary(
str(diss_id),
name,
cover=item.get("logo"),
track_count=item.get("songnum") or 0,
play_count=item.get("listennum") or 0,
creator=item.get("nickname") or item.get("uin") or uin,
)
)
return rows
async def _fetch_favorite_summary(cookie: str, uin: str) -> dict[str, Any] | None:
"""「我喜欢的歌曲」摘要(reqtype=1,limit=1)。总数为 0 时返回 None。"""
body = await _fetch_json(_profile_asset_url(uin, "1", 1, 1), cookie)
if body.get("code") != 0:
return None
data = body.get("data") or {}
total = int(data.get("totalsong") or 0)
if total <= 0:
return None
first = ((data.get("songlist") or [{}])[0] or {}).get("data") or {}
album_mid = first.get("albummid") if isinstance(first, dict) else None
return _make_summary(
FAVORITE_ID,
FAVORITE_NAME,
cover=f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "",
track_count=total,
creator=uin,
description="QQ音乐我喜欢的歌曲",
link="https://y.qq.com/n/ryqq/profile",
)
async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]:
"""个人歌单列表。未登录 / 缺 uin 直接抛错,不返回空列表。"""
auth = get_tx_auth()
cookie = str((auth or {}).get("cookie") or "").strip()
if not cookie:
raise RuntimeError("tx 个人歌单需要登录 cookie(请先登录 QQ音乐)")
uin = normalize_qq_uin(cookie)
if not uin:
raise RuntimeError("tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)")
safe_page = max(1, int(page or 1))
safe_limit = int(limit or 30)
if safe_limit <= 0:
safe_limit = 30
if safe_limit > 100:
safe_limit = 100
rows: list[dict[str, Any]] = []
seen: set[str] = set()
def add(summary: dict[str, Any] | None) -> None:
if not summary:
return
playlist_id = summary.get("id")
if not playlist_id or not summary.get("name") or playlist_id in seen:
return
seen.add(playlist_id)
rows.append(summary)
try:
add(await _fetch_favorite_summary(cookie, uin))
except (RuntimeError, OSError, TimeoutError):
# 摘要失败不阻塞列表(与上游一致)
pass
for item in await _fetch_created(cookie, uin, safe_page, safe_limit):
add(item)
for item in await _fetch_collected(cookie, uin, safe_page, safe_limit):
add(item)
return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "tx"}
+63
View File
@@ -0,0 +1,63 @@
"""QQ音乐(tx)工具集 —— 签名通道与 zzc 签名。
源: sdk/tx/utils/index.ts + sdk/tx/utils/crypto.ts
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils.crypto import zzc_sign
MUSICU_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg"
# 原 TS 用两条不同 UA:musics.fcg 走客户端 UA,musicu.fcg 走旧 IE UA
UA_CLIENT = "QQMusic 14090508(android 12)"
UA_MUSICU = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
async def sign_request(data: dict[str, Any]) -> Any:
"""musics.fcg 签名通道:sign 由 zzc_sign(JSON 串) 生成。
签名与实际发送的 body 必须是同一个字符串:http_fetch 对 dict 会自行
json.dumps(默认分隔符带空格),与这里的紧凑序列化不一致会导致服务端验签失败并回
code 2000。因此统一序列化一次,并把该字符串原样作为 body 发送(str 走原样发送分支)。
"""
text = _to_json(data)
sign = zzc_sign(text)
return await http_fetch(
f"https://u.y.qq.com/cgi-bin/musics.fcg?sign={sign}",
method="post",
headers={"User-Agent": UA_CLIENT},
body=text,
)
async def create_musicu_fetch(data: dict[str, Any], retry_num: int = 0) -> dict[str, Any]:
"""musicu.fcg 通用请求,失败重试 3 次(文案与原文一致:try max num)。
注意:comm 固定 uin=0,即便已登录也走匿名通道 —— 与原文一致(登录态只影响取流)。
"""
if retry_num > 2:
raise RuntimeError("try max num")
try:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": UA_MUSICU},
body={"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859}, **data},
)
except (RuntimeError, OSError, TimeoutError):
return await create_musicu_fetch(data, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
return await create_musicu_fetch(data, retry_num + 1)
return body
def _to_json(data: Any) -> str:
import json
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
+56
View File
@@ -0,0 +1,56 @@
"""QQ音乐 musics.fcg 签名(移植自 sdk/tx/utils/crypto.ts)。
算法:SHA1 摘要的十六进制串 → 按固定下标取两段字符 → 第三段用固定混淆表
与摘要每字节异或后 base64(剔除 + / = 与反斜杠)→ 拼成 `zzc{part1}{b64}{part2}` 转小写。
"""
from __future__ import annotations
import base64
import hashlib
import re
PART_1_INDEXES = (23, 14, 6, 36, 16, 40, 7, 19)
PART_2_INDEXES = (16, 1, 32, 12, 19, 27, 8, 5)
SCRAMBLE_VALUES = (
89,
39,
179,
150,
218,
82,
58,
252,
177,
52,
186,
123,
120,
64,
242,
133,
143,
161,
121,
179,
)
_B64_STRIP = re.compile(r"[\\/+=]")
def _pick_by_indexes(hash_hex: str, indexes: tuple[int, ...]) -> str:
"""按固定下标取字符。
PART_1_INDEXES 含下标 40,而 SHA1 十六进制串正好 40 字符(合法下标 0–39)。
TS 侧 `hash[40]` 取到 undefined,`join('')` 会把它并成空串;Python 直接下标会抛
IndexError,因此这里显式跳过越界下标,保持与上游签名逐字节一致。
"""
return "".join(hash_hex[index] for index in indexes if 0 <= index < len(hash_hex))
def zzc_sign(text: str) -> str:
hash_hex = hashlib.sha1(text.encode()).hexdigest()
part1 = _pick_by_indexes(hash_hex, PART_1_INDEXES)
part2 = _pick_by_indexes(hash_hex, PART_2_INDEXES)
scrambled = bytes(value ^ int(hash_hex[i * 2 : i * 2 + 2], 16) for i, value in enumerate(SCRAMBLE_VALUES))
part3 = _B64_STRIP.sub("", base64.b64encode(scrambled).decode())
return f"zzc{part1}{part3}{part2}".lower()