重构:Get* 命名统一 + 用户数据落盘 + use_ticket / reincarnate

1) 命名统一(服务端 API 名带 Get 的,脚本名与函数名一律带 get/Get)
   - 26 个 api 脚本改名:userdata.py -> get_user_data.py 等
   - 7 个 game_*.py -> get_game_*.py
   - 38 个函数改名:UserData -> GetUserData 等
   - upsert_snapshot.py -> get_need_user_data.py,
     fetch_upsert_snapshot -> GetNeedUserData
   - user_getter_common.fetch_all_pages -> get_all_pages

2) GetNeedUserData 抓到的数据自动落盘
   - <工程根>/data/<userId>.json,新数据总是覆盖老数据
   - 新增 save_user_data / load_user_data / user_data_path /
     list_saved_user_ids / DATA_DIR / PROJECT_ROOT
   - data/ 已加入 .gitignore

3) use_ticket.py(新)
   - 读取账号当前持有的功能票,用 UpsertUserAll 消耗掉
   - 依据:GetUserChargeApi 与 UpsertUserAllApi 的 userChargeList 格式相同;
     真机实证 userChargeList.stock -1 + useTicketId + playCredit
   - playCredit = 投币数 + 票的 creditNum(VOExtensions.cs:838-845)

4) reincarnate.py(新)
   - 账号数据迁移(转生):A 的数据克隆到 B
   - 每包最多 4 首(真机一局上限),包间用续关登录换新 loginId
     续关依据:ContinueProcess.cs:403 -> PacketUserLogin(isContinue: true)
   - playlog 从曲目记录反推合成(没有对应的 Get 接口)

自检 238 项全绿。
This commit is contained in:
2026-10-06 23:32:09 +08:00
parent c297818d8a
commit 8e3bd6fcaa
46 changed files with 1234 additions and 356 deletions
+1
View File
@@ -1,4 +1,5 @@
# ---- 运行产物 ---- # ---- 运行产物 ----
data/ # 每账号一份用户数据快照(<userId>.json)
logs/ logs/
__pycache__/ __pycache__/
*.pyc *.pyc
+81 -11
View File
@@ -25,20 +25,26 @@ WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。** **v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
| 模块 | 状态 | | 脚本 | 状态 |
|---|---| |---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 | | **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 | | **`buy_ticket.py`** | ✅ **可用** —— 购买功能票(2026-10-06 真机验证通过) |
| **`use_ticket.py`** | ✅ **可用** —— 读取当前持有的功能票并用 `UpsertUserAll` 消耗掉 |
| **`reincarnate.py`** | 🔧 已实现(未实跑)—— 账号数据迁移(转生),每包 ≤4 首 + 续关登录 |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** | | `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
| **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 | | **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 |
### ⚠️ 关于购买功能票 ### 关于购买功能票(已解决)
`buy_ticket.py`(`UpsertUserChargelogApi`)在**报文与真机逐字节一致**的情况下 `UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`。根因是 **请求头多发了
仍被服务端拒绝(`returnCode=0`),之后该账号**疑似被拉黑,暂时无法再购买功能票**。 `Accept` 与 `Connection`** —— 真机(Unity Mono 的 `HttpWebRequest`)**不发这两个头**:
**请勿轻易重试该接口。** 完整排查过程见 ```
[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md) 第六节。 number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
```
删掉之后一次通过(`stock` 0 → 1,落库确认)。完整排查过程见
[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md)。
--- ---
@@ -66,19 +72,23 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
``` ```
WaHappy/ WaHappy/
├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别) ├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板(机台与店铺标识留空待填)
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出 ├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py 主脚本:购买功能票(★ 当前不可用) ├── buy_ticket.py 主脚本:购买功能票
├── selftest.py 零网络自检(236 项,判据 = 源码 + 真机日志) ├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生)
├── selftest.py 零网络自检(238 项,判据 = 源码 + 真机日志)
│ │
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本) ├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪 │ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie) │ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── log_setup.py 日志(INFO / DEBUG 双模式) │ ├── log_setup.py 日志(INFO / DEBUG 双模式)
│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分) │ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分)
│ ├── upsert_snapshot.py 结算基线快照 │ ├── get_need_user_data.py 结算快照编排 + 落盘 data/<userId>.json
│ └── ... 40+ 个接口脚本 │ └── ... 40+ 个接口脚本
│ │
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库 ├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
└── source/ 反编译源码 ★ 不入库,需自备 └── source/ 反编译源码 ★ 不入库,需自备
``` ```
@@ -86,6 +96,9 @@ WaHappy/
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。 **设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。 主脚本只负责编排,日志和协议细节都在 `api/` 内部。
**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`**
(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。
> **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。** > **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。**
> >
> `source/` 是反编译源码,**需要自备**。 > `source/` 是反编译源码,**需要自备**。
@@ -158,6 +171,63 @@ python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。 > 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。
### buy_ticket.py 用法
```bash
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
| chargeId | 票 | 上报的 price |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 4 | 4倍チケット | 3 |
| 5 | 5倍チケット | 4 |
| 6 | 6倍チケット | 5 |
> ⚠️ **账号已经持有功能票时,本脚本会直接拒绝购买**(重复购买有封号风险)。
> 想清掉手里的票,用下面的 `use_ticket.py`。
### use_ticket.py 用法
读取账号当前**持有**的功能票,然后用 `UpsertUserAll` 表达「用这张票打了一局」把它消耗掉。
```bash
python use_ticket.py "<二维码>" --list # 只列当前持有的票
python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
```
原理:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
**格式完全相同**,所以只要把该节点里目标票的 `stock` 减 1,
再让 playlog 带上 `useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
`playCredit` 的算法(`VOExtensions.cs:838-845`):
```
playCredit = 投币数(普通局 = 1) + 票的 creditNum
```
### reincarnate.py 用法(账号数据迁移)
把一个账号的数据克隆到另一个账号。**每包最多 4 首**(真机一局最多 4 首),
每包之间用**续关登录**(`UserLogin(is_continue=True)`)换新的 `loginId`。
```bash
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
# 源数据来自已保存的 JSON(离线,推荐)
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
# 试跑
--limit 20 # 只迁前 20 首
--clone-identity # 连 userName / accessCode 一起克隆(默认保留 B 自己的)
```
### 额外塞节点:`--extra-data` ### 额外塞节点:`--extra-data`
真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来, 真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来,
+151 -134
View File
@@ -26,47 +26,55 @@ api 包 —— 统一导出
会话链路 会话链路
chime.py 扫码登录 -> userId + token chime.py 扫码登录 -> userId + token
gamesettings.py 取游戏设置 get_game_settings.py GetGameSettingApi(游戏设置)
ping.py 心跳探活 ping.py 心跳探活
userpreview.py 用户预览 get_user_preview.py GetUserPreviewApi(用户预览)
userlogin.py 正式登录 -> loginId + loginDateTime userlogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
userlogout.py 登出 userlogout.py UserLogoutApi(登出)
上传成绩 上传 / 结算(有副作用的接口)
upsert_user_all.py ★ 上传成绩(唯一有副作用的接口) upsert_user_all.py ★ UpsertUserAllApi(上传成绩)
upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本) upsert_user_chargelog.py UpsertUserChargelogApi(购买功能票)
get_need_user_data.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
取数(一个接口一个脚本) ★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
userdata.py GetUserDataApi (不要模棱两可)。所以下面这些文件全是 get_ 开头。
user_extend.py GetUserExtendApi
user_option.py GetUserOptionApi 取数 —— GetUser*(一个接口一个脚本)
user_character.py GetUserCharacterApi get_user_data.py GetUserDataApi
user_ghost.py GetUserGhostApi get_user_extend.py GetUserExtendApi
user_map.py GetUserMapApi get_user_option.py GetUserOptionApi
user_login_bonus.py GetUserLoginBonusApi get_user_character.py GetUserCharacterApi
user_rating.py GetUserRatingApi get_user_ghost.py GetUserGhostApi
user_item.py GetUserItemApi get_user_map.py GetUserMapApi
user_music.py GetUserMusicApi get_user_login_bonus.py GetUserLoginBonusApi
user_course.py GetUserCourseApi get_user_rating.py GetUserRatingApi
user_charge.py GetUserChargeApi get_user_item.py GetUserItemApi
user_favorite.py GetUserFavoriteApi get_user_music.py GetUserMusicApi
user_activity.py GetUserActivityApi get_user_course.py GetUserCourseApi
user_mission.py GetUserMissionDataApi get_user_charge.py GetUserChargeApi
user_intimate.py GetUserIntimateApi get_user_favorite.py GetUserFavoriteApi
user_shop_stock.py GetUserShopStockApi get_user_activity.py GetUserActivityApi
user_favorite_item.py GetUserFavoriteItemApi get_user_mission.py GetUserMissionDataApi
user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi get_user_intimate.py GetUserIntimateApi
user_friend_bonus.py GetUserFriendBonusApi get_user_shop_stock.py GetUserShopStockApi
user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi get_user_favorite_item.py GetUserFavoriteItemApi
user_region.py GetUserRegionApi get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi get_user_friend_bonus.py GetUserFriendBonusApi
user_new_item.py GetUserNewItemListApi get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
game_ranking.py GetGameRankingApi get_user_region.py GetUserRegionApi
game_event.py GetGameEventApi get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
game_charge.py GetGameChargeApi get_user_new_item.py GetUserNewItemListApi
game_weekly_data.py GetGameWeeklyDataApi
game_tournament_info.py GetGameTournamentInfoApi 取数 —— GetGame*
game_ng_music_id.py GetGameNgMusicIdApi get_game_settings.py GetGameSettingApi
get_game_ranking.py GetGameRankingApi
get_game_event.py GetGameEventApi
get_game_charge.py GetGameChargeApi
get_game_weekly_data.py GetGameWeeklyDataApi
get_game_tournament_info.py GetGameTournamentInfoApi
get_game_ng_music_id.py GetGameNgMusicIdApi
""" """
from .log_setup import ( from .log_setup import (
@@ -135,7 +143,7 @@ from .maimai_net import (
from .chime import ParseQRCode from .chime import ParseQRCode
from .gamesettings import ( from .get_game_settings import (
GetGameSettings, GetGameSettings,
GameSetting, GameSetting,
GameSettingRequestVO, GameSettingRequestVO,
@@ -146,8 +154,8 @@ from .gamesettings import (
from .ping import Ping from .ping import Ping
from .userpreview import ( from .get_user_preview import (
UserPreview, GetUserPreview,
UserPreviewRequestVO, UserPreviewRequestVO,
UserPreviewResponseVO, UserPreviewResponseVO,
) )
@@ -230,12 +238,19 @@ from .upsert_user_all import (
USER_MISSION_DROP_KEYS, USER_MISSION_DROP_KEYS,
) )
from .upsert_snapshot import ( from .get_need_user_data import (
fetch_upsert_snapshot, GetNeedUserData,
save_snapshot, save_snapshot,
load_snapshot, load_snapshot,
summarize_snapshot, summarize_snapshot,
DEFAULT_SNAPSHOT_NODES, DEFAULT_SNAPSHOT_NODES,
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
PROJECT_ROOT,
DATA_DIR,
user_data_path,
save_user_data,
load_user_data,
list_saved_user_ids,
) )
from .upsert_user_chargelog import ( from .upsert_user_chargelog import (
@@ -269,7 +284,7 @@ from .user_getter_common import (
UserShopStockRequestVO, UserShopStockRequestVO,
UserRivalRequestVO, UserRivalRequestVO,
UserRivalMusicRequestVO, UserRivalMusicRequestVO,
fetch_all_pages, get_all_pages,
ITEM_KINDS, ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR, ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS, FAVORITE_ITEM_KINDS,
@@ -280,42 +295,42 @@ from .user_getter_common import (
# 5) 取数脚本(一个接口一个脚本) # 5) 取数脚本(一个接口一个脚本)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .userdata import UserData from .get_user_data import GetUserData
from .user_extend import UserExtend from .get_user_extend import GetUserExtend
from .user_option import UserOption from .get_user_option import GetUserOption
from .user_character import UserCharacter from .get_user_character import GetUserCharacter
from .user_ghost import UserGhost from .get_user_ghost import GetUserGhost
from .user_map import UserMap, UserMapAll from .get_user_map import GetUserMap, GetUserMapAll
from .user_login_bonus import UserLoginBonus, UserLoginBonusAll from .get_user_login_bonus import GetUserLoginBonus, GetUserLoginBonusAll
from .user_rating import UserRating from .get_user_rating import GetUserRating
from .user_item import UserItem, UserItemAll from .get_user_item import GetUserItem, GetUserItemAll
from .user_music import UserMusicDetail, UserMusicDetailAll from .get_user_music import GetUserMusicDetail, GetUserMusicDetailAll
from .user_course import UserCourse, UserCourseAll from .get_user_course import GetUserCourse, GetUserCourseAll
from .user_charge import UserCharge from .get_user_charge import GetUserCharge
from .user_favorite import UserFavorite, UserFavoriteAll from .get_user_favorite import GetUserFavorite, GetUserFavoriteAll
from .user_activity import UserActivity from .get_user_activity import GetUserActivity
from .user_mission import UserMissionData from .get_user_mission import GetUserMissionData
from .user_intimate import UserIntimate from .get_user_intimate import GetUserIntimate
from .user_shop_stock import UserShopStock from .get_user_shop_stock import GetUserShopStock
from .user_favorite_item import ( from .get_user_favorite_item import (
UserFavoriteItem, GetUserFavoriteItem,
UserFavoriteItemAll, GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC, FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL, FAVORITE_ITEM_KIND_RIVAL,
) )
from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll from .get_user_rival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .user_friend_bonus import UserFriendBonus, extract_gift_points from .get_user_friend_bonus import GetUserFriendBonus, extract_gift_points
from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope from .get_user_kaleidx_scope import GetUserKaleidxScope, GetGameKaleidxScope
from .user_region import UserRegion from .get_user_region import GetUserRegion
from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .user_new_item import UserNewItemList, UserNewItemListRequestVO from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO
from .game_ranking import GetGameRanking, GameRankingRequestVO from .get_game_ranking import GetGameRanking, GameRankingRequestVO
from .game_event import GetGameEvent, GameEventRequestVO from .get_game_event import GetGameEvent, GameEventRequestVO
from .game_charge import GetGameCharge, GameChargeRequestVO from .get_game_charge import GetGameCharge, GameChargeRequestVO
from .game_weekly_data import GetGameWeeklyData from .get_game_weekly_data import GetGameWeeklyData
from .game_tournament_info import GetGameTournamentInfo from .get_game_tournament_info import GetGameTournamentInfo
from .game_ng_music_id import GetGameNgMusicId from .get_game_ng_music_id import GetGameNgMusicId
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里) # 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
@@ -326,7 +341,7 @@ _API_FUNCS = (
"ParseQRCode", "ParseQRCode",
"GetGameSettings", "GetGameSettings",
"Ping", "Ping",
"UserPreview", "GetUserPreview",
"UserLogin", "UserLogin",
"UserLogout", "UserLogout",
# --- 上传成绩 --- # --- 上传成绩 ---
@@ -350,45 +365,45 @@ _API_FUNCS = (
"build_delta", "build_delta",
"build_favorite_delta", "build_favorite_delta",
"normalize_snapshot", "normalize_snapshot",
"fetch_upsert_snapshot", "GetNeedUserData",
"summarize_snapshot", "summarize_snapshot",
"UserNewItemList", "GetUserNewItemList",
# --- 取数:单接口 --- # --- 取数:单接口 ---
"UserData", # GetUserDataApi "GetUserData", # GetUserDataApi
"UserExtend", # GetUserExtendApi "GetUserExtend", # GetUserExtendApi
"UserOption", # GetUserOptionApi "GetUserOption", # GetUserOptionApi
"UserCharacter", # GetUserCharacterApi "GetUserCharacter", # GetUserCharacterApi
"UserGhost", # GetUserGhostApi "GetUserGhost", # GetUserGhostApi
"UserMap", # GetUserMapApi(单页) "GetUserMap", # GetUserMapApi(单页)
"UserMapAll", # GetUserMapApi(全量) "GetUserMapAll", # GetUserMapApi(全量)
"UserLoginBonus", # GetUserLoginBonusApi(单页) "GetUserLoginBonus", # GetUserLoginBonusApi(单页)
"UserLoginBonusAll", # GetUserLoginBonusApi(全量) "GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
"UserRating", # GetUserRatingApi "GetUserRating", # GetUserRatingApi
"UserItem", # GetUserItemApi(单类) "GetUserItem", # GetUserItemApi(单类)
"UserItemAll", # GetUserItemApi(全量) "GetUserItemAll", # GetUserItemApi(全量)
"UserMusicDetail", # GetUserMusicApi(单页) "GetUserMusicDetail", # GetUserMusicApi(单页)
"UserMusicDetailAll", # GetUserMusicApi(全量) "GetUserMusicDetailAll", # GetUserMusicApi(全量)
"UserCourse", # GetUserCourseApi(单页) "GetUserCourse", # GetUserCourseApi(单页)
"UserCourseAll", # GetUserCourseApi(全量) "GetUserCourseAll", # GetUserCourseApi(全量)
"UserCharge", # GetUserChargeApi "GetUserCharge", # GetUserChargeApi
"UserFavorite", # GetUserFavoriteApi(单类) "GetUserFavorite", # GetUserFavoriteApi(单类)
"UserFavoriteAll", # GetUserFavoriteApi(全量) "GetUserFavoriteAll", # GetUserFavoriteApi(全量)
"UserActivity", # GetUserActivityApi "GetUserActivity", # GetUserActivityApi
"UserMissionData", # GetUserMissionDataApi "GetUserMissionData", # GetUserMissionDataApi
"UserIntimate", # GetUserIntimateApi "GetUserIntimate", # GetUserIntimateApi
"UserShopStock", # GetUserShopStockApi "GetUserShopStock", # GetUserShopStockApi
"UserFavoriteItem", # GetUserFavoriteItemApi(单页) "GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
"UserFavoriteItemAll", # GetUserFavoriteItemApi(全量) "GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"UserRivalData", # GetUserRivalDataApi "GetUserRivalData", # GetUserRivalDataApi
"UserRivalMusic", # GetUserRivalMusicApi(单页) "GetUserRivalMusic", # GetUserRivalMusicApi(单页)
"UserRivalMusicAll", # GetUserRivalMusicApi(全量) "GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
"UserFriendBonus", # GetUserFriendBonusApi "GetUserFriendBonus", # GetUserFriendBonusApi
"UserKaleidxScope", # GetUserKaleidxScopeApi "GetUserKaleidxScope", # GetUserKaleidxScopeApi
"GameKaleidxScope", # GetGameKaleidxScopeApi "GetGameKaleidxScope", # GetGameKaleidxScopeApi
"UserRegion", # GetUserRegionApi "GetUserRegion", # GetUserRegionApi
"UserRecommendRateMusic", # GetUserRecommendRateMusicApi "GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
"UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi "GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"UserNewItemList", # GetUserNewItemListApi "GetUserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 --- # --- 取数:游戏侧 ---
"GetGameRanking", # GetGameRankingApi "GetGameRanking", # GetGameRankingApi
"GetGameEvent", # GetGameEventApi "GetGameEvent", # GetGameEventApi
@@ -435,7 +450,7 @@ __all__ = [
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO", "GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled", "is_maintenance", "is_ping_disabled",
"Ping", "Ping",
"UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO", "GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO", "UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI", "UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO", "UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
@@ -462,34 +477,36 @@ __all__ = [
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS", "USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS", "USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
# ---- 快照编排 ---- # ---- 快照编排 ----
"fetch_upsert_snapshot", "save_snapshot", "load_snapshot", "GetNeedUserData", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES", "summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
"save_user_data", "load_user_data", "list_saved_user_ids",
# ---- 取数公共底座 ---- # ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO", "GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO", "UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO", "UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO", "UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"fetch_all_pages", "get_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS", "ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ---- # ---- 取数脚本 ----
"UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost", "GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
"UserMap", "UserMapAll", "GetUserMap", "GetUserMapAll",
"UserLoginBonus", "UserLoginBonusAll", "GetUserLoginBonus", "GetUserLoginBonusAll",
"UserRating", "GetUserRating",
"UserItem", "UserItemAll", "GetUserItem", "GetUserItemAll",
"UserMusicDetail", "UserMusicDetailAll", "GetUserMusicDetail", "GetUserMusicDetailAll",
"UserCourse", "UserCourseAll", "GetUserCourse", "GetUserCourseAll",
"UserCharge", "GetUserCharge",
"UserFavorite", "UserFavoriteAll", "GetUserFavorite", "GetUserFavoriteAll",
"UserActivity", "UserMissionData", "UserIntimate", "UserShopStock", "GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
"UserFavoriteItem", "UserFavoriteItemAll", "GetUserFavoriteItem", "GetUserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL", "FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"UserRivalData", "UserRivalMusic", "UserRivalMusicAll", "GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
"UserFriendBonus", "extract_gift_points", "GetUserFriendBonus", "extract_gift_points",
"UserKaleidxScope", "GameKaleidxScope", "GetUserKaleidxScope", "GetGameKaleidxScope",
"UserRegion", "GetUserRegion",
"UserRecommendRateMusic", "UserRecommendSelectMusic", "GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
"UserNewItemList", "UserNewItemListRequestVO", "GetUserNewItemList", "UserNewItemListRequestVO",
"GetGameRanking", "GameRankingRequestVO", "GetGameRanking", "GameRankingRequestVO",
"GetGameEvent", "GameEventRequestVO", "GetGameEvent", "GameEventRequestVO",
"GetGameCharge", "GameChargeRequestVO", "GetGameCharge", "GameChargeRequestVO",
+1 -1
View File
@@ -235,7 +235,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID) chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回: 返回:
(user_id, token) 元组,可直接传给 UserPreview / UserLogin 等 (user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
异常: 异常:
ValueError: 二维码格式不合法 ValueError: 二维码格式不合法
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_charge.py —— 取游戏计费配置(GetGameChargeApi) get_game_charge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================ ================================================================================
取服务器下发的计费档位。 取服务器下发的计费档位。
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_event.py —— 取游戏活动(GetGameEventApi) get_game_event.py —— 取游戏活动(GetGameEventApi)
================================================================================ ================================================================================
取服务器下发的活动列表。 取服务器下发的活动列表。
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi) get_game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
================================================================================ ================================================================================
取服务器下发的 NG(不可游玩)曲目 ID 列表。 取服务器下发的 NG(不可游玩)曲目 ID 列表。
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_ranking.py —— 取游戏排行(GetGameRankingApi) get_game_ranking.py —— 取游戏排行(GetGameRankingApi)
================================================================================ ================================================================================
取服务器下发的排行榜数据。 取服务器下发的排行榜数据。
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
gamesettings.py —— 获取游戏设置(GetGameSettingApi) get_game_settings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================ ================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。 功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi) get_game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
================================================================================ ================================================================================
取服务器下发的大会/赛事信息。 取服务器下发的大会/赛事信息。
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi) get_game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
================================================================================ ================================================================================
取服务器下发的周常周期信息。 取服务器下发的周常周期信息。
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本) get_need_user_data.py —— 结算快照编排(统一调用各取数脚本)
================================================================================ ================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。 功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(userdata.py / user_map.py …)。 ★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(get_user_data.py / get_user_map.py …)。
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
为什么需要快照 为什么需要快照
@@ -26,15 +26,17 @@ upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
调用示例 调用示例
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
from api import fetch_upsert_snapshot from api import GetNeedUserData
snapshot = fetch_upsert_snapshot(user_id, token=token) snapshot = GetNeedUserData(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...) # 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
@@ -43,25 +45,103 @@ from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
# 各取数脚本(一个接口一个脚本) # 各取数脚本(一个接口一个脚本)
from .userdata import UserData from .get_user_data import GetUserData
from .user_extend import UserExtend from .get_user_extend import GetUserExtend
from .user_option import UserOption from .get_user_option import GetUserOption
from .user_character import UserCharacter from .get_user_character import GetUserCharacter
from .user_ghost import UserGhost from .get_user_ghost import GetUserGhost
from .user_map import UserMapAll from .get_user_map import GetUserMapAll
from .user_login_bonus import UserLoginBonusAll from .get_user_login_bonus import GetUserLoginBonusAll
from .user_rating import UserRating from .get_user_rating import GetUserRating
from .user_item import UserItemAll from .get_user_item import GetUserItemAll
from .user_music import UserMusicDetailAll from .get_user_music import GetUserMusicDetailAll
from .user_course import UserCourseAll from .get_user_course import GetUserCourseAll
from .user_charge import UserCharge from .get_user_charge import GetUserCharge
from .user_favorite import UserFavoriteAll from .get_user_favorite import GetUserFavoriteAll
from .user_activity import UserActivity from .get_user_activity import GetUserActivity
from .user_mission import UserMissionData from .get_user_mission import GetUserMissionData
from .user_intimate import UserIntimate from .get_user_intimate import GetUserIntimate
from .user_shop_stock import UserShopStock from .get_user_shop_stock import GetUserShopStock
from .user_favorite_item import UserFavoriteItemAll from .get_user_favorite_item import GetUserFavoriteItemAll
from .user_kaleidx_scope import UserKaleidxScope from .get_user_kaleidx_scope import GetUserKaleidxScope
# =============================================================================
# 落盘目录:<工程根>/data/<userId>.json
# =============================================================================
#: 工程根目录(本文件在 <root>/api/ 下)
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
#: 用户数据目录:<工程根>/data
DATA_DIR: Path = PROJECT_ROOT / "data"
def user_data_path(user_id: int) -> Path:
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
return DATA_DIR / (str(int(user_id)) + ".json")
def save_user_data(snapshot: Dict[str, Any], user_id: int,
path: Optional[str] = None) -> str:
"""
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
★ 目录不存在会自动创建。
参数:
snapshot: GetNeedUserData 的返回值
user_id: 用户 ID(决定文件名)
path: 自定义路径(可选,默认走 data/<userId>.json)
返回:实际写入的文件路径
"""
target = Path(path) if path else user_data_path(user_id)
try:
os.makedirs(str(target.parent), exist_ok=True)
with open(str(target), "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return str(target)
size = 0
try:
size = os.path.getsize(str(target))
except Exception:
pass
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
return str(target)
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
"""
读回 <工程根>/data/<userId>.json。
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
"""
target = Path(path) if path else user_data_path(user_id)
if not target.exists():
raise FileNotFoundError("用户数据文件不存在:" + str(target))
with open(str(target), "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("用户数据已加载:" + str(target))
return data
def list_saved_user_ids() -> List[int]:
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
if not DATA_DIR.exists():
return []
out: List[int] = []
for name in os.listdir(str(DATA_DIR)):
if not name.endswith(".json"):
continue
try:
out.append(int(name[:-5]))
except ValueError:
continue
return sorted(out)
#: 默认要抓的节点(顺序即抓取顺序) #: 默认要抓的节点(顺序即抓取顺序)
@@ -89,7 +169,7 @@ DEFAULT_SNAPSHOT_NODES: tuple = (
) )
def fetch_upsert_snapshot( def GetNeedUserData(
user_id: int, user_id: int,
*, *,
token: str = "", token: str = "",
@@ -101,6 +181,8 @@ def fetch_upsert_snapshot(
timeout_ms: Optional[int] = None, timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None, client: Optional[MaimaiNetClient] = None,
strict: bool = False, strict: bool = False,
save: bool = True,
save_path: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
抓取结算所需的全部基线数据。 抓取结算所需的全部基线数据。
@@ -117,6 +199,9 @@ def fetch_upsert_snapshot(
client: 可复用的 MaimaiNetClient client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常; strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩 False(默认)= 记录失败并跳过,保证链路不崩
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
(★ 新数据总是覆盖老数据)
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
返回: 返回:
{ {
@@ -151,78 +236,87 @@ def fetch_upsert_snapshot(
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)---- # ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted: if "userData" in wanted:
_call("userData", UserData, user_id, **common) _call("userData", GetUserData, user_id, **common)
if "userExtend" in wanted: if "userExtend" in wanted:
_call("userExtend", UserExtend, user_id, **common) _call("userExtend", GetUserExtend, user_id, **common)
if "userOption" in wanted: if "userOption" in wanted:
_call("userOption", UserOption, user_id, **common) _call("userOption", GetUserOption, user_id, **common)
if "userCharacterList" in wanted: if "userCharacterList" in wanted:
_call("userCharacterList", UserCharacter, user_id, **common) _call("userCharacterList", GetUserCharacter, user_id, **common)
if "userMapList" in wanted: if "userMapList" in wanted:
_call("userMapList", UserMapAll, user_id, **common) _call("userMapList", GetUserMapAll, user_id, **common)
if "userLoginBonusList" in wanted: if "userLoginBonusList" in wanted:
_call("userLoginBonusList", UserLoginBonusAll, user_id, **common) _call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted: if "userRatingList" in wanted:
_call("userRatingList", UserRating, user_id, **common) _call("userRatingList", GetUserRating, user_id, **common)
if "userItemList" in wanted: if "userItemList" in wanted:
_call("userItemList", UserItemAll, user_id, **common) _call("userItemList", GetUserItemAll, user_id, **common)
if "userMusicDetailList" in wanted: if "userMusicDetailList" in wanted:
_call("userMusicDetailList", UserMusicDetailAll, user_id, **common) _call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted: if "userCourseList" in wanted:
_call("userCourseList", UserCourseAll, user_id, **common) _call("userCourseList", GetUserCourseAll, user_id, **common)
if "userChargeList" in wanted: if "userChargeList" in wanted:
_call("userChargeList", UserCharge, user_id, **common) _call("userChargeList", GetUserCharge, user_id, **common)
if "userFavoriteList" in wanted: if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw), # ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用 # 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。 # —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", UserFavoriteAll, user_id, **common): if _call("userFavoriteList", GetUserFavoriteAll, user_id, **common):
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw") fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
if fav_raw: if fav_raw:
raw["_raw_favorite"] = fav_raw raw["_raw_favorite"] = fav_raw
if "userActivityList" in wanted: if "userActivityList" in wanted:
_call("userActivityList", UserActivity, user_id, **common) _call("userActivityList", GetUserActivity, user_id, **common)
if "userMissionDataList" in wanted: if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界), # ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。 # 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", UserMissionData, user_id, **common) _call("userMissionDataList", GetUserMissionData, user_id, **common)
if "userIntimateList" in wanted: if "userIntimateList" in wanted:
_call("userIntimateList", UserIntimate, user_id, **common) _call("userIntimateList", GetUserIntimate, user_id, **common)
if "userShopItemStockList" in wanted: if "userShopItemStockList" in wanted:
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS) ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
if ids: if ids:
_call("userShopItemStockList", UserShopStock, user_id, _call("userShopItemStockList", GetUserShopStock, user_id,
shop_item_id_list=ids, **common) shop_item_id_list=ids, **common)
else: else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点") log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted: if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common) _call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted: if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", UserKaleidxScope, user_id, **common) _call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
if "userGhost" in wanted: if "userGhost" in wanted:
_call("userGhost", UserGhost, user_id, **common) _call("userGhost", GetUserGhost, user_id, **common)
raw["_failed"] = failed raw["_failed"] = failed
log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 " raw["_userId"] = int(user_id)
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
+ str(len(failed)) + " 个") + str(len(failed)) + " 个")
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
if save or save_path:
try:
save_user_data(raw, user_id, path=save_path)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return raw return raw
@@ -281,10 +375,10 @@ if __name__ == "__main__":
from .log_setup import log_error, setup_logging, teardown_logging from .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("upsert_snapshot_test") setup_logging("get_need_user_data_test")
try: try:
if len(sys.argv) < 2: if len(sys.argv) < 2:
print("用法: python -m api.upsert_snapshot <user_id> [--token TOKEN] [--save 路径]") print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
return 1 return 1
user_id = int(sys.argv[1]) user_id = int(sys.argv[1])
@@ -302,7 +396,7 @@ if __name__ == "__main__":
else: else:
i += 1 i += 1
snap = fetch_upsert_snapshot(user_id, token=token) snap = GetNeedUserData(user_id, token=token)
summary = summarize_snapshot(snap) summary = summarize_snapshot(snap)
print("") print("")
for node, count in summary.items(): for node, count in summary.items():
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_activity.py —— 取用户活动记录(GetUserActivityApi) get_user_activity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================ ================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。 取玩家的活动/成就记录(playList 与 musicList 两个列表)。
@@ -16,7 +16,7 @@ user_activity.py —— 取用户活动记录(GetUserActivityApi)
不是把两个数组首尾相接摊平。 不是把两个数组首尾相接摊平。
调用示例: 调用示例:
from api import UserActivity from api import GetUserActivity
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,7 +30,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserActivity( def GetUserActivity(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_character.py —— 取用户角色列表(GetUserCharacterApi) get_user_character.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================ ================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。 取玩家所有角色的等级与觉醒度(真机 312 条)。
@@ -14,7 +14,7 @@ user_character.py —— 取用户角色列表(GetUserCharacterApi)
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。 (真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
调用示例: 调用示例:
from api import UserCharacter from api import GetUserCharacter
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserCharacter( def GetUserCharacter(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_charge.py —— 取用户票券(GetUserChargeApi) get_user_charge.py —— 取用户票券(GetUserChargeApi)
================================================================================ ================================================================================
取玩家的票券持有情况(真机 3 条)。 取玩家的票券持有情况(真机 3 条)。
@@ -13,7 +13,7 @@ user_charge.py —— 取用户票券(GetUserChargeApi)
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。 ★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
调用示例: 调用示例:
from api import UserCharge from api import GetUserCharge
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,7 +27,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserCharge( def GetUserCharge(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_course.py —— 取用户课题模式成绩(GetUserCourseApi) get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================ ================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。 取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
@@ -12,7 +12,7 @@ user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0} source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
调用示例: 调用示例:
from api import UserCourse from api import GetUserCourse
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,10 +23,10 @@ from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .log_setup import log_info
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserCourseRequestVO, fetch_all_pages from .user_getter_common import _get, UserCourseRequestVO, get_all_pages
def UserCourse( def GetUserCourse(
user_id: int, user_id: int,
*, *,
next_index: int = 0, next_index: int = 0,
@@ -48,7 +48,7 @@ def UserCourse(
) )
def UserCourseAll( def GetUserCourseAll(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -62,7 +62,7 @@ def UserCourseAll(
{"userCourseList": [...]} {"userCourseList": [...]}
""" """
log_info("取用户课题成绩(全量):user_id=" + str(user_id)) log_info("取用户课题成绩(全量):user_id=" + str(user_id))
items = fetch_all_pages( items = get_all_pages(
"GetUserCourseApi", user_id, "userCourseList", "GetUserCourseApi", user_id, "userCourseList",
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0), body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
client_id=client_id, timeout_ms=timeout_ms, client=client, client_id=client_id, timeout_ms=timeout_ms, client=client,
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
userdata.py —— 取用户全量数据(GetUserDataApi) get_user_data.py —— 取用户全量数据(GetUserDataApi)
================================================================================ ================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。 取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
@@ -20,7 +20,7 @@ userdata.py —— 取用户全量数据(GetUserDataApi)
记录造成的假象(两条响应被合并显示)。 记录造成的假象(两条响应被合并显示)。
调用示例: 调用示例:
from api import UserData from api import GetUserData
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,7 +34,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserData( def GetUserData(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_extend.py —— 取用户扩展设置(GetUserExtendApi) get_user_extend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================ ================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。 取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
@@ -14,7 +14,7 @@ user_extend.py —— 取用户扩展设置(GetUserExtendApi)
(真机两处都是 2022),组包时要注意。 (真机两处都是 2022),组包时要注意。
调用示例: 调用示例:
from api import UserExtend from api import GetUserExtend
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserExtend( def GetUserExtend(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_favorite.py —— 取用户收藏(GetUserFavoriteApi) get_user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================ ================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。 取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
@@ -15,7 +15,7 @@ user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。 位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
调用示例: 调用示例:
from api import UserFavorite, UserFavoriteAll from api import GetUserFavorite, GetUserFavoriteAll
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def UserFavorite( def GetUserFavorite(
user_id: int, user_id: int,
item_kind: int, item_kind: int,
*, *,
@@ -55,7 +55,7 @@ def UserFavorite(
) )
def UserFavoriteAll( def GetUserFavoriteAll(
user_id: int, user_id: int,
*, *,
item_kinds: Optional[List[int]] = None, item_kinds: Optional[List[int]] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi) get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================ ================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。 取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
@@ -15,7 +15,7 @@ user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemA
★ 字段名是 kind(不是 itemKind),别写错。 ★ 字段名是 kind(不是 itemKind),别写错。
调用示例: 调用示例:
from api import UserFavoriteItem, UserFavoriteItemAll from api import GetUserFavoriteItem, GetUserFavoriteItemAll
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,14 +26,14 @@ from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .log_setup import log_info
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteItemRequestVO, fetch_all_pages from .user_getter_common import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值 #: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲 FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手 FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
def UserFavoriteItem( def GetUserFavoriteItem(
user_id: int, user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC, kind: int = FAVORITE_ITEM_KIND_MUSIC,
*, *,
@@ -65,7 +65,7 @@ def UserFavoriteItem(
) )
def UserFavoriteItemAll( def GetUserFavoriteItemAll(
user_id: int, user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC, kind: int = FAVORITE_ITEM_KIND_MUSIC,
*, *,
@@ -81,7 +81,7 @@ def UserFavoriteItemAll(
{"userFavoriteItemList": [...]} {"userFavoriteItemList": [...]}
""" """
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind)) log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
items = fetch_all_pages( items = get_all_pages(
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList", "GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
body_factory=lambda ni: UserFavoriteItemRequestVO( body_factory=lambda ni: UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=ni or 0, userId=user_id, kind=kind, nextIndex=ni or 0,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi) get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================ ================================================================================
取玩家本次可领取的好友里程(getMiles)。 取玩家本次可领取的好友里程(getMiles)。
@@ -12,7 +12,7 @@ user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90} source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
调用示例: 调用示例:
from api import UserFriendBonus, extract_gift_points from api import GetUserFriendBonus, extract_gift_points
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,7 +26,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserFriendBonus( def GetUserFriendBonus(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -53,7 +53,7 @@ def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
从好友里程响应里取出 getMiles(本次可领取的里程数)。 从好友里程响应里取出 getMiles(本次可领取的里程数)。
参数: 参数:
friend_bonus: UserFriendBonus 的返回值 friend_bonus: GetUserFriendBonus 的返回值
返回: 返回:
getMiles 整数(取不到则为 0) getMiles 整数(取不到则为 0)
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_ghost.py —— 取用户幽灵数据(GetUserGhostApi) get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================ ================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。 取玩家的「幽灵」对战记录(真机 15 条)。
@@ -11,11 +11,11 @@ user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是 ★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new UserGhost[1] {...}; dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。 只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例: 调用示例:
from api import UserGhost from api import GetUserGhost
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserGhost( def GetUserGhost(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi) get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================ ================================================================================
取玩家与各搭档的亲密度(真机 2 条)。 取玩家与各搭档的亲密度(真机 2 条)。
@@ -11,7 +11,7 @@ user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例: 调用示例:
from api import UserIntimate from api import GetUserIntimate
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,7 +25,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserIntimate( def GetUserIntimate(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_item.py —— 取用户道具(GetUserItemApi) get_user_item.py —— 取用户道具(GetUserItemApi)
================================================================================ ================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。 取玩家各类道具的库存(真机按 kind 分 12 次请求)。
@@ -29,7 +29,7 @@ source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
后续翻页沿用响应回的 nextIndex 后续翻页沿用响应回的 nextIndex
调用示例: 调用示例:
from api import UserItem, UserItemAll from api import GetUserItem, GetUserItemAll
""" """
from __future__ import annotations from __future__ import annotations
@@ -43,7 +43,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def UserItem( def GetUserItem(
user_id: int, user_id: int,
item_kind: int, item_kind: int,
*, *,
@@ -77,7 +77,7 @@ def UserItem(
) )
def UserItemAll( def GetUserItemAll(
user_id: int, user_id: int,
*, *,
item_kinds: Optional[List[int]] = None, item_kinds: Optional[List[int]] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi) get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================ ================================================================================
对应接口: 对应接口:
@@ -17,14 +17,14 @@ user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / Ge
★★ 重要区分(曾导致 userData 取空) ★★ 重要区分(曾导致 userData 取空)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。 GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 userdata.py)。 玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的 真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId, 两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。 多发字段会让服务端返回空内容。
调用示例: 调用示例:
from api import UserKaleidxScope, GameKaleidxScope from api import GetUserKaleidxScope, GetGameKaleidxScope
""" """
from __future__ import annotations from __future__ import annotations
@@ -38,7 +38,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
def UserKaleidxScope( def GetUserKaleidxScope(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -56,7 +56,7 @@ def UserKaleidxScope(
client_id=client_id, timeout_ms=timeout_ms, client=client) client_id=client_id, timeout_ms=timeout_ms, client=client)
def GameKaleidxScope( def GetGameKaleidxScope(
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None, timeout_ms: Optional[int] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi) get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================ ================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。 取玩家的登录奖励领取进度(真机 38 条,分两页)。
@@ -13,7 +13,7 @@ user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。 ★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
调用示例: 调用示例:
from api import UserLoginBonus, UserLoginBonusAll from api import GetUserLoginBonus, GetUserLoginBonusAll
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,10 +24,10 @@ from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .log_setup import log_info
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserLoginBonusRequestVO, fetch_all_pages from .user_getter_common import _get, UserLoginBonusRequestVO, get_all_pages
def UserLoginBonus( def GetUserLoginBonus(
user_id: int, user_id: int,
*, *,
next_index: int = 0, next_index: int = 0,
@@ -52,7 +52,7 @@ def UserLoginBonus(
) )
def UserLoginBonusAll( def GetUserLoginBonusAll(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -66,7 +66,7 @@ def UserLoginBonusAll(
{"userLoginBonusList": [...]} {"userLoginBonusList": [...]}
""" """
log_info("取用户登录奖励(全量):user_id=" + str(user_id)) log_info("取用户登录奖励(全量):user_id=" + str(user_id))
items = fetch_all_pages( items = get_all_pages(
"GetUserLoginBonusApi", user_id, "userLoginBonusList", "GetUserLoginBonusApi", user_id, "userLoginBonusList",
body_factory=lambda ni: UserLoginBonusRequestVO( body_factory=lambda ni: UserLoginBonusRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=20), userId=user_id, nextIndex=ni or 0, maxCount=20),
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_map.py —— 取用户地图进度(GetUserMapApi) get_user_map.py —— 取用户地图进度(GetUserMapApi)
================================================================================ ================================================================================
取玩家各地图的推进距离(真机 104 条)。 取玩家各地图的推进距离(真机 104 条)。
@@ -14,7 +14,7 @@ user_map.py —— 取用户地图进度(GetUserMapApi)
(真机:mapId=550003 的 distance 1424000 -> 1435000)。 (真机:mapId=550003 的 distance 1424000 -> 1435000)。
调用示例: 调用示例:
from api import UserMap / UserMapAll from api import GetUserMap / GetUserMapAll
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,10 +25,10 @@ from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .log_setup import log_info
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdPageRequestVO, fetch_all_pages from .user_getter_common import _get, UserIdPageRequestVO, get_all_pages
def UserMap( def GetUserMap(
user_id: int, user_id: int,
*, *,
next_index: int = 0, next_index: int = 0,
@@ -53,7 +53,7 @@ def UserMap(
) )
def UserMapAll( def GetUserMapAll(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -67,7 +67,7 @@ def UserMapAll(
{"userMapList": [...]} {"userMapList": [...]}
""" """
log_info("取用户地图进度(全量):user_id=" + str(user_id)) log_info("取用户地图进度(全量):user_id=" + str(user_id))
items = fetch_all_pages( items = get_all_pages(
"GetUserMapApi", user_id, "userMapList", "GetUserMapApi", user_id, "userMapList",
body_factory=lambda ni: UserIdPageRequestVO( body_factory=lambda ni: UserIdPageRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=1000), userId=user_id, nextIndex=ni or 0, maxCount=1000),
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi) get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================ ================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。 取玩家的任务进度与周常数据(真机 6 条任务)。
@@ -16,7 +16,7 @@ user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。 ★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例: 调用示例:
from api import UserMissionData from api import GetUserMissionData
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,7 +30,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserMissionData( def GetUserMissionData(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
+7 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_music.py —— 取用户曲目成绩(GetUserMusicApi) get_user_music.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================ ================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。 取玩家全部曲目的历史成绩归档(真机 739 组)。
@@ -10,7 +10,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi)
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]} 响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组) source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
source/Assembly-CSharp/Net.VO.Mai2/UserMusicDetail.cs(内层按难度) source/Assembly-CSharp/Net.VO.Mai2/GetUserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是 ★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。 按 (musicId, level) 的成绩条目。取数时要摊平。
@@ -18,7 +18,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi)
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。 ★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例: 调用示例:
from api import UserMusicDetail, UserMusicDetailFlat from api import GetUserMusicDetail, UserMusicDetailFlat
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,10 +29,10 @@ from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .log_setup import log_info
from .maimai_net import MaimaiNetClient from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserMusicRequestVO, fetch_all_pages from .user_getter_common import _get, UserMusicRequestVO, get_all_pages
def UserMusicDetail( def GetUserMusicDetail(
user_id: int, user_id: int,
*, *,
next_index: int = 0, next_index: int = 0,
@@ -57,7 +57,7 @@ def UserMusicDetail(
) )
def UserMusicDetailAll( def GetUserMusicDetailAll(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -72,7 +72,7 @@ def UserMusicDetailAll(
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。 每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
""" """
log_info("取用户曲目成绩(全量):user_id=" + str(user_id)) log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
groups = fetch_all_pages( groups = get_all_pages(
"GetUserMusicApi", user_id, "userMusicList", "GetUserMusicApi", user_id, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO( body_factory=lambda ni: UserMusicRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=50), userId=user_id, nextIndex=ni or 0, maxCount=50),
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_new_item.py —— 取新增道具(GetUserNewItemListApi) get_user_new_item.py —— 取新增道具(GetUserNewItemListApi)
================================================================================ ================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。 上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
@@ -15,7 +15,7 @@ user_new_item.py —— 取新增道具(GetUserNewItemListApi)
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。 ★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
调用示例: 调用示例:
from api import UserNewItemList from api import GetUserNewItemList
""" """
from __future__ import annotations from __future__ import annotations
@@ -38,7 +38,7 @@ class UserNewItemListRequestVO:
userPlaylogList: List[Any] = field(default_factory=list) userPlaylogList: List[Any] = field(default_factory=list)
def UserNewItemList( def GetUserNewItemList(
user_id: int, user_id: int,
*, *,
version: int = PLAY_VERSION_INT, version: int = PLAY_VERSION_INT,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_option.py —— 取用户游戏设置(GetUserOptionApi) get_user_option.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================ ================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。 取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
@@ -15,7 +15,7 @@ user_option.py —— 取用户游戏设置(GetUserOptionApi)
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。 裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例: 调用示例:
from api import UserOption from api import GetUserOption
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserOption( def GetUserOption(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
userpreview.py —— 用户预览(GetUserPreviewApi) get_user_preview.py —— 用户预览(GetUserPreviewApi)
================================================================================ ================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。 功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
@@ -17,8 +17,8 @@ userpreview.py —— 用户预览(GetUserPreviewApi)
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。 (source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例: 调用示例:
from api import UserPreview from api import GetUserPreview
pv = UserPreview(12345678, "TESTTOKEN0000000000") pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating) print(pv.userName, pv.playerRating)
""" """
@@ -74,7 +74,7 @@ class UserPreviewResponseVO:
errorId: int = 0 errorId: int = 0
def UserPreview( def GetUserPreview(
user_id: int, user_id: int,
token: str, token: str,
*, *,
@@ -139,10 +139,10 @@ if __name__ == "__main__":
setup_logging("userpreview_test") setup_logging("userpreview_test")
try: try:
if len(sys.argv) < 3: if len(sys.argv) < 3:
print("用法: python -m api.userpreview <user_id> <token> [client_id]") print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
return 1 return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = UserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid) pv = GetUserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
print("") print("")
for name in UserPreviewResponseVO.__dataclass_fields__: for name in UserPreviewResponseVO.__dataclass_fields__:
print(" " + name.ljust(18) + "= " + str(getattr(pv, name))) print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_rating.py —— 取用户 Rating 明细(GetUserRatingApi) get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================ ================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。 取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
@@ -14,7 +14,7 @@ user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。 ★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
调用示例: 调用示例:
from api import UserRating from api import GetUserRating
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserRating( def GetUserRating(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi) get_user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================ ================================================================================
对应接口: 对应接口:
@@ -14,7 +14,7 @@ user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi /
source/2026-10-05.log:79,81 source/2026-10-05.log:79,81
调用示例: 调用示例:
from api import UserRecommendRateMusic, UserRecommendSelectMusic from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
""" """
from __future__ import annotations from __future__ import annotations
@@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserRecommendRateMusic( def GetUserRecommendRateMusic(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -46,7 +46,7 @@ def UserRecommendRateMusic(
client_id=client_id, timeout_ms=timeout_ms, client=client) client_id=client_id, timeout_ms=timeout_ms, client=client)
def UserRecommendSelectMusic( def GetUserRecommendSelectMusic(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_region.py —— 取用户地区游玩统计(GetUserRegionApi) get_user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================ ================================================================================
取玩家在各地区的游玩次数。 取玩家在各地区的游玩次数。
@@ -12,7 +12,7 @@ user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
source/2026-10-05.log:77 source/2026-10-05.log:77
调用示例: 调用示例:
from api import UserRegion from api import GetUserRegion
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,7 +26,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .user_getter_common import _get, UserIdRequestVO
def UserRegion( def GetUserRegion(
user_id: int, user_id: int,
*, *,
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
+7 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi) get_user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================ ================================================================================
取玩家的对手名单及其曲目成绩。 取玩家的对手名单及其曲目成绩。
@@ -19,7 +19,7 @@ user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalM
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。 ★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
调用示例: 调用示例:
from api import UserRivalData, UserRivalMusic from api import GetUserRivalData, GetUserRivalMusic
""" """
from __future__ import annotations from __future__ import annotations
@@ -35,11 +35,11 @@ from .user_getter_common import (
UserRivalRequestVO, UserRivalRequestVO,
UserRivalMusicRequestVO, UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS, RIVAL_MUSIC_LEVELS,
fetch_all_pages, get_all_pages,
) )
def UserRivalData( def GetUserRivalData(
user_id: int, user_id: int,
rival_id: int, rival_id: int,
*, *,
@@ -65,7 +65,7 @@ def UserRivalData(
) )
def UserRivalMusic( def GetUserRivalMusic(
user_id: int, user_id: int,
rival_id: int, rival_id: int,
*, *,
@@ -100,7 +100,7 @@ def UserRivalMusic(
) )
def UserRivalMusicAll( def GetUserRivalMusicAll(
user_id: int, user_id: int,
rival_id: int, rival_id: int,
*, *,
@@ -117,7 +117,7 @@ def UserRivalMusicAll(
""" """
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS) lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id)) log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
items = fetch_all_pages( items = get_all_pages(
"GetUserRivalMusicApi", user_id, "userRivalMusicList", "GetUserRivalMusicApi", user_id, "userRivalMusicList",
body_factory=lambda ni: UserRivalMusicRequestVO( body_factory=lambda ni: UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=ni or 0, userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi) get_user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================ ================================================================================
取玩家在各商店商品的已兑换次数。 取玩家在各商店商品的已兑换次数。
@@ -15,7 +15,7 @@ user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。 不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
调用示例: 调用示例:
from api import UserShopStock from api import GetUserShopStock
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserShopStockRequestVO from .user_getter_common import _get, UserShopStockRequestVO
def UserShopStock( def GetUserShopStock(
user_id: int, user_id: int,
*, *,
shop_item_id_list: Optional[List[int]] = None, shop_item_id_list: Optional[List[int]] = None,
+1 -1
View File
@@ -57,7 +57,7 @@ log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
from api import log_info, log_debug, log_request, log_response from api import log_info, log_debug, log_request, log_response
def UserPreview(user_id, token, ...): def GetUserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」 # 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息''' # 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写 log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
+30 -30
View File
@@ -85,16 +85,16 @@ source/2026-10-05.log:123 逐节点核对:
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
五、调用示例 五、调用示例
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
from api import (setup_logging, ParseQRCode, UserPreview, UserLogin, from api import (setup_logging, ParseQRCode, GetUserPreview, UserLogin,
fetch_upsert_snapshot, UpsertUserAll, build_play_from_config) GetNeedUserData, UpsertUserAll, build_play_from_config)
setup_logging("结算") setup_logging("结算")
user_id, token = ParseQRCode(qr) user_id, token = ParseQRCode(qr)
UserPreview(user_id, token) GetUserPreview(user_id, token)
login = UserLogin(user_id, token) login = UserLogin(user_id, token)
# 1) 登录后立刻抓基线快照(差分的基准) # 1) 登录后立刻抓基线快照(差分的基准)
snapshot = fetch_upsert_snapshot(user_id, token=token) snapshot = GetNeedUserData(user_id, token=token)
# 2) 组装本局数据(默认读 config.py 的 PLAY_*) # 2) 组装本局数据(默认读 config.py 的 PLAY_*)
play = build_play_from_config() play = build_play_from_config()
@@ -527,7 +527,7 @@ def build_favorite_delta(
"2" = 服务器侧有对应 itemKind(无论内容是否一致) "2" = 服务器侧有对应 itemKind(无论内容是否一致)
"1" = 服务器侧查不到该 itemKind "1" = 服务器侧查不到该 itemKind
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 user_favorite.py ★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 get_user_favorite.py
的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是 的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是
{"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。 {"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。
@@ -729,12 +729,12 @@ def derive_play_characters(
数据来源(都在 Get 响应里): 数据来源(都在 Get 响应里):
userData.charaSlot 本局 5 个角色槽的 characterId(顺序即 slot1..5) userData.charaSlot 本局 5 个角色槽的 characterId(顺序即 slot1..5)
source/Assembly-CSharp/Net.VO.Mai2/UserData.cs source/Assembly-CSharp/Net.VO.Mai2/GetUserData.cs
userCharacterList 每只角色的 level / awakening userCharacterList 每只角色的 level / awakening
GetUserCharacterApi 响应 GetUserCharacterApi 响应
参数: 参数:
snapshot: fetch_upsert_snapshot 的返回值(或任何含 userData / snapshot: GetNeedUserData 的返回值(或任何含 userData /
userCharacterList 的字典)。 userCharacterList 的字典)。
play: 当前的 PlayData;快照里查不到的槽位回退到它同位置的配置。 play: 当前的 PlayData;快照里查不到的槽位回退到它同位置的配置。
@@ -811,7 +811,7 @@ def build_post_play_user_data(
**完全一致**(playCount=1258、point=4460、lastPlayDate=19:22:53.0)。 **完全一致**(playCount=1258、point=4460、lastPlayDate=19:22:53.0)。
返回: 返回:
游玩后的 userData 字典(可直接放进 [ ] 传给 UserNewItemList) 游玩后的 userData 字典(可直接放进 [ ] 传给 GetUserNewItemList)
""" """
p = play or build_play_from_config() p = play or build_play_from_config()
ua = build_user_all( ua = build_user_all(
@@ -1265,7 +1265,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
"userMapList": <GetUserMapApi 的响应>, "userMapList": <GetUserMapApi 的响应>,
... ...
} }
也接受 fetch_upsert_snapshot() 已经整理好的节点字典(幂等)。 也接受 GetNeedUserData() 已经整理好的节点字典(幂等)。
返回: 返回:
节点名 -> 节点值(已裁剪) 节点名 -> 节点值(已裁剪)
@@ -1318,7 +1318,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
# 真机值 = 服务器下发的周界,**不是本地算的**: # 真机值 = 服务器下发的周界,**不是本地算的**:
# GetGameWeeklyDataApi.updateDate = "2026-09-28 04:00:00" # GetGameWeeklyDataApi.updateDate = "2026-09-28 04:00:00"
# Upsert 侧 lastLoginWeek = "2026-09-28 04:00:00" ← 完全一致 # Upsert 侧 lastLoginWeek = "2026-09-28 04:00:00" ← 完全一致
# 依据 source/Assembly-CSharp/Manager/UserData.cs:1040-1049 # 依据 source/Assembly-CSharp/Manager/GetUserData.cs:1040-1049
# WeeklyData.LastLoginWeek = gameWeeklyData.updateDate; # WeeklyData.LastLoginWeek = gameWeeklyData.updateDate;
# 所以优先用快照里的值;只有拿不到时才退回本地推导。 # 所以优先用快照里的值;只有拿不到时才退回本地推导。
if "userWeeklyData" not in out and "userMissionDataList" in raw: if "userWeeklyData" not in out and "userMissionDataList" in raw:
@@ -1364,7 +1364,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
# ★ 曲目成绩有两种形态,都要支持: # ★ 曲目成绩有两种形态,都要支持:
# a) 接口原始响应:两层嵌套(UserMusic.cs 外层按曲分组, # a) 接口原始响应:两层嵌套(UserMusic.cs 外层按曲分组,
# 内层 userMusicDetailList 才是 (musicId, level) 条目) # 内层 userMusicDetailList 才是 (musicId, level) 条目)
# b) UserMusicDetailAll() 的产物:已摊平成单条 # b) GetUserMusicDetailAll() 的产物:已摊平成单条
# 判据:元素里带 "userMusicDetailList" 键 -> 嵌套;否则已摊平。 # 判据:元素里带 "userMusicDetailList" 键 -> 嵌套;否则已摊平。
groups = src.get("userMusicList") or [] groups = src.get("userMusicList") or []
nested = any( nested = any(
@@ -1382,7 +1382,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
else: else:
items = src.get(list_key) items = src.get(list_key)
# ★ 兜底:取数脚本返回的字典有时用**节点名**作键 # ★ 兜底:取数脚本返回的字典有时用**节点名**作键
# (如 UserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]}, # (如 GetUserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]},
# 而 GetUserFavoriteApi 的原始响应是 {"userFavorite": {...}})。 # 而 GetUserFavoriteApi 的原始响应是 {"userFavorite": {...}})。
if items is None: if items is None:
items = src.get(node) items = src.get(node)
@@ -2152,7 +2152,7 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
★ 为什么要绕这一下: ★ 为什么要绕这一下:
autowrap() 只替换 api/__init__.py 命名空间里的名字 autowrap() 只替换 api/__init__.py 命名空间里的名字
(见 log_setup.autowrap 的注释),模块内部的直接引用拿到的还是原始函数。 (见 log_setup.autowrap 的注释),模块内部的直接引用拿到的还是原始函数。
UpsertUserAll 内部要调 fetch_upsert_snapshot / UserNewItemList / UserData, UpsertUserAll 内部要调 GetNeedUserData / GetUserNewItemList / GetUserData,
绕这一下它们才会像用户直接调用那样打印「=== xxx 开始运行 ===」, 绕这一下它们才会像用户直接调用那样打印「=== xxx 开始运行 ===」,
主脚本就完全不用自己写日志了。 主脚本就完全不用自己写日志了。
""" """
@@ -2168,18 +2168,18 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
def _lazy(name: str) -> Any: def _lazy(name: str) -> Any:
"""按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。""" """按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。"""
if name == "fetch_upsert_snapshot": if name == "GetNeedUserData":
from .upsert_snapshot import fetch_upsert_snapshot from .get_need_user_data import GetNeedUserData
return fetch_upsert_snapshot return GetNeedUserData
if name == "UserNewItemList": if name == "GetUserNewItemList":
from .user_new_item import UserNewItemList from .get_user_new_item import GetUserNewItemList
return UserNewItemList return GetUserNewItemList
if name == "UserData": if name == "GetUserData":
from .userdata import UserData from .get_user_data import GetUserData
return UserData return GetUserData
if name == "UserMusicDetailAll": if name == "GetUserMusicDetailAll":
from .user_music import UserMusicDetailAll from .get_user_music import GetUserMusicDetailAll
return UserMusicDetailAll return GetUserMusicDetailAll
raise PatchError("未知的内部 api:" + str(name)) raise PatchError("未知的内部 api:" + str(name))
@@ -2227,7 +2227,7 @@ def verify_upsert_landing(
before_ud = (base.get("userData") or [{}])[0] if base.get("userData") else {} before_ud = (base.get("userData") or [{}])[0] if base.get("userData") else {}
try: try:
after_raw = _api("UserData")(user_id, client_id=client_id, after_raw = _api("GetUserData")(user_id, client_id=client_id,
timeout_ms=timeout_ms, client=client) timeout_ms=timeout_ms, client=client)
after_ud = (after_raw.get("userData") or {}) if isinstance(after_raw, dict) else {} after_ud = (after_raw.get("userData") or {}) if isinstance(after_raw, dict) else {}
except Exception as exc: except Exception as exc:
@@ -2245,7 +2245,7 @@ def verify_upsert_landing(
# 本局曲目的 playCount # 本局曲目的 playCount
try: try:
music_raw = _api("UserMusicDetailAll")(user_id, client_id=client_id, music_raw = _api("GetUserMusicDetailAll")(user_id, client_id=client_id,
timeout_ms=timeout_ms, client=client) timeout_ms=timeout_ms, client=client)
rows = music_raw.get("userMusicList") or [] if isinstance(music_raw, dict) else [] rows = music_raw.get("userMusicList") or [] if isinstance(music_raw, dict) else []
hit = [m for m in rows hit = [m for m in rows
@@ -2336,7 +2336,7 @@ def UpsertUserAll(
auth_date_time: UserLoginApi 请求里的 dateTime(可选; auth_date_time: UserLoginApi 请求里的 dateTime(可选;
userData.dateTime 要用它,真机实测两处一致) userData.dateTime 要用它,真机实测两处一致)
token: 会话令牌(可选)。★ 只在「没传 snapshot 且 auto_snapshot=True」 token: 会话令牌(可选)。★ 只在「没传 snapshot 且 auto_snapshot=True」
时需要 —— 内部用它自动调 fetch_upsert_snapshot。 时需要 —— 内部用它自动调 GetNeedUserData。
snapshot: 基线快照(可选,见 normalize_snapshot)。 snapshot: 基线快照(可选,见 normalize_snapshot)。
传了就用它;不传则按 auto_snapshot 自动抓。 传了就用它;不传则按 auto_snapshot 自动抓。
★ 服务器对 userData 是整表覆盖、对差分节点是「只回传变化项」, ★ 服务器对 userData 是整表覆盖、对差分节点是「只回传变化项」,
@@ -2390,7 +2390,7 @@ def UpsertUserAll(
"推荐传 token=...(自动抓)或 snapshot=...(自己抓好传进来)") "推荐传 token=...(自动抓)或 snapshot=...(自己抓好传进来)")
else: else:
log_info("未传 snapshot,自动抓取结算基线快照 ...") log_info("未传 snapshot,自动抓取结算基线快照 ...")
snapshot = _api("fetch_upsert_snapshot")( snapshot = _api("GetNeedUserData")(
user_id, token=token, client_id=client_id, user_id, token=token, client_id=client_id,
place_id=place_id, region_id=region_id, client=client, place_id=place_id, region_id=region_id, client=client,
) )
@@ -2426,7 +2426,7 @@ def UpsertUserAll(
region_id=region_id, region_name=region_name, client_id=client_id, region_id=region_id, region_name=region_name, client_id=client_id,
play_date_time=play_now, play_date_time=play_now,
) )
_api("UserNewItemList")( _api("GetUserNewItemList")(
user_id, version=p.version_int, user_id, version=p.version_int,
user_data=[post_user_data], user_data=[post_user_data],
user_playlog_list=list(user_playlog_list), user_playlog_list=list(user_playlog_list),
+5 -5
View File
@@ -10,7 +10,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId; public ulong userId;
public UserChargelog userChargelog; public UserChargelog userChargelog;
public UserCharge userCharge; public GetUserCharge userCharge;
public long loginDateTime; public long loginDateTime;
响应 :{returnCode, apiName} 响应 :{returnCode, apiName}
@@ -26,7 +26,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
│ └─ 没有 -> 新增一条 stock=1 │ └─ 没有 -> 新增一条 stock=1
└─ TicketManager.cs:234 RequestUserCharge(playerId) └─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId) ├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge │ -> 取**购买后**(stock > 0)的那条 GetUserCharge
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId) ├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ -> chargeId = ticketId │ -> chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price │ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
@@ -130,7 +130,7 @@ class ChargelogGuardError(RuntimeError):
@dataclass @dataclass
class UserChargeVO: class UserChargeVO:
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。""" """GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
chargeId: int = 0 chargeId: int = 0
stock: int = 0 stock: int = 0
purchaseDate: str = "" purchaseDate: str = ""
@@ -349,13 +349,13 @@ def UpsertUserChargelog(
# ---------- 1. 取当前状态 ---------- # ---------- 1. 取当前状态 ----------
if charge_list is None: if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...") log_info("未传 charge_list,自动抓取当前票券 ...")
raw = resolve_api_func("UserCharge")( raw = resolve_api_func("GetUserCharge")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client) user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or [] charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
if user_data is None: if user_data is None:
log_info("未传 user_data,自动抓取当前档案 ...") log_info("未传 user_data,自动抓取当前档案 ...")
raw_ud = resolve_api_func("UserData")( raw_ud = resolve_api_func("GetUserData")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client) user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {} user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
+2 -2
View File
@@ -7,7 +7,7 @@ user_getter_common.py —— 各取数脚本的公共底座
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs) 1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict** 2. _get() 发一次请求并返回**原始响应 dict**
3. fetch_all_pages() 自动翻页 3. get_all_pages() 自动翻页
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一 ★ 为什么单独抽出来:各接口的请求体并不统一
@@ -187,7 +187,7 @@ def _get(
return raw or {} return raw or {}
def fetch_all_pages( def get_all_pages(
api_name: str, api_name: str,
user_id: int, user_id: int,
list_key: str, list_key: str,
+7 -7
View File
@@ -6,7 +6,7 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登
一条完整的「买票」链路: 一条完整的「买票」链路:
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管): UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」) 1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
@@ -51,17 +51,17 @@ from api import (
log_error, log_error,
log_info, log_info,
ParseQRCode, ParseQRCode,
UserPreview, GetUserPreview,
UserLogin, UserLogin,
UserLogout, UserLogout,
LogoutType, LogoutType,
UpsertUserChargelog, UpsertUserChargelog,
ChargelogConfirmError, ChargelogConfirmError,
ChargelogGuardError, ChargelogGuardError,
UserCharge, GetUserCharge,
GetGameCharge, GetGameCharge,
ticket_name, ticket_name,
fetch_upsert_snapshot, GetNeedUserData,
summarize_snapshot, summarize_snapshot,
Ping, Ping,
) )
@@ -78,7 +78,7 @@ def show_charges(user_id, label):
**已经持票还去买,服务端看到的是重复购买,有封号风险。** **已经持票还去买,服务端看到的是重复购买,有封号风险。**
所以买之前必须确认没有持票;买之后要确认状态符合预期。 所以买之前必须确认没有持票;买之后要确认状态符合预期。
""" """
raw = UserCharge(user_id) raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or [] lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:") log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst: if not lst:
@@ -164,7 +164,7 @@ def main() -> int:
user_id, token = ParseQRCode(opts["qr"]) user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token) session.update(user_id=user_id, token=token)
preview = UserPreview(user_id, token) preview = GetUserPreview(user_id, token)
if preview.isLogin: if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时") log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2 return 2
@@ -177,7 +177,7 @@ def main() -> int:
# -> 13:20:10 才发 UpsertUserChargelogApi # -> 13:20:10 才发 UpsertUserChargelogApi
# 本脚本原来 login 后直接买,跳过了这一段。 # 本脚本原来 login 后直接买,跳过了这一段。
if not opts["skip_entry"]: if not opts["skip_entry"]:
snap = fetch_upsert_snapshot(user_id, token=token, snap = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID) place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snap) _sum = summarize_snapshot(snap)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 " log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
================================================================================
┌─ 源账号 A ─────────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
│ 或:直接读 data/<userId>.json(离线复用) │
└────────────────────────────────────────────────────────┘
↓ 节点数据
┌─ 目标账号 B ───────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
│ 循环:UpsertUserAll(每包最多 4 首曲目) │
│ 然后 UserLogin(isContinue=true) 续关,拿新 loginId│
│ 收尾:UserLogout │
└────────────────────────────────────────────────────────┘
--------------------------------------------------------------------------------
★ 为什么一次最多传 4 首
--------------------------------------------------------------------------------
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
userPlaylogList / userGamePlaylogList **最多 4 条**。
本脚本把源账号的 userMusicDetailList 按 4 条一组切分,一组 = 一局。
★ 每局都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
--------------------------------------------------------------------------------
★ 为什么需要「续关登录」
--------------------------------------------------------------------------------
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
真机走的就是「续关」:
Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
对应本工程:UserLogin(user_id, token, is_continue=True)
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
--------------------------------------------------------------------------------
★ playlog 从哪来
--------------------------------------------------------------------------------
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
───────────────────────────── ─────────────────────────
musicId musicId
level level
achievement achievement
deluxscoreMax deluxscore
scoreRank scoreRank / isClear
comboStatus comboStatus
syncStatus syncStatus
其余字段 取自 config.PLAY_*(合理默认值)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
# ★ 真实上传
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
# 只看计划,不发包
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
# 常用开关
--limit N 只迁前 N 首(试跑用)
--per-packet N 每包曲目数(默认 4,★ 不要超过 4)
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
--no-continue 不续关(只发一包,调试用)
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
"""
from __future__ import annotations
import json
import sys
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetNeedUserData,
normalize_snapshot,
NODE_ORDER,
NODE_TYPES,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
PlayData,
build_user_playlog,
build_user_game_playlog,
UpsertUserAll,
ConfirmRequiredError,
Ping,
load_user_data,
list_saved_user_ids,
DATA_DIR,
)
from config import PLACE_ID, REGION_ID
#: 真机一局最多 4 首
MAX_TRACKS_PER_CREDIT = 4
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
IDENTITY_KEYS = ("userName", "accessCode")
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
SKIP_NODES = (
"userMusicDetailList", # 单独分批
"userGamePlaylogList", # 由本脚本按曲目合成
"user2pPlaylog", # 2P 记录,迁移无意义
"userGetPointList", # 里程/奖励,迁移会凭空发点
"userTradeItemList", # 无 Get 来源
"userFriendSeasonRankingList", # 无 Get 来源
)
def parse_args(argv):
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
"plan": False, "debug": False, "limit": 0,
"per_packet": MAX_TRACKS_PER_CREDIT,
"clone_identity": False, "no_continue": False}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--plan":
opts["plan"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--clone-identity":
opts["clone_identity"] = True
elif a == "--no-continue":
opts["no_continue"] = True
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit", "--per-packet") and i + 1 < len(argv):
key = a[2:].replace("-", "_")
opts[key] = int(argv[i + 1]) if key in ("limit", "per_packet") else argv[i + 1]
i += 1
i += 1
return opts
# =============================================================================
# 工具
# =============================================================================
def _bits_for(node, items, base_items):
"""
按目标账号 B 的基线算 isNew* 位串:
B 里没有该条目 -> "1"(新增)
B 里已有 -> "0"(更新)
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
"""
keys = DIFF_PRIMARY_KEYS.get(node)
if not keys:
return None
have = set()
for b in (base_items or []):
if isinstance(b, dict):
have.add(tuple(str(b.get(k)) for k in keys))
return "".join(
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
for it in (items or []) if isinstance(it, dict)
)
def play_from_detail(detail, base_play=None):
"""
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
"""
p = PlayData() if base_play is None else base_play
p.music_id = int(detail.get("musicId", p.music_id) or 0)
p.music_level = int(detail.get("level", p.music_level) or 0)
p.achievement = int(detail.get("achievement", p.achievement) or 0)
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
rank = int(detail.get("scoreRank", 0) or 0)
p.score_rank = rank
p.is_clear = rank > 0
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
return p
def collect_nodes(src_nodes, base_nodes, clone_identity):
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
out = {}
for node in NODE_ORDER:
if node in SKIP_NODES:
continue
if node.startswith("isNew"):
continue # 位串单独算
kind = NODE_TYPES.get(node)
if kind not in ("singleton", "array", "object"):
continue
val = src_nodes.get(node)
if val in (None, [], {}):
continue
out[node] = json.loads(json.dumps(val)) # 深拷贝
# 身份字段:默认保留 B 自己的
if not clone_identity and out.get("userData"):
for item in out["userData"]:
if isinstance(item, dict):
for k in IDENTITY_KEYS:
item.pop(k, None)
# 位串:按 B 的基线算
for arr, bits in ARRAY_TO_BITS.items():
if arr not in out:
continue
b = _bits_for(arr, out[arr], base_nodes.get(arr))
if b:
out[bits] = b
return out
# =============================================================================
# 主流程
# =============================================================================
def fetch_source_by_qr(qr):
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
log_info("=== 源账号:联网抓取 ===")
uid, token = ParseQRCode(qr)
preview = GetUserPreview(uid, token)
if preview.isLogin:
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
try:
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
finally:
try:
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_warning("源账号登出失败:" + str(exc))
return raw
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
if opts["per_packet"] > MAX_TRACKS_PER_CREDIT:
print("--per-packet 不能超过 " + str(MAX_TRACKS_PER_CREDIT) + "(真机一局最多 4 首)")
return 1
setup_logging("reincarnate")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---------------- 1. 源数据 ----------------
if opts["src_json"]:
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
else:
src_raw = fetch_source_by_qr(opts["src_qr"])
src_nodes = normalize_snapshot(src_raw)
musics = src_nodes.get("userMusicDetailList") or []
if opts["limit"] > 0:
musics = musics[:opts["limit"]]
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
if not musics:
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
return 4
# ---------------- 2. 目标账号 ----------------
Ping()
uid, token = ParseQRCode(opts["dst_qr"])
session.update(user_id=uid, token=token)
preview = GetUserPreview(uid, token)
if preview.isLogin:
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
base_nodes = normalize_snapshot(base_raw)
log_info("目标账号基线已抓取")
# ---------------- 3. 分批 ----------------
per = int(opts["per_packet"])
batches = [musics[i:i + per] for i in range(0, len(musics), per)]
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
+ " 包(每包 " + str(per) + " 首)")
if opts["plan"]:
for i, b in enumerate(batches):
log_info(" 第 " + str(i + 1) + " 包:" + ", ".join(
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
log_info("--plan:只列计划,不发包")
return 0
# 第一包才带非曲目节点(整账号克隆)
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
+ " 个将在第 1 包上传")
# ---------------- 4. 逐包上传 ----------------
sent = 0
for idx, batch in enumerate(batches):
log_info("")
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
+ " 包(" + str(len(batch)) + " 首)==========")
nodes = {}
if idx == 0:
nodes.update(json.loads(json.dumps(shared_nodes)))
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
b = _bits_for("userMusicDetailList", batch,
base_nodes.get("userMusicDetailList"))
if b:
nodes["isNewMusicDetailList"] = b
log_info(" isNewMusicDetailList = " + b)
# 合成 playlog(每首一条)与 game playlog
playlogs = []
game_playlogs = []
for d in batch:
p = play_from_detail(d)
playlogs.append(build_user_playlog(
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
game_playlogs.append(build_user_game_playlog(
login.loginId, login.loginDateTime, p))
nodes["userGamePlaylogList"] = game_playlogs
UpsertUserAll(
uid, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=base_raw,
auto_characters=False,
play=play_from_detail(batch[0]),
extra_data=nodes,
user_playlog_list=playlogs,
pre_new_item_list=False,
verify_landing=False,
dry_run=not opts["send"],
confirm=opts["send"],
place_id=PLACE_ID, region_id=REGION_ID,
)
sent += 1
# ---- ★ 续关登录:拿下一局的 loginId ----
if idx < len(batches) - 1:
if opts["no_continue"]:
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
break
log_info("--- 续关登录(isContinue=true)---")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
is_continue=True)
session.update(login_dt=login.loginDateTime)
if not opts["send"]:
log_info("")
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
return 0
log_info("")
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
+ str(min(len(musics), sent * per)) + " 首")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -333,7 +333,7 @@ fav_server_raw.append({"itemKind": None, "itemIdList": []})
_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw) _, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw) check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw)
# 归一化兜底:基线被 user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全, # 归一化兜底:基线被 get_user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。 # 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,)) _, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
@@ -1137,7 +1137,7 @@ check("UpsertUserAll 已被包装", getattr(api.UpsertUserAll, "__mai_logged__",
check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True) check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True)
check("会话链路 6 个函数都在", check("会话链路 6 个函数都在",
all(hasattr(api, n) for n in all(hasattr(api, n) for n in
("ParseQRCode", "GetGameSettings", "Ping", "UserPreview", ("ParseQRCode", "GetGameSettings", "Ping", "GetUserPreview",
"UserLogin", "UserLogout"))) "UserLogin", "UserLogout")))
+2 -2
View File
@@ -106,7 +106,7 @@ from api import (
log_warning, log_warning,
log_error, log_error,
ParseQRCode, ParseQRCode,
UserPreview, GetUserPreview,
UserLogin, UserLogin,
UserLogout, UserLogout,
LogoutType, LogoutType,
@@ -194,7 +194,7 @@ def main() -> int:
session.update(user_id=user_id, token=token) session.update(user_id=user_id, token=token)
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ---- # ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
preview = UserPreview(user_id, token) preview = GetUserPreview(user_id, token)
if preview.isLogin: if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时") log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2 return 2
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
================================================================================
一条完整的「用票」链路:
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
--------------------------------------------------------------------------------
★ 为什么可以用 UpsertUserAll 消耗票券
--------------------------------------------------------------------------------
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
服务端就会认为这张票被这一局用掉了。
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
userChargeList: [
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
{"chargeId":3,"stock":0,...},
{"chargeId":5,"stock":0,...}
]
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
creditNum 表见 config.TICKET_CREDIT_NUM。
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
所以这里也不需要给位串。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python use_ticket.py <二维码> # 预检:只看报文,不发包
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
python use_ticket.py <二维码> --send --music-id 143 --level 4
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetUserCharge,
GetNeedUserData,
summarize_snapshot,
UpsertUserAll,
PlayData,
ConfirmRequiredError,
ticket_name,
Ping,
)
from config import (
PLACE_ID,
REGION_ID,
PLAY_MUSIC_ID,
PLAY_MUSIC_LEVEL,
PLAY_CREDIT,
TICKET_CREDIT_NUM,
)
def read_charges(user_id, label):
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return lst, held
def parse_args(argv):
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
"debug": False, "skip_entry": False,
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--ticket-id" and i + 1 < len(argv):
opts["ticket_id"] = int(argv[i + 1])
i += 1
elif a == "--music-id" and i + 1 < len(argv):
opts["music_id"] = int(argv[i + 1])
i += 1
elif a == "--level" and i + 1 < len(argv):
opts["level"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("use_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 心跳:决定后续所有请求的 number 头 ----
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 读当前持有的功能券 ----
all_charges, held = read_charges(user_id, "消耗前")
if opts["list"]:
log_info("--list:只列票券,不做任何写操作")
return 0
if not held:
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
return 4
# ---- 选定目标票 ----
target = None
if opts["ticket_id"] is not None:
for c in held:
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
target = c
break
if target is None:
log_error("指定的 chargeId=" + str(opts["ticket_id"])
+ " 当前并不持有;持有的有:"
+ ", ".join(str(c.get("chargeId")) for c in held))
return 4
else:
target = held[0]
ticket_id = int(target.get("chargeId"))
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
play_credit = int(PLAY_CREDIT) + credit_num
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
+ " stock=" + str(target.get("stock"))
+ " creditNum=" + str(credit_num)
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
+ "=" + str(play_credit))
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
new_stock = int(target.get("stock", 0) or 0) - 1
if new_stock < 0:
new_stock = 0
new_list = []
for c in all_charges:
if not isinstance(c, dict):
continue
item = dict(c)
if int(item.get("chargeId", -1)) == ticket_id:
item["stock"] = new_stock
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
new_list.append(item)
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
snapshot = None
if not opts["skip_entry"]:
snapshot = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snapshot)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snapshot.get("_failed") or [])) + " 个")
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
# / userData.lastPlayCredit
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
play = PlayData(
music_id=opts["music_id"],
music_level=opts["level"],
use_ticket_id=ticket_id,
play_credit=play_credit,
)
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=snapshot,
play=play,
extra_data={"userChargeList": new_list},
dry_run=not opts["send"],
confirm=opts["send"],
verify_landing=bool(opts["send"]),
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★ 复查:票券 stock 应该减 1 ----
_, held_after = read_charges(user_id, "消耗后")
if not opts["send"]:
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
return 0
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
if still:
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
return 6
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())