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

+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,
}