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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user