feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)

当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。

- api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿
- 上传成绩链路已真机跑通,报文与真机抓包逐字节一致
- 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
Txiaochen
2026-10-06 14:42:06 +08:00
commit 1636a79141
51 changed files with 10965 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
user_getter_common.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. fetch_all_pages() 自动翻页
--------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一
--------------------------------------------------------------------------------
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
GameKaleidxScopeRequestVO : 空类,一个字段都没有
UserDetailRequestVO : 只有 userId
UserMapRequestVO : userId + nextIndex + maxCount = 1000
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
UserItemRequestVO : userId + nextIndex + maxCount = 100
UserMusicRequestVO : userId + nextIndex + maxCount = 50
UserFavoriteRequestVO : userId + itemKind
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
UserShopStockRequestVO : userId + shopItemIdList
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
发错字段名或漏字段,服务端会返回异常或空数据。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_warning
from .maimai_net import MaimaiNetClient, get_default_client
# =============================================================================
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
userId: int
@dataclass
class UserIdPageRequestVO:
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
userId: int
nextIndex: int = 0
maxCount: int = 1000
@dataclass
class UserLoginBonusRequestVO:
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
userId: int
nextIndex: int = 0
maxCount: int = 20
@dataclass
class UserItemRequestVO:
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
userId: int
nextIndex: int = 0
maxCount: int = 100
@dataclass
class UserMusicRequestVO:
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
userId: int
nextIndex: int = 0
maxCount: int = 50
@dataclass
class UserCourseRequestVO:
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
userId: int
nextIndex: int = 0
@dataclass
class UserFavoriteRequestVO:
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
userId: int
itemKind: int = 0
@dataclass
class UserFavoriteItemRequestVO:
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
userId: int
kind: int = 1
nextIndex: int = 0
maxCount: int = 100
isAllFavoriteItem: bool = False
@dataclass
class UserShopStockRequestVO:
"""对应 UserShopStockRequestVO。"""
userId: int
shopItemIdList: List[int] = field(default_factory=list)
@dataclass
class UserRivalRequestVO:
"""对应 UserRivalRequestVO。"""
userId: int
rivalId: int = 0
@dataclass
class UserRivalMusicRequestVO:
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
userId: int
rivalId: int = 0
nextIndex: int = 0
userRivalMusicLevelList: List[Dict[str, int]] = field(
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
)
# =============================================================================
# 常量
# =============================================================================
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
#: Create(GetNetQuery(userId, itemId * 10000000000L));
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
# =============================================================================
# 内部工具
# =============================================================================
def _get(
api_name: str,
body: Any,
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
net = client or get_default_client()
raw = net.request(
api_name, body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
)
if isinstance(raw, str):
return json.loads(raw) if raw else {}
return raw or {}
def fetch_all_pages(
api_name: str,
user_id: int,
list_key: str,
*,
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
max_pages: int = 200,
) -> List[Any]:
"""
自动翻页抓取某个列表接口的全部条目。
分页约定(各 Packet 的 Proc 里都是这个逻辑):
nextIndex == 0 -> 没有更多数据
nextIndex != 0 -> 下一页的起始索引
参数:
api_name: 逻辑接口名
list_key: 响应里承载列表的键名(如 "userMapList")
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
★ 首页会以 next_index=None 调用它 —— 这是为了支持
GetUserItemApi 这类「首发要带哨兵值」的接口
(首包 nextIndex = itemKind * 1e10,不能是 0)。
max_pages: 翻页上限(防御性)
返回:
合并后的全部条目
"""
all_items: List[Any] = []
next_index: Optional[int] = None
for page in range(max_pages):
if body_factory is not None:
body = body_factory(next_index)
else:
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
resp = _get(api_name, body, user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
items = resp.get(list_key) or []
if isinstance(items, dict):
items = [items]
all_items.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug(
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
)
if not next_index:
break
else:
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
return all_items