当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库
246 lines
8.0 KiB
Python
246 lines
8.0 KiB
Python
# -*- 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
|