- 新增 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 路由验证通过
259 lines
8.2 KiB
Python
259 lines
8.2 KiB
Python
"""网易云评论(移植自 sdk/wy/comment.ts)。
|
||
|
||
网易云的评论接口是游标式(orderType + cursor + offset),不是页码式。原实现用
|
||
`cursor_tools` 缓存每首歌最近一次的游标:向后翻页用旧游标 + offset,向前翻页用 orderType=0,
|
||
重刷同一页则回退到 prevCursor。这里逐行保留该逻辑,否则翻页会跳条。
|
||
|
||
注意请求头里的 `Refere` 是原实现的拼写,上游不校验该头,保留以免引入行为差异。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import time
|
||
from typing import Any
|
||
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import date_format2
|
||
from app.sdk.platforms.wy.utils import weapi_headers
|
||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||
|
||
EMOJIS: list[tuple[str, str]] = [
|
||
("大笑", "😃"),
|
||
("可爱", "😊"),
|
||
("憨笑", "☺️"),
|
||
("色", "😍"),
|
||
("亲亲", "😙"),
|
||
("惊恐", "😱"),
|
||
("流泪", "😭"),
|
||
("亲", "😚"),
|
||
("呆", "😳"),
|
||
("哀伤", "😔"),
|
||
("呲牙", "😁"),
|
||
("吐舌", "😝"),
|
||
("撇嘴", "😒"),
|
||
("怒", "😡"),
|
||
("奸笑", "😏"),
|
||
("汗", "😓"),
|
||
("痛苦", "😖"),
|
||
("惶恐", "😰"),
|
||
("生病", "😨"),
|
||
("口罩", "😷"),
|
||
("大哭", "😂"),
|
||
("晕", "😵"),
|
||
("发怒", "👿"),
|
||
("开心", "😄"),
|
||
("鬼脸", "😜"),
|
||
("皱眉", "😞"),
|
||
("流感", "😢"),
|
||
("爱心", "❤️"),
|
||
("心碎", "💔"),
|
||
("钟情", "💘"),
|
||
("星星", "⭐️"),
|
||
("生气", "💢"),
|
||
("便便", "💩"),
|
||
("强", "👍"),
|
||
("弱", "👎"),
|
||
("拜", "🙏"),
|
||
("牵手", "👫"),
|
||
("跳舞", "👯♀️"),
|
||
("禁止", "🙅♀️"),
|
||
("这边", "💁♀️"),
|
||
("爱意", "💏"),
|
||
("示爱", "👩❤️👨"),
|
||
("嘴唇", "👄"),
|
||
("狗", "🐶"),
|
||
("猫", "🐱"),
|
||
("猪", "🐷"),
|
||
("兔子", "🐰"),
|
||
("小鸡", "🐤"),
|
||
("公鸡", "🐔"),
|
||
("幽灵", "👻"),
|
||
("圣诞", "🎅"),
|
||
("外星", "👽"),
|
||
("钻石", "💎"),
|
||
("礼物", "🎁"),
|
||
("男孩", "👦"),
|
||
("女孩", "👧"),
|
||
("蛋糕", "🎂"),
|
||
("18", "🔞"),
|
||
("圈", "⭕"),
|
||
("叉", "❌"),
|
||
]
|
||
|
||
|
||
def apply_emoji(text: str) -> str:
|
||
for name, emoji in EMOJIS:
|
||
text = text.replace(f"[{name}]", emoji)
|
||
return text
|
||
|
||
|
||
class _CursorTools:
|
||
"""按歌曲缓存游标。原实现是模块级单例对象,这里用类实例保持同一语义。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.cache: dict[str, dict[str, Any]] = {}
|
||
|
||
def get_cursor(self, id_: str, page: int, limit: int) -> dict[str, Any]:
|
||
data = self.cache.setdefault(id_, {})
|
||
order_type: int | None = None
|
||
cursor: int | None = None
|
||
offset: int | None = None
|
||
|
||
if page == 1:
|
||
data["page"] = 1
|
||
cursor = int(time.time() * 1000)
|
||
data["cursor"] = cursor
|
||
data["prevCursor"] = cursor
|
||
order_type = 1
|
||
offset = 0
|
||
elif data.get("page"):
|
||
cursor = data.get("cursor")
|
||
if page > data["page"]:
|
||
order_type = 1
|
||
offset = (page - data["page"] - 1) * limit
|
||
elif page < data["page"]:
|
||
order_type = 0
|
||
offset = (data["page"] - page - 1) * limit
|
||
else:
|
||
cursor = data.get("prevCursor")
|
||
data["cursor"] = cursor
|
||
offset = data.get("offset")
|
||
order_type = data.get("orderType")
|
||
|
||
return {"orderType": order_type, "cursor": cursor, "offset": offset}
|
||
|
||
def set_cursor(self, id_: str, cursor: Any, order_type: Any, offset: Any, page: int) -> None:
|
||
data = self.cache.setdefault(id_, {})
|
||
data["prevCursor"] = data.get("cursor")
|
||
data["cursor"] = cursor
|
||
data["orderType"] = order_type
|
||
data["offset"] = offset
|
||
data["page"] = page
|
||
|
||
|
||
cursor_tools = _CursorTools()
|
||
|
||
|
||
def filter_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
|
||
user = item.get("user") or {}
|
||
raw_time = item.get("time")
|
||
content = item.get("content")
|
||
data = {
|
||
"id": item.get("commentId"),
|
||
"text": apply_emoji(str(content)) if content else "",
|
||
"time": raw_time or "",
|
||
"timeStr": date_format2(raw_time) if raw_time else "",
|
||
"location": (item.get("ipLocation") or {}).get("location"),
|
||
"userName": user.get("nickname"),
|
||
"avatar": user.get("avatarUrl"),
|
||
"userId": user.get("userId"),
|
||
"likedCount": item.get("likedCount"),
|
||
"reply": [],
|
||
}
|
||
|
||
replied = item.get("beReplied") or []
|
||
reply_data = replied[0] if replied else None
|
||
if not isinstance(reply_data, dict):
|
||
rows.append(data)
|
||
continue
|
||
reply_user = reply_data.get("user") or {}
|
||
reply_content = reply_data.get("content")
|
||
rows.append(
|
||
{
|
||
"id": item.get("commentId"),
|
||
"rootId": reply_data.get("beRepliedCommentId"),
|
||
"text": apply_emoji(str(reply_content)) if reply_content else "",
|
||
"time": item.get("time"),
|
||
"timeStr": None,
|
||
"location": (reply_data.get("ipLocation") or {}).get("location"),
|
||
"userName": reply_user.get("nickname"),
|
||
"avatar": reply_user.get("avatarUrl"),
|
||
"userId": reply_user.get("userId"),
|
||
"likedCount": None,
|
||
"reply": [data],
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
def _max_page(total: int, limit: int) -> int:
|
||
return math.ceil(total / limit) if limit else 1
|
||
|
||
|
||
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||
"""歌曲评论。失败文案与原文一致:获取评论失败。"""
|
||
song_mid = str(song_info.get("songmid"))
|
||
rid = f"R_SO_4_{song_mid}"
|
||
cursor_info = cursor_tools.get_cursor(song_mid, page, limit)
|
||
|
||
resp = await http_fetch(
|
||
"https://music.163.com/weapi/comment/resource/comments/get",
|
||
method="post",
|
||
headers=weapi_headers(with_cookie=False),
|
||
form=weapi(
|
||
{
|
||
"cursor": cursor_info["cursor"],
|
||
"offset": cursor_info["offset"],
|
||
"orderType": cursor_info["orderType"],
|
||
"pageNo": page,
|
||
"pageSize": limit,
|
||
"rid": rid,
|
||
"threadId": rid,
|
||
}
|
||
),
|
||
)
|
||
body = resp.body if isinstance(resp.body, dict) else {}
|
||
if resp.status_code != 200 or body.get("code") != 200:
|
||
raise RuntimeError("获取评论失败")
|
||
|
||
data = body.get("data") or {}
|
||
cursor_tools.set_cursor(song_mid, data.get("cursor"), cursor_info["orderType"], cursor_info["offset"], page)
|
||
total = int(data.get("totalCount") or 0)
|
||
return {
|
||
"source": "wy",
|
||
"comments": filter_comment(data.get("comments")),
|
||
"total": data.get("totalCount"),
|
||
"page": page,
|
||
"limit": limit,
|
||
"maxPage": _max_page(total, limit) or 1,
|
||
}
|
||
|
||
|
||
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||
"""热门评论。失败文案与原文一致:获取热门评论失败。"""
|
||
song_mid = str(song_info.get("songmid"))
|
||
rid = f"R_SO_4_{song_mid}"
|
||
page = page - 1
|
||
|
||
resp = await http_fetch(
|
||
f"https://music.163.com/weapi/v1/resource/hotcomments/{rid}",
|
||
method="post",
|
||
headers=weapi_headers(with_cookie=False),
|
||
form=weapi(
|
||
{
|
||
"rid": rid,
|
||
"limit": limit,
|
||
"offset": limit * page,
|
||
"beforeTime": str(int(time.time() * 1000)),
|
||
}
|
||
),
|
||
)
|
||
body = resp.body if isinstance(resp.body, dict) else {}
|
||
if resp.status_code != 200 or body.get("code") != 200:
|
||
raise RuntimeError("获取热门评论失败")
|
||
|
||
total = int(body.get("total") or 0)
|
||
return {
|
||
"source": "wy",
|
||
"comments": filter_comment(body.get("hotComments")),
|
||
"total": total,
|
||
"page": page,
|
||
"limit": limit,
|
||
"maxPage": _max_page(total, limit) or 1,
|
||
}
|