From 8e3bd6fcaa2a1868984fcada4827a4dba72b5398 Mon Sep 17 00:00:00 2001 From: Txiaochen Date: Tue, 6 Oct 2026 23:32:09 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9AGet*=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=BB=9F=E4=B8=80=20+=20=E7=94=A8=E6=88=B7=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=90=BD=E7=9B=98=20+=20use=5Fticket=20/=20reincarnat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 项全绿。 --- .gitignore | 1 + README.md | 92 +++- api/__init__.py | 289 ++++++------ api/chime.py | 2 +- api/{game_charge.py => get_game_charge.py} | 2 +- api/{game_event.py => get_game_event.py} | 2 +- ...ng_music_id.py => get_game_ng_music_id.py} | 2 +- api/{game_ranking.py => get_game_ranking.py} | 2 +- api/{gamesettings.py => get_game_settings.py} | 2 +- ...nt_info.py => get_game_tournament_info.py} | 2 +- ...weekly_data.py => get_game_weekly_data.py} | 2 +- ...sert_snapshot.py => get_need_user_data.py} | 188 ++++++-- ...{user_activity.py => get_user_activity.py} | 6 +- ...ser_character.py => get_user_character.py} | 6 +- api/{user_charge.py => get_user_charge.py} | 6 +- api/{user_course.py => get_user_course.py} | 12 +- api/{userdata.py => get_user_data.py} | 6 +- api/{user_extend.py => get_user_extend.py} | 6 +- ...{user_favorite.py => get_user_favorite.py} | 8 +- ...rite_item.py => get_user_favorite_item.py} | 12 +- ...iend_bonus.py => get_user_friend_bonus.py} | 8 +- api/{user_ghost.py => get_user_ghost.py} | 8 +- ...{user_intimate.py => get_user_intimate.py} | 6 +- api/{user_item.py => get_user_item.py} | 8 +- ...idx_scope.py => get_user_kaleidx_scope.py} | 10 +- ...login_bonus.py => get_user_login_bonus.py} | 12 +- api/{user_map.py => get_user_map.py} | 12 +- api/{user_mission.py => get_user_mission.py} | 6 +- api/{user_music.py => get_user_music.py} | 14 +- ...{user_new_item.py => get_user_new_item.py} | 6 +- api/{user_option.py => get_user_option.py} | 6 +- api/{userpreview.py => get_user_preview.py} | 12 +- api/{user_rating.py => get_user_rating.py} | 6 +- ...d_music.py => get_user_recommend_music.py} | 8 +- api/{user_region.py => get_user_region.py} | 6 +- api/{user_rival.py => get_user_rival.py} | 14 +- ...r_shop_stock.py => get_user_shop_stock.py} | 6 +- api/log_setup.py | 2 +- api/upsert_user_all.py | 60 +-- api/upsert_user_chargelog.py | 10 +- api/user_getter_common.py | 4 +- buy_ticket.py | 14 +- reincarnate.py | 410 ++++++++++++++++++ selftest.py | 4 +- upsert.py | 4 +- use_ticket.py | 286 ++++++++++++ 46 files changed, 1234 insertions(+), 356 deletions(-) rename api/{game_charge.py => get_game_charge.py} (95%) rename api/{game_event.py => get_game_event.py} (96%) rename api/{game_ng_music_id.py => get_game_ng_music_id.py} (93%) rename api/{game_ranking.py => get_game_ranking.py} (95%) rename api/{gamesettings.py => get_game_settings.py} (98%) rename api/{game_tournament_info.py => get_game_tournament_info.py} (93%) rename api/{game_weekly_data.py => get_game_weekly_data.py} (93%) rename api/{upsert_snapshot.py => get_need_user_data.py} (60%) rename api/{user_activity.py => get_user_activity.py} (91%) rename api/{user_character.py => get_user_character.py} (90%) rename api/{user_charge.py => get_user_charge.py} (91%) rename api/{user_course.py => get_user_course.py} (89%) rename api/{userdata.py => get_user_data.py} (94%) rename api/{user_extend.py => get_user_extend.py} (91%) rename api/{user_favorite.py => get_user_favorite.py} (95%) rename api/{user_favorite_item.py => get_user_favorite_item.py} (93%) rename api/{user_friend_bonus.py => get_user_friend_bonus.py} (89%) rename api/{user_ghost.py => get_user_ghost.py} (86%) rename api/{user_intimate.py => get_user_intimate.py} (89%) rename api/{user_item.py => get_user_item.py} (96%) rename api/{user_kaleidx_scope.py => get_user_kaleidx_scope.py} (89%) rename api/{user_login_bonus.py => get_user_login_bonus.py} (88%) rename api/{user_map.py => get_user_map.py} (89%) rename api/{user_mission.py => get_user_mission.py} (91%) rename api/{user_music.py => get_user_music.py} (88%) rename api/{user_new_item.py => get_user_new_item.py} (95%) rename api/{user_option.py => get_user_option.py} (92%) rename api/{userpreview.py => get_user_preview.py} (93%) rename api/{user_rating.py => get_user_rating.py} (91%) rename api/{user_recommend_music.py => get_user_recommend_music.py} (88%) rename api/{user_region.py => get_user_region.py} (90%) rename api/{user_rival.py => get_user_rival.py} (93%) rename api/{user_shop_stock.py => get_user_shop_stock.py} (93%) create mode 100644 reincarnate.py create mode 100644 use_ticket.py diff --git a/.gitignore b/.gitignore index 3b69d04..3a74ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # ---- 运行产物 ---- +data/ # 每账号一份用户数据快照(.json) logs/ __pycache__/ *.pyc diff --git a/README.md b/README.md index c81a657..dc79466 100644 --- a/README.md +++ b/README.md @@ -25,20 +25,26 @@ WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游 **v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。** -| 模块 | 状态 | +| 脚本 | 状态 | |---|---| | **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 | -| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 | +| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票(2026-10-06 真机验证通过) | +| **`use_ticket.py`** | ✅ **可用** —— 读取当前持有的功能票并用 `UpsertUserAll` 消耗掉 | +| **`reincarnate.py`** | 🔧 已实现(未实跑)—— 账号数据迁移(转生),每包 ≤4 首 + 续关登录 | | `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** | | **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 | -### ⚠️ 关于购买功能票 +### 关于购买功能票(已解决) -`buy_ticket.py`(`UpsertUserChargelogApi`)在**报文与真机逐字节一致**的情况下 -仍被服务端拒绝(`returnCode=0`),之后该账号**疑似被拉黑,暂时无法再购买功能票**。 +`UpsertUserChargelogApi` 曾经一直返回 `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/ -├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别) +├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)★ 需自建 +├── config.py.example 配置模板(机台与店铺标识留空待填) ├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出 -├── buy_ticket.py 主脚本:购买功能票(★ 当前不可用) -├── selftest.py 零网络自检(236 项,判据 = 源码 + 真机日志) +├── buy_ticket.py 主脚本:购买功能票 +├── use_ticket.py 主脚本:消耗当前持有的功能票 +├── reincarnate.py 主脚本:账号数据迁移(转生) +├── selftest.py 零网络自检(238 项,判据 = 源码 + 真机日志) │ ├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本) │ ├── __init__.py 统一导出 + 日志自动跟踪 │ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie) │ ├── log_setup.py 日志(INFO / DEBUG 双模式) │ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分) -│ ├── upsert_snapshot.py 结算基线快照 +│ ├── get_need_user_data.py 结算快照编排 + 落盘 data/.json │ └── ... 40+ 个接口脚本 │ +├── data/ 每账号一份用户数据快照(.json)★ 不入库 ├── logs/ 日志输出(latest.log + 日期.log)★ 不入库 └── source/ 反编译源码 ★ 不入库,需自备 ``` @@ -86,6 +96,9 @@ WaHappy/ **设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。 主脚本只负责编排,日志和协议细节都在 `api/` 内部。 +**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`** +(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。 + > **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。** > > `source/` 是反编译源码,**需要自备**。 @@ -158,6 +171,63 @@ python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库 > 二维码从机台的「扫码登录」界面获取,**有效期 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/.json) +python reincarnate.py --dst-qr "" --src-qr "" + +# 源数据来自已保存的 JSON(离线,推荐) +python reincarnate.py --dst-qr "" --src-json data/12345678.json --plan +python reincarnate.py --dst-qr "" --src-json data/12345678.json --send + +# 试跑 +--limit 20 # 只迁前 20 首 +--clone-identity # 连 userName / accessCode 一起克隆(默认保留 B 自己的) +``` + ### 额外塞节点:`--extra-data` 真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来, diff --git a/api/__init__.py b/api/__init__.py index d7e65df..ec00c29 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -25,48 +25,56 @@ api 包 —— 统一导出 user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页) 会话链路 - chime.py 扫码登录 -> userId + token - gamesettings.py 取游戏设置 - ping.py 心跳探活 - userpreview.py 用户预览 - userlogin.py 正式登录 -> loginId + loginDateTime - userlogout.py 登出 + chime.py 扫码登录 -> userId + token + get_game_settings.py GetGameSettingApi(游戏设置) + ping.py 心跳探活 + get_user_preview.py GetUserPreviewApi(用户预览) + userlogin.py UserLoginApi(正式登录 -> loginId + loginDateTime) + userlogout.py UserLogoutApi(登出) - 上传成绩 - upsert_user_all.py ★ 上传成绩(唯一有副作用的接口) - upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本) + 上传 / 结算(有副作用的接口) + upsert_user_all.py ★ UpsertUserAllApi(上传成绩) + upsert_user_chargelog.py UpsertUserChargelogApi(购买功能票) + get_need_user_data.py ★ 结算快照编排(统一调用下面的取数脚本), + 并把结果落盘到 <工程根>/data/.json - 取数(一个接口一个脚本) - userdata.py GetUserDataApi - user_extend.py GetUserExtendApi - user_option.py GetUserOptionApi - user_character.py GetUserCharacterApi - user_ghost.py GetUserGhostApi - user_map.py GetUserMapApi - user_login_bonus.py GetUserLoginBonusApi - user_rating.py GetUserRatingApi - user_item.py GetUserItemApi - user_music.py GetUserMusicApi - user_course.py GetUserCourseApi - user_charge.py GetUserChargeApi - user_favorite.py GetUserFavoriteApi - user_activity.py GetUserActivityApi - user_mission.py GetUserMissionDataApi - user_intimate.py GetUserIntimateApi - user_shop_stock.py GetUserShopStockApi - user_favorite_item.py GetUserFavoriteItemApi - user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi - user_friend_bonus.py GetUserFriendBonusApi - user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi - user_region.py GetUserRegionApi - user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi - user_new_item.py GetUserNewItemListApi - game_ranking.py GetGameRankingApi - game_event.py GetGameEventApi - game_charge.py GetGameChargeApi - game_weekly_data.py GetGameWeeklyDataApi - game_tournament_info.py GetGameTournamentInfoApi - game_ng_music_id.py GetGameNgMusicIdApi + ★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get** + (不要模棱两可)。所以下面这些文件全是 get_ 开头。 + + 取数 —— GetUser*(一个接口一个脚本) + get_user_data.py GetUserDataApi + get_user_extend.py GetUserExtendApi + get_user_option.py GetUserOptionApi + get_user_character.py GetUserCharacterApi + get_user_ghost.py GetUserGhostApi + get_user_map.py GetUserMapApi + get_user_login_bonus.py GetUserLoginBonusApi + get_user_rating.py GetUserRatingApi + get_user_item.py GetUserItemApi + get_user_music.py GetUserMusicApi + get_user_course.py GetUserCourseApi + get_user_charge.py GetUserChargeApi + get_user_favorite.py GetUserFavoriteApi + get_user_activity.py GetUserActivityApi + get_user_mission.py GetUserMissionDataApi + get_user_intimate.py GetUserIntimateApi + get_user_shop_stock.py GetUserShopStockApi + get_user_favorite_item.py GetUserFavoriteItemApi + get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi + get_user_friend_bonus.py GetUserFriendBonusApi + get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi + get_user_region.py GetUserRegionApi + get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi + get_user_new_item.py GetUserNewItemListApi + + 取数 —— GetGame* + 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 ( @@ -135,7 +143,7 @@ from .maimai_net import ( from .chime import ParseQRCode -from .gamesettings import ( +from .get_game_settings import ( GetGameSettings, GameSetting, GameSettingRequestVO, @@ -146,8 +154,8 @@ from .gamesettings import ( from .ping import Ping -from .userpreview import ( - UserPreview, +from .get_user_preview import ( + GetUserPreview, UserPreviewRequestVO, UserPreviewResponseVO, ) @@ -230,12 +238,19 @@ from .upsert_user_all import ( USER_MISSION_DROP_KEYS, ) -from .upsert_snapshot import ( - fetch_upsert_snapshot, +from .get_need_user_data import ( + GetNeedUserData, save_snapshot, load_snapshot, summarize_snapshot, DEFAULT_SNAPSHOT_NODES, + # ★ 用户数据落盘(<工程根>/data/.json) + PROJECT_ROOT, + DATA_DIR, + user_data_path, + save_user_data, + load_user_data, + list_saved_user_ids, ) from .upsert_user_chargelog import ( @@ -269,7 +284,7 @@ from .user_getter_common import ( UserShopStockRequestVO, UserRivalRequestVO, UserRivalMusicRequestVO, - fetch_all_pages, + get_all_pages, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR, FAVORITE_ITEM_KINDS, @@ -280,42 +295,42 @@ from .user_getter_common import ( # 5) 取数脚本(一个接口一个脚本) # --------------------------------------------------------------------------- -from .userdata import UserData -from .user_extend import UserExtend -from .user_option import UserOption -from .user_character import UserCharacter -from .user_ghost import UserGhost -from .user_map import UserMap, UserMapAll -from .user_login_bonus import UserLoginBonus, UserLoginBonusAll -from .user_rating import UserRating -from .user_item import UserItem, UserItemAll -from .user_music import UserMusicDetail, UserMusicDetailAll -from .user_course import UserCourse, UserCourseAll -from .user_charge import UserCharge -from .user_favorite import UserFavorite, UserFavoriteAll -from .user_activity import UserActivity -from .user_mission import UserMissionData -from .user_intimate import UserIntimate -from .user_shop_stock import UserShopStock -from .user_favorite_item import ( - UserFavoriteItem, - UserFavoriteItemAll, +from .get_user_data import GetUserData +from .get_user_extend import GetUserExtend +from .get_user_option import GetUserOption +from .get_user_character import GetUserCharacter +from .get_user_ghost import GetUserGhost +from .get_user_map import GetUserMap, GetUserMapAll +from .get_user_login_bonus import GetUserLoginBonus, GetUserLoginBonusAll +from .get_user_rating import GetUserRating +from .get_user_item import GetUserItem, GetUserItemAll +from .get_user_music import GetUserMusicDetail, GetUserMusicDetailAll +from .get_user_course import GetUserCourse, GetUserCourseAll +from .get_user_charge import GetUserCharge +from .get_user_favorite import GetUserFavorite, GetUserFavoriteAll +from .get_user_activity import GetUserActivity +from .get_user_mission import GetUserMissionData +from .get_user_intimate import GetUserIntimate +from .get_user_shop_stock import GetUserShopStock +from .get_user_favorite_item import ( + GetUserFavoriteItem, + GetUserFavoriteItemAll, FAVORITE_ITEM_KIND_MUSIC, FAVORITE_ITEM_KIND_RIVAL, ) -from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll -from .user_friend_bonus import UserFriendBonus, extract_gift_points -from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope -from .user_region import UserRegion -from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic -from .user_new_item import UserNewItemList, UserNewItemListRequestVO +from .get_user_rival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll +from .get_user_friend_bonus import GetUserFriendBonus, extract_gift_points +from .get_user_kaleidx_scope import GetUserKaleidxScope, GetGameKaleidxScope +from .get_user_region import GetUserRegion +from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic +from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO -from .game_ranking import GetGameRanking, GameRankingRequestVO -from .game_event import GetGameEvent, GameEventRequestVO -from .game_charge import GetGameCharge, GameChargeRequestVO -from .game_weekly_data import GetGameWeeklyData -from .game_tournament_info import GetGameTournamentInfo -from .game_ng_music_id import GetGameNgMusicId +from .get_game_ranking import GetGameRanking, GameRankingRequestVO +from .get_game_event import GetGameEvent, GameEventRequestVO +from .get_game_charge import GetGameCharge, GameChargeRequestVO +from .get_game_weekly_data import GetGameWeeklyData +from .get_game_tournament_info import GetGameTournamentInfo +from .get_game_ng_music_id import GetGameNgMusicId # --------------------------------------------------------------------------- # 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里) @@ -326,7 +341,7 @@ _API_FUNCS = ( "ParseQRCode", "GetGameSettings", "Ping", - "UserPreview", + "GetUserPreview", "UserLogin", "UserLogout", # --- 上传成绩 --- @@ -350,45 +365,45 @@ _API_FUNCS = ( "build_delta", "build_favorite_delta", "normalize_snapshot", - "fetch_upsert_snapshot", + "GetNeedUserData", "summarize_snapshot", - "UserNewItemList", + "GetUserNewItemList", # --- 取数:单接口 --- - "UserData", # GetUserDataApi - "UserExtend", # GetUserExtendApi - "UserOption", # GetUserOptionApi - "UserCharacter", # GetUserCharacterApi - "UserGhost", # GetUserGhostApi - "UserMap", # GetUserMapApi(单页) - "UserMapAll", # GetUserMapApi(全量) - "UserLoginBonus", # GetUserLoginBonusApi(单页) - "UserLoginBonusAll", # GetUserLoginBonusApi(全量) - "UserRating", # GetUserRatingApi - "UserItem", # GetUserItemApi(单类) - "UserItemAll", # GetUserItemApi(全量) - "UserMusicDetail", # GetUserMusicApi(单页) - "UserMusicDetailAll", # GetUserMusicApi(全量) - "UserCourse", # GetUserCourseApi(单页) - "UserCourseAll", # GetUserCourseApi(全量) - "UserCharge", # GetUserChargeApi - "UserFavorite", # GetUserFavoriteApi(单类) - "UserFavoriteAll", # GetUserFavoriteApi(全量) - "UserActivity", # GetUserActivityApi - "UserMissionData", # GetUserMissionDataApi - "UserIntimate", # GetUserIntimateApi - "UserShopStock", # GetUserShopStockApi - "UserFavoriteItem", # GetUserFavoriteItemApi(单页) - "UserFavoriteItemAll", # GetUserFavoriteItemApi(全量) - "UserRivalData", # GetUserRivalDataApi - "UserRivalMusic", # GetUserRivalMusicApi(单页) - "UserRivalMusicAll", # GetUserRivalMusicApi(全量) - "UserFriendBonus", # GetUserFriendBonusApi - "UserKaleidxScope", # GetUserKaleidxScopeApi - "GameKaleidxScope", # GetGameKaleidxScopeApi - "UserRegion", # GetUserRegionApi - "UserRecommendRateMusic", # GetUserRecommendRateMusicApi - "UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi - "UserNewItemList", # GetUserNewItemListApi + "GetUserData", # GetUserDataApi + "GetUserExtend", # GetUserExtendApi + "GetUserOption", # GetUserOptionApi + "GetUserCharacter", # GetUserCharacterApi + "GetUserGhost", # GetUserGhostApi + "GetUserMap", # GetUserMapApi(单页) + "GetUserMapAll", # GetUserMapApi(全量) + "GetUserLoginBonus", # GetUserLoginBonusApi(单页) + "GetUserLoginBonusAll", # GetUserLoginBonusApi(全量) + "GetUserRating", # GetUserRatingApi + "GetUserItem", # GetUserItemApi(单类) + "GetUserItemAll", # GetUserItemApi(全量) + "GetUserMusicDetail", # GetUserMusicApi(单页) + "GetUserMusicDetailAll", # GetUserMusicApi(全量) + "GetUserCourse", # GetUserCourseApi(单页) + "GetUserCourseAll", # GetUserCourseApi(全量) + "GetUserCharge", # GetUserChargeApi + "GetUserFavorite", # GetUserFavoriteApi(单类) + "GetUserFavoriteAll", # GetUserFavoriteApi(全量) + "GetUserActivity", # GetUserActivityApi + "GetUserMissionData", # GetUserMissionDataApi + "GetUserIntimate", # GetUserIntimateApi + "GetUserShopStock", # GetUserShopStockApi + "GetUserFavoriteItem", # GetUserFavoriteItemApi(单页) + "GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量) + "GetUserRivalData", # GetUserRivalDataApi + "GetUserRivalMusic", # GetUserRivalMusicApi(单页) + "GetUserRivalMusicAll", # GetUserRivalMusicApi(全量) + "GetUserFriendBonus", # GetUserFriendBonusApi + "GetUserKaleidxScope", # GetUserKaleidxScopeApi + "GetGameKaleidxScope", # GetGameKaleidxScopeApi + "GetUserRegion", # GetUserRegionApi + "GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi + "GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi + "GetUserNewItemList", # GetUserNewItemListApi # --- 取数:游戏侧 --- "GetGameRanking", # GetGameRankingApi "GetGameEvent", # GetGameEventApi @@ -435,7 +450,7 @@ __all__ = [ "GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO", "is_maintenance", "is_ping_disabled", "Ping", - "UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO", + "GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO", "UserLogin", "UserLoginRequestVO", "UserLoginResponseVO", "UserLogout", "LogoutType", "DelayLog", "RequestAPI", "UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO", @@ -462,34 +477,36 @@ __all__ = [ "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", # ---- 快照编排 ---- - "fetch_upsert_snapshot", "save_snapshot", "load_snapshot", + "GetNeedUserData", "save_snapshot", "load_snapshot", "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", "UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO", "UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO", "UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO", - "fetch_all_pages", + "get_all_pages", "ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS", # ---- 取数脚本 ---- - "UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost", - "UserMap", "UserMapAll", - "UserLoginBonus", "UserLoginBonusAll", - "UserRating", - "UserItem", "UserItemAll", - "UserMusicDetail", "UserMusicDetailAll", - "UserCourse", "UserCourseAll", - "UserCharge", - "UserFavorite", "UserFavoriteAll", - "UserActivity", "UserMissionData", "UserIntimate", "UserShopStock", - "UserFavoriteItem", "UserFavoriteItemAll", + "GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost", + "GetUserMap", "GetUserMapAll", + "GetUserLoginBonus", "GetUserLoginBonusAll", + "GetUserRating", + "GetUserItem", "GetUserItemAll", + "GetUserMusicDetail", "GetUserMusicDetailAll", + "GetUserCourse", "GetUserCourseAll", + "GetUserCharge", + "GetUserFavorite", "GetUserFavoriteAll", + "GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock", + "GetUserFavoriteItem", "GetUserFavoriteItemAll", "FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL", - "UserRivalData", "UserRivalMusic", "UserRivalMusicAll", - "UserFriendBonus", "extract_gift_points", - "UserKaleidxScope", "GameKaleidxScope", - "UserRegion", - "UserRecommendRateMusic", "UserRecommendSelectMusic", - "UserNewItemList", "UserNewItemListRequestVO", + "GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll", + "GetUserFriendBonus", "extract_gift_points", + "GetUserKaleidxScope", "GetGameKaleidxScope", + "GetUserRegion", + "GetUserRecommendRateMusic", "GetUserRecommendSelectMusic", + "GetUserNewItemList", "UserNewItemListRequestVO", "GetGameRanking", "GameRankingRequestVO", "GetGameEvent", "GameEventRequestVO", "GetGameCharge", "GameChargeRequestVO", diff --git a/api/chime.py b/api/chime.py index 9bfc90f..bbf0d8d 100644 --- a/api/chime.py +++ b/api/chime.py @@ -235,7 +235,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID) 返回: - (user_id, token) 元组,可直接传给 UserPreview / UserLogin 等 + (user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等 异常: ValueError: 二维码格式不合法 diff --git a/api/game_charge.py b/api/get_game_charge.py similarity index 95% rename from api/game_charge.py rename to api/get_game_charge.py index 14262fa..dc840dc 100644 --- a/api/game_charge.py +++ b/api/get_game_charge.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_charge.py —— 取游戏计费配置(GetGameChargeApi) +get_game_charge.py —— 取游戏计费配置(GetGameChargeApi) ================================================================================ 取服务器下发的计费档位。 diff --git a/api/game_event.py b/api/get_game_event.py similarity index 96% rename from api/game_event.py rename to api/get_game_event.py index d1b9216..3fffbba 100644 --- a/api/game_event.py +++ b/api/get_game_event.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_event.py —— 取游戏活动(GetGameEventApi) +get_game_event.py —— 取游戏活动(GetGameEventApi) ================================================================================ 取服务器下发的活动列表。 diff --git a/api/game_ng_music_id.py b/api/get_game_ng_music_id.py similarity index 93% rename from api/game_ng_music_id.py rename to api/get_game_ng_music_id.py index 6376094..0661b10 100644 --- a/api/game_ng_music_id.py +++ b/api/get_game_ng_music_id.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi) +get_game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi) ================================================================================ 取服务器下发的 NG(不可游玩)曲目 ID 列表。 diff --git a/api/game_ranking.py b/api/get_game_ranking.py similarity index 95% rename from api/game_ranking.py rename to api/get_game_ranking.py index 3039dcc..65b1657 100644 --- a/api/game_ranking.py +++ b/api/get_game_ranking.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_ranking.py —— 取游戏排行(GetGameRankingApi) +get_game_ranking.py —— 取游戏排行(GetGameRankingApi) ================================================================================ 取服务器下发的排行榜数据。 diff --git a/api/gamesettings.py b/api/get_game_settings.py similarity index 98% rename from api/gamesettings.py rename to api/get_game_settings.py index 982fde4..42281e9 100644 --- a/api/gamesettings.py +++ b/api/get_game_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -gamesettings.py —— 获取游戏设置(GetGameSettingApi) +get_game_settings.py —— 获取游戏设置(GetGameSettingApi) ================================================================================ 功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。 diff --git a/api/game_tournament_info.py b/api/get_game_tournament_info.py similarity index 93% rename from api/game_tournament_info.py rename to api/get_game_tournament_info.py index af0a252..8d1afba 100644 --- a/api/game_tournament_info.py +++ b/api/get_game_tournament_info.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi) +get_game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi) ================================================================================ 取服务器下发的大会/赛事信息。 diff --git a/api/game_weekly_data.py b/api/get_game_weekly_data.py similarity index 93% rename from api/game_weekly_data.py rename to api/get_game_weekly_data.py index 49d6645..19ed41e 100644 --- a/api/game_weekly_data.py +++ b/api/get_game_weekly_data.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi) +get_game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi) ================================================================================ 取服务器下发的周常周期信息。 diff --git a/api/upsert_snapshot.py b/api/get_need_user_data.py similarity index 60% rename from api/upsert_snapshot.py rename to api/get_need_user_data.py index 113fab7..3ffe97d 100644 --- a/api/upsert_snapshot.py +++ b/api/get_need_user_data.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """ -upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本) +get_need_user_data.py —— 结算快照编排(统一调用各取数脚本) ================================================================================ 功能:一键抓取 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, ...) """ from __future__ import annotations import json +import os +from pathlib import Path from typing import Any, Dict, List, Optional, Sequence 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 .userdata import UserData -from .user_extend import UserExtend -from .user_option import UserOption -from .user_character import UserCharacter -from .user_ghost import UserGhost -from .user_map import UserMapAll -from .user_login_bonus import UserLoginBonusAll -from .user_rating import UserRating -from .user_item import UserItemAll -from .user_music import UserMusicDetailAll -from .user_course import UserCourseAll -from .user_charge import UserCharge -from .user_favorite import UserFavoriteAll -from .user_activity import UserActivity -from .user_mission import UserMissionData -from .user_intimate import UserIntimate -from .user_shop_stock import UserShopStock -from .user_favorite_item import UserFavoriteItemAll -from .user_kaleidx_scope import UserKaleidxScope +from .get_user_data import GetUserData +from .get_user_extend import GetUserExtend +from .get_user_option import GetUserOption +from .get_user_character import GetUserCharacter +from .get_user_ghost import GetUserGhost +from .get_user_map import GetUserMapAll +from .get_user_login_bonus import GetUserLoginBonusAll +from .get_user_rating import GetUserRating +from .get_user_item import GetUserItemAll +from .get_user_music import GetUserMusicDetailAll +from .get_user_course import GetUserCourseAll +from .get_user_charge import GetUserCharge +from .get_user_favorite import GetUserFavoriteAll +from .get_user_activity import GetUserActivity +from .get_user_mission import GetUserMissionData +from .get_user_intimate import GetUserIntimate +from .get_user_shop_stock import GetUserShopStock +from .get_user_favorite_item import GetUserFavoriteItemAll +from .get_user_kaleidx_scope import GetUserKaleidxScope + + +# ============================================================================= +# 落盘目录:<工程根>/data/.json +# ============================================================================= + +#: 工程根目录(本文件在 /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/.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/.json。 + + ★ **新数据总是覆盖老数据**(不是追加、也不是合并)。 + ★ 目录不存在会自动创建。 + + 参数: + snapshot: GetNeedUserData 的返回值 + user_id: 用户 ID(决定文件名) + path: 自定义路径(可选,默认走 data/.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/.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, *, token: str = "", @@ -101,6 +181,8 @@ def fetch_upsert_snapshot( timeout_ms: Optional[int] = None, client: Optional[MaimaiNetClient] = None, strict: bool = False, + save: bool = True, + save_path: Optional[str] = None, ) -> Dict[str, Any]: """ 抓取结算所需的全部基线数据。 @@ -117,6 +199,9 @@ def fetch_upsert_snapshot( client: 可复用的 MaimaiNetClient strict: True = 任一接口失败就抛异常; False(默认)= 记录失败并跳过,保证链路不崩 + save: True(默认)= 抓完自动落盘到 <工程根>/data/.json + (★ 新数据总是覆盖老数据) + save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则) 返回: { @@ -151,78 +236,87 @@ def fetch_upsert_snapshot( # ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)---- if "userData" in wanted: - _call("userData", UserData, user_id, **common) + _call("userData", GetUserData, user_id, **common) if "userExtend" in wanted: - _call("userExtend", UserExtend, user_id, **common) + _call("userExtend", GetUserExtend, user_id, **common) if "userOption" in wanted: - _call("userOption", UserOption, user_id, **common) + _call("userOption", GetUserOption, user_id, **common) if "userCharacterList" in wanted: - _call("userCharacterList", UserCharacter, user_id, **common) + _call("userCharacterList", GetUserCharacter, user_id, **common) if "userMapList" in wanted: - _call("userMapList", UserMapAll, user_id, **common) + _call("userMapList", GetUserMapAll, user_id, **common) if "userLoginBonusList" in wanted: - _call("userLoginBonusList", UserLoginBonusAll, user_id, **common) + _call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common) if "userRatingList" in wanted: - _call("userRatingList", UserRating, user_id, **common) + _call("userRatingList", GetUserRating, user_id, **common) if "userItemList" in wanted: - _call("userItemList", UserItemAll, user_id, **common) + _call("userItemList", GetUserItemAll, user_id, **common) if "userMusicDetailList" in wanted: - _call("userMusicDetailList", UserMusicDetailAll, user_id, **common) + _call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common) if "userCourseList" in wanted: - _call("userCourseList", UserCourseAll, user_id, **common) + _call("userCourseList", GetUserCourseAll, user_id, **common) if "userChargeList" in wanted: - _call("userChargeList", UserCharge, user_id, **common) + _call("userChargeList", GetUserCharge, user_id, **common) if "userFavoriteList" in wanted: # ★ 该接口额外带一份**原始** itemKind 副本(_raw), # 供 UpsertUserAll 生成 isNewFavoriteList 位串用 # —— 真机 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") if fav_raw: raw["_raw_favorite"] = fav_raw if "userActivityList" in wanted: - _call("userActivityList", UserActivity, user_id, **common) + _call("userActivityList", GetUserActivity, user_id, **common) if "userMissionDataList" in wanted: # ★ 该接口的响应里同时带着 userWeeklyData(周界), # 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。 - _call("userMissionDataList", UserMissionData, user_id, **common) + _call("userMissionDataList", GetUserMissionData, user_id, **common) if "userIntimateList" in wanted: - _call("userIntimateList", UserIntimate, user_id, **common) + _call("userIntimateList", GetUserIntimate, user_id, **common) if "userShopItemStockList" in wanted: ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS) if ids: - _call("userShopItemStockList", UserShopStock, user_id, + _call("userShopItemStockList", GetUserShopStock, user_id, shop_item_id_list=ids, **common) else: log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点") if "userFavoritemusicList" in wanted: - _call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common) + _call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common) if "userKaleidxScopeList" in wanted: - _call("userKaleidxScopeList", UserKaleidxScope, user_id, **common) + _call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common) if "userGhost" in wanted: - _call("userGhost", UserGhost, user_id, **common) + _call("userGhost", GetUserGhost, user_id, **common) raw["_failed"] = failed - log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 " + raw["_userId"] = int(user_id) + log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 " + str(len(failed)) + " 个") + + # ★ 落盘:<工程根>/data/.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 @@ -281,10 +375,10 @@ if __name__ == "__main__": from .log_setup import log_error, setup_logging, teardown_logging def _main() -> int: - setup_logging("upsert_snapshot_test") + setup_logging("get_need_user_data_test") try: if len(sys.argv) < 2: - print("用法: python -m api.upsert_snapshot [--token TOKEN] [--save 路径]") + print("用法: python -m api.get_need_user_data [--token TOKEN] [--save 路径]") return 1 user_id = int(sys.argv[1]) @@ -302,7 +396,7 @@ if __name__ == "__main__": else: i += 1 - snap = fetch_upsert_snapshot(user_id, token=token) + snap = GetNeedUserData(user_id, token=token) summary = summarize_snapshot(snap) print("") for node, count in summary.items(): diff --git a/api/user_activity.py b/api/get_user_activity.py similarity index 91% rename from api/user_activity.py rename to api/get_user_activity.py index f92689e..878e396 100644 --- a/api/user_activity.py +++ b/api/get_user_activity.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_activity.py —— 取用户活动记录(GetUserActivityApi) +get_user_activity.py —— 取用户活动记录(GetUserActivityApi) ================================================================================ 取玩家的活动/成就记录(playList 与 musicList 两个列表)。 @@ -16,7 +16,7 @@ user_activity.py —— 取用户活动记录(GetUserActivityApi) 不是把两个数组首尾相接摊平。 调用示例: - from api import UserActivity + from api import GetUserActivity """ from __future__ import annotations @@ -30,7 +30,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserActivity( +def GetUserActivity( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_character.py b/api/get_user_character.py similarity index 90% rename from api/user_character.py rename to api/get_user_character.py index 869fd34..0a1d187 100644 --- a/api/user_character.py +++ b/api/get_user_character.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_character.py —— 取用户角色列表(GetUserCharacterApi) +get_user_character.py —— 取用户角色列表(GetUserCharacterApi) ================================================================================ 取玩家所有角色的等级与觉醒度(真机 312 条)。 @@ -14,7 +14,7 @@ user_character.py —— 取用户角色列表(GetUserCharacterApi) (真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。 调用示例: - from api import UserCharacter + from api import GetUserCharacter """ from __future__ import annotations @@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserCharacter( +def GetUserCharacter( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_charge.py b/api/get_user_charge.py similarity index 91% rename from api/user_charge.py rename to api/get_user_charge.py index 1b139e2..7f165aa 100644 --- a/api/user_charge.py +++ b/api/get_user_charge.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_charge.py —— 取用户票券(GetUserChargeApi) +get_user_charge.py —— 取用户票券(GetUserChargeApi) ================================================================================ 取玩家的票券持有情况(真机 3 条)。 @@ -13,7 +13,7 @@ user_charge.py —— 取用户票券(GetUserChargeApi) ★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。 调用示例: - from api import UserCharge + from api import GetUserCharge """ from __future__ import annotations @@ -27,7 +27,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserCharge( +def GetUserCharge( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_course.py b/api/get_user_course.py similarity index 89% rename from api/user_course.py rename to api/get_user_course.py index 1a26ca2..e1a0a34 100644 --- a/api/user_course.py +++ b/api/get_user_course.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_course.py —— 取用户课题模式成绩(GetUserCourseApi) +get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi) ================================================================================ 取玩家的 COURSE(课题)模式通关记录(真机 16 条)。 @@ -12,7 +12,7 @@ user_course.py —— 取用户课题模式成绩(GetUserCourseApi) source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0} 调用示例: - from api import UserCourse + from api import GetUserCourse """ from __future__ import annotations @@ -23,10 +23,10 @@ from config import CLIENT_ID, TIMEOUT_MS from .log_setup import log_info 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, *, next_index: int = 0, @@ -48,7 +48,7 @@ def UserCourse( ) -def UserCourseAll( +def GetUserCourseAll( user_id: int, *, client_id: str = CLIENT_ID, @@ -62,7 +62,7 @@ def UserCourseAll( {"userCourseList": [...]} """ log_info("取用户课题成绩(全量):user_id=" + str(user_id)) - items = fetch_all_pages( + items = get_all_pages( "GetUserCourseApi", user_id, "userCourseList", body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0), client_id=client_id, timeout_ms=timeout_ms, client=client, diff --git a/api/userdata.py b/api/get_user_data.py similarity index 94% rename from api/userdata.py rename to api/get_user_data.py index 19b31e5..bc66644 100644 --- a/api/userdata.py +++ b/api/get_user_data.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -userdata.py —— 取用户全量数据(GetUserDataApi) +get_user_data.py —— 取用户全量数据(GetUserDataApi) ================================================================================ 取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。 @@ -20,7 +20,7 @@ userdata.py —— 取用户全量数据(GetUserDataApi) 记录造成的假象(两条响应被合并显示)。 调用示例: - from api import UserData + from api import GetUserData """ from __future__ import annotations @@ -34,7 +34,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserData( +def GetUserData( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_extend.py b/api/get_user_extend.py similarity index 91% rename from api/user_extend.py rename to api/get_user_extend.py index af52718..ce04197 100644 --- a/api/user_extend.py +++ b/api/get_user_extend.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_extend.py —— 取用户扩展设置(GetUserExtendApi) +get_user_extend.py —— 取用户扩展设置(GetUserExtendApi) ================================================================================ 取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。 @@ -14,7 +14,7 @@ user_extend.py —— 取用户扩展设置(GetUserExtendApi) (真机两处都是 2022),组包时要注意。 调用示例: - from api import UserExtend + from api import GetUserExtend """ from __future__ import annotations @@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserExtend( +def GetUserExtend( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_favorite.py b/api/get_user_favorite.py similarity index 95% rename from api/user_favorite.py rename to api/get_user_favorite.py index 4c69a59..f62ce22 100644 --- a/api/user_favorite.py +++ b/api/get_user_favorite.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_favorite.py —— 取用户收藏(GetUserFavoriteApi) +get_user_favorite.py —— 取用户收藏(GetUserFavoriteApi) ================================================================================ 取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。 @@ -15,7 +15,7 @@ user_favorite.py —— 取用户收藏(GetUserFavoriteApi) 位串 isNewFavoriteList 是三值 "0"/"1"/"2"。 调用示例: - from api import UserFavorite, UserFavoriteAll + from api import GetUserFavorite, GetUserFavoriteAll """ from __future__ import annotations @@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS -def UserFavorite( +def GetUserFavorite( user_id: int, item_kind: int, *, @@ -55,7 +55,7 @@ def UserFavorite( ) -def UserFavoriteAll( +def GetUserFavoriteAll( user_id: int, *, item_kinds: Optional[List[int]] = None, diff --git a/api/user_favorite_item.py b/api/get_user_favorite_item.py similarity index 93% rename from api/user_favorite_item.py rename to api/get_user_favorite_item.py index 551ca73..2e2dc8a 100644 --- a/api/user_favorite_item.py +++ b/api/get_user_favorite_item.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi) +get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi) ================================================================================ 取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。 @@ -15,7 +15,7 @@ user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemA ★ 字段名是 kind(不是 itemKind),别写错。 调用示例: - from api import UserFavoriteItem, UserFavoriteItemAll + from api import GetUserFavoriteItem, GetUserFavoriteItemAll """ from __future__ import annotations @@ -26,14 +26,14 @@ from config import CLIENT_ID, TIMEOUT_MS from .log_setup import log_info 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 取值 FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲 FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手 -def UserFavoriteItem( +def GetUserFavoriteItem( user_id: int, kind: int = FAVORITE_ITEM_KIND_MUSIC, *, @@ -65,7 +65,7 @@ def UserFavoriteItem( ) -def UserFavoriteItemAll( +def GetUserFavoriteItemAll( user_id: int, kind: int = FAVORITE_ITEM_KIND_MUSIC, *, @@ -81,7 +81,7 @@ def UserFavoriteItemAll( {"userFavoriteItemList": [...]} """ log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind)) - items = fetch_all_pages( + items = get_all_pages( "GetUserFavoriteItemApi", user_id, "userFavoriteItemList", body_factory=lambda ni: UserFavoriteItemRequestVO( userId=user_id, kind=kind, nextIndex=ni or 0, diff --git a/api/user_friend_bonus.py b/api/get_user_friend_bonus.py similarity index 89% rename from api/user_friend_bonus.py rename to api/get_user_friend_bonus.py index 8314614..81d608e 100644 --- a/api/user_friend_bonus.py +++ b/api/get_user_friend_bonus.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi) +get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi) ================================================================================ 取玩家本次可领取的好友里程(getMiles)。 @@ -12,7 +12,7 @@ user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi) 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 @@ -26,7 +26,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserFriendBonus( +def GetUserFriendBonus( user_id: int, *, client_id: str = CLIENT_ID, @@ -53,7 +53,7 @@ def extract_gift_points(friend_bonus: Dict[str, Any]) -> int: 从好友里程响应里取出 getMiles(本次可领取的里程数)。 参数: - friend_bonus: UserFriendBonus 的返回值 + friend_bonus: GetUserFriendBonus 的返回值 返回: getMiles 整数(取不到则为 0) diff --git a/api/user_ghost.py b/api/get_user_ghost.py similarity index 86% rename from api/user_ghost.py rename to api/get_user_ghost.py index 2f274f9..1e69116 100644 --- a/api/user_ghost.py +++ b/api/get_user_ghost.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_ghost.py —— 取用户幽灵数据(GetUserGhostApi) +get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi) ================================================================================ 取玩家的「幽灵」对战记录(真机 15 条)。 @@ -11,11 +11,11 @@ user_ghost.py —— 取用户幽灵数据(GetUserGhostApi) 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs ★ 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 @@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserGhost( +def GetUserGhost( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_intimate.py b/api/get_user_intimate.py similarity index 89% rename from api/user_intimate.py rename to api/get_user_intimate.py index d825dca..f6cbb5b 100644 --- a/api/user_intimate.py +++ b/api/get_user_intimate.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi) +get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi) ================================================================================ 取玩家与各搭档的亲密度(真机 2 条)。 @@ -11,7 +11,7 @@ user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi) 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs 调用示例: - from api import UserIntimate + from api import GetUserIntimate """ from __future__ import annotations @@ -25,7 +25,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserIntimate( +def GetUserIntimate( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_item.py b/api/get_user_item.py similarity index 96% rename from api/user_item.py rename to api/get_user_item.py index f0bbd29..21a7632 100644 --- a/api/user_item.py +++ b/api/get_user_item.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_item.py —— 取用户道具(GetUserItemApi) +get_user_item.py —— 取用户道具(GetUserItemApi) ================================================================================ 取玩家各类道具的库存(真机按 kind 分 12 次请求)。 @@ -29,7 +29,7 @@ source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24 后续翻页沿用响应回的 nextIndex 调用示例: - from api import UserItem, UserItemAll + from api import GetUserItem, GetUserItemAll """ 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 -def UserItem( +def GetUserItem( user_id: int, item_kind: int, *, @@ -77,7 +77,7 @@ def UserItem( ) -def UserItemAll( +def GetUserItemAll( user_id: int, *, item_kinds: Optional[List[int]] = None, diff --git a/api/user_kaleidx_scope.py b/api/get_user_kaleidx_scope.py similarity index 89% rename from api/user_kaleidx_scope.py rename to api/get_user_kaleidx_scope.py index cac328e..301beaf 100644 --- a/api/user_kaleidx_scope.py +++ b/api/get_user_kaleidx_scope.py @@ -1,6 +1,6 @@ # -*- 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 取空) -------------------------------------------------------------------------------- GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。 -玩家档案 userData 的正确来源是 GetUserDataApi(见 userdata.py)。 +玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。 真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的 两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId, 多发字段会让服务端返回空内容。 调用示例: - from api import UserKaleidxScope, GameKaleidxScope + from api import GetUserKaleidxScope, GetGameKaleidxScope """ from __future__ import annotations @@ -38,7 +38,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO -def UserKaleidxScope( +def GetUserKaleidxScope( user_id: int, *, client_id: str = CLIENT_ID, @@ -56,7 +56,7 @@ def UserKaleidxScope( client_id=client_id, timeout_ms=timeout_ms, client=client) -def GameKaleidxScope( +def GetGameKaleidxScope( *, client_id: str = CLIENT_ID, timeout_ms: Optional[int] = None, diff --git a/api/user_login_bonus.py b/api/get_user_login_bonus.py similarity index 88% rename from api/user_login_bonus.py rename to api/get_user_login_bonus.py index dee6d3e..a4b9d14 100644 --- a/api/user_login_bonus.py +++ b/api/get_user_login_bonus.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi) +get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi) ================================================================================ 取玩家的登录奖励领取进度(真机 38 条,分两页)。 @@ -13,7 +13,7 @@ user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi) ★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。 调用示例: - from api import UserLoginBonus, UserLoginBonusAll + from api import GetUserLoginBonus, GetUserLoginBonusAll """ from __future__ import annotations @@ -24,10 +24,10 @@ from config import CLIENT_ID, TIMEOUT_MS from .log_setup import log_info 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, *, next_index: int = 0, @@ -52,7 +52,7 @@ def UserLoginBonus( ) -def UserLoginBonusAll( +def GetUserLoginBonusAll( user_id: int, *, client_id: str = CLIENT_ID, @@ -66,7 +66,7 @@ def UserLoginBonusAll( {"userLoginBonusList": [...]} """ log_info("取用户登录奖励(全量):user_id=" + str(user_id)) - items = fetch_all_pages( + items = get_all_pages( "GetUserLoginBonusApi", user_id, "userLoginBonusList", body_factory=lambda ni: UserLoginBonusRequestVO( userId=user_id, nextIndex=ni or 0, maxCount=20), diff --git a/api/user_map.py b/api/get_user_map.py similarity index 89% rename from api/user_map.py rename to api/get_user_map.py index e94d140..d6513d0 100644 --- a/api/user_map.py +++ b/api/get_user_map.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_map.py —— 取用户地图进度(GetUserMapApi) +get_user_map.py —— 取用户地图进度(GetUserMapApi) ================================================================================ 取玩家各地图的推进距离(真机 104 条)。 @@ -14,7 +14,7 @@ user_map.py —— 取用户地图进度(GetUserMapApi) (真机:mapId=550003 的 distance 1424000 -> 1435000)。 调用示例: - from api import UserMap / UserMapAll + from api import GetUserMap / GetUserMapAll """ from __future__ import annotations @@ -25,10 +25,10 @@ from config import CLIENT_ID, TIMEOUT_MS from .log_setup import log_info 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, *, next_index: int = 0, @@ -53,7 +53,7 @@ def UserMap( ) -def UserMapAll( +def GetUserMapAll( user_id: int, *, client_id: str = CLIENT_ID, @@ -67,7 +67,7 @@ def UserMapAll( {"userMapList": [...]} """ log_info("取用户地图进度(全量):user_id=" + str(user_id)) - items = fetch_all_pages( + items = get_all_pages( "GetUserMapApi", user_id, "userMapList", body_factory=lambda ni: UserIdPageRequestVO( userId=user_id, nextIndex=ni or 0, maxCount=1000), diff --git a/api/user_mission.py b/api/get_user_mission.py similarity index 91% rename from api/user_mission.py rename to api/get_user_mission.py index 8245ef0..516a6cf 100644 --- a/api/user_mission.py +++ b/api/get_user_mission.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi) +get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi) ================================================================================ 取玩家的任务进度与周常数据(真机 6 条任务)。 @@ -16,7 +16,7 @@ user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi) ★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。 调用示例: - from api import UserMissionData + from api import GetUserMissionData """ from __future__ import annotations @@ -30,7 +30,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserMissionData( +def GetUserMissionData( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_music.py b/api/get_user_music.py similarity index 88% rename from api/user_music.py rename to api/get_user_music.py index af7bd41..dc0073e 100644 --- a/api/user_music.py +++ b/api/get_user_music.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_music.py —— 取用户曲目成绩(GetUserMusicApi) +get_user_music.py —— 取用户曲目成绩(GetUserMusicApi) ================================================================================ 取玩家全部曲目的历史成绩归档(真机 739 组)。 @@ -10,7 +10,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi) 响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]} 依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.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 才是 按 (musicId, level) 的成绩条目。取数时要摊平。 @@ -18,7 +18,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi) ★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。 调用示例: - from api import UserMusicDetail, UserMusicDetailFlat + from api import GetUserMusicDetail, UserMusicDetailFlat """ from __future__ import annotations @@ -29,10 +29,10 @@ from config import CLIENT_ID, TIMEOUT_MS from .log_setup import log_info 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, *, next_index: int = 0, @@ -57,7 +57,7 @@ def UserMusicDetail( ) -def UserMusicDetailAll( +def GetUserMusicDetailAll( user_id: int, *, client_id: str = CLIENT_ID, @@ -72,7 +72,7 @@ def UserMusicDetailAll( 每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。 """ log_info("取用户曲目成绩(全量):user_id=" + str(user_id)) - groups = fetch_all_pages( + groups = get_all_pages( "GetUserMusicApi", user_id, "userMusicList", body_factory=lambda ni: UserMusicRequestVO( userId=user_id, nextIndex=ni or 0, maxCount=50), diff --git a/api/user_new_item.py b/api/get_user_new_item.py similarity index 95% rename from api/user_new_item.py rename to api/get_user_new_item.py index 6753b4d..7df9afb 100644 --- a/api/user_new_item.py +++ b/api/get_user_new_item.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_new_item.py —— 取新增道具(GetUserNewItemListApi) +get_user_new_item.py —— 取新增道具(GetUserNewItemListApi) ================================================================================ 上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。 @@ -15,7 +15,7 @@ user_new_item.py —— 取新增道具(GetUserNewItemListApi) ★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。 调用示例: - from api import UserNewItemList + from api import GetUserNewItemList """ from __future__ import annotations @@ -38,7 +38,7 @@ class UserNewItemListRequestVO: userPlaylogList: List[Any] = field(default_factory=list) -def UserNewItemList( +def GetUserNewItemList( user_id: int, *, version: int = PLAY_VERSION_INT, diff --git a/api/user_option.py b/api/get_user_option.py similarity index 92% rename from api/user_option.py rename to api/get_user_option.py index 85707fd..298ef01 100644 --- a/api/user_option.py +++ b/api/get_user_option.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_option.py —— 取用户游戏设置(GetUserOptionApi) +get_user_option.py —— 取用户游戏设置(GetUserOptionApi) ================================================================================ 取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。 @@ -15,7 +15,7 @@ user_option.py —— 取用户游戏设置(GetUserOptionApi) 裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。 调用示例: - from api import UserOption + from api import GetUserOption """ from __future__ import annotations @@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserOption( +def GetUserOption( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/userpreview.py b/api/get_user_preview.py similarity index 93% rename from api/userpreview.py rename to api/get_user_preview.py index 5d1d5eb..a0eb785 100644 --- a/api/userpreview.py +++ b/api/get_user_preview.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -userpreview.py —— 用户预览(GetUserPreviewApi) +get_user_preview.py —— 用户预览(GetUserPreviewApi) ================================================================================ 功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。 @@ -17,8 +17,8 @@ userpreview.py —— 用户预览(GetUserPreviewApi) (source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。 调用示例: - from api import UserPreview - pv = UserPreview(12345678, "TESTTOKEN0000000000") + from api import GetUserPreview + pv = GetUserPreview(12345678, "TESTTOKEN0000000000") print(pv.userName, pv.playerRating) """ @@ -74,7 +74,7 @@ class UserPreviewResponseVO: errorId: int = 0 -def UserPreview( +def GetUserPreview( user_id: int, token: str, *, @@ -139,10 +139,10 @@ if __name__ == "__main__": setup_logging("userpreview_test") try: if len(sys.argv) < 3: - print("用法: python -m api.userpreview [client_id]") + print("用法: python -m api.get_user_preview [client_id]") return 1 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("") for name in UserPreviewResponseVO.__dataclass_fields__: print(" " + name.ljust(18) + "= " + str(getattr(pv, name))) diff --git a/api/user_rating.py b/api/get_user_rating.py similarity index 91% rename from api/user_rating.py rename to api/get_user_rating.py index f3fdce7..1dae87d 100644 --- a/api/user_rating.py +++ b/api/get_user_rating.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_rating.py —— 取用户 Rating 明细(GetUserRatingApi) +get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi) ================================================================================ 取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。 @@ -14,7 +14,7 @@ user_rating.py —— 取用户 Rating 明细(GetUserRatingApi) ★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。 调用示例: - from api import UserRating + from api import GetUserRating """ from __future__ import annotations @@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserRating( +def GetUserRating( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_recommend_music.py b/api/get_user_recommend_music.py similarity index 88% rename from api/user_recommend_music.py rename to api/get_user_recommend_music.py index d856720..efc5458 100644 --- a/api/user_recommend_music.py +++ b/api/get_user_recommend_music.py @@ -1,6 +1,6 @@ # -*- 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 调用示例: - from api import UserRecommendRateMusic, UserRecommendSelectMusic + from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic """ from __future__ import annotations @@ -28,7 +28,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserRecommendRateMusic( +def GetUserRecommendRateMusic( user_id: int, *, client_id: str = CLIENT_ID, @@ -46,7 +46,7 @@ def UserRecommendRateMusic( client_id=client_id, timeout_ms=timeout_ms, client=client) -def UserRecommendSelectMusic( +def GetUserRecommendSelectMusic( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_region.py b/api/get_user_region.py similarity index 90% rename from api/user_region.py rename to api/get_user_region.py index de7f78f..d29253f 100644 --- a/api/user_region.py +++ b/api/get_user_region.py @@ -1,6 +1,6 @@ # -*- 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 调用示例: - from api import UserRegion + from api import GetUserRegion """ from __future__ import annotations @@ -26,7 +26,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserIdRequestVO -def UserRegion( +def GetUserRegion( user_id: int, *, client_id: str = CLIENT_ID, diff --git a/api/user_rival.py b/api/get_user_rival.py similarity index 93% rename from api/user_rival.py rename to api/get_user_rival.py index 4296fef..3c5436d 100644 --- a/api/user_rival.py +++ b/api/get_user_rival.py @@ -1,6 +1,6 @@ # -*- 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 取到)。 调用示例: - from api import UserRivalData, UserRivalMusic + from api import GetUserRivalData, GetUserRivalMusic """ from __future__ import annotations @@ -35,11 +35,11 @@ from .user_getter_common import ( UserRivalRequestVO, UserRivalMusicRequestVO, RIVAL_MUSIC_LEVELS, - fetch_all_pages, + get_all_pages, ) -def UserRivalData( +def GetUserRivalData( user_id: int, rival_id: int, *, @@ -65,7 +65,7 @@ def UserRivalData( ) -def UserRivalMusic( +def GetUserRivalMusic( user_id: int, rival_id: int, *, @@ -100,7 +100,7 @@ def UserRivalMusic( ) -def UserRivalMusicAll( +def GetUserRivalMusicAll( user_id: int, rival_id: int, *, @@ -117,7 +117,7 @@ def UserRivalMusicAll( """ lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS) log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id)) - items = fetch_all_pages( + items = get_all_pages( "GetUserRivalMusicApi", user_id, "userRivalMusicList", body_factory=lambda ni: UserRivalMusicRequestVO( userId=user_id, rivalId=rival_id, nextIndex=ni or 0, diff --git a/api/user_shop_stock.py b/api/get_user_shop_stock.py similarity index 93% rename from api/user_shop_stock.py rename to api/get_user_shop_stock.py index aa80373..68fd2a8 100644 --- a/api/user_shop_stock.py +++ b/api/get_user_shop_stock.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi) +get_user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi) ================================================================================ 取玩家在各商店商品的已兑换次数。 @@ -15,7 +15,7 @@ user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi) 不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。 调用示例: - from api import UserShopStock + from api import GetUserShopStock """ from __future__ import annotations @@ -29,7 +29,7 @@ from .maimai_net import MaimaiNetClient from .user_getter_common import _get, UserShopStockRequestVO -def UserShopStock( +def GetUserShopStock( user_id: int, *, shop_item_id_list: Optional[List[int]] = None, diff --git a/api/log_setup.py b/api/log_setup.py index a56962d..241cc94 100644 --- a/api/log_setup.py +++ b/api/log_setup.py @@ -57,7 +57,7 @@ log_setup.py —— 统一日志模块(INFO / DEBUG 双模式) from api import log_info, log_debug, log_request, log_response - def UserPreview(user_id, token, ...): + def GetUserPreview(user_id, token, ...): # 函数的第一行 docstring 会成为日志里的「[接口说明]」 # 例如: '''获取用户预览信息''' log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写 diff --git a/api/upsert_user_all.py b/api/upsert_user_all.py index 65857e4..985df36 100644 --- a/api/upsert_user_all.py +++ b/api/upsert_user_all.py @@ -85,16 +85,16 @@ source/2026-10-05.log:123 逐节点核对: -------------------------------------------------------------------------------- 五、调用示例 -------------------------------------------------------------------------------- - from api import (setup_logging, ParseQRCode, UserPreview, UserLogin, - fetch_upsert_snapshot, UpsertUserAll, build_play_from_config) + from api import (setup_logging, ParseQRCode, GetUserPreview, UserLogin, + GetNeedUserData, UpsertUserAll, build_play_from_config) setup_logging("结算") user_id, token = ParseQRCode(qr) - UserPreview(user_id, token) + GetUserPreview(user_id, token) login = UserLogin(user_id, token) # 1) 登录后立刻抓基线快照(差分的基准) - snapshot = fetch_upsert_snapshot(user_id, token=token) + snapshot = GetNeedUserData(user_id, token=token) # 2) 组装本局数据(默认读 config.py 的 PLAY_*) play = build_play_from_config() @@ -527,7 +527,7 @@ def build_favorite_delta( "2" = 服务器侧有对应 itemKind(无论内容是否一致) "1" = 服务器侧查不到该 itemKind - ★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 user_favorite.py + ★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 get_user_favorite.py 的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是 {"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。 @@ -729,12 +729,12 @@ def derive_play_characters( 数据来源(都在 Get 响应里): 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 GetUserCharacterApi 响应 参数: - snapshot: fetch_upsert_snapshot 的返回值(或任何含 userData / + snapshot: GetNeedUserData 的返回值(或任何含 userData / userCharacterList 的字典)。 play: 当前的 PlayData;快照里查不到的槽位回退到它同位置的配置。 @@ -811,7 +811,7 @@ def build_post_play_user_data( **完全一致**(playCount=1258、point=4460、lastPlayDate=19:22:53.0)。 返回: - 游玩后的 userData 字典(可直接放进 [ ] 传给 UserNewItemList) + 游玩后的 userData 字典(可直接放进 [ ] 传给 GetUserNewItemList) """ p = play or build_play_from_config() ua = build_user_all( @@ -1265,7 +1265,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]: "userMapList": , ... } - 也接受 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" # 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; # 所以优先用快照里的值;只有拿不到时才退回本地推导。 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 外层按曲分组, # 内层 userMusicDetailList 才是 (musicId, level) 条目) - # b) UserMusicDetailAll() 的产物:已摊平成单条 + # b) GetUserMusicDetailAll() 的产物:已摊平成单条 # 判据:元素里带 "userMusicDetailList" 键 -> 嵌套;否则已摊平。 groups = src.get("userMusicList") or [] nested = any( @@ -1382,7 +1382,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]: else: items = src.get(list_key) # ★ 兜底:取数脚本返回的字典有时用**节点名**作键 - # (如 UserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]}, + # (如 GetUserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]}, # 而 GetUserFavoriteApi 的原始响应是 {"userFavorite": {...}})。 if items is None: items = src.get(node) @@ -2152,7 +2152,7 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any: ★ 为什么要绕这一下: autowrap() 只替换 api/__init__.py 命名空间里的名字 (见 log_setup.autowrap 的注释),模块内部的直接引用拿到的还是原始函数。 - UpsertUserAll 内部要调 fetch_upsert_snapshot / UserNewItemList / UserData, + UpsertUserAll 内部要调 GetNeedUserData / GetUserNewItemList / GetUserData, 绕这一下它们才会像用户直接调用那样打印「=== xxx 开始运行 ===」, 主脚本就完全不用自己写日志了。 """ @@ -2168,18 +2168,18 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any: def _lazy(name: str) -> Any: """按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。""" - if name == "fetch_upsert_snapshot": - from .upsert_snapshot import fetch_upsert_snapshot - return fetch_upsert_snapshot - if name == "UserNewItemList": - from .user_new_item import UserNewItemList - return UserNewItemList - if name == "UserData": - from .userdata import UserData - return UserData - if name == "UserMusicDetailAll": - from .user_music import UserMusicDetailAll - return UserMusicDetailAll + if name == "GetNeedUserData": + from .get_need_user_data import GetNeedUserData + return GetNeedUserData + if name == "GetUserNewItemList": + from .get_user_new_item import GetUserNewItemList + return GetUserNewItemList + if name == "GetUserData": + from .get_user_data import GetUserData + return GetUserData + if name == "GetUserMusicDetailAll": + from .get_user_music import GetUserMusicDetailAll + return GetUserMusicDetailAll 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 {} 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) after_ud = (after_raw.get("userData") or {}) if isinstance(after_raw, dict) else {} except Exception as exc: @@ -2245,7 +2245,7 @@ def verify_upsert_landing( # 本局曲目的 playCount 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) rows = music_raw.get("userMusicList") or [] if isinstance(music_raw, dict) else [] hit = [m for m in rows @@ -2336,7 +2336,7 @@ def UpsertUserAll( auth_date_time: UserLoginApi 请求里的 dateTime(可选; userData.dateTime 要用它,真机实测两处一致) token: 会话令牌(可选)。★ 只在「没传 snapshot 且 auto_snapshot=True」 - 时需要 —— 内部用它自动调 fetch_upsert_snapshot。 + 时需要 —— 内部用它自动调 GetNeedUserData。 snapshot: 基线快照(可选,见 normalize_snapshot)。 传了就用它;不传则按 auto_snapshot 自动抓。 ★ 服务器对 userData 是整表覆盖、对差分节点是「只回传变化项」, @@ -2390,7 +2390,7 @@ def UpsertUserAll( "推荐传 token=...(自动抓)或 snapshot=...(自己抓好传进来)") else: log_info("未传 snapshot,自动抓取结算基线快照 ...") - snapshot = _api("fetch_upsert_snapshot")( + snapshot = _api("GetNeedUserData")( user_id, token=token, client_id=client_id, 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, play_date_time=play_now, ) - _api("UserNewItemList")( + _api("GetUserNewItemList")( user_id, version=p.version_int, user_data=[post_user_data], user_playlog_list=list(user_playlog_list), diff --git a/api/upsert_user_chargelog.py b/api/upsert_user_chargelog.py index 6843893..c7435c0 100644 --- a/api/upsert_user_chargelog.py +++ b/api/upsert_user_chargelog.py @@ -10,7 +10,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs public ulong userId; public UserChargelog userChargelog; - public UserCharge userCharge; + public GetUserCharge userCharge; public long loginDateTime; 响应 :{returnCode, apiName} @@ -26,7 +26,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse │ └─ 没有 -> 新增一条 stock=1 └─ TicketManager.cs:234 RequestUserCharge(playerId) ├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId) - │ -> 取**购买后**(stock > 0)的那条 UserCharge + │ -> 取**购买后**(stock > 0)的那条 GetUserCharge ├─ VOExtensions.cs:403 ExportUserChargelog(ticketId) │ -> chargeId = ticketId │ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price @@ -130,7 +130,7 @@ class ChargelogGuardError(RuntimeError): @dataclass class UserChargeVO: - """UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。""" + """GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。""" chargeId: int = 0 stock: int = 0 purchaseDate: str = "" @@ -349,13 +349,13 @@ def UpsertUserChargelog( # ---------- 1. 取当前状态 ---------- if charge_list is None: 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) charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or [] if user_data is None: 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_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {} diff --git a/api/user_getter_common.py b/api/user_getter_common.py index 12683c5..bfc308b 100644 --- a/api/user_getter_common.py +++ b/api/user_getter_common.py @@ -7,7 +7,7 @@ user_getter_common.py —— 各取数脚本的公共底座 1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs) 2. _get() 发一次请求并返回**原始响应 dict** - 3. fetch_all_pages() 自动翻页 + 3. get_all_pages() 自动翻页 -------------------------------------------------------------------------------- ★ 为什么单独抽出来:各接口的请求体并不统一 @@ -187,7 +187,7 @@ def _get( return raw or {} -def fetch_all_pages( +def get_all_pages( api_name: str, user_id: int, list_key: str, diff --git a/buy_ticket.py b/buy_ticket.py index c1e786e..9bf4db9 100644 --- a/buy_ticket.py +++ b/buy_ticket.py @@ -6,7 +6,7 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登 一条完整的「买票」链路: - ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout + ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管): 1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」) @@ -51,17 +51,17 @@ from api import ( log_error, log_info, ParseQRCode, - UserPreview, + GetUserPreview, UserLogin, UserLogout, LogoutType, UpsertUserChargelog, ChargelogConfirmError, ChargelogGuardError, - UserCharge, + GetUserCharge, GetGameCharge, ticket_name, - fetch_upsert_snapshot, + GetNeedUserData, summarize_snapshot, 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 [] log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:") if not lst: @@ -164,7 +164,7 @@ def main() -> int: user_id, token = ParseQRCode(opts["qr"]) session.update(user_id=user_id, token=token) - preview = UserPreview(user_id, token) + preview = GetUserPreview(user_id, token) if preview.isLogin: log_error("账号处于已登录状态,请先在机台登出或等待会话超时") return 2 @@ -177,7 +177,7 @@ def main() -> int: # -> 13:20:10 才发 UpsertUserChargelogApi # 本脚本原来 login 后直接买,跳过了这一段。 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) _sum = summarize_snapshot(snap) log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 " diff --git a/reincarnate.py b/reincarnate.py new file mode 100644 index 0000000..c1c158d --- /dev/null +++ b/reincarnate.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B +================================================================================ + + ┌─ 源账号 A ─────────────────────────────────────────────┐ + │ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │ + │ 或:直接读 data/.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/.json) + python reincarnate.py --dst-qr --src-qr + + # 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码) + python reincarnate.py --dst-qr --src-json data/12345678.json + + # ★ 真实上传 + python reincarnate.py --dst-qr --src-json data/12345678.json --send + + # 只看计划,不发包 + python reincarnate.py --dst-qr --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/.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()) diff --git a/selftest.py b/selftest.py index 8bdca71..390f3f2 100644 --- a/selftest.py +++ b/selftest.py @@ -333,7 +333,7 @@ fav_server_raw.append({"itemKind": None, "itemIdList": []}) _, fav_flags_raw = build_favorite_delta(fav_local, fav_server_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 显式声明。 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,)) @@ -1137,7 +1137,7 @@ check("UpsertUserAll 已被包装", getattr(api.UpsertUserAll, "__mai_logged__", check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True) check("会话链路 6 个函数都在", all(hasattr(api, n) for n in - ("ParseQRCode", "GetGameSettings", "Ping", "UserPreview", + ("ParseQRCode", "GetGameSettings", "Ping", "GetUserPreview", "UserLogin", "UserLogout"))) diff --git a/upsert.py b/upsert.py index b3af3e0..0337631 100644 --- a/upsert.py +++ b/upsert.py @@ -106,7 +106,7 @@ from api import ( log_warning, log_error, ParseQRCode, - UserPreview, + GetUserPreview, UserLogin, UserLogout, LogoutType, @@ -194,7 +194,7 @@ def main() -> int: session.update(user_id=user_id, token=token) # ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ---- - preview = UserPreview(user_id, token) + preview = GetUserPreview(user_id, token) if preview.isLogin: log_error("账号处于已登录状态,请先在机台登出或等待会话超时") return 2 diff --git a/use_ticket.py b/use_ticket.py new file mode 100644 index 0000000..13ff13d --- /dev/null +++ b/use_ticket.py @@ -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())