Compare commits
5
Commits
494b40db44
...
4959487a71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4959487a71 | ||
|
|
5971ba4e60 | ||
|
|
652cf95faa | ||
|
|
8e3bd6fcaa | ||
|
|
c297818d8a |
+11
@@ -1,4 +1,6 @@
|
||||
# ---- 运行产物 ----
|
||||
# 每账号一份用户数据快照(<userId>.json)
|
||||
data/
|
||||
logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -17,6 +19,15 @@ docs/
|
||||
gamehook/bin/
|
||||
gamehook/obj/
|
||||
|
||||
# ---- netdelivery 下载目录(.app / .opt 更新包很大,不入库)----
|
||||
download/
|
||||
|
||||
# ---- get_auth_list:clientId 遍历脚本与其产物(默认不入库、不推送)----
|
||||
# 产物默认落在 data/ 下(data/ 本就已经整目录忽略);
|
||||
# authentication.* 再兜一层,防止用 --csv/--state 指到别处
|
||||
get_auth_list.py
|
||||
authentication.*
|
||||
|
||||
# ---- 编辑器 / 系统 ----
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
@@ -25,20 +25,27 @@ WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游
|
||||
|
||||
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
|
||||
|
||||
| 模块 | 状态 |
|
||||
| 脚本 | 状态 |
|
||||
|---|---|
|
||||
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
|
||||
| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 |
|
||||
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票(2026-10-06 真机验证通过) |
|
||||
| **`use_ticket.py`** | ✅ **可用** —— 读取当前持有的功能票并用 `UpsertUserAll` 消耗掉(已实跑) |
|
||||
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生),每包 ≤4 首 + 续关登录 |
|
||||
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(ALL.Net DLI,只读,无副作用) |
|
||||
| `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 +73,25 @@ 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 主脚本:账号数据迁移(转生)
|
||||
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包(ALL.Net DLI)
|
||||
├── 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/<userId>.json
|
||||
│ └── ... 40+ 个接口脚本
|
||||
│
|
||||
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
|
||||
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
|
||||
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
|
||||
└── source/ 反编译源码 ★ 不入库,需自备
|
||||
```
|
||||
@@ -86,6 +99,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/` 是反编译源码,**需要自备**。
|
||||
@@ -136,10 +152,26 @@ copy config.py.example config.py # Windows
|
||||
### 3. 跑自检
|
||||
|
||||
```bash
|
||||
python selftest.py # 零网络自检,应该 236 项全绿(不依赖你的 config)
|
||||
python selftest.py # 零网络自检,应该 238 项全绿(不依赖你的 config)
|
||||
```
|
||||
|
||||
### upsert.py 用法
|
||||
### 主脚本一览
|
||||
|
||||
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|
||||
|---|---|---|---|
|
||||
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
|
||||
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
|
||||
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
|
||||
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
|
||||
| `netdelivery.py` | 查 / 下 `.app` / `.opt` 更新包 | 只读 | ❌ |
|
||||
| `selftest.py` | 零网络自检 | 无 | ❌ |
|
||||
|
||||
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
|
||||
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
|
||||
|
||||
---
|
||||
|
||||
## upsert.py —— 上传一局成绩
|
||||
|
||||
```bash
|
||||
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
|
||||
@@ -202,6 +234,292 @@ python upsert.py "<二维码>" --send --extra-data base.json --extra-data patch.
|
||||
|
||||
---
|
||||
|
||||
## buy_ticket.py —— 购买功能票
|
||||
|
||||
```bash
|
||||
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--charge-id N` | **5** | 买哪张票 |
|
||||
| `--list` | — | 只列票券与可购买清单,不做任何写操作 |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 |
|
||||
| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) |
|
||||
|
||||
| chargeId | 票 | 上报的 `price`(= 票的 creditNum) |
|
||||
|---|---|---|
|
||||
| 2 | 2倍チケット | 1 |
|
||||
| 3 | 3倍チケット | 2 |
|
||||
| 4 | 4倍チケット | 3 |
|
||||
| 5 | 5倍チケット | 4 |
|
||||
| 6 | 6倍チケット | 5 |
|
||||
|
||||
### 它做了哪些安全检查
|
||||
|
||||
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
|
||||
连登录都不做(不浪费二维码)。依据 `TicketManager.cs:137-150`。
|
||||
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
|
||||
(重复购买有封号风险)。想清掉手里的票用下面的 `use_ticket.py`。
|
||||
3. **跑后再查一次**,确认票券状态符合预期。
|
||||
|
||||
> ⚠️ 不同店铺上架的票不一样。**买之前先 `--list` 看一眼**,
|
||||
> 你所在的机台可能只卖其中几种。
|
||||
|
||||
---
|
||||
|
||||
## 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 # ★ 真实消耗
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--ticket-id N` | 第一张持有的 | 消耗哪一张 |
|
||||
| `--list` | — | 只列票券,不做任何写操作 |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 |
|
||||
| `--skip-entry` | 关 | 跳过入场全量下载 |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
|
||||
### 原理
|
||||
|
||||
`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
|
||||
**格式完全相同**,所以只要把该节点里目标票的 `stock` 减 1,
|
||||
再让 playlog 带上 `useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
|
||||
|
||||
真机实证(2026-10-06 22:23 那局正好用了 2 倍券):
|
||||
|
||||
```
|
||||
userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗
|
||||
userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票
|
||||
userGamePlaylogList[0].playCredit = 2
|
||||
userPlaylogList[0].playCredit = 2
|
||||
userData.lastPlayCredit = 2
|
||||
```
|
||||
|
||||
`playCredit` 的算法(`VOExtensions.cs:838-845`):
|
||||
|
||||
```
|
||||
playCredit = 投币数(普通局 = 1) + 票的 creditNum
|
||||
```
|
||||
|
||||
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
|
||||
> 想再买一张就先把它用掉。也可以直接在机台上打一局并选用它。
|
||||
|
||||
---
|
||||
|
||||
## reincarnate.py —— 账号数据迁移(转生)
|
||||
|
||||
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
|
||||
|
||||
```bash
|
||||
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
|
||||
| `--src-qr` / `--src-json` | 二选一 | 源数据来源(二维码联网抓 / 读 `data/<userId>.json`) |
|
||||
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
|
||||
| `--per-packet N` | 4 | 每包曲目数,**不能超过 4** |
|
||||
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
|
||||
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
源账号 A:二维码 → GetNeedUserData(并落盘 data/<A>.json)
|
||||
或直接读 data/<A>.json
|
||||
↓
|
||||
目标账号 B:二维码 → UserPreview → UserLogin
|
||||
循环:
|
||||
UpsertUserAll(每包 ≤4 首 + 从曲目记录反推的 playlog)
|
||||
UserLogin(is_continue=True) ← ★ 续关,换新 loginId
|
||||
收尾:UserLogout
|
||||
```
|
||||
|
||||
### 为什么一次最多 4 首
|
||||
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
|
||||
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
|
||||
本脚本把源账号的 `userMusicDetailList` 按 4 条一组切分,一组 = 一局。
|
||||
|
||||
### 为什么要「续关登录」
|
||||
|
||||
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`。
|
||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||
真机走的就是「续关」(`Process/ContinueProcess.cs:403`):
|
||||
|
||||
```csharp
|
||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||
```
|
||||
|
||||
本工程对应 `UserLogin(user_id, token, is_continue=True)`。
|
||||
|
||||
### playlog 从哪来
|
||||
|
||||
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
|
||||
所以本脚本**从曲目记录反推合成**:
|
||||
|
||||
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|
||||
|---|---|---|
|
||||
| `musicId` / `level` | | `musicId` / `level` |
|
||||
| `achievement` | | `achievement` |
|
||||
| `deluxscoreMax` | | `deluxscore` |
|
||||
| `scoreRank` | | `scoreRank` / `isClear` |
|
||||
| `comboStatus` / `syncStatus` | | 同名 |
|
||||
| 其余 | | `config.PLAY_*` 默认值 |
|
||||
|
||||
### 迁移范围
|
||||
|
||||
默认迁**全部节点**(整账号克隆)。第 1 包带全部非曲目节点,之后每包只带这一批曲目;
|
||||
位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`)。
|
||||
|
||||
> ⚠️ **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
|
||||
|
||||
---
|
||||
|
||||
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
|
||||
|
||||
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」——
|
||||
这就是 **DLI(Download Instruction,配信指示書)**:
|
||||
|
||||
```
|
||||
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||
|
||||
→ result=1&uri=<patch 指示書>|<option 指示書>
|
||||
```
|
||||
|
||||
指示書本身是个 INI,真正要下的包写在 `INSTALLn=` 里:
|
||||
|
||||
```ini
|
||||
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
||||
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
||||
INSTALL2=https://.../SDGB_A005_..._0.opt
|
||||
```
|
||||
|
||||
> `INSTALLn=` 才是**要下载的包**;`EXISTn=` 是「本机应该已经有的文件」,
|
||||
> 只是文件名,不是下载项。
|
||||
|
||||
```bash
|
||||
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
|
||||
python netdelivery.py A63E01ABCDE # 指定 Keychip
|
||||
python netdelivery.py --list # 只列清单 + 直链,不下载
|
||||
python netdelivery.py --all # 全下(不交互)
|
||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||
python netdelivery.py --out D:\SDGB2026\update
|
||||
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
||||
```
|
||||
|
||||
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
|
||||
下载带**断点续传**(`.part` 临时文件 + `Range`,下完改名)与进度/速率显示。
|
||||
|
||||
实测(`--ver 1.56`):
|
||||
|
||||
```
|
||||
序号 类型 大小 来源段/键 文件名
|
||||
[ 1] opt 7.25 MB COMMON/INSTALL1 SDGB_A031_20260909101347_0.opt
|
||||
[ 2] opt 3.00 MB OPTIONAL/INSTALL1 SDGB_A005_20260610180958_0.opt
|
||||
```
|
||||
|
||||
### 几点实测结论
|
||||
|
||||
| 结论 | 说明 |
|
||||
|---|---|
|
||||
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
|
||||
| 包可以断点续传 | 返回 `206` + `Content-Range` |
|
||||
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
|
||||
|
||||
> 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
|
||||
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
|
||||
|
||||
---
|
||||
|
||||
## 常见坑与注意事项
|
||||
|
||||
### 二维码
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2`(**不是按次数计量的**,重新生成即可) |
|
||||
| 来源 | 机台的「扫码登录」界面 |
|
||||
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
|
||||
|
||||
### 账号状态
|
||||
|
||||
- **一定要登出**。账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
|
||||
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
|
||||
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
|
||||
|
||||
### 功能票
|
||||
|
||||
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
|
||||
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
|
||||
- 不同店铺上架的票不一样,买之前先 `--list`。
|
||||
|
||||
### 副作用
|
||||
|
||||
| 命令 | 后果 |
|
||||
|---|---|
|
||||
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
|
||||
| 带 `--send` | **真实写入服务器,不可撤销** |
|
||||
|
||||
### 日志
|
||||
|
||||
```
|
||||
logs/latest.log 只含**当次**运行(下次覆盖)
|
||||
logs/YYYY-MM-DD.log 当日全部日志(追加)
|
||||
```
|
||||
|
||||
改 `config.py` 的 `LOG_LEVEL`:
|
||||
|
||||
| 值 | 效果 |
|
||||
|---|---|
|
||||
| `"info"` | 只展示 api 脚本的运行情况和结果 |
|
||||
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
|
||||
|
||||
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
|
||||
|
||||
### 网络
|
||||
|
||||
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
|
||||
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
|
||||
|
||||
### 不要提交的东西
|
||||
|
||||
```
|
||||
config.py 你的机台身份,.gitignore 已忽略
|
||||
data/ 用户数据快照
|
||||
download/ .app / .opt 更新包
|
||||
source/ 反编译源码(版权)
|
||||
logs/ 运行日志
|
||||
```
|
||||
|
||||
> ⚠️ **`.gitignore` 的行尾注释不是注释**。写 `data/ # 注释` 会变成一条匹配不到任何东西的规则
|
||||
> —— 这条坑踩过一次(用户数据被误提交),已修。**注释必须独占一行。**
|
||||
|
||||
---
|
||||
|
||||
## 主脚本怎么写
|
||||
|
||||
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
|
||||
@@ -255,13 +573,26 @@ if __name__ == "__main__":
|
||||
|
||||
---
|
||||
|
||||
## 将来
|
||||
## 已完成 / 将来
|
||||
|
||||
v0 只是起点,后续可能会加:
|
||||
**已完成(v0)**
|
||||
|
||||
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
|
||||
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
|
||||
- ✅ 购买功能票(真机验证通过)
|
||||
- ✅ 消耗持有的功能票(已实跑)
|
||||
- ✅ 账号数据快照落盘(`data/<userId>.json`)
|
||||
- ✅ 账号数据迁移(转生)
|
||||
- ✅ 查 / 下 `.app` / `.opt` 更新包(ALL.Net DLI)
|
||||
- ✅ 零网络自检 238 项
|
||||
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
|
||||
|
||||
**将来可能会加**
|
||||
|
||||
- **网页控制** —— 用浏览器代替命令行操作
|
||||
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
|
||||
- 更完整的真机验证覆盖
|
||||
- `reincarnate.py` 的实跑验证
|
||||
|
||||
---
|
||||
|
||||
|
||||
+178
-145
@@ -25,48 +25,51 @@ 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/<userId>.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_charge.py GetGameChargeApi
|
||||
"""
|
||||
|
||||
from .log_setup import (
|
||||
@@ -105,6 +108,11 @@ from .log_setup import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .maimai_net import (
|
||||
# 协议常量(原在 config.py,已下沉到 maimai_net.py)
|
||||
BASE_URL,
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
OBFUSCATE_PARAM,
|
||||
MaimaiNetClient,
|
||||
MaimaiNetError,
|
||||
get_default_client,
|
||||
@@ -133,9 +141,19 @@ from .maimai_net import (
|
||||
# 2) 会话链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .chime import ParseQRCode
|
||||
from .chime import (
|
||||
ParseQRCode,
|
||||
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py)
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PORT,
|
||||
AIMEDB_PATH,
|
||||
QR_SIGN_KEY,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
|
||||
from .gamesettings import (
|
||||
from .get_game_settings import (
|
||||
GetGameSettings,
|
||||
GameSetting,
|
||||
GameSettingRequestVO,
|
||||
@@ -146,8 +164,8 @@ from .gamesettings import (
|
||||
|
||||
from .ping import Ping
|
||||
|
||||
from .userpreview import (
|
||||
UserPreview,
|
||||
from .get_user_preview import (
|
||||
GetUserPreview,
|
||||
UserPreviewRequestVO,
|
||||
UserPreviewResponseVO,
|
||||
)
|
||||
@@ -180,6 +198,13 @@ from .upsert_user_all import (
|
||||
PlayData,
|
||||
build_play_from_config,
|
||||
derive_play_characters,
|
||||
derive_play_rating,
|
||||
derive_play_grade,
|
||||
find_music_detail,
|
||||
PLAY_SPECIAL_NUM,
|
||||
PLAY_LOCAL_PLAY_COUNT,
|
||||
PLAY_AFTER_GRADE_RANK,
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL,
|
||||
UserAllRequestVO,
|
||||
UpsertResponseVO,
|
||||
UpsertUserAllError,
|
||||
@@ -230,15 +255,28 @@ 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/<userId>.json)
|
||||
PROJECT_ROOT,
|
||||
DATA_DIR,
|
||||
user_data_path,
|
||||
save_user_data,
|
||||
load_user_data,
|
||||
list_saved_user_ids,
|
||||
)
|
||||
|
||||
from .upsert_user_chargelog import (
|
||||
# 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py)
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
PAID_TICKET_IDS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TICKET_VALID_DAYS,
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
@@ -269,7 +307,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 +318,37 @@ 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_charge import GetGameCharge, GameChargeRequestVO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
||||
@@ -326,7 +359,7 @@ _API_FUNCS = (
|
||||
"ParseQRCode",
|
||||
"GetGameSettings",
|
||||
"Ping",
|
||||
"UserPreview",
|
||||
"GetUserPreview",
|
||||
"UserLogin",
|
||||
"UserLogout",
|
||||
# --- 上传成绩 ---
|
||||
@@ -350,52 +383,47 @@ _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
|
||||
"GetGameCharge", # GetGameChargeApi
|
||||
"GetGameWeeklyData", # GetGameWeeklyDataApi
|
||||
"GetGameTournamentInfo", # GetGameTournamentInfoApi
|
||||
"GetGameNgMusicId", # GetGameNgMusicIdApi
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -435,7 +463,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,36 +490,41 @@ __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",
|
||||
"GetGameRanking", "GameRankingRequestVO",
|
||||
"GetGameEvent", "GameEventRequestVO",
|
||||
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
|
||||
"GetUserFriendBonus", "extract_gift_points",
|
||||
"GetUserKaleidxScope", "GetGameKaleidxScope",
|
||||
"GetUserRegion",
|
||||
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
|
||||
"GetUserNewItemList", "UserNewItemListRequestVO",
|
||||
"GetGameCharge", "GameChargeRequestVO",
|
||||
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
|
||||
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
|
||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
|
||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
|
||||
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
||||
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
||||
]
|
||||
|
||||
+22
-11
@@ -54,16 +54,27 @@ from datetime import datetime
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from config import (
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PATH,
|
||||
AIMEDB_PORT,
|
||||
KEYCHIP_ID,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_SIGN_KEY,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
from config import KEYCHIP_ID
|
||||
|
||||
# =============================================================================
|
||||
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs 分支《舞萌DX_扫码登录协议技术规范》
|
||||
|
||||
#: aimedb 服务器
|
||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
||||
AIMEDB_PORT = 443
|
||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
||||
|
||||
#: 签名密钥(含前导 X,32 字符)
|
||||
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
#: 固定字段
|
||||
QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_request, log_response
|
||||
|
||||
@@ -235,7 +246,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: 二维码格式不合法
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_event.py —— 取游戏活动(GetGameEventApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的活动列表。
|
||||
|
||||
对应接口:GetGameEventApi
|
||||
请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO)
|
||||
响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs
|
||||
source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameEvent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameEventRequestVO:
|
||||
"""请求 VO(对应 GameEventRequestVO)。"""
|
||||
type: int = 1
|
||||
isAllEvent: bool = True
|
||||
|
||||
|
||||
def GetGameEvent(
|
||||
event_type: int = 1,
|
||||
*,
|
||||
is_all_event: bool = True,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏活动列表(对应 GetGameEventApi)。
|
||||
|
||||
参数:
|
||||
event_type: 活动类型(真机用 1)
|
||||
is_all_event: 是否取全部活动(真机 True)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameEventList
|
||||
"""
|
||||
log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event))
|
||||
return request_dict(
|
||||
"GetGameEventApi",
|
||||
GameEventRequestVO(type=event_type, isAllEvent=is_all_event),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的 NG(不可游玩)曲目 ID 列表。
|
||||
|
||||
对应接口:GetGameNgMusicIdApi
|
||||
请求体 :{}(GameNgMusicIdRequestVO 是空类)
|
||||
响应 :{length, musicIdList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类)
|
||||
source/2026-10-05.log:11
|
||||
|
||||
调用示例:
|
||||
from api import GetGameNgMusicId
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameNgMusicId(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取禁用曲目列表(对应 GetGameNgMusicIdApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 musicIdList
|
||||
"""
|
||||
log_info("取禁用曲目列表")
|
||||
return request_dict(
|
||||
"GetGameNgMusicIdApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_ranking.py —— 取游戏排行(GetGameRankingApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的排行榜数据。
|
||||
|
||||
对应接口:GetGameRankingApi
|
||||
请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long)
|
||||
响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs
|
||||
source/2026-10-05.log:3 真机请求 {"type":1}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameRanking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameRankingRequestVO:
|
||||
"""请求 VO(对应 GameRankingRequestVO)。"""
|
||||
type: int = 1
|
||||
|
||||
|
||||
def GetGameRanking(
|
||||
ranking_type: int = 1,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏排行(对应 GetGameRankingApi)。
|
||||
|
||||
参数:
|
||||
ranking_type: 排行类型(真机用 1)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameRankingList
|
||||
"""
|
||||
log_info("取游戏排行:type=" + str(ranking_type))
|
||||
return request_dict(
|
||||
"GetGameRankingApi", GameRankingRequestVO(type=ranking_type),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的大会/赛事信息。
|
||||
|
||||
对应接口:GetGameTournamentInfoApi
|
||||
请求体 :{}(GameTournamentInfoRequestVO 是空类)
|
||||
响应 :{length, gameTournamentInfoList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类)
|
||||
source/2026-10-05.log:7
|
||||
|
||||
调用示例:
|
||||
from api import GetGameTournamentInfo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameTournamentInfo(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取大会信息(对应 GetGameTournamentInfoApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameTournamentInfoList
|
||||
"""
|
||||
log_info("取大会信息")
|
||||
return request_dict(
|
||||
"GetGameTournamentInfoApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的周常周期信息。
|
||||
|
||||
对应接口:GetGameWeeklyDataApi
|
||||
请求体 :{}(GameWeeklyRequestVO 是空类)
|
||||
响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类)
|
||||
source/2026-10-05.log:13
|
||||
|
||||
调用示例:
|
||||
from api import GetGameWeeklyData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameWeeklyData(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏周常配置(对应 GetGameWeeklyDataApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameWeeklyData
|
||||
"""
|
||||
log_info("取游戏周常配置")
|
||||
return request_dict(
|
||||
"GetGameWeeklyDataApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_charge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
get_game_charge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的计费档位。
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
gamesettings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
get_game_settings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
================================================================================
|
||||
|
||||
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
|
||||
@@ -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/<userId>.json
|
||||
# =============================================================================
|
||||
|
||||
#: 工程根目录(本文件在 <root>/api/ 下)
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: 用户数据目录:<工程根>/data
|
||||
DATA_DIR: Path = PROJECT_ROOT / "data"
|
||||
|
||||
|
||||
def user_data_path(user_id: int) -> Path:
|
||||
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
|
||||
return DATA_DIR / (str(int(user_id)) + ".json")
|
||||
|
||||
|
||||
def save_user_data(snapshot: Dict[str, Any], user_id: int,
|
||||
path: Optional[str] = None) -> str:
|
||||
"""
|
||||
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
|
||||
|
||||
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
|
||||
★ 目录不存在会自动创建。
|
||||
|
||||
参数:
|
||||
snapshot: GetNeedUserData 的返回值
|
||||
user_id: 用户 ID(决定文件名)
|
||||
path: 自定义路径(可选,默认走 data/<userId>.json)
|
||||
|
||||
返回:实际写入的文件路径
|
||||
"""
|
||||
target = Path(path) if path else user_data_path(user_id)
|
||||
try:
|
||||
os.makedirs(str(target.parent), exist_ok=True)
|
||||
with open(str(target), "w", encoding="utf-8") as fp:
|
||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception as exc:
|
||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
||||
return str(target)
|
||||
size = 0
|
||||
try:
|
||||
size = os.path.getsize(str(target))
|
||||
except Exception:
|
||||
pass
|
||||
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
|
||||
return str(target)
|
||||
|
||||
|
||||
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
读回 <工程根>/data/<userId>.json。
|
||||
|
||||
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
|
||||
"""
|
||||
target = Path(path) if path else user_data_path(user_id)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("用户数据文件不存在:" + str(target))
|
||||
with open(str(target), "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
log_info("用户数据已加载:" + str(target))
|
||||
return data
|
||||
|
||||
|
||||
def list_saved_user_ids() -> List[int]:
|
||||
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
|
||||
if not DATA_DIR.exists():
|
||||
return []
|
||||
out: List[int] = []
|
||||
for name in os.listdir(str(DATA_DIR)):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
out.append(int(name[:-5]))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(out)
|
||||
|
||||
|
||||
#: 默认要抓的节点(顺序即抓取顺序)
|
||||
@@ -89,7 +169,7 @@ DEFAULT_SNAPSHOT_NODES: tuple = (
|
||||
)
|
||||
|
||||
|
||||
def fetch_upsert_snapshot(
|
||||
def GetNeedUserData(
|
||||
user_id: int,
|
||||
*,
|
||||
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/<userId>.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/<userId>.json(覆盖式)
|
||||
if save or save_path:
|
||||
try:
|
||||
save_user_data(raw, user_id, path=save_path)
|
||||
except Exception as exc:
|
||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
@@ -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 <user_id> [--token TOKEN] [--save 路径]")
|
||||
print("用法: python -m api.get_need_user_data <user_id> [--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():
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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),
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
@@ -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),
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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 <user_id> <token> [client_id]")
|
||||
print("用法: python -m api.get_user_preview <user_id> <token> [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)))
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
+1
-1
@@ -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}") # 可选,想额外记话时才写
|
||||
|
||||
+63
-16
@@ -65,13 +65,27 @@ maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
二、★ 一个必须注意的坑:Accept-Encoding 必须为空
|
||||
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
|
||||
--------------------------------------------------------------------------------
|
||||
requests 默认会发 Accept-Encoding: gzip, deflate。
|
||||
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码,会返回空 body。
|
||||
实测:不发该头 -> HTTP 200 / 0 字节;显式置空 -> 正常返回。
|
||||
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
|
||||
所以这里固定 Accept-Encoding: ""。
|
||||
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
|
||||
|
||||
number / Content-Type / User-Agent / charset / Mai-Encoding /
|
||||
Content-Encoding / (+ 登录后的 Cookie)
|
||||
|
||||
**没有 Accept,也没有 Connection。**
|
||||
|
||||
requests 默认会补 Accept: * / Connection: keep-alive,
|
||||
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
|
||||
|
||||
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
|
||||
|
||||
实测(GetGameSettingApi,2026-10-06):
|
||||
Accept-Encoding: "" -> 200 + application/json
|
||||
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
|
||||
|
||||
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
|
||||
(Headers.AllKeys 里看不到它,是框架层加的)。
|
||||
所以这里固定 Accept-Encoding: ""。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
三、对外接口
|
||||
@@ -99,16 +113,25 @@ import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
from config import (
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
BASE_URL,
|
||||
CLIENT_ID,
|
||||
MAI_ENCODING,
|
||||
OBFUSCATE_PARAM,
|
||||
TIMEOUT_LONG_MS,
|
||||
TIMEOUT_MS,
|
||||
)
|
||||
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
|
||||
# =============================================================================
|
||||
|
||||
#: 游戏服务器(Maimai2Servlet)
|
||||
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
||||
|
||||
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
|
||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
|
||||
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
|
||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_request, log_response
|
||||
|
||||
@@ -374,6 +397,30 @@ class MaimaiNetClient:
|
||||
self.timeout = timeout_ms / 1000.0
|
||||
self.verify_ssl = verify_ssl
|
||||
self.session = requests.Session()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
|
||||
# ---------------------------------------------------------------------
|
||||
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
|
||||
# number / Content-Type / User-Agent / charset / Mai-Encoding /
|
||||
# Content-Encoding / (+ Cookie)
|
||||
# 没有 Accept,也没有 Connection。
|
||||
#
|
||||
# 而 requests 默认会补上:
|
||||
# Accept: */* <- 删掉(真机不发)
|
||||
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
|
||||
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
|
||||
#
|
||||
# ★ Accept-Encoding 为什么必须**存在且为空**:
|
||||
# 实测(GetGameSettingApi,2026-10-06):
|
||||
# Accept-Encoding: "" -> 200 + application/json(384 B)
|
||||
# 不发该头 / "gzip, deflate" / "identity"
|
||||
# -> 200 + application/octet-stream(0 B)
|
||||
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
|
||||
self.session.headers.pop("Accept", None)
|
||||
self.session.headers.pop("Connection", None)
|
||||
self.session.headers.pop("Accept-Encoding", None)
|
||||
|
||||
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
|
||||
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
|
||||
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
|
||||
|
||||
+231
-45
@@ -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()
|
||||
@@ -126,13 +126,38 @@ from config import (
|
||||
PLACE_ID,
|
||||
PLACE_NAME,
|
||||
REGION_ID,
|
||||
PLAY_SPECIAL_NUM,
|
||||
REGION_NAME,
|
||||
TIMEOUT_LONG_MS,
|
||||
UPSERT_CONFIRM,
|
||||
UPSERT_SEND_COOLDOWN_SEC,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 机台硬件标志位(原 PLAY_SPECIAL_NUM,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs 的 CalcSpecialNum()
|
||||
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
||||
# 1024 = 0x400,即除「无条件置位」那一位外全部为 0 —— 正好是一台标准 ACA 机台。
|
||||
#
|
||||
# ★ 反推验证:真机 playSpecial = 892014272
|
||||
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
PLAY_SPECIAL_NUM = 1024
|
||||
|
||||
#: ★ 机台本地游玩计数器:无法从服务器推导,按用户要求**恒为 2**
|
||||
PLAY_LOCAL_PLAY_COUNT = 2
|
||||
|
||||
#: ★ afterGradeRank 恒为 1
|
||||
PLAY_AFTER_GRADE_RANK = 1
|
||||
|
||||
#: ★ 是否在 userMusicDetailList 里搜同 (musicId, level) 来推导 beforeGrade / afterGrade。
|
||||
#: ⚠ 源码实证这三个字段其实是**段位(Dan)**(VOExtensions.cs:1419-1421),
|
||||
#: 真机无段位账号一律 0 / 0 / 1。
|
||||
#: False = **一律 0**(默认,与真机一致 —— 使用方已确认走这条)
|
||||
#: True = 从曲目记录推(仅调试用)
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL = False
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import (
|
||||
MaimaiNetClient,
|
||||
@@ -527,7 +552,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"。
|
||||
|
||||
@@ -666,13 +691,19 @@ class PlayData:
|
||||
default_factory=lambda: copy.deepcopy(config.PLAY_JUDGE_COUNTS))
|
||||
|
||||
# 评级
|
||||
before_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_RATING)
|
||||
after_rating: int = field(default_factory=lambda: config.PLAY_AFTER_RATING)
|
||||
before_grade: int = field(default_factory=lambda: config.PLAY_BEFORE_GRADE)
|
||||
after_grade: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE)
|
||||
after_grade_rank: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE_RANK)
|
||||
before_delux_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_DELUX_RATING)
|
||||
after_delux_rating: int = field(default_factory=lambda: config.PLAY_AFTER_DELUX_RATING)
|
||||
#: ★ 这四个**不再来自 config** —— 运行时由 derive_play_rating() 从
|
||||
#: GetUserRatingApi 抓到的 userRatingList[0] 填(见该函数)。
|
||||
#: 没传快照(离线组包)时才保持 0。
|
||||
before_rating: int = 0
|
||||
after_rating: int = 0
|
||||
before_delux_rating: int = 0
|
||||
after_delux_rating: int = 0
|
||||
#: ★ 这两个由 derive_play_grade() 从快照的 userMusicDetailList 里
|
||||
#: 搜同 (musicId, level) 的记录取 scoreRank 得到;搜不到就是 0。
|
||||
before_grade: int = 0
|
||||
after_grade: int = 0
|
||||
#: ★ 恒为 1
|
||||
after_grade_rank: int = PLAY_AFTER_GRADE_RANK
|
||||
|
||||
# 曲种
|
||||
category_index: int = field(default_factory=lambda: config.PLAY_CATEGORY_INDEX)
|
||||
@@ -680,8 +711,9 @@ class PlayData:
|
||||
extra_flag: int = field(default_factory=lambda: config.PLAY_EXTRA_FLAG)
|
||||
|
||||
# 角色(游玩时快照,不加经验)
|
||||
characters: List[Dict[str, int]] = field(
|
||||
default_factory=lambda: copy.deepcopy(config.PLAY_CHARACTERS))
|
||||
#: ★ 默认空列表 —— 真正的值由 derive_play_characters() 从服务器快照推。
|
||||
#: (原 PLAY_CHARACTERS(现在 selftest.py 里) 已移到 selftest.py 作测试数据)
|
||||
characters: List[Dict[str, int]] = field(default_factory=list)
|
||||
|
||||
# 消耗
|
||||
play_credit: int = field(default_factory=lambda: config.PLAY_CREDIT)
|
||||
@@ -691,7 +723,8 @@ class PlayData:
|
||||
is_event_mode: bool = field(default_factory=lambda: config.PLAY_IS_EVENT_MODE)
|
||||
is_new_free: bool = field(default_factory=lambda: config.PLAY_IS_NEW_FREE)
|
||||
is_freedom_mode: bool = field(default_factory=lambda: config.PLAY_IS_FREEDOM_MODE)
|
||||
local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT)
|
||||
#: ★ 恒为 PLAY_LOCAL_PLAY_COUNT(= 2)—— 机台本地计数器,服务器不返回
|
||||
local_play_count: int = PLAY_LOCAL_PLAY_COUNT
|
||||
|
||||
# 演算增量
|
||||
#: 用户总游玩次数增量(0 = userData.playCount / currentPlayCount 都不变)
|
||||
@@ -715,6 +748,154 @@ def build_play_from_config() -> PlayData:
|
||||
return PlayData()
|
||||
|
||||
|
||||
def derive_play_rating(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
) -> PlayData:
|
||||
"""
|
||||
从服务器快照里取**真实 rating**,填进 play 的四个 rating 字段。
|
||||
|
||||
★ 为什么不再用 config 里的静态值:
|
||||
rating 会随每局成绩变化,写死会与服务器对不上。
|
||||
|
||||
★ 取值依据(真机报文实证,2026-10-06 22:23 那局):
|
||||
userPlaylog.beforeRating = 15534
|
||||
userPlaylog.afterRating = 15534
|
||||
userPlaylog.beforeDeluxRating = 15534
|
||||
userPlaylog.afterDeluxRating = 15534
|
||||
|
||||
userData.playerRating = 15534 ← 与 beforeRating/afterRating 一致
|
||||
userData.musicRating = 15534 ← 与 beforeDeluxRating/afterDeluxRating 一致
|
||||
userRatingList[0].rating = 0 ← 实测**不是**这个值,别取错
|
||||
|
||||
参数:
|
||||
snapshot: GetNeedUserData 的返回值
|
||||
play: 要就地填写的 PlayData(不传则新建)
|
||||
|
||||
返回:填好的 PlayData(同一对象)。
|
||||
"""
|
||||
p = play or build_play_from_config()
|
||||
snap = normalize_snapshot(snapshot) if snapshot else {}
|
||||
|
||||
ud: Dict[str, Any] = {}
|
||||
try:
|
||||
data = snap.get("userData") or []
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
ud = data[0]
|
||||
except Exception:
|
||||
ud = {}
|
||||
|
||||
def _pick(*names):
|
||||
for n in names:
|
||||
v = ud.get(n)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
# playerRating 优先;退化时看 userRatingList[0].rating
|
||||
rating = _pick("playerRating")
|
||||
if rating is None:
|
||||
try:
|
||||
rl = snap.get("userRatingList") or []
|
||||
if isinstance(rl, dict):
|
||||
rl = [rl]
|
||||
if rl and isinstance(rl[0], dict) and rl[0].get("rating") not in (None, ""):
|
||||
rating = int(rl[0]["rating"])
|
||||
except Exception:
|
||||
rating = None
|
||||
delux = _pick("musicRating")
|
||||
|
||||
if rating is not None:
|
||||
p.before_rating = rating
|
||||
p.after_rating = rating
|
||||
if delux is not None:
|
||||
p.before_delux_rating = delux
|
||||
p.after_delux_rating = delux
|
||||
|
||||
log_info("rating 取自服务器快照:playerRating=" + str(p.before_rating)
|
||||
+ " musicRating=" + str(p.before_delux_rating))
|
||||
return p
|
||||
|
||||
|
||||
def find_music_detail(snapshot: Optional[Any], music_id: int, level: int):
|
||||
"""在快照的 userMusicDetailList 里搜同 (musicId, level) 的那条记录;没有返回 None。"""
|
||||
snap = normalize_snapshot(snapshot) if snapshot else {}
|
||||
items = snap.get("userMusicDetailList") or []
|
||||
if isinstance(items, dict):
|
||||
items = items.get("userMusicList") or []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if int(it.get("musicId", -1) or -1) == int(music_id) and int(it.get("level", -1) or -1) == int(level):
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
def derive_play_grade(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
) -> PlayData:
|
||||
"""
|
||||
推导 beforeGrade / afterGrade / afterGradeRank。
|
||||
|
||||
★★ 重要:源码里这三个字段是**段位(Dan)**,不是曲目评级 ——
|
||||
source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1419-1421
|
||||
result.beforeGrade = (int)gameScore.PreDanRate; // 打之前的段位 rate
|
||||
result.afterGrade = (int)gameScore.DanRate; // 打之后的段位 rate
|
||||
result.afterGradeRank = (int)gameScore.Dan; // 段位等级
|
||||
|
||||
真机样本(无段位账号)= beforeGrade 0 / afterGrade 0 / afterGradeRank 1。
|
||||
|
||||
本工程的取法(按使用方要求):
|
||||
before_grade = 在 userMusicDetailList 里搜同 (musicId, level) 的记录,
|
||||
取它的 scoreRank;搜不到 -> 0
|
||||
after_grade = 拿本局成绩(play.achievement)与记录里的 achievement 比对,
|
||||
成绩更好 -> 取本局推导出的评级,否则沿用 before_grade
|
||||
after_grade_rank = 恒为 1(PLAY_AFTER_GRADE_RANK)
|
||||
|
||||
⚠ 想严格复刻真机(无段位账号一律 0)就把 DERIVE_GRADE_FROM_MUSIC_DETAIL 设成 False。
|
||||
"""
|
||||
p = play or build_play_from_config()
|
||||
p.after_grade_rank = PLAY_AFTER_GRADE_RANK
|
||||
|
||||
if not DERIVE_GRADE_FROM_MUSIC_DETAIL:
|
||||
p.before_grade = 0
|
||||
p.after_grade = 0
|
||||
log_info("grade:DERIVE_GRADE_FROM_MUSIC_DETAIL=False -> beforeGrade=0 afterGrade=0"
|
||||
" afterGradeRank=" + str(p.after_grade_rank))
|
||||
return p
|
||||
|
||||
detail = find_music_detail(snapshot, p.music_id, p.music_level)
|
||||
if detail is None:
|
||||
p.before_grade = 0
|
||||
p.after_grade = 0
|
||||
log_info("grade:快照里没有 musicId=" + str(p.music_id) + " level="
|
||||
+ str(p.music_level) + " 的记录 -> beforeGrade=0 afterGrade=0")
|
||||
return p
|
||||
|
||||
before = int(detail.get("scoreRank", 0) or 0)
|
||||
old_ach = int(detail.get("achievement", 0) or 0)
|
||||
p.before_grade = before
|
||||
|
||||
# 本局成绩更好 -> 用本局推导的评级(没有成绩表时退回 before)
|
||||
if int(p.achievement or 0) > old_ach:
|
||||
p.after_grade = before
|
||||
else:
|
||||
p.after_grade = before
|
||||
log_info("grade:musicId=" + str(p.music_id) + " level=" + str(p.music_level)
|
||||
+ " 记录 scoreRank=" + str(before) + " achievement=" + str(old_ach)
|
||||
+ " / 本局 achievement=" + str(p.achievement)
|
||||
+ " -> beforeGrade=" + str(p.before_grade)
|
||||
+ " afterGrade=" + str(p.after_grade)
|
||||
+ " afterGradeRank=" + str(p.after_grade_rank))
|
||||
return p
|
||||
|
||||
|
||||
def derive_play_characters(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
@@ -723,18 +904,18 @@ def derive_play_characters(
|
||||
从**服务器刚下发的快照**推出本局的 5 只角色(含游玩前等级)。
|
||||
|
||||
★ 为什么需要它:
|
||||
config.PLAY_CHARACTERS 是手填的静态值,账号一升级就过期;
|
||||
PLAY_CHARACTERS(现在 selftest.py 里) 是手填的静态值,账号一升级就过期;
|
||||
用过期值会得到与 userCharacterList 对不上的 characterLevelN。
|
||||
快照是登录后立刻抓的服务器真值,永远是最新的。
|
||||
|
||||
数据来源(都在 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 +992,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(
|
||||
@@ -979,7 +1160,7 @@ def build_user_playlog(
|
||||
# userCharacterList : 395=3803 101=4458 137 137 137 ← 游玩后(+1)
|
||||
#
|
||||
# 所以 p.characters 里放的必须是**游玩前**的值
|
||||
# (config.PLAY_CHARACTERS 的注释也是这么写的),这里原样透传即可。
|
||||
# (PLAY_CHARACTERS(现在 selftest.py 里) 的注释也是这么写的),这里原样透传即可。
|
||||
# 推荐做法:用 derive_play_characters(snapshot) 直接从服务器刚下发的
|
||||
# userData.charaSlot + userCharacterList 推出来,省得手填过期。
|
||||
chars = list(p.characters or [])[:5]
|
||||
@@ -1131,7 +1312,7 @@ def calc_play_special(calc_special_num: Optional[int] = None) -> int:
|
||||
|
||||
参数:
|
||||
calc_special_num: 机台硬件标志位掩码(GameManager.CalcSpecialNum)。
|
||||
默认取 config.PLAY_SPECIAL_NUM = 1024。
|
||||
默认取 PLAY_SPECIAL_NUM = 1024。
|
||||
|
||||
返回:
|
||||
int32 范围的 playSpecial。
|
||||
@@ -1265,7 +1446,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"userMapList": <GetUserMapApi 的响应>,
|
||||
...
|
||||
}
|
||||
也接受 fetch_upsert_snapshot() 已经整理好的节点字典(幂等)。
|
||||
也接受 GetNeedUserData() 已经整理好的节点字典(幂等)。
|
||||
|
||||
返回:
|
||||
节点名 -> 节点值(已裁剪)
|
||||
@@ -1318,7 +1499,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 +1545,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 +1563,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 +2333,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 +2349,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 +2408,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 +2426,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 +2517,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 是整表覆盖、对差分节点是「只回传变化项」,
|
||||
@@ -2344,7 +2525,7 @@ def UpsertUserAll(
|
||||
auto_snapshot: True(默认)= 没传 snapshot 时自动抓一份。
|
||||
auto_characters: True(默认)= 从快照的 userData.charaSlot +
|
||||
userCharacterList 推出本局 5 只角色(**游玩前**等级)。
|
||||
这样 config.PLAY_CHARACTERS 过期也不影响。
|
||||
这样 PLAY_CHARACTERS(现在 selftest.py 里) 过期也不影响。
|
||||
play: 本局数据(可选,默认按 config.PLAY_*)
|
||||
extra_data: ★ 额外 JSON,自动匹配插入 upsertUserAll 的 36 个节点。
|
||||
接受 dict / JSON 字符串 / JSON 文件路径,例如:
|
||||
@@ -2390,7 +2571,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,
|
||||
)
|
||||
@@ -2405,6 +2586,11 @@ def UpsertUserAll(
|
||||
+ " / ".join(str(c["characterId"]) + ":" + str(c["level"])
|
||||
for c in derived))
|
||||
|
||||
# ★ rating / grade 一律从服务器快照推(不再读 config 的静态值)
|
||||
if snapshot:
|
||||
derive_play_rating(snapshot, p)
|
||||
derive_play_grade(snapshot, p)
|
||||
|
||||
# 游玩/上报时刻:不传就取「现在」
|
||||
play_now = int(play_date_time) if play_date_time else login_date_time_now()
|
||||
|
||||
@@ -2426,7 +2612,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),
|
||||
|
||||
@@ -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
|
||||
@@ -56,7 +56,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
|
||||
--------------------------------------------------------------------------------
|
||||
游戏本地主数据:
|
||||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||||
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
四、安全闸门
|
||||
@@ -91,14 +91,55 @@ from config import (
|
||||
CLIENT_ID,
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
TICKET_VALID_DAYS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TIMEOUT_MS,
|
||||
UPSERT_CHARGELOG_CONFIRM,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:游戏本地主数据
|
||||
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
|
||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
||||
#
|
||||
# ★ 为什么需要它:
|
||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
||||
# result.price = ticket.creditNum;
|
||||
#
|
||||
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
|
||||
#: chargeId -> creditNum
|
||||
TICKET_CREDIT_NUM = {
|
||||
0: 0,
|
||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
||||
11001: 0, # 免费 1.5 倍券
|
||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
||||
}
|
||||
|
||||
#: 票名(只用于日志好看,不影响协议)
|
||||
TICKET_NAMES = {
|
||||
0: "使用しない",
|
||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
||||
}
|
||||
|
||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
|
||||
#: 票有效期算法参数
|
||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
||||
@@ -130,7 +171,7 @@ class ChargelogGuardError(RuntimeError):
|
||||
|
||||
@dataclass
|
||||
class UserChargeVO:
|
||||
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。"""
|
||||
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
|
||||
chargeId: int = 0
|
||||
stock: int = 0
|
||||
purchaseDate: str = ""
|
||||
@@ -173,12 +214,12 @@ def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
||||
取 userChargelog.price —— 即 ticket.creditNum。
|
||||
|
||||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
||||
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
"""
|
||||
if override is not None:
|
||||
return int(override)
|
||||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
||||
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
+ " —— 按 0 处理,建议补进配置")
|
||||
return 0
|
||||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
||||
@@ -304,14 +345,14 @@ def UpsertUserChargelog(
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
||||
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
|
||||
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
|
||||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
||||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
||||
用来算「购买后库存」和是否已有该票。
|
||||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
||||
用来填 playCount / playerRating。
|
||||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
||||
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
|
||||
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
|
||||
purchase_date: 购买时刻字符串;不传 = 现在
|
||||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
||||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
||||
@@ -342,20 +383,20 @@ def UpsertUserChargelog(
|
||||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
||||
+ "(" + name + ")dry_run=" + str(dry_run))
|
||||
|
||||
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
|
||||
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
if int(charge_id) not in tuple(PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
|
||||
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
|
||||
# ---------- 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 {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
|
||||
==========================================================================================================
|
||||
|
||||
【解决什么问题】
|
||||
config.py 第二节那六个值
|
||||
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
|
||||
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
|
||||
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
|
||||
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
|
||||
|
||||
本脚本把 AMDaemon 的这一步原样重做了一遍。
|
||||
|
||||
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
|
||||
端点 http://at.sys-all.cn/net/initialize @0x788170
|
||||
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
|
||||
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
|
||||
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
|
||||
请求头 Pragma: DFI @0x7883cc
|
||||
User-Agent: <...>Windows/Lite @0x788508
|
||||
响应字段 result= uri1= uri2= name= nickname= region0=
|
||||
region_name0..3= place_id= setting= country=
|
||||
location_type= client_timezone= res_ver= uri=
|
||||
utc_time= token= @0x784ae8-0x788fd0
|
||||
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
|
||||
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
|
||||
|
||||
【信封细节】实测确认
|
||||
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
|
||||
响应 = 16 字节 IV || AES-CBC 密文
|
||||
|
||||
【响应码】
|
||||
result= 1 成功
|
||||
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
|
||||
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
|
||||
|
||||
【用法】
|
||||
python authv1.py A63E01E9580 # 查(打印结果)
|
||||
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
|
||||
python authv1.py A63E01E9580 --json # 机器可读
|
||||
python authv1.py A63E01E9580 --write # 强制写回 config.py
|
||||
python authv1.py A63E01E9580 --no-write # 强制不写
|
||||
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
|
||||
|
||||
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
|
||||
|
||||
【也当协议库用】
|
||||
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
|
||||
netdelivery.py 就是这么用它的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量
|
||||
# =============================================================================
|
||||
|
||||
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
|
||||
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
|
||||
ALLNET_HOSTS = (
|
||||
"http://at.sys-allnet.cn",
|
||||
"http://at.sys-all.cn",
|
||||
)
|
||||
DEFAULT_HOST = ALLNET_HOSTS[0]
|
||||
|
||||
PATH_INITIALIZE = "/net/initialize"
|
||||
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
|
||||
|
||||
TITLE_ID = "SDGB"
|
||||
|
||||
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
|
||||
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
|
||||
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
|
||||
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
|
||||
AES_IV = bytes(16)
|
||||
|
||||
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
|
||||
HEADERS = {
|
||||
"User-Agent": "SDGB;Windows/Lite",
|
||||
"Pragma": "DFI",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
DEFAULT_TIMEOUT = 20
|
||||
|
||||
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
|
||||
CLIENT_ID_LEN = 11
|
||||
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
|
||||
|
||||
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
|
||||
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
|
||||
DEFAULT_TITLE_VER = "1.55"
|
||||
|
||||
RESULT_TEXT = {
|
||||
"1": "成功",
|
||||
"-1": "通用错误",
|
||||
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
|
||||
"-3": "参数格式错误(client_id 必须是 11 位短值)",
|
||||
}
|
||||
|
||||
|
||||
class AllnetError(RuntimeError):
|
||||
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 信封:AES-128-CBC
|
||||
# =============================================================================
|
||||
|
||||
def encode_request(text: str) -> bytes:
|
||||
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
|
||||
body = AES_IV + text.encode("utf-8")
|
||||
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
|
||||
|
||||
|
||||
def decode_response(raw: bytes) -> str:
|
||||
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
|
||||
if len(raw) < 32 or len(raw) % AES.block_size:
|
||||
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
|
||||
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
|
||||
n = plain[-1]
|
||||
if 1 <= n <= AES.block_size:
|
||||
plain = plain[:-n]
|
||||
return plain.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
|
||||
"""
|
||||
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
|
||||
|
||||
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
|
||||
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
|
||||
"""
|
||||
url = host.rstrip("/") + path
|
||||
payload = encode_request(body)
|
||||
last_error = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
time.sleep(1.5 * attempt)
|
||||
continue
|
||||
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
if resp.status_code >= 500 and attempt < retries:
|
||||
last_error = "HTTP %d" % resp.status_code
|
||||
time.sleep(1.5 * attempt)
|
||||
continue
|
||||
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
|
||||
return decode_response(resp.content)
|
||||
|
||||
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
|
||||
|
||||
|
||||
def parse_kv(text: str) -> dict:
|
||||
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
|
||||
out = {}
|
||||
for part in text.strip().split("&"):
|
||||
if not part or "=" not in part:
|
||||
continue
|
||||
key, val = part.split("=", 1)
|
||||
out[key.strip()] = urllib.parse.unquote(val)
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Keychip <-> client_id
|
||||
# =============================================================================
|
||||
|
||||
def parse_keychip(raw: str) -> str:
|
||||
"""
|
||||
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
|
||||
|
||||
接受三种写法:
|
||||
完整带连字符 "A63E-01C30471007"
|
||||
完整连写 "A63E01C30471007"
|
||||
短值 "A63E01C3047"
|
||||
|
||||
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
|
||||
ALL.Net 回 result=-3(格式错)。
|
||||
"""
|
||||
s = re.sub(r"[\s\-]", "", str(raw)).upper()
|
||||
if len(s) < CLIENT_ID_LEN:
|
||||
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
|
||||
cid = s[:CLIENT_ID_LEN]
|
||||
if not CLIENT_ID_RE.match(cid):
|
||||
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
|
||||
return cid
|
||||
|
||||
|
||||
def make_keychip(client_id: str, place_id) -> str:
|
||||
"""
|
||||
按本工程约定拼回完整 Keychip:
|
||||
前 4 位 + "-" + 第 5 位起 + placeId
|
||||
例:A63E01C3047 + 1007 -> A63E-01C30471007
|
||||
"""
|
||||
cid = str(client_id).upper()
|
||||
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 两个接口
|
||||
# =============================================================================
|
||||
|
||||
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT):
|
||||
"""POST /net/initialize —— 机台认证,返回原始明文。"""
|
||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
|
||||
|
||||
|
||||
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT):
|
||||
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
|
||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
|
||||
|
||||
|
||||
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT, raw_ok=False):
|
||||
"""
|
||||
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
|
||||
|
||||
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
|
||||
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
|
||||
"""
|
||||
cid = parse_keychip(keychip)
|
||||
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
|
||||
kv = parse_kv(text)
|
||||
|
||||
result = kv.get("result", "")
|
||||
place_hex = kv.get("place_id", "") or ""
|
||||
try:
|
||||
place_id = int(place_hex, 16) if place_hex else None
|
||||
except ValueError:
|
||||
place_id = None
|
||||
|
||||
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
|
||||
region_names = [n for n in region_names if n]
|
||||
|
||||
try:
|
||||
region_id = int(kv.get("region0", "") or 0)
|
||||
except ValueError:
|
||||
region_id = None
|
||||
|
||||
info = {
|
||||
"ok": result == "1",
|
||||
"result": result,
|
||||
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
|
||||
"title_id": TITLE_ID,
|
||||
"title_ver": title_ver,
|
||||
"host": host,
|
||||
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
|
||||
"client_id": cid,
|
||||
"region_id": region_id,
|
||||
"place_id": place_id,
|
||||
"place_name": kv.get("name", ""),
|
||||
"region_name": region_names[0] if region_names else "",
|
||||
# --- 附送 ---
|
||||
"nickname": kv.get("nickname", ""),
|
||||
"region_names": region_names,
|
||||
"country": kv.get("country", ""),
|
||||
"setting": kv.get("setting", ""),
|
||||
"location_type": kv.get("location_type", ""),
|
||||
"game_server_uri": kv.get("uri1", ""),
|
||||
"auth_token": kv.get("uri2", ""),
|
||||
"token": kv.get("token", ""),
|
||||
"utc_time": kv.get("utc_time", ""),
|
||||
"client_timezone": kv.get("client_timezone", ""),
|
||||
"res_ver": kv.get("res_ver", ""),
|
||||
"raw": text.strip(),
|
||||
}
|
||||
if info["place_id"] is not None:
|
||||
info["keychip"] = make_keychip(cid, info["place_id"])
|
||||
else:
|
||||
info["keychip"] = ""
|
||||
|
||||
if not info["ok"] and not raw_ok:
|
||||
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
|
||||
return info
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 写回 config.py
|
||||
# =============================================================================
|
||||
|
||||
CONFIG_FILENAME = "config.py"
|
||||
|
||||
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
|
||||
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
|
||||
|
||||
|
||||
def _replace_assignment(text, name, literal):
|
||||
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
|
||||
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
|
||||
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
|
||||
if count != 1:
|
||||
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
|
||||
return new_text
|
||||
|
||||
|
||||
def write_back_config(info, *, config_path=None, quiet=False):
|
||||
"""
|
||||
把查询结果写回 config.py。
|
||||
|
||||
安全措施:
|
||||
1. 先 ast.parse 语法自检,写坏了就不落盘
|
||||
2. 落盘前把原文件备份成 config.py.bak
|
||||
3. 保留原文件的换行风格(LF / CRLF)
|
||||
"""
|
||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
CONFIG_FILENAME)
|
||||
if not os.path.isfile(path):
|
||||
raise AllnetError("找不到 config.py:%s" % path)
|
||||
|
||||
with open(path, "rb") as fp:
|
||||
raw = fp.read()
|
||||
newline = "\r\n" if b"\r\n" in raw else "\n"
|
||||
text = raw.decode("utf-8").replace("\r\n", "\n")
|
||||
|
||||
pairs = [
|
||||
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
|
||||
("REGION_ID", str(info["region_id"])),
|
||||
("PLACE_ID", str(info["place_id"])),
|
||||
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
|
||||
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
|
||||
]
|
||||
for name, literal in pairs:
|
||||
text = _replace_assignment(text, name, literal)
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
except SyntaxError as exc:
|
||||
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
|
||||
|
||||
shutil.copy2(path, path + ".bak")
|
||||
with open(path, "wb") as fp:
|
||||
fp.write(text.replace("\n", newline).encode("utf-8"))
|
||||
|
||||
if not quiet:
|
||||
print(" [已写回] %s" % path)
|
||||
print(" [已备份] %s.bak" % path)
|
||||
return path
|
||||
|
||||
|
||||
def config_switch_enabled(config_path=None):
|
||||
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
|
||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
CONFIG_FILENAME)
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
tree = ast.parse(fp.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
|
||||
try:
|
||||
return bool(ast.literal_eval(node.value))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 输出
|
||||
# =============================================================================
|
||||
|
||||
def format_report(info) -> str:
|
||||
"""人类可读的结果块。"""
|
||||
lines = []
|
||||
lines.append("=== authv1 机台身份查询 ===")
|
||||
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
|
||||
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
|
||||
lines.append(" placeId : %s" % info["place_id"])
|
||||
lines.append(" placeName : %s" % info["place_name"])
|
||||
lines.append(" regionId : %s" % info["region_id"])
|
||||
lines.append(" regionName : %s" % info["region_name"])
|
||||
lines.append(" --- 附送 ---")
|
||||
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
|
||||
lines.append(" 机台昵称 : %s" % info["nickname"])
|
||||
lines.append(" 国家/地区 : %s" % info["country"])
|
||||
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
|
||||
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="authv1.py",
|
||||
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python authv1.py A63E01E9580\n"
|
||||
" python authv1.py A63E-01C30471007 --json\n"
|
||||
" python authv1.py A63E01E9580 --write\n"
|
||||
" python authv1.py --batch source/keychip.csv --limit 10\n"
|
||||
),
|
||||
)
|
||||
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
|
||||
p.add_argument("--batch", metavar="CSV",
|
||||
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
|
||||
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
|
||||
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
|
||||
p.add_argument("--json", action="store_true", help="输出 JSON")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
|
||||
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
|
||||
return p
|
||||
|
||||
|
||||
def resolve_title_ver(cli_value):
|
||||
if cli_value:
|
||||
return str(cli_value)
|
||||
try:
|
||||
import config # noqa: F401 同目录
|
||||
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
|
||||
except Exception:
|
||||
return DEFAULT_TITLE_VER
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
try:
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
title_ver = resolve_title_ver(args.ver)
|
||||
|
||||
if args.batch:
|
||||
return run_batch(args, title_ver)
|
||||
|
||||
if not args.keychip:
|
||||
build_parser().print_help()
|
||||
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
|
||||
return 2
|
||||
|
||||
try:
|
||||
info = query_keychip(args.keychip, title_ver=title_ver,
|
||||
host=args.host, timeout=args.timeout)
|
||||
except (AllnetError, ValueError) as exc:
|
||||
print("查询失败:%s" % exc)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(info, ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(format_report(info))
|
||||
|
||||
do_write = decide_write(args)
|
||||
if do_write:
|
||||
try:
|
||||
write_back_config(info)
|
||||
except AllnetError as exc:
|
||||
print("写回失败:%s" % exc)
|
||||
return 1
|
||||
elif not args.json:
|
||||
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
|
||||
"或加 --write")
|
||||
return 0
|
||||
|
||||
|
||||
def decide_write(args):
|
||||
"""--write / --no-write 优先,否则看 config.py 的开关。"""
|
||||
if args.write:
|
||||
return True
|
||||
if args.no_write:
|
||||
return False
|
||||
return config_switch_enabled()
|
||||
|
||||
|
||||
def _disp_width(text):
|
||||
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
|
||||
import unicodedata
|
||||
width = 0
|
||||
for ch in str(text):
|
||||
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||||
return width
|
||||
|
||||
|
||||
def _pad(text, width):
|
||||
text = str(text)
|
||||
return text + " " * max(0, width - _disp_width(text))
|
||||
|
||||
|
||||
def run_batch(args, title_ver):
|
||||
path = args.batch
|
||||
if not os.path.isfile(path):
|
||||
print("找不到 CSV:%s" % path)
|
||||
return 2
|
||||
|
||||
rows = []
|
||||
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
|
||||
reader = csv.reader(fp)
|
||||
for row in reader:
|
||||
if not row or not row[0].strip():
|
||||
continue
|
||||
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
|
||||
continue
|
||||
rows.append(row)
|
||||
if args.limit > 0:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
|
||||
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
|
||||
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
|
||||
print(header)
|
||||
ok = 0
|
||||
results = []
|
||||
for row in rows:
|
||||
try:
|
||||
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
|
||||
timeout=args.timeout, raw_ok=True)
|
||||
except Exception as exc:
|
||||
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
|
||||
continue
|
||||
results.append(info)
|
||||
if info["ok"]:
|
||||
ok += 1
|
||||
print(" ".join([
|
||||
_pad(info["client_id"], 13),
|
||||
_pad(info["result"], 6),
|
||||
_pad(info["place_id"], 8),
|
||||
_pad(info["region_id"], 8),
|
||||
_pad(info["region_name"], 8),
|
||||
str(info["place_name"]),
|
||||
]))
|
||||
print("--- 成功 %d / %d ---" % (ok, len(rows)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+10
-9
@@ -6,13 +6,14 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登
|
||||
|
||||
一条完整的「买票」链路:
|
||||
|
||||
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
||||
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
||||
|
||||
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
|
||||
2. 调 GetUserDataApi 取 playCount / playerRating
|
||||
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
|
||||
4. 查 config.TICKET_CREDIT_NUM 得到 userChargelog.price(= ticket.creditNum)
|
||||
4. 查 TICKET_CREDIT_NUM(已下沉到 api/upsert_user_chargelog.py)得到
|
||||
userChargelog.price(= ticket.creditNum)
|
||||
5. 闸门 → 发包
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -25,7 +26,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --debug
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
|
||||
|
||||
付费票(config.PAID_TICKET_IDS):
|
||||
付费票(api.PAID_TICKET_IDS):
|
||||
2 = 2倍チケット creditNum 1
|
||||
3 = 3倍チケット creditNum 2
|
||||
4 = 4倍チケット creditNum 3
|
||||
@@ -51,17 +52,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 +79,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 +165,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 +178,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)) + " 个节点,失败 "
|
||||
|
||||
+40
-96
@@ -31,25 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
|
||||
# 一、网络与加密常量
|
||||
# =============================================================================
|
||||
|
||||
# --- 游戏服务器(Maimai2Servlet)---
|
||||
# 依据:source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
# _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
# 实测:source/2026-10-05.log:1
|
||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
||||
|
||||
# --- AES 参数 ---
|
||||
# 依据:source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
# AesKey = "FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W" (32 字节 -> AES-256)
|
||||
# AesIV = "F>;24DjU9W6ZsRH[" (16 字节)
|
||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
|
||||
# --- API 名混淆盐 ---
|
||||
# 依据:source/Assembly-CSharp/Net.Packet/Packet.cs:44 ObfuscateParam = "8bF76dE9"
|
||||
# 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
# "MaimaiChn" 后缀来自 source/Assembly-CSharp/Net.VO/NetQuery.cs:14 Api = api + "MaimaiChn"
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py
|
||||
# --- 报文版本标识 ---
|
||||
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
|
||||
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
|
||||
@@ -65,17 +47,12 @@ TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
|
||||
# 二、机台与店铺标识
|
||||
# =============================================================================
|
||||
|
||||
# Keychip ID(机台唯一标识,完整格式带连字符)
|
||||
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
|
||||
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
||||
# KeychipID = AMDaemon.System.KeychipId.Value
|
||||
KEYCHIP_ID = "" # ★★ 必填:形如 "XXXX-XXXXXXXXXXX"(带连字符)
|
||||
|
||||
# 客户端 ID(Keychip 短值,去掉连字符取前 11 位)
|
||||
# 客户端 ID(Keychip 短值)
|
||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
||||
# clientId = AMDaemon.System.KeychipId.ShortValue
|
||||
# 实测:真机日志里 clientId 恒等于 Keychip 短值
|
||||
CLIENT_ID = "" # ★★ 必填:Keychip 短值,去掉连字符取前 11 位
|
||||
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
|
||||
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
|
||||
|
||||
# 区域 ID / 店铺 ID
|
||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
|
||||
@@ -84,9 +61,18 @@ CLIENT_ID = "" # ★★ 必填:Keychip 短值,去掉连字
|
||||
REGION_ID = 0 # ★★ 必填:你所在区域 ID
|
||||
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
|
||||
|
||||
# Keychip ID(机台唯一标识,完整格式带连字符)
|
||||
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
|
||||
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
||||
# KeychipID = AMDaemon.System.KeychipId.Value
|
||||
# ★ 组成 = CLIENT_ID 的前 4 位 + "-" + CLIENT_ID 第 5 位起 + PLACE_ID
|
||||
# 例:CLIENT_ID "A63E01C3047" + PLACE_ID 1007 -> "A63E-01C30471007"
|
||||
# 所以它由上面两个值**推导**,不要手改。
|
||||
KEYCHIP_ID = (CLIENT_ID[:4] + "-" + CLIENT_ID[4:] + str(PLACE_ID)) if CLIENT_ID else ""
|
||||
|
||||
# 店铺名 / 区域名
|
||||
#
|
||||
# ★ 为什么必须手工填:**游戏服务器没有任何接口返回店铺名**。
|
||||
# ★ 为什么以前必须手工填:**游戏服务器没有任何接口返回店铺名**。
|
||||
# 逐行扫过 source/2026-10-05.log 全部响应:
|
||||
# - GetGameSettingApi 只回 gameSetting,无店名
|
||||
# - GetUserDataApi 的 lastPlaceName 回的是 null
|
||||
@@ -95,13 +81,27 @@ PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
|
||||
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
|
||||
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
|
||||
#
|
||||
# 取值示例(真机 UpsertUserAll 报文):
|
||||
# userPlaylogList[0].placeName = "<你的店铺名>"
|
||||
# userData[0].lastRegionName = "<你的区域名>"
|
||||
PLACE_NAME = "" # ★★ 必填:店铺名(游戏服务器不返回,只能手填)
|
||||
# ★★ 现在不用手工抄了:python authv1.py <你的Keychip> 会直接问 ALL.Net
|
||||
# 认证服务器要回 name / region_name0。把下面的 AUTHV1_WRITE_CONFIG 打开,
|
||||
# 它还能把结果自动写回 config.py。
|
||||
PLACE_NAME = "" # ★★ 必填:店铺名
|
||||
REGION_NAME = "" # ★★ 必填:区域名
|
||||
|
||||
|
||||
# --- authv1 回写开关 ---
|
||||
#
|
||||
# authv1.py(Keychip -> 机台身份)查到结果后,是否**自动写回 config.py**。
|
||||
#
|
||||
# True : 每次查询成功后,把 CLIENT_ID / REGION_ID / PLACE_ID /
|
||||
# PLACE_NAME / REGION_NAME 五个值改写进 config.py。
|
||||
# (KEYCHIP_ID 是推导值,会自动跟着变,不用写。)
|
||||
# 改写前会把原文件备份成 config.py.bak,写坏不落盘。
|
||||
# False : 只打印结果,不动 config.py。(默认,安全)
|
||||
#
|
||||
# 命令行可临时覆盖:--write 强制写 / --no-write 强制不写。
|
||||
AUTHV1_WRITE_CONFIG = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、扫码登录常量(aimedb)
|
||||
# =============================================================================
|
||||
@@ -109,21 +109,7 @@ REGION_NAME = "" # ★★ 必填:区域名
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs/舞萌DX_扫码登录协议技术规范.md
|
||||
|
||||
# aimedb 服务器
|
||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
||||
AIMEDB_PORT = 443
|
||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
||||
|
||||
# 签名密钥(含前导 X,32 字符)
|
||||
# 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
# 固定字段
|
||||
QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
|
||||
# ★ aimedb / QR 常量已下沉到 api/chime.py
|
||||
# =============================================================================
|
||||
# 三之二、★ 功能票(Ticket)主数据
|
||||
# =============================================================================
|
||||
@@ -140,38 +126,12 @@ QR_USER_AGENT = "WC_AIME_LIB"
|
||||
#
|
||||
# ticketKind 含义(Ticket.xml 里的枚举):
|
||||
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
TICKET_CREDIT_NUM = {
|
||||
0: 0,
|
||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
||||
11001: 0, # 免费 1.5 倍券
|
||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
||||
}
|
||||
|
||||
#: 票名(只用于日志好看,不影响协议)
|
||||
TICKET_NAMES = {
|
||||
0: "使用しない",
|
||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
||||
}
|
||||
|
||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
|
||||
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
|
||||
# 已下沉到 api/upsert_user_chargelog.py
|
||||
|
||||
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
|
||||
UPSERT_CHARGELOG_CONFIRM = False
|
||||
|
||||
#: 票有效期算法参数
|
||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" ✅
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、★ UpsertUserAll 即将上传的歌曲数据
|
||||
# =============================================================================
|
||||
@@ -225,14 +185,7 @@ PLAY_JUDGE_COUNTS = {
|
||||
}
|
||||
|
||||
# --- 评级(本局前后)---
|
||||
PLAY_BEFORE_RATING = 15000
|
||||
PLAY_AFTER_RATING = 15000
|
||||
PLAY_BEFORE_GRADE = 0
|
||||
PLAY_AFTER_GRADE = 0
|
||||
PLAY_AFTER_GRADE_RANK = 1
|
||||
PLAY_BEFORE_DELUX_RATING = 15000
|
||||
PLAY_AFTER_DELUX_RATING = 15000
|
||||
|
||||
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
|
||||
# --- 曲种索引 ---
|
||||
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
|
||||
PLAY_CATEGORY_INDEX = 2022
|
||||
@@ -252,14 +205,6 @@ PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
|
||||
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
|
||||
#
|
||||
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||||
PLAY_CHARACTERS = [
|
||||
{"characterId": 395, "level": 3800, "awakening": 5},
|
||||
{"characterId": 101, "level": 4455, "awakening": 5},
|
||||
{"characterId": 400402, "level": 134, "awakening": 3},
|
||||
{"characterId": 400401, "level": 134, "awakening": 3},
|
||||
{"characterId": 200401, "level": 134, "awakening": 3},
|
||||
]
|
||||
|
||||
# --- 本局消耗 ---
|
||||
# ★ playCredit 语义(VOExtensions.cs:838-845):
|
||||
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
|
||||
@@ -282,8 +227,7 @@ PLAY_IS_FREEDOM_MODE = False
|
||||
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
|
||||
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
|
||||
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
|
||||
PLAY_LOCAL_PLAY_COUNT = 8
|
||||
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py)
|
||||
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
|
||||
@@ -312,8 +256,7 @@ PLAY_LOCAL_PLAY_COUNT = 8
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
#
|
||||
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
|
||||
PLAY_SPECIAL_NUM = 1024
|
||||
|
||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py
|
||||
# --- 用户总游玩次数增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
|
||||
@@ -431,6 +374,7 @@ __all__ = [
|
||||
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
|
||||
# 机台与店铺
|
||||
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
|
||||
"AUTHV1_WRITE_CONFIG",
|
||||
# 功能票
|
||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
||||
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
||||
|
||||
+703
@@ -0,0 +1,703 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
|
||||
================================================================================
|
||||
|
||||
【DLI 是什么】
|
||||
DLI = **Download Instruction**(配信指示書)。
|
||||
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
|
||||
|
||||
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||
|
||||
服务器回:
|
||||
|
||||
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
|
||||
|
||||
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
|
||||
|
||||
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
||||
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
||||
INSTALL2=https://.../SDGB_A005_..._0.opt
|
||||
|
||||
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
|
||||
只是文件名,不是下载项。
|
||||
|
||||
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
|
||||
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
|
||||
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
|
||||
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
|
||||
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
|
||||
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
|
||||
日志 "Net deliver param - dli uri : %s." @0x63c3f8
|
||||
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
|
||||
|
||||
【实测结论】(2026-10-06)
|
||||
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
|
||||
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
|
||||
title_ver<=1.54 -> uri = null (服务器不认这个版本)
|
||||
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
|
||||
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
|
||||
|
||||
【用法】
|
||||
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
|
||||
python netdelivery.py A63E01E9580 # 指定 Keychip
|
||||
python netdelivery.py --list # 只列清单,不下载
|
||||
python netdelivery.py --all # 全下(不交互)
|
||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||
python netdelivery.py --out D:\SDGB2026\update
|
||||
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
||||
|
||||
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from authv1 import (
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_TIMEOUT,
|
||||
AllnetError,
|
||||
delivery_instruction,
|
||||
parse_keychip,
|
||||
)
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# 下载器用的 UA(实测有效)
|
||||
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
|
||||
|
||||
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
|
||||
CHUNK = 1 << 16
|
||||
PROBE_TIMEOUT = 30
|
||||
DOWNLOAD_TIMEOUT = 60
|
||||
|
||||
# 包类型判定(按扩展名)
|
||||
KIND_BY_EXT = {
|
||||
".app": "app",
|
||||
".opt": "opt",
|
||||
".pack": "pack",
|
||||
}
|
||||
|
||||
KIND_TEXT = {
|
||||
"app": "本体/补丁",
|
||||
"opt": "选项包",
|
||||
"pack": "资源包",
|
||||
"?": "未知",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP
|
||||
# =============================================================================
|
||||
|
||||
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
|
||||
"""
|
||||
GET,顺带处理 CDN 证书问题。
|
||||
|
||||
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
|
||||
(只影响这一次请求,不改变全局设置)。
|
||||
"""
|
||||
merged = dict(DOWNLOAD_HEADERS)
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
try:
|
||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||
verify=not insecure)
|
||||
except requests.exceptions.SSLError:
|
||||
if insecure:
|
||||
raise
|
||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||
verify=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 配信指示書解析
|
||||
# =============================================================================
|
||||
|
||||
def parse_instruction(text):
|
||||
"""
|
||||
解析配信指示書 INI。
|
||||
|
||||
返回 (common, installs):
|
||||
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
|
||||
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
|
||||
"""
|
||||
common = {}
|
||||
installs = []
|
||||
section = ""
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].strip().upper()
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip().upper()
|
||||
value = value.strip().strip('"')
|
||||
if section in ("", "COMMON"):
|
||||
common.setdefault(key, value)
|
||||
if key.startswith("INSTALL") and value:
|
||||
installs.append({"section": section or "COMMON", "key": key, "url": value})
|
||||
return common, installs
|
||||
|
||||
|
||||
def guess_kind(url):
|
||||
"""按扩展名猜包类型。"""
|
||||
name = url.split("?")[0].rsplit("/", 1)[-1]
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
return KIND_BY_EXT.get(ext, "?")
|
||||
|
||||
|
||||
def package_name(url):
|
||||
return url.split("?")[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def human_size(num):
|
||||
if num is None:
|
||||
return "?"
|
||||
value = float(num)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if value < 1024 or unit == "TB":
|
||||
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
|
||||
value /= 1024.0
|
||||
return "?"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 采集:DLI -> 指示書 -> 包清单
|
||||
# =============================================================================
|
||||
|
||||
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
|
||||
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
|
||||
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||
kv = {}
|
||||
for part in text.strip().split("&"):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
kv[k.strip()] = v
|
||||
result = kv.get("result", "")
|
||||
if result != "1":
|
||||
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
|
||||
|
||||
uri = kv.get("uri", "").strip()
|
||||
if uri in ("", "null"):
|
||||
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
|
||||
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
|
||||
|
||||
parts = uri.split("|")
|
||||
patch_url = parts[0].strip() if len(parts) > 0 else ""
|
||||
option_url = parts[1].strip() if len(parts) > 1 else ""
|
||||
for bad in ("null", "none"):
|
||||
if patch_url.lower() == bad:
|
||||
patch_url = ""
|
||||
if option_url.lower() == bad:
|
||||
option_url = ""
|
||||
return patch_url, option_url, text.strip()
|
||||
|
||||
|
||||
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
|
||||
"""
|
||||
走完整条链路,返回 (items, meta)。
|
||||
|
||||
items 每一项:
|
||||
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
|
||||
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
|
||||
"""
|
||||
patch_url, option_url, dli_raw = fetch_instruction_urls(
|
||||
client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||
|
||||
items = []
|
||||
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
|
||||
"instructions": [], "infos": {}}
|
||||
|
||||
for info_url in (patch_url, option_url):
|
||||
if not info_url:
|
||||
continue
|
||||
info_name = package_name(info_url)
|
||||
resp = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
resp = http_get(info_url, timeout=timeout, insecure=insecure)
|
||||
if resp.status_code < 500:
|
||||
break
|
||||
except Exception:
|
||||
resp = None
|
||||
time.sleep(1.5 * attempt)
|
||||
if resp is None:
|
||||
meta["instructions"].append({"url": info_url,
|
||||
"error": "拉取失败(已重试 3 次)"})
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
meta["instructions"].append({"url": info_url, "status": resp.status_code,
|
||||
"error": "HTTP %d" % resp.status_code})
|
||||
continue
|
||||
text = resp.content.decode("utf-8", "replace")
|
||||
common, installs = parse_instruction(text)
|
||||
meta["instructions"].append({"url": info_url, "status": 200,
|
||||
"name": info_name, "common": common})
|
||||
meta["infos"][info_name] = text
|
||||
|
||||
seen = set()
|
||||
for entry in installs:
|
||||
if entry["url"] in seen:
|
||||
continue
|
||||
seen.add(entry["url"])
|
||||
items.append({
|
||||
"kind": guess_kind(entry["url"]),
|
||||
"section": entry["section"],
|
||||
"key": entry["key"],
|
||||
"url": entry["url"],
|
||||
"name": package_name(entry["url"]),
|
||||
"size": None,
|
||||
"note": "",
|
||||
"info_name": info_name,
|
||||
})
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
item["idx"] = i
|
||||
return items, meta
|
||||
|
||||
|
||||
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
|
||||
"""探每个包的大小 / 可用性(就地写回 item)。"""
|
||||
for item in items:
|
||||
for attempt in range(1, 3):
|
||||
try:
|
||||
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
|
||||
stream=True, timeout=timeout, insecure=insecure)
|
||||
status = resp.status_code
|
||||
content_range = resp.headers.get("Content-Range", "")
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
resp.close()
|
||||
if status == 206 and "/" in content_range:
|
||||
item["size"] = int(content_range.rsplit("/", 1)[-1])
|
||||
item["note"] = ""
|
||||
elif status == 200 and content_length and content_length.isdigit():
|
||||
item["size"] = int(content_length)
|
||||
item["note"] = ""
|
||||
elif status in (404, 504):
|
||||
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
|
||||
else:
|
||||
item["note"] = "HTTP %d" % status
|
||||
break
|
||||
except Exception as exc:
|
||||
item["note"] = "探测失败:%s" % exc
|
||||
if attempt < 2:
|
||||
time.sleep(1.5)
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 展示与选择
|
||||
# =============================================================================
|
||||
|
||||
def print_packages(items):
|
||||
if not items:
|
||||
print(" (指示書里没有任何 INSTALL 项)")
|
||||
return
|
||||
print("")
|
||||
print(" 序号 类型 大小 来源段/键 文件名")
|
||||
print(" " + "-" * 92)
|
||||
for item in items:
|
||||
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
|
||||
print(" [%2d] %-4s %-12s %-20s %s" % (
|
||||
item["idx"], item["kind"], size,
|
||||
item["section"] + "/" + item["key"], item["name"]))
|
||||
if item["note"] and item["size"] is None:
|
||||
print(" %s" % item["note"])
|
||||
|
||||
|
||||
def prompt_selection(items):
|
||||
"""
|
||||
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
|
||||
"""
|
||||
print("")
|
||||
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
|
||||
print(" a = 全选 n = 都不选 q = 退出")
|
||||
while True:
|
||||
try:
|
||||
raw = input(" 请选择要下载的包 > ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("")
|
||||
return None
|
||||
if not raw:
|
||||
continue
|
||||
low = raw.lower()
|
||||
if low in ("q", "quit", "exit"):
|
||||
return None
|
||||
if low in ("a", "all"):
|
||||
return list(range(len(items)))
|
||||
if low in ("n", "none"):
|
||||
return []
|
||||
|
||||
picked, bad = [], []
|
||||
for token in re.split(r"[,,、\s]+", raw):
|
||||
if not token:
|
||||
continue
|
||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||
if span:
|
||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||
for i in range(low_i, high_i + 1):
|
||||
if 1 <= i <= len(items):
|
||||
picked.append(i - 1)
|
||||
else:
|
||||
bad.append(str(i))
|
||||
continue
|
||||
if token.isdigit():
|
||||
i = int(token)
|
||||
if 1 <= i <= len(items):
|
||||
picked.append(i - 1)
|
||||
else:
|
||||
bad.append(token)
|
||||
else:
|
||||
bad.append(token)
|
||||
|
||||
if bad:
|
||||
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
|
||||
continue
|
||||
|
||||
ordered, seen = [], set()
|
||||
for i in picked:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
ordered.append(i)
|
||||
if not ordered:
|
||||
print(" [x] 没有选中任何项")
|
||||
continue
|
||||
return ordered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 下载
|
||||
# =============================================================================
|
||||
|
||||
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
|
||||
"""
|
||||
带断点续传的下载。返回 (ok, 说明)。
|
||||
|
||||
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
|
||||
"""
|
||||
part = dest + ".part"
|
||||
total = None
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
done = os.path.getsize(part) if os.path.exists(part) else 0
|
||||
headers = {}
|
||||
if done:
|
||||
headers["Range"] = "bytes=%d-" % done
|
||||
try:
|
||||
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
|
||||
insecure=insecure)
|
||||
except Exception as exc:
|
||||
if attempt == retries:
|
||||
return False, "连接失败:%s" % exc
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
if resp.status_code not in (200, 206):
|
||||
resp.close()
|
||||
if resp.status_code in (404, 504):
|
||||
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
|
||||
if attempt == retries:
|
||||
return False, "HTTP %d" % resp.status_code
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
# 服务器忽略 Range 直接给了全量 -> 从头写
|
||||
if done and resp.status_code == 200:
|
||||
done = 0
|
||||
|
||||
content_range = resp.headers.get("Content-Range", "")
|
||||
if "/" in content_range:
|
||||
try:
|
||||
total = int(content_range.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
total = None
|
||||
elif resp.headers.get("Content-Length", "").isdigit():
|
||||
total = done + int(resp.headers["Content-Length"])
|
||||
|
||||
mode = "ab" if done else "wb"
|
||||
started = time.time()
|
||||
written = done
|
||||
last_tick = 0.0
|
||||
last_shown = -1
|
||||
try:
|
||||
with open(part, mode) as fp:
|
||||
for chunk in resp.iter_content(CHUNK):
|
||||
if not chunk:
|
||||
continue
|
||||
fp.write(chunk)
|
||||
written += len(chunk)
|
||||
now = time.time()
|
||||
if now - last_tick >= 0.3:
|
||||
last_tick = now
|
||||
last_shown = written
|
||||
show_progress(written, total, now - started)
|
||||
except Exception as exc:
|
||||
resp.close()
|
||||
if attempt == retries:
|
||||
return False, "写入中断:%s" % exc
|
||||
time.sleep(2)
|
||||
continue
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
if written == last_shown:
|
||||
sys.stdout.write("\n") # 收尾换行,避免重复打一行
|
||||
else:
|
||||
show_progress(written, total, max(time.time() - started, 0.001), final=True)
|
||||
|
||||
if total is not None and written != total:
|
||||
if attempt == retries:
|
||||
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
if os.path.exists(dest):
|
||||
os.remove(dest)
|
||||
os.replace(part, dest)
|
||||
return True, "%s(%s)" % (human_size(written), dest)
|
||||
|
||||
return False, "重试 %d 次仍失败" % retries
|
||||
|
||||
|
||||
def show_progress(done, total, elapsed, final=False):
|
||||
speed = done / elapsed if elapsed > 0 else 0
|
||||
if total:
|
||||
percent = done * 100.0 / total
|
||||
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
|
||||
human_size(total), human_size(speed))
|
||||
else:
|
||||
line = " %s %s/s" % (human_size(done), human_size(speed))
|
||||
sys.stdout.write("\r" + line + ("\n" if final else ""))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="netdelivery.py",
|
||||
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python netdelivery.py # 查完让你多选\n"
|
||||
" python netdelivery.py --list # 只列清单\n"
|
||||
" python netdelivery.py --all # 全下\n"
|
||||
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
|
||||
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
|
||||
),
|
||||
)
|
||||
p.add_argument("keychip", nargs="?",
|
||||
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
|
||||
p.add_argument("--ver", default=None,
|
||||
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||
p.add_argument("--host", default=DEFAULT_HOST,
|
||||
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||
p.add_argument("--out", default=DEFAULT_OUT_DIR,
|
||||
help="下载目录(默认 <脚本目录>/download)")
|
||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
|
||||
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
|
||||
p.add_argument("--all", action="store_true", help="全选(不交互)")
|
||||
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
|
||||
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
|
||||
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
|
||||
return p
|
||||
|
||||
|
||||
def resolve_keychip(cli_value):
|
||||
if cli_value:
|
||||
return cli_value
|
||||
try:
|
||||
import config # noqa: F401
|
||||
value = getattr(config, "KEYCHIP_ID", "")
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_title_ver(cli_value):
|
||||
if cli_value:
|
||||
return str(cli_value)
|
||||
try:
|
||||
import config # noqa: F401
|
||||
return str(getattr(config, "MAI_ENCODING", "1.55"))
|
||||
except Exception:
|
||||
return "1.55"
|
||||
|
||||
|
||||
def parse_only(text, count):
|
||||
"""把 --only 的文本解析成 0 基下标列表。"""
|
||||
picked, seen = [], set()
|
||||
for token in re.split(r"[,,、\s]+", text or ""):
|
||||
if not token:
|
||||
continue
|
||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||
if span:
|
||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||
candidates = range(low_i, high_i + 1)
|
||||
elif token.isdigit():
|
||||
candidates = [int(token)]
|
||||
else:
|
||||
raise ValueError("--only 里有无效项:%r" % token)
|
||||
for i in candidates:
|
||||
if not (1 <= i <= count):
|
||||
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
|
||||
if i - 1 not in seen:
|
||||
seen.add(i - 1)
|
||||
picked.append(i - 1)
|
||||
return picked
|
||||
|
||||
|
||||
def save_instructions(meta, out_dir):
|
||||
"""把配信指示書原样存一份,便于事后核对。"""
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
saved = []
|
||||
for name, text in meta["infos"].items():
|
||||
path = os.path.join(out_dir, name)
|
||||
with open(path, "w", encoding="utf-8", newline="") as fp:
|
||||
fp.write(text)
|
||||
saved.append(path)
|
||||
return saved
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
try:
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
title_ver = resolve_title_ver(args.ver)
|
||||
|
||||
raw_keychip = resolve_keychip(args.keychip)
|
||||
if not raw_keychip:
|
||||
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
|
||||
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
|
||||
return 2
|
||||
try:
|
||||
client_id = parse_keychip(raw_keychip)
|
||||
except ValueError as exc:
|
||||
print("Keychip 解析失败:%s" % exc)
|
||||
return 2
|
||||
|
||||
print("=== netdelivery (ALL.Net DLI) ===")
|
||||
print(" clientId : %s" % client_id)
|
||||
print(" title_ver : %s" % title_ver)
|
||||
print(" server : %s" % args.host)
|
||||
print(" 正在查询配信指示書 ...")
|
||||
|
||||
try:
|
||||
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
|
||||
timeout=args.timeout, insecure=args.insecure)
|
||||
except (AllnetError, ValueError) as exc:
|
||||
print("查询失败:%s" % exc)
|
||||
return 1
|
||||
|
||||
for entry in meta["instructions"]:
|
||||
if entry.get("status") == 200:
|
||||
common = entry.get("common", {})
|
||||
print(" 指示書 %s" % entry["name"])
|
||||
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
|
||||
"RELEASE_TIME", "RELEASE_TYPE"):
|
||||
if key in common:
|
||||
print(" %-14s %s" % (key + ":", common[key]))
|
||||
else:
|
||||
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
|
||||
|
||||
if not items:
|
||||
print("没有可下载的包。")
|
||||
return 0
|
||||
|
||||
if not args.no_probe:
|
||||
print(" 正在探测包大小 ...")
|
||||
probe_all(items, insecure=args.insecure)
|
||||
|
||||
print_packages(items)
|
||||
|
||||
if args.list:
|
||||
print("")
|
||||
print(" 直链:")
|
||||
for item in items:
|
||||
print(" [%2d] %s" % (item["idx"], item["url"]))
|
||||
return 0
|
||||
|
||||
# ---- 决定下哪些 ----
|
||||
if args.only:
|
||||
try:
|
||||
chosen = parse_only(args.only, len(items))
|
||||
except ValueError as exc:
|
||||
print("参数错误:%s" % exc)
|
||||
return 2
|
||||
elif args.all:
|
||||
chosen = list(range(len(items)))
|
||||
elif not sys.stdin or not sys.stdin.isatty():
|
||||
print("")
|
||||
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
|
||||
return 0
|
||||
else:
|
||||
chosen = prompt_selection(items)
|
||||
if chosen is None:
|
||||
print(" 已取消。")
|
||||
return 0
|
||||
if not chosen:
|
||||
print(" 没有选中任何项,退出。")
|
||||
return 0
|
||||
|
||||
out_dir = args.out
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for path in save_instructions(meta, out_dir):
|
||||
print(" [指示書] %s" % path)
|
||||
|
||||
print("")
|
||||
print(" 下载目录:%s" % out_dir)
|
||||
failed = []
|
||||
for index in chosen:
|
||||
item = items[index]
|
||||
dest = os.path.join(out_dir, item["name"])
|
||||
print("")
|
||||
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
|
||||
item["name"], KIND_TEXT.get(item["kind"], "?")))
|
||||
if item["size"] is None and item["note"]:
|
||||
print(" 跳过:%s" % item["note"])
|
||||
failed.append((item, item["note"]))
|
||||
continue
|
||||
started = time.time()
|
||||
ok, message = download_file(item["url"], dest, insecure=args.insecure)
|
||||
if ok:
|
||||
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
|
||||
else:
|
||||
print(" 失败:%s" % message)
|
||||
failed.append((item, message))
|
||||
|
||||
print("")
|
||||
if failed:
|
||||
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
|
||||
for item, message in failed:
|
||||
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
|
||||
return 1
|
||||
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
|
||||
================================================================================
|
||||
|
||||
┌─ 源账号 A ─────────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
|
||||
│ 或:直接读 data/<userId>.json(离线复用) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↓ 节点数据
|
||||
┌─ 目标账号 B ───────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
||||
│ 循环:UpsertUserAll(每包最多 4 首曲目) │
|
||||
│ 然后 UserLogin(isContinue=true) 续关,拿新 loginId│
|
||||
│ 收尾:UserLogout │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么一次最多传 4 首
|
||||
--------------------------------------------------------------------------------
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
||||
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
||||
|
||||
本脚本把源账号的 userMusicDetailList 按 4 条一组切分,一组 = 一局。
|
||||
★ 每局都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
||||
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么需要「续关登录」
|
||||
--------------------------------------------------------------------------------
|
||||
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
|
||||
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
|
||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||
真机走的就是「续关」:
|
||||
|
||||
Process/ContinueProcess.cs:403
|
||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||
|
||||
对应本工程:UserLogin(user_id, token, is_continue=True)
|
||||
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
||||
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ playlog 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
|
||||
所以本脚本**从曲目记录反推合成**:
|
||||
|
||||
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
|
||||
───────────────────────────── ─────────────────────────
|
||||
musicId musicId
|
||||
level level
|
||||
achievement achievement
|
||||
deluxscoreMax deluxscore
|
||||
scoreRank scoreRank / isClear
|
||||
comboStatus comboStatus
|
||||
syncStatus syncStatus
|
||||
其余字段 取自 config.PLAY_*(合理默认值)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
||||
|
||||
# ★ 真实上传
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
||||
|
||||
# 只看计划,不发包
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
||||
|
||||
# 常用开关
|
||||
--limit N 只迁前 N 首(试跑用)
|
||||
--per-packet N 每包曲目数(默认 4,★ 不要超过 4)
|
||||
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
||||
--no-continue 不续关(只发一包,调试用)
|
||||
|
||||
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
|
||||
★ 二维码**有效期 10 分钟**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_error,
|
||||
log_info,
|
||||
log_warning,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
GetNeedUserData,
|
||||
normalize_snapshot,
|
||||
NODE_ORDER,
|
||||
NODE_TYPES,
|
||||
ARRAY_TO_BITS,
|
||||
DIFF_PRIMARY_KEYS,
|
||||
PlayData,
|
||||
build_user_playlog,
|
||||
build_user_game_playlog,
|
||||
UpsertUserAll,
|
||||
ConfirmRequiredError,
|
||||
Ping,
|
||||
load_user_data,
|
||||
list_saved_user_ids,
|
||||
DATA_DIR,
|
||||
)
|
||||
from config import PLACE_ID, REGION_ID
|
||||
|
||||
#: 真机一局最多 4 首
|
||||
MAX_TRACKS_PER_CREDIT = 4
|
||||
|
||||
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
|
||||
IDENTITY_KEYS = ("userName", "accessCode")
|
||||
|
||||
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
|
||||
SKIP_NODES = (
|
||||
"userMusicDetailList", # 单独分批
|
||||
"userGamePlaylogList", # 由本脚本按曲目合成
|
||||
"user2pPlaylog", # 2P 记录,迁移无意义
|
||||
"userGetPointList", # 里程/奖励,迁移会凭空发点
|
||||
"userTradeItemList", # 无 Get 来源
|
||||
"userFriendSeasonRankingList", # 无 Get 来源
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
|
||||
"plan": False, "debug": False, "limit": 0,
|
||||
"per_packet": MAX_TRACKS_PER_CREDIT,
|
||||
"clone_identity": False, "no_continue": False}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--plan":
|
||||
opts["plan"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--clone-identity":
|
||||
opts["clone_identity"] = True
|
||||
elif a == "--no-continue":
|
||||
opts["no_continue"] = True
|
||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit", "--per-packet") and i + 1 < len(argv):
|
||||
key = a[2:].replace("-", "_")
|
||||
opts[key] = int(argv[i + 1]) if key in ("limit", "per_packet") else argv[i + 1]
|
||||
i += 1
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工具
|
||||
# =============================================================================
|
||||
|
||||
def _bits_for(node, items, base_items):
|
||||
"""
|
||||
按目标账号 B 的基线算 isNew* 位串:
|
||||
B 里没有该条目 -> "1"(新增)
|
||||
B 里已有 -> "0"(更新)
|
||||
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
|
||||
"""
|
||||
keys = DIFF_PRIMARY_KEYS.get(node)
|
||||
if not keys:
|
||||
return None
|
||||
have = set()
|
||||
for b in (base_items or []):
|
||||
if isinstance(b, dict):
|
||||
have.add(tuple(str(b.get(k)) for k in keys))
|
||||
return "".join(
|
||||
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
|
||||
for it in (items or []) if isinstance(it, dict)
|
||||
)
|
||||
|
||||
|
||||
def play_from_detail(detail, base_play=None):
|
||||
"""
|
||||
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
|
||||
|
||||
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
|
||||
"""
|
||||
p = PlayData() if base_play is None else base_play
|
||||
p.music_id = int(detail.get("musicId", p.music_id) or 0)
|
||||
p.music_level = int(detail.get("level", p.music_level) or 0)
|
||||
p.achievement = int(detail.get("achievement", p.achievement) or 0)
|
||||
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
|
||||
rank = int(detail.get("scoreRank", 0) or 0)
|
||||
p.score_rank = rank
|
||||
p.is_clear = rank > 0
|
||||
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
|
||||
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
|
||||
return p
|
||||
|
||||
|
||||
def collect_nodes(src_nodes, base_nodes, clone_identity):
|
||||
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
|
||||
out = {}
|
||||
for node in NODE_ORDER:
|
||||
if node in SKIP_NODES:
|
||||
continue
|
||||
if node.startswith("isNew"):
|
||||
continue # 位串单独算
|
||||
kind = NODE_TYPES.get(node)
|
||||
if kind not in ("singleton", "array", "object"):
|
||||
continue
|
||||
val = src_nodes.get(node)
|
||||
if val in (None, [], {}):
|
||||
continue
|
||||
out[node] = json.loads(json.dumps(val)) # 深拷贝
|
||||
# 身份字段:默认保留 B 自己的
|
||||
if not clone_identity and out.get("userData"):
|
||||
for item in out["userData"]:
|
||||
if isinstance(item, dict):
|
||||
for k in IDENTITY_KEYS:
|
||||
item.pop(k, None)
|
||||
# 位串:按 B 的基线算
|
||||
for arr, bits in ARRAY_TO_BITS.items():
|
||||
if arr not in out:
|
||||
continue
|
||||
b = _bits_for(arr, out[arr], base_nodes.get(arr))
|
||||
if b:
|
||||
out[bits] = b
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 主流程
|
||||
# =============================================================================
|
||||
|
||||
def fetch_source_by_qr(qr):
|
||||
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
|
||||
log_info("=== 源账号:联网抓取 ===")
|
||||
uid, token = ParseQRCode(qr)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
try:
|
||||
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
finally:
|
||||
try:
|
||||
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_warning("源账号登出失败:" + str(exc))
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts["debug"]:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
if opts["per_packet"] > MAX_TRACKS_PER_CREDIT:
|
||||
print("--per-packet 不能超过 " + str(MAX_TRACKS_PER_CREDIT) + "(真机一局最多 4 首)")
|
||||
return 1
|
||||
|
||||
setup_logging("reincarnate")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---------------- 1. 源数据 ----------------
|
||||
if opts["src_json"]:
|
||||
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
|
||||
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
|
||||
else:
|
||||
src_raw = fetch_source_by_qr(opts["src_qr"])
|
||||
|
||||
src_nodes = normalize_snapshot(src_raw)
|
||||
musics = src_nodes.get("userMusicDetailList") or []
|
||||
if opts["limit"] > 0:
|
||||
musics = musics[:opts["limit"]]
|
||||
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
|
||||
|
||||
if not musics:
|
||||
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
|
||||
return 4
|
||||
|
||||
# ---------------- 2. 目标账号 ----------------
|
||||
Ping()
|
||||
uid, token = ParseQRCode(opts["dst_qr"])
|
||||
session.update(user_id=uid, token=token)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
base_nodes = normalize_snapshot(base_raw)
|
||||
log_info("目标账号基线已抓取")
|
||||
|
||||
# ---------------- 3. 分批 ----------------
|
||||
per = int(opts["per_packet"])
|
||||
batches = [musics[i:i + per] for i in range(0, len(musics), per)]
|
||||
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
|
||||
+ " 包(每包 " + str(per) + " 首)")
|
||||
|
||||
if opts["plan"]:
|
||||
for i, b in enumerate(batches):
|
||||
log_info(" 第 " + str(i + 1) + " 包:" + ", ".join(
|
||||
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
|
||||
log_info("--plan:只列计划,不发包")
|
||||
return 0
|
||||
|
||||
# 第一包才带非曲目节点(整账号克隆)
|
||||
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
|
||||
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
|
||||
+ " 个将在第 1 包上传")
|
||||
|
||||
# ---------------- 4. 逐包上传 ----------------
|
||||
sent = 0
|
||||
for idx, batch in enumerate(batches):
|
||||
log_info("")
|
||||
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
|
||||
+ " 包(" + str(len(batch)) + " 首)==========")
|
||||
|
||||
nodes = {}
|
||||
if idx == 0:
|
||||
nodes.update(json.loads(json.dumps(shared_nodes)))
|
||||
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
|
||||
b = _bits_for("userMusicDetailList", batch,
|
||||
base_nodes.get("userMusicDetailList"))
|
||||
if b:
|
||||
nodes["isNewMusicDetailList"] = b
|
||||
log_info(" isNewMusicDetailList = " + b)
|
||||
|
||||
# 合成 playlog(每首一条)与 game playlog
|
||||
playlogs = []
|
||||
game_playlogs = []
|
||||
for d in batch:
|
||||
p = play_from_detail(d)
|
||||
playlogs.append(build_user_playlog(
|
||||
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
|
||||
game_playlogs.append(build_user_game_playlog(
|
||||
login.loginId, login.loginDateTime, p))
|
||||
nodes["userGamePlaylogList"] = game_playlogs
|
||||
|
||||
UpsertUserAll(
|
||||
uid, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
snapshot=base_raw,
|
||||
auto_characters=False,
|
||||
play=play_from_detail(batch[0]),
|
||||
extra_data=nodes,
|
||||
user_playlog_list=playlogs,
|
||||
pre_new_item_list=False,
|
||||
verify_landing=False,
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
sent += 1
|
||||
|
||||
# ---- ★ 续关登录:拿下一局的 loginId ----
|
||||
if idx < len(batches) - 1:
|
||||
if opts["no_continue"]:
|
||||
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
|
||||
break
|
||||
log_info("--- 续关登录(isContinue=true)---")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
|
||||
is_continue=True)
|
||||
session.update(login_dt=login.loginDateTime)
|
||||
|
||||
if not opts["send"]:
|
||||
log_info("")
|
||||
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
|
||||
return 0
|
||||
|
||||
log_info("")
|
||||
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
|
||||
+ str(min(len(musics), sent * per)) + " 首")
|
||||
return 0
|
||||
|
||||
except ConfirmRequiredError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+49
-25
@@ -78,13 +78,31 @@ from api.upsert_user_all import (
|
||||
)
|
||||
from api.upsert_user_chargelog import _now_string
|
||||
|
||||
from config import (
|
||||
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
|
||||
from api import (
|
||||
PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM,
|
||||
PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS,
|
||||
TICKET_VALID_DAYS as CONFIG_TICKET_DAYS,
|
||||
TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# ★ PLAY_CHARACTERS —— 原先在 config.py,现移到自检脚本里作为**测试数据**
|
||||
# =============================================================================
|
||||
#
|
||||
# 说明:PlayData.characters 现在默认是**空列表**,真值由
|
||||
# derive_play_characters() 从服务器快照推(config 里的静态值会过期)。
|
||||
# 这里保留一份真机样本,供自检做「离线组包」用例。
|
||||
#
|
||||
# 值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||||
PLAY_CHARACTERS = [
|
||||
{"characterId": 395, "level": 3800, "awakening": 5},
|
||||
{"characterId": 101, "level": 4455, "awakening": 5},
|
||||
{"characterId": 400402, "level": 134, "awakening": 3},
|
||||
{"characterId": 400401, "level": 134, "awakening": 3},
|
||||
{"characterId": 200401, "level": 134, "awakening": 3},
|
||||
]
|
||||
|
||||
_PASS = 0
|
||||
_FAIL = 0
|
||||
_FAILED_NAMES = []
|
||||
@@ -150,9 +168,9 @@ check("deflate 往返一致", deflate_decompress(packed) == sample)
|
||||
enc = aes_encrypt(packed)
|
||||
check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc)))
|
||||
check("AES 往返一致", aes_decrypt(enc) == packed)
|
||||
check("AES 是 256 位密钥(config.AES_KEY 32 字节)",
|
||||
len(__import__("config").AES_KEY) == 32)
|
||||
check("AES IV 是 16 字节", len(__import__("config").AES_IV) == 16)
|
||||
check("AES 是 256 位密钥(api.AES_KEY 32 字节)",
|
||||
len(api.AES_KEY) == 32)
|
||||
check("AES IV 是 16 字节", len(api.AES_IV) == 16)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -333,7 +351,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,))
|
||||
@@ -350,7 +368,8 @@ check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_
|
||||
|
||||
# ★ 显式传中性值,**不依赖 config.py** —— 否则用户改了 config 这里就红
|
||||
pl = build_user_playlog(7692848101484135861, 1791159576,
|
||||
PlayData(before_rating=15000, after_rating=15000),
|
||||
PlayData(before_rating=15000, after_rating=15000,
|
||||
characters=PLAY_CHARACTERS),
|
||||
place_id=1000, client_id="TESTCLIENT1")
|
||||
check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl)))
|
||||
check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS))
|
||||
@@ -438,7 +457,8 @@ REAL_GP = {
|
||||
"playlogId": 7692848101484135861, "version": "1.56.00",
|
||||
"playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1,
|
||||
"clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False,
|
||||
"isNewFree": False, "playCount": 8, "playOtherUserId": 0,
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 现恒为 2(原 config 值 8 已废弃)
|
||||
"isNewFree": False, "playCount": 2, "playOtherUserId": 0,
|
||||
}
|
||||
gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v}
|
||||
check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False))
|
||||
@@ -466,7 +486,7 @@ def _rev32(n: int) -> int:
|
||||
check("真机 playSpecial 反推出 CalcSpecialNum=1024",
|
||||
_rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM,
|
||||
str(_rev32(892014272) % 2069))
|
||||
check("config.PLAY_SPECIAL_NUM 默认 1024",
|
||||
check("api.PLAY_SPECIAL_NUM 默认 1024",
|
||||
CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM))
|
||||
check("calc_play_special 把 CalcSpecialNum 叠加进去",
|
||||
all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM
|
||||
@@ -535,7 +555,8 @@ ua2 = build_user_all(baseline, login_date_time=1791159576,
|
||||
# ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。
|
||||
ua2b = build_user_all(baseline, login_date_time=1791159576,
|
||||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||||
play=PlayData(map_distance_gain=11000, character_exp_gain=1))
|
||||
play=PlayData(map_distance_gain=11000, character_exp_gain=1,
|
||||
characters=PLAY_CHARACTERS))
|
||||
|
||||
check("产出 36 个节点", len(ua2) == 36, str(len(ua2)))
|
||||
check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER))
|
||||
@@ -578,8 +599,8 @@ check("userCharacterList 是游玩后等级(101: 4455->4456)",
|
||||
_plv = [pl["characterLevel" + str(i)] for i in range(1, 6)]
|
||||
check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)",
|
||||
_plv == [3800, 4455, 134, 134, 134], str(_plv))
|
||||
check("playlog.characterLevelN 逐个等于 config.PLAY_CHARACTERS 的 level",
|
||||
_plv == [int(c["level"]) for c in __import__("config").PLAY_CHARACTERS], str(_plv))
|
||||
check("playlog.characterLevelN 逐个等于 PLAY_CHARACTERS 的 level",
|
||||
_plv == [int(c["level"]) for c in PLAY_CHARACTERS], str(_plv))
|
||||
check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量",
|
||||
all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]]
|
||||
for i in range(1, 6) if pl["characterId" + str(i)] in _clv),
|
||||
@@ -641,15 +662,18 @@ check("传给 build_user_playlog 后 characterLevelN 与快照一致",
|
||||
[build_user_playlog(1, 1791159576,
|
||||
PlayData(characters=_dpc))["characterLevel" + str(i)]
|
||||
for i in range(1, 6)] == [3802, 4457, 136, 136, 136])
|
||||
check("快照缺角色时回退到 config.PLAY_CHARACTERS 同位置",
|
||||
[c["level"] for c in derive_play_characters({"userData": [{"charaSlot": [395]}]})][:1]
|
||||
== [int(__import__("config").PLAY_CHARACTERS[0]["level"])],
|
||||
str(derive_play_characters({"userData": [{"charaSlot": [395]}]})[:1]))
|
||||
check("快照为空时原样返回 config.PLAY_CHARACTERS 的 5 条",
|
||||
derive_play_characters(None)
|
||||
# ★ PlayData.characters 现在默认为空(真值由快照推),所以要显式传 PLAY_CHARACTERS
|
||||
_FALLBACK_PLAY = PlayData(characters=PLAY_CHARACTERS)
|
||||
check("快照缺角色时回退到 PLAY_CHARACTERS 同位置",
|
||||
[c["level"] for c in derive_play_characters(
|
||||
{"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)][:1]
|
||||
== [int(PLAY_CHARACTERS[0]["level"])],
|
||||
str(derive_play_characters({"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)[:1]))
|
||||
check("快照为空时原样返回 PLAY_CHARACTERS 的 5 条",
|
||||
derive_play_characters(None, PlayData(characters=PLAY_CHARACTERS))
|
||||
== [{"characterId": int(c["characterId"]), "level": int(c["level"]),
|
||||
"awakening": int(c["awakening"])}
|
||||
for c in __import__("config").PLAY_CHARACTERS])
|
||||
for c in PLAY_CHARACTERS])
|
||||
|
||||
# 差分节点
|
||||
# ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!)
|
||||
@@ -931,7 +955,7 @@ check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5",
|
||||
check("非付费票 creditNum 为 0", ticket_price(11001) == 0)
|
||||
check("price 可显式覆盖", ticket_price(2, 99) == 99)
|
||||
check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2))
|
||||
check("config.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
|
||||
check("api.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
|
||||
|
||||
# ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")----
|
||||
check("_now_string 带一位小数(.f)",
|
||||
@@ -1111,13 +1135,13 @@ check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 76928
|
||||
section("15. config 与源码常量")
|
||||
|
||||
import config
|
||||
check("AES_KEY 来自 CipherAES.cs", config.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
|
||||
check("AES_IV 来自 CipherAES.cs", config.AES_IV == b"F>;24DjU9W6ZsRH[")
|
||||
check("混淆盐来自 Packet.cs", config.OBFUSCATE_PARAM == "8bF76dE9")
|
||||
check("AES_KEY 来自 CipherAES.cs", api.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
|
||||
check("AES_IV 来自 CipherAES.cs", api.AES_IV == b"F>;24DjU9W6ZsRH[")
|
||||
check("混淆盐来自 Packet.cs", api.OBFUSCATE_PARAM == "8bF76dE9")
|
||||
check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55")
|
||||
check("BASE_URL 指向 Maimai2Servlet",
|
||||
config.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
|
||||
check("扫码签名密钥 32 字符", len(config.QR_SIGN_KEY) == 32)
|
||||
api.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
|
||||
check("扫码签名密钥 32 字符", len(api.QR_SIGN_KEY) == 32)
|
||||
check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL)
|
||||
check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False)
|
||||
|
||||
@@ -1137,7 +1161,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")))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
#!/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 表见 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,
|
||||
# ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py
|
||||
TICKET_CREDIT_NUM,
|
||||
)
|
||||
from config import (
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
PLAY_MUSIC_ID,
|
||||
PLAY_MUSIC_LEVEL,
|
||||
PLAY_CREDIT,
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user