v0版本完成:新增 4 篇详细文档(README 精简内容的落点)

README 为了防冗余做了精简,被移出的详细内容写成文档放这里:

- 功能票使用说明.md      买票/用票的完整原理、报文依据、playCredit 公式、实测记录
- 账号转生说明.md        reincarnate 完整流程、分批规则(避开 2 首)、
                          续关登录依据、登出 type=1 依据、playlog 合成、迁移范围
- 常见坑与注意事项.md    二维码 / 账号状态 / 功能票 / 副作用 / 日志 / 网络 /
                          不入库清单 + .gitignore 行尾注释的坑
- netdelivery说明.md     DLI 协议、amdaemon.exe 字符串出处、用法与实测结论
This commit is contained in:
2026-10-07 11:23:47 +08:00
parent ddacc97b18
commit 3e30dffb31
4 changed files with 576 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# netdelivery.py 说明(ALL.Net DLI)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里
> netdelivery 部分的**详细版**(含运行原理)。
---
## 一、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=` 里:
```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=` 是「本机应该已经有的文件」,只是文件名,不是下载项。
---
## 二、协议出处
全部来自 `source/amdaemon.exe` 的字符串(数字是文件内偏移):
| 字符串 | 偏移 |
|---|---|
| `http://at.sys-all.cn/net/initialize` | `@0x788170` |
| `http://at.sys-all.cn/net/delivery/instruction` | `@0x788198` |
| `title_id=%s&title_ver=%s&client_id=%s` | `@0x7882b8` |
| `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` |
| `alAbEx_lite Ver 4.00.01` | `@0x784a31` |
协议是 ALL.Net **alAbEx v4**:AES-128-CBC 信封,明文是 `key=value&...`。
```
请求 = AES-CBC( 16 字节全 0 前缀 || 明文, IV = 全 0 )
响应 = 16 字节 IV || 密文
```
---
## 三、用法
```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`,下完改名)与进度/速率显示。
---
## 四、实测结论(2026-10-06)
| 结论 | 证据 |
|---|---|
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
| `client_id` **必须**是 11 位短值 | 发 15 位完整 Keychip → `result=-3`;发小写 → `result=-2` |
| 包可以断点续传 | 返回 `206` + `Content-Range` |
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
实测清单(`--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
```
下载并校验字节数完全一致:`SDGB_A031...opt` = 7602176、`SDGB_A005...opt` = 3145728。
---
## 五、注意
- 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
- 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
- 这是**只读**操作,不写任何账号数据。
+173
View File
@@ -0,0 +1,173 @@
# 功能票使用说明(购买 / 消耗)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里功能票部分的**详细版**。
> 购买接口的完整排查过程见 [购买功能票流程.md](购买功能票流程.md) 与
> [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
---
## 一、票的种类
机台**可购买**的是付费票(`ticketKind=1`,`api.PAID_TICKET_IDS = (2,3,4,5,6)`):
| chargeId | 票 | 上报的 `price`(= 票的 `creditNum`) |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 5 | 5倍チケット | 4 |
> 完整表(含 4/6 倍与活动票)见 `api/upsert_user_chargelog.py` 的 `TICKET_CREDIT_NUM`。
>
> ⚠️ **不同店铺上架的票不一样。** 买之前先 `--list` 看一眼 ——
> 服务端对未上架的票会直接 `returnCode=0`(实测过)。
---
## 二、购买:`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 秒) |
### 三道安全检查
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
连登录都不做(不浪费二维码)。
依据 `source/Assembly-CSharp/Manager/TicketManager.cs:137-150`。
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
(重复购买有封号风险)。想清掉手里的票用 `use_ticket.py`。
3. **跑后再查一次**,确认票券状态符合预期。
### 报文形态
```json
{
"userId": <uid>,
"userChargelog": {
"chargeId": 2, "price": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"playCount": 1268, "playerRating": 15534,
"placeId": 1007, "regionId": 8, "clientId": "A63E01C3047"
},
"userCharge": {
"chargeId": 2, "stock": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791325354
}
```
| 字段 | 依据 |
|---|---|
| `price` | = `ticket.creditNum`(**不是** `GetGameChargeApi` 里的 money price)。`VOExtensions.cs:410` |
| `stock` | **购买后**的库存(原值 + 1) |
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f`,**带一位小数** |
| `validDate` | `TicketManager.cs:562-565`:`Now.AddHours(-4).AddDays(90).ToString("yyyy-MM-dd 04:00:00")`,**不带小数** |
> ⚠️ 这两个日期格式不一样 —— `purchaseDate` 带 `.f` 而 `validDate` 不带。
> 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
---
## 三、消耗:`use_ticket.py`
```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`
**格式完全相同**(`UserCharge.cs`:`chargeId` / `stock` / `purchaseDate` / `validDate`),
所以只要把该节点里目标票的 `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
(报文里**没有** isNewChargeList 这个节点)
```
`playCredit` 的算法(`VOExtensions.cs:838-845`):
```csharp
int num = Credit.GameCostEnoughPlay(); // 投币数,普通局 = 1
int useTicketId = TicketManager.GetUseTicketId(playerId);
int ticketCredit= TicketManager.GetTicketCredit(useTicketId); // = 票的 creditNum
int playCredit = num + ticketCredit;
```
2 倍券的 `creditNum = 1` → `playCredit = 1 + 1 = 2`,与抓包吻合。
### 实测记录(2026-10-07 00:24)
```
[消耗前] chargeId=2 2倍チケット stock=1 ← 持有中
选定要消耗的票:chargeId=2 creditNum=1 -> playCredit=1+1=2
userChargeList 里 chargeId=2 的 stock:1 -> 0
★ 上传成绩成功:returnCode=1
落库回查:
playCount 1269 -> 1269 (PLAY_USER_PLAY_COUNT_GAIN=0,不再 +1)
currentPlayCount 197 -> 197
lastPlayDate 2026-10-06 22:23:56 -> 2026-10-07 00:24:07 ← 落库成功
musicId=143 level=4 playCount -> 11 (曲目次数不变)
★ 票券已消耗:chargeId=2 不再持有 ✅
```
---
## 四、典型用法:票挂在手里,想再买一张
`buy_ticket.py` 检测到**已持票**会拒绝购买。想再买一张:
1. 用 `use_ticket.py --send` 把手里那张消耗掉,或
2. 直接在机台上打一局并选用它
然后再跑 `buy_ticket.py`。
---
## 五、购买接口曾经的坑(摘要)
`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`,报文与真机**逐字节一致**也照样失败。
**根因:请求头多发了 `Accept` 与 `Connection`。**
真机(Unity Mono 的 `HttpWebRequest`)实际只发这几个头:
```
number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
```
删掉这两个头之后一次通过(`stock` 0 → 1,落库确认)。
> 完整排查过程(12 项已排除假设、账号对照实验、真机抓包对比)见
> [购买功能票流程.md](购买功能票流程.md) 与 [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
+114
View File
@@ -0,0 +1,114 @@
# 常见坑与注意事项
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里被精简掉的
> 「常见坑与注意事项」章节的**详细版**。
---
## 一、二维码
| 项 | 说明 |
|---|---|
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2` |
| 计量方式 | ★ **不是按次数计量的**,只是有时效;重新生成即可 |
| 来源 | 机台的「扫码登录」界面 |
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
---
## 二、账号状态
- **一定要登出。** 账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
- 登出类型:真机用 **type=1(Logout)**,见 [账号转生说明.md](账号转生说明.md) 第五节。
---
## 三、功能票
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
- 不同店铺上架的票不一样,买之前先 `--list`。
- 详见 [功能票使用说明.md](功能票使用说明.md)。
---
## 四、副作用
| 命令 | 后果 |
|---|---|
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
| 带 `--send` | **真实写入服务器,不可撤销** |
涉及真实写入的脚本:`upsert.py` / `buy_ticket.py` / `use_ticket.py` / `reincarnate.py`。
---
## 五、日志
```
logs/latest.log 只含**当次**运行(下次覆盖)
logs/YYYY-MM-DD.log 当日全部日志(追加)
```
改 `config.py` 的 `LOG_LEVEL`:
| 值 | 效果 |
|---|---|
| `"info"` | 只展示 api 脚本的运行情况和结果 |
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
> DEBUG 日志很大(一局能到十几 MB),`config.LOG_VALUE_MAX` 可以限制单值长度。
---
## 六、网络
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
- `GetGameChargeApi` 偶尔会读超时,重跑一次通常就好。
---
## 七、不要提交的东西
```
config.py 你的机台身份,.gitignore 已忽略
data/ 用户数据快照
download/ .app / .opt 更新包
source/ 反编译源码(版权)
logs/ 运行日志
```
### ⚠️ `.gitignore` 的行尾注释不是注释
写:
```gitignore
data/ # 每账号一份用户数据快照
```
**是错的** —— `#` 只在**行首**才是注释,整行会被当成一个含空格和 `#` 的 pattern,
**匹配不到任何东西**。结果就是规则失效。
正确写法:
```gitignore
# 每账号一份用户数据快照
data/
```
> 这条坑踩过一次:`data/12570152.json`(604 KB 用户数据)被误提交进 git。
> 修复方式是 `git rm --cached` + `commit --amend` + `reflog expire` + `gc --prune=now`。
---
## 八、其它
- **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动任何代码。
- `config.py` 不入库,仓库里只有 `config.py.example` 模板;
第一次用要先 `copy config.py.example config.py` 并填好「机台与店铺标识」。
- `api/` 里每个脚本 = 一个接口,函数名与文件名都带 `Get`(如果 API 名里有 Get)。
- 想离线复现某次请求,用 `--debug` 抓报文 + `selftest.py` 里的对拍用例。
+186
View File
@@ -0,0 +1,186 @@
# 账号转生说明(`reincarnate.py`)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里转生部分的**详细版**。
---
## 一、它干了什么
把一个账号(源 **A**)的数据克隆到另一个账号(目标 **B**):
```
源账号 A:二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData
或:直接读 data/<A>.json(离线复用)
↓ 节点数据(19 个节点)
目标账号 B:二维码 -> ParseQRCode -> UserPreview -> UserLogin
循环:
等待 -> UpsertUserAll(一包曲目)
-> UserLogout(type=1)
-> UserLogin(isContinue=true) 续关,拿新 loginId
收尾:最后一包登出后结束(不再续关)
```
---
## 二、用法
```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
# ★ 真实上传
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
# 只看计划,不发包(★ 建议先跑这个)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
| `--src-qr` / `--src-json` | 二选一 | 源数据来源 |
| `--plan` | — | 只列分批计划,不发包 |
| `--send` | 关 | ★ 真实发包 |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数(0 = 不等) |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
| `--debug` | 关 | 日志里带完整收发报文 |
---
## 三、★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
| 总量 | `--per-packet` | 原始切分 | 修正后 |
|---|---|---|---|
| 10 首 | 4 | `[4,4,2]` | `[4,3,3]` |
| 6 首 | 4 | `[4,2]` | `[3,3]` |
| 2 首 | 4 | `[2]` | `[1,1]` |
| 5 首 | 3 | `[3,2]` | `[3,1,1]` |
| 8 首 | 3 | `[3,3,2]` | `[3,3,1,1]` |
| 5 首 | 4 | `[4,1]` | 本来就合法,不动 |
修正规则(`reincarnate.split_batches`):
1. 前一批 **>= 4** → 从它借 1 首过来(前一批变 >=3,本批变 3)
2. 借不到(前一批只有 3,或自己就是第一批)→ 把这个 2 **拆成 1 + 1**
> ★ 只借位是不够的:前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
自检里有 `1..299 首 × per∈{1,3,4}` 的全量扫描,保证永不产生 2 首包。
---
## 四、★ 为什么要「续关登录」
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`
(`PacketUpsertUserAll.cs:31` / `UserAllRequestVO`)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— 真机走的就是「续关」:
```csharp
// source/Assembly-CSharp/Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, GameManager.IsEventMode ? 1 : 0, ...)
```
对应本工程:`UserLogin(user_id, token, is_continue=True)`
—— 和普通登录的唯一区别就是 `UserLoginRequestVO.isContinue = true`,
会返回**新的 `loginId` / `loginDateTime`**。
---
## 五、★ 登出必须用 type=1(Logout),不要用 4(TestIn)
`Net.VO.Mai2/LogoutType.cs`:
```csharp
public enum LogoutType { None=0, Logout=1, Cancel=2, Error=3, TestIn=4, Quit=5 }
```
**真机抓包实证** `UserLogoutApi` 用的是 **type=1**:
```json
{"userId":12570152,"accessCode":"","regionId":8,"placeId":1007,
"clientId":"A63E01C3047","loginDateTime":1791325354,"type":1,"delayLog":{...}}
```
源码侧的正常会话结束也是 type=1:
```csharp
// source/Assembly-CSharp/Process.UserDataNet.State.UserDataULState/StateULUserLogout.cs:24
PacketHelper.StartPacket(new PacketUserLogout(_index, _userId, LogoutType.Logout, ...));
```
**续关场景下用 4(TestIn)会出问题** —— 那是「测试登入 / 顶号」的语义
(`GameMain.cs:104` 退出游戏时才用)。
`UserLogout` 对 type=1 会自动带上完整 `delayLog`
(`PacketUserLogout.cs:21`:`logoutType == LogoutType.Logout` 时才收集会话统计)。
---
## 六、playlog 从哪来
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|---|---|---|
| `musicId` / `level` | | `musicId` / `level` |
| `achievement` | | `achievement` |
| `deluxscoreMax` | | `deluxscore` |
| `scoreRank` | | `scoreRank` / `isClear` |
| `comboStatus` / `syncStatus` | | 同名 |
| 其余 | | `config.PLAY_*` 默认值 |
`UserMusicDetail` 的全部字段(`Net.VO.Mai2/UserMusicDetail.cs`):
`musicId` / `level` / `playCount` / `achievement` / `comboStatus` /
`syncStatus` / `deluxscoreMax` / `scoreRank` / `extNum1`。
---
## 七、迁移范围
默认迁**全部节点**(整账号克隆):
- **第 1 包**带全部非曲目节点(`userData` / `userExtend` / `userOption` /
`userCharacterList` / `userMapList` / …)
- **之后每包**只带这一批曲目 + 合成的 `userGamePlaylogList`
- 位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`),
主键表见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
**不迁的节点**(`SKIP_NODES`):
| 节点 | 原因 |
|---|---|
| `userMusicDetailList` | 单独分批 |
| `userGamePlaylogList` | 本脚本按曲目合成 |
| `user2pPlaylog` | 2P 记录,迁移无意义 |
| `userGetPointList` | 里程/奖励,迁过去会凭空发点 |
| `userTradeItemList` | 无 Get 来源 |
| `userFriendSeasonRankingList` | 无 Get 来源 |
**默认不克隆 `userName` / `accessCode`**(避免把 B 改名);要连身份一起搬就加
`--clone-identity`。
---
## 八、⚠️ 注意事项
1. **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
2. **会真实写入账号 B(不可撤销)。** 不传 `--send` 时只构造报文。
3. **耗时**:曲目多的话包数很多(每包最多 4 首),加上 `--wait` 的等待,
几百首会跑很久。想快可以调小 `--wait`。
4. **二维码有效期 10 分钟**,但转生过程中只用一次(登录时)——
续关用的是 token,不需要新二维码。
5. 基线快照只在**第一次登录后抓一次**,后续包沿用同一份做差分。
曲目节点由 `extra_data` 整体覆盖,位串也单独算,不受影响。