Initial commit: Add maimaiDX API web application with AimeDB scanning and logging features

This commit is contained in:
kejiz
2025-09-18 10:19:08 +08:00
commit 4e83f159f0
84 changed files with 14012 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Dependencies
node_modules/
__pycache__/
*.pyc
.env
# IDE
.vscode/
.idea/
# Logs
logs/
*.log
# Build outputs
dist/
build/
*.exe
*.dmg
*.app
# OS
.DS_Store
Thumbs.db
# Python
*.pyo
*.pyd
*.pyc
*.pyo
.pytest_cache/
.coverage
htmlcov/
# Virtual environments
.venv/
venv/
env/
# Frontend build outputs
frontend/dist/
frontend/node_modules/
# Backend build outputs
backend/__pycache__/
backend/*.pyc

82
README.md Normal file
View File

@@ -0,0 +1,82 @@
# Maimai DX Web 工具
这是一个基于 Python (FastAPI) 和 Vue.js (Element Plus) 构建的 Web 工具,旨在提供一个用户友好的界面来管理和查看 maimai DX 模拟器的数据。
## 项目结构
```
maimai-web-app/
├── backend/ # Python 后端服务 (FastAPI)
└── frontend/ # Vue.js 前端应用 (Element Plus)
```
## 运行项目
请确保您的系统已安装 Python 3.8+ 和 Node.js (包含 npm)。
### 1\. 启动后端服务
打开您的命令行工具,进入 `maimai-web-app/backend` 目录,然后安装 Python 依赖并启动服务:
```bash
cd maimai-web-app/backend
pip install -r requirements.txt
python main.py
```
**注意:**
* 后端服务默认运行在 `http://0.0.0.0:8000`。如果您需要从其他设备访问(例如手机),请确保您的防火墙允许 8000 端口的传入连接,并且您的设备在同一局域网内,或者您已正确配置了端口映射。
* `main.py` 中的 `testUid8` 是一个默认的用户ID您可以在 `MyConfig.py` 中修改它。
### 2\. 启动前端应用
打开一个新的命令行工具(不要关闭后端服务的窗口),进入 `maimai-web-app/frontend` 目录,然后安装 Node.js 依赖并启动应用:
```bash
cd maimai-web-app/frontend
npm install
npm run dev -- --port 5174
```
前端应用默认运行在 `http://localhost:5174`。如果该端口被占用,它会自动尝试其他端口。您也可以通过 `--port` 参数指定端口,例如 `npm run dev -- --port 3000`
### 3\. 访问应用
在浏览器中打开前端应用提供的地址(通常是 `http://localhost:5174`)。
## 已实现功能
### 乐曲列表
* 浏览所有 maimai DX 乐曲。
* 支持按歌曲名和艺术家搜索。
### 用户中心
* **用户选择**: 通过输入用户ID获取玩家的完整信息或预览信息。
* **玩家操作**:
* **领取登录奖励**: 领取所有可用的每日登录奖励。
* **道具管理**: 解锁指定种类和ID的道具。
* **票券管理**: 购买指定类型的票券。
* **分数管理**: 上传或删除指定乐曲的分数记录。
* **上传至水鱼**: 将用户的分数上传至 [diving-fish.com](https://www.diving-fish.com/maimaidx/prober) 进行详细分析。
* **通用接口调用**: 通过输入API名称调用任意 `GetUser...Api` 接口,并显示原始响应。
* **危险操作**: 清空所有票券、强制用户登出。
* **分数详情**: 获取并显示用户的所有乐曲分数记录。
### 更新链接
* 获取 AuthLiteDelivery 更新链接列表。
* 解析每个更新链接指向的 INI 文件,并美观地展示其中的更新信息(如游戏描述、发布时间、主更新包和可选更新包链接)。
## 贡献
欢迎提交 Pull Request 或报告 Bug。在提交代码之前请确保您的代码符合项目规范。
## 许可证
本项目采用 MIT 许可证。详情请参阅 `LICENSE` 文件。

179
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,179 @@
# Anti-leak
Private_Static_Settings.py
MyConfig.py
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc
Private_Static_Settings.py
Private_Static_Settings.py
/.python-version

84
backend/APILogger.py Normal file
View File

@@ -0,0 +1,84 @@
import os
import json
from datetime import datetime
from loguru import logger
class APILogger:
def __init__(self, log_dir="logs"):
self.log_dir = log_dir
self._setup_logging()
def _setup_logging(self):
"""设置日志配置"""
# 创建日志目录(如果不存在)
if not os.path.exists(self.log_dir):
os.makedirs(self.log_dir)
# 配置loguru
logger.add(
f"{self.log_dir}/app_{{time}}.log",
rotation="100 MB",
retention="30 days",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
)
logger.add(
f"{self.log_dir}/error_{{time}}.log",
level="ERROR",
rotation="100 MB",
retention="90 days",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"
)
def log_request(self, method, url, headers, body=None):
"""记录API请求"""
log_data = {
"type": "request",
"method": method,
"url": str(url),
"headers": dict(headers),
"timestamp": datetime.now().isoformat()
}
if body:
log_data["body"] = body
logger.info(f"API Request: {json.dumps(log_data, ensure_ascii=False)}")
def log_response(self, status_code, response_data, process_time=None):
"""记录API响应"""
log_data = {
"type": "response",
"status_code": status_code,
"response_data": response_data,
"process_time": process_time,
"timestamp": datetime.now().isoformat()
}
if status_code >= 400:
logger.error(f"API Response: {json.dumps(log_data, ensure_ascii=False)}")
else:
logger.info(f"API Response: {json.dumps(log_data, ensure_ascii=False)}")
def log_error(self, error_msg, error_details=None):
"""记录错误"""
log_data = {
"type": "error",
"error_msg": error_msg,
"error_details": error_details,
"timestamp": datetime.now().isoformat()
}
logger.error(f"API Error: {json.dumps(log_data, ensure_ascii=False)}")
def log_info(self, message):
"""记录信息"""
logger.info(message)
def log_warning(self, message):
"""记录警告"""
logger.warning(message)
# 创建全局日志实例
api_logger = APILogger()

132
backend/API_AimeDB.py Normal file
View File

@@ -0,0 +1,132 @@
# 100% Standalone 的舞萌国服 AimeDB 通讯实现
# Ver.CN1.41
# Mainline 20250203
import hashlib
import time
import requests
import json
import re
from loguru import logger
# 常量
CHIP_ID = "A63E-01E68606624"
COMMON_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
API_URL = "http://ai.sys-allnet.cn/wc_aime/api/get_data"
# 计算 SHA256
def getSHA256(input_str):
"""SHA256计算"""
return hashlib.sha256(input_str.encode("utf-8")).hexdigest().upper()
# 生成时间戳
def generateSEGATimestamp():
"""SEGA格式的 YYMMDDHHMMSS 时间戳sb玩意"""
return time.strftime("%y%m%d%H%M%S", time.localtime())
# 计算认证 key
def calcSEGAAimeDBAuthKey(
varString: str, timestamp: str, commonKey: str = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
) -> str:
"""计算 SEGA AimeDB 的认证 key"""
return (
hashlib.sha256((varString + timestamp + commonKey).encode("utf-8"))
.hexdigest()
.upper()
)
def apiAimeDB(qrCode):
"""AimeDB 扫码 API 实现"""
# 生成一个时间戳
timestamp = generateSEGATimestamp()
# 使用时间戳计算 key
currentKey = calcSEGAAimeDBAuthKey(CHIP_ID, timestamp, COMMON_KEY)
# 构造请求数据
payload = {
"chipID": CHIP_ID,
"openGameID": "MAID",
"key": currentKey,
"qrCode": qrCode,
"timestamp": timestamp,
}
# 输出准备好的请求数据
print("Payload:", json.dumps(payload, separators=(",", ":")))
# 发送 POST 请求
headers = {
"Connection": "Keep-Alive",
"Host": API_URL.split("//")[-1].split("/")[0],
"User-Agent": "WC_AIME_LIB",
"Content-Type": "application/json",
}
response = requests.post(
API_URL, data=json.dumps(payload, separators=(",", ":")), headers=headers
)
# 返回服务器的响应
return response
def isSGWCFormat(input_string: str) -> bool:
"""简单检查二维码字符串是否符合格式"""
if (
len(input_string) != 84 # 长度
or not input_string.startswith("SGWCMAID") # 识别字
or re.match("^[0-9A-F]+$", input_string[20:]) is None # 有效字符
):
return False
else:
return True
def implAimeDB(qrCode: str, isAlreadyFinal: bool = False) -> str:
"""
Aime DB 的请求的参考实现。
提供完整 QRCode 内容返回响应的字符串Json格式
"""
if isAlreadyFinal:
qr_code_final = qrCode
else:
# 提取有效部分(Hash)
qr_code_final = qrCode[20:]
# 发送请求
response = apiAimeDB(qr_code_final)
# 获得结果
print("implAimeDB: StatusCode is ", response.status_code)
print("implAimeDB: Response Body is:", response.text)
return response.text
def implGetUID(qr_content: str) -> dict:
"""
包装后的 UID 扫码器实现。
此函数会返回 AimeDB 传回的 Json 转成 Python 字典的结果。
主要特点是添加了几个新的错误码(6000x)用来应对程序的错误。
"""
# 检查格式
if not isSGWCFormat(qr_content):
return {"errorID": 60001} # 二维码内容明显无效
# 发送请求并处理响应
try:
result = json.loads(implAimeDB(qr_content))
logger.info(f"QRScan Got Response {result}")
except Exception:
return {"errorID": 60002} # 无法解码 Response 的内容
# 返回结果
return result
if __name__ == "__main__":
userInputQR = input("QRCode: ")
print(implAimeDB(userInputQR))

View File

@@ -0,0 +1,113 @@
# All.Net AuthLite 更新获取
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import httpx
from loguru import logger
from urllib.parse import parse_qs
import configparser as ini
LITE_AUTH_KEY = bytes(
[47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71]
)
LITE_AUTH_IV = bytes.fromhex("00000000000000000000000000000000")
def auth_lite_encrypt(plaintext: str) -> bytes:
# 构造数据16字节头 + 16字节0前缀 + 明文
content = bytes(32) + plaintext.encode("utf-8")
data = content
# 填充并加密
padded_data = pad(data, AES.block_size)
cipher = AES.new(LITE_AUTH_KEY, AES.MODE_CBC, LITE_AUTH_IV)
return cipher.encrypt(padded_data)
def auth_lite_decrypt(ciphertext: bytes) -> str:
# 解密并去除填充
cipher = AES.new(LITE_AUTH_KEY, AES.MODE_CBC, LITE_AUTH_IV)
decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size)
# 提取内容并解码
content = decrypted_data[16:] # 去除头部的16字节
return content.decode("utf-8").strip()
def getRawDelivery(title_ver: str = "1.51"):
encrypted = auth_lite_encrypt(
f"title_id=SDGB&title_ver={title_ver}&client_id=A63E01C2805"
)
r = httpx.post(
"http://at.sys-allnet.cn/net/delivery/instruction",
data=encrypted,
headers={"User-Agent": "SDGB;Windows/Lite", "Pragma": "DFI"},
)
resp_data = r.content
decrypted_str = auth_lite_decrypt(resp_data)
# 过滤所有控制字符
decrypted_str = "".join([i for i in decrypted_str if 31 < ord(i) < 127])
logger.info(f"RAW Response: {decrypted_str}")
return decrypted_str
def parseRawDelivery(deliveryStr):
"""解析 RAW 的 Delivery 字符串,返回其中的有效的 instruction URL 的列表"""
parsedResponseDict: dict[str, str] = {
key: value[0] for key, value in parse_qs(deliveryStr).items()
}
urlList = parsedResponseDict["uri"].split("|")
# 过滤掉空字符串和内容为 null 的情况
urlList = [url for url in urlList if url and url != "null"]
logger.info(f"Parsed URL List: {urlList}")
validURLs = []
for url in urlList:
# 检查是否是 HTTPS 的 URL以及是否是 txt 文件,否则忽略
if not url.startswith("https://") or not url.endswith(".txt"):
logger.warning(f"Invalid URL will be ignored: {url}")
continue
validURLs.append(url)
logger.info(f"Verified Valid URLs: {validURLs}")
return validURLs
def getUpdateIniFromURL(url):
# 发送请求
response = httpx.get(
url, headers={"User-Agent": "SDGB;Windows/Lite", "Pragma": "DFI"}
)
logger.info(f"成功自 {url} 获取更新信息")
return response.text
def parseUpdateIni(iniText):
# 解析配置
config = ini.ConfigParser(allow_no_value=True)
config.read_string(iniText)
logger.info(f"成功解析配置文件,包含的节有:{config.sections()}")
# 获取 COMMON 节的配置
common = config["COMMON"]
game_desc = common.get("GAME_DESC", "").strip('"')
release_time = common.get("RELEASE_TIME", "").replace("T", " ")
main_file = common.get("INSTALL1", "")
optional_files = []
if "OPTIONAL" in config:
for key, url in config.items("OPTIONAL"):
optional_files.append(f"{url.split('/')[-1]} {url}")
return {
"game_desc": game_desc,
"release_time": release_time,
"main_file": main_file,
"optional_files": optional_files,
}
if __name__ == "__main__":
raw = getRawDelivery("1.51")
urlList = parseRawDelivery(raw)
for url in urlList:
iniText = getUpdateIniFromURL(url)
message = parseUpdateIni(iniText)

352
backend/API_TitleServer.py Normal file
View File

@@ -0,0 +1,352 @@
# 舞萌DX
# 标题服务器通讯实现
import zlib
import hashlib
import random
import time
import httpx
from loguru import logger
from ctypes import c_int32
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Config import (
useProxy,
proxyUrl,
)
AesKey = "a>32bVP7v<63BVLkY[xM>daZ1s9MBP<R"
AesIV = "d6xHIKq]1J]Dt^ue"
ObfuscateParam = "B44df8yT"
def use2024Api():
global AesKey, AesIV, ObfuscateParam
AesKey = "n7bx6:@Fg_:2;5E89Phy7AyIcpxEQ:R@"
AesIV = ";;KjR1C3hgB1ovXa"
ObfuscateParam = "BEs2D5vW"
class SDGBApiError(Exception):
pass
class SDGBRequestError(SDGBApiError):
pass
class SDGBResponseError(SDGBApiError):
pass
class aes_pkcs7(object):
def __init__(self, key: str, iv: str):
self.key = key.encode("utf-8")
self.iv = iv.encode("utf-8")
self.mode = AES.MODE_CBC
def encrypt(self, content: bytes) -> bytes:
cipher = AES.new(self.key, self.mode, self.iv)
content_padded = pad(content, AES.block_size)
encrypted_bytes = cipher.encrypt(content_padded)
return encrypted_bytes
def decrypt(self, content):
cipher = AES.new(self.key, self.mode, self.iv)
decrypted_padded = cipher.decrypt(content)
decrypted = unpad(decrypted_padded, AES.block_size)
return decrypted
def pkcs7unpadding(self, text):
length = len(text)
unpadding = ord(text[length - 1])
return text[0 : length - unpadding]
def pkcs7padding(self, text):
bs = 16
length = len(text)
bytes_length = len(text.encode("utf-8"))
padding_size = length if (bytes_length == length) else bytes_length
padding = bs - padding_size % bs
padding_text = chr(padding) * padding
return text + padding_text
def getSDGBApiHash(api):
# API 的 Hash 的生成
# 有空做一下 Hash 的彩虹表?
return hashlib.md5((api + "MaimaiChn" + ObfuscateParam).encode()).hexdigest()
def apiSDGB(
data: str,
targetApi: str,
userAgentExtraData: str,
noLog: bool = False,
timeout: int = 5,
maxRetries: int = 3,
) -> str:
"""
舞萌DX 2025 API 通讯用函数
:param data: 请求数据
:param targetApi: 使用的 API
:param userAgentExtraData: UA 附加信息机台相关则为狗号如A63E01E9564用户相关则为 UID
:param noLog: 是否不记录日志
:param timeout: 请求超时时间(秒)
:return: 解码后的响应数据
"""
# 处理参数
agentExtra = str(userAgentExtraData)
aes = aes_pkcs7(AesKey, AesIV)
endpoint = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/"
# 准备好请求数据
requestDataFinal = aes.encrypt(zlib.compress(data.encode("utf-8")))
if not noLog:
logger.debug(f"[Stage 1] 准备开始请求 {targetApi},以 {data}")
retries = 0
while retries < maxRetries:
try:
# 配置 HTTP 客户端
if useProxy and proxyUrl:
logger.debug("使用代理")
httpClient = httpx.Client(proxy=proxyUrl, verify=False)
else:
httpClient = httpx.Client(verify=False)
api_hash = getSDGBApiHash(targetApi)
logger.info(f"hash: {api_hash}")
# 发送请求
response = httpClient.post(
url=endpoint + api_hash,
headers={
"User-Agent": f"{api_hash}#{agentExtra}",
"Content-Type": "application/json",
"Mai-Encoding": "1.50",
"Accept-Encoding": "",
"Charset": "UTF-8",
"Content-Encoding": "deflate",
"Expect": "100-continue",
},
content=requestDataFinal, # 数据
timeout=timeout,
)
if not noLog:
logger.info(f"[Stage 2] {targetApi} 请求结果: {response.status_code}")
if response.status_code != 200:
errorMessage = f"[Stage 2] 请求失败: {response.status_code}"
logger.error(errorMessage)
raise SDGBRequestError(errorMessage)
# 处理响应内容
responseContentRaw = response.content
# 先尝试解密
try:
responseContentDecrypted = aes.decrypt(responseContentRaw)
if not noLog:
logger.debug("[Stage 3] Decryption SUCCESS.")
except Exception as e:
logger.warning(
f"[Stage 3] Decryption FAILED. Raw Content: {responseContentRaw}, Error: {e}"
)
raise SDGBResponseError("Decryption failed")
# 然后尝试解压
try:
# 看看文件头是否是压缩过的
if responseContentDecrypted.startswith(b"\x78\x9c"):
logger.debug("[Stage 4] Zlib detected, decompressing...")
responseContentFinal = zlib.decompress(
responseContentDecrypted
).decode("utf-8")
else:
logger.warning(
f"[Stage 4] Not Zlib Format!! using raw content: {responseContentDecrypted}"
)
responseContentFinal = responseContentDecrypted.decode("utf-8")
# 完成解压
if not noLog:
logger.debug(
f"[Stage 4] Process OK, Content: {responseContentFinal}"
)
# 最终处理,检查是否是 JSON 格式
if responseContentFinal.startswith(
"{"
) and responseContentFinal.endswith("}"):
# 如果是 JSON 格式,直接返回
logger.debug("[Stage 5] Response is JSON, returning.")
return responseContentFinal
else:
# 如果不是 JSON 格式,直接返回但是警告
logger.warning(
"[Stage 5] Response is not JSON, returning as is, take care!"
)
return responseContentFinal
except Exception:
logger.warning(f"解压失败,原始响应: {responseContentDecrypted}")
raise SDGBResponseError("解压失败")
except SDGBRequestError as e:
logger.error(f"请求格式错误: {e}")
raise
except SDGBResponseError as e:
logger.warning(f"响应错误,将重试: {e}")
retries += 1
time.sleep(2)
except Exception as e:
logger.warning(f"请求失败,将重试: {e}")
retries += 1
time.sleep(2)
finally:
if "httpClient" in locals():
httpClient.close()
raise SDGBApiError("重试多次仍然无法成功请求服务器")
def calcPlaySpecial():
"""使用 c_int32 实现的 SpecialNumber 算法"""
rng = random.SystemRandom()
num2 = rng.randint(1, 1037933) * 2069
num2 += 1024 # GameManager.CalcSpecialNum()
num2 = c_int32(num2).value
result = c_int32(0)
for _ in range(32):
result.value <<= 1
result.value += num2 & 1
num2 >>= 1
return c_int32(result.value).value
class AESPKCS7_2024:
# 实现了 maimai 通讯所用的 AES 加密的类
def __init__(self, key: str, iv: str):
self.key = key.encode("utf-8")
self.iv = iv.encode("utf-8")
self.mode = AES.MODE_CBC
# 加密
def encrypt(self, content) -> bytes:
# if content is str, convert to bytes
if isinstance(content, str):
encodedData = content.encode("utf-8")
cipher = AES.new(self.key, self.mode, self.iv)
content_padded = pad(encodedData, AES.block_size)
encrypted_bytes = cipher.encrypt(content_padded)
return encrypted_bytes
# 解密
def decrypt(self, encrypted_content: bytes) -> str:
cipher = AES.new(self.key, self.mode, self.iv)
decrypted_padded = cipher.decrypt(encrypted_content)
decrypted = unpad(decrypted_padded, AES.block_size)
return decrypted
def apiSDGB_2024(
data: str,
targetApi: str,
userAgentExtraData: str,
noLog: bool = False,
timeout: int = 5,
):
"""
舞萌DX 2024 API 通讯用函数
:param data: 请求数据
:param targetApi: 使用的 API
:param userAgentExtraData: UA 附加信息机台相关则为狗号如A63E01E9564用户相关则为 UID
:param noLog: 是否不记录日志
"""
maxRetries = 3
agentExtra = str(userAgentExtraData)
aes = AESPKCS7_2024(AesKey, AesIV)
reqData_encrypted = aes.encrypt(data)
reqData_deflated = zlib.compress(reqData_encrypted)
endpoint = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/"
if not noLog:
logger.debug(f"开始请求 {targetApi},以 {data}")
retries = 0
while retries < maxRetries:
try:
if useProxy:
# 使用代理
logger.debug("使用代理")
httpClient = httpx.Client(proxy=proxyUrl, verify=False)
else:
# 不使用代理
logger.debug("不使用代理")
httpClient = httpx.Client(verify=False)
responseOriginal = httpClient.post(
url=endpoint + getSDGBApiHash(targetApi),
headers={
"User-Agent": f"{getSDGBApiHash(targetApi)}#{agentExtra}",
"Content-Type": "application/json",
"Mai-Encoding": "1.40",
"Accept-Encoding": "",
"Charset": "UTF-8",
"Content-Encoding": "deflate",
"Expect": "100-continue",
},
content=reqData_deflated,
# 经测试,加 Verify 之后速度慢好多,因此建议选择性开
# verify=certifi.where(),
# verify=False,
timeout=timeout,
)
if not noLog:
logger.info(f"{targetApi} 请求结果: {responseOriginal.status_code}")
if responseOriginal.status_code == 200:
logger.debug("200 OK!")
else:
errorMessage = f"请求失败: {responseOriginal.status_code}"
logger.error(errorMessage)
raise SDGBRequestError(errorMessage)
responseRAWContent = responseOriginal.content
try:
responseDecompressed = zlib.decompress(responseRAWContent)
logger.debug("成功解压响应!")
except Exception:
logger.warning(f"无法解压,得到的原始响应: {responseRAWContent}")
raise SDGBResponseError("解压失败")
try:
resultResponse = aes.decrypt(responseDecompressed)
logger.debug("成功解密响应!")
except Exception:
logger.warning(f"解密失败,得到的原始响应: {responseDecompressed}")
raise SDGBResponseError("解密失败")
if not noLog:
logger.debug(f"响应: {resultResponse}")
return resultResponse
# 异常处理
except SDGBRequestError:
# 请求格式错误,不需要重试
raise SDGBRequestError("请求格式错误")
except SDGBResponseError as e:
# 响应解析错误,这种有一定可能是我们的问题,所以只重试一次
logger.warning(f"将重试一次 Resp Err: {e}")
retries += 2
time.sleep(2)
except Exception as e:
# 其他错误,重试多次
logger.warning(f"将开始重试请求. {e}")
retries += 1
time.sleep(2)
raise SDGBApiError("重试多次仍然无法成功请求服务器")

View File

@@ -0,0 +1,55 @@
# 改变版本号,实现伪封号和解封号之类
from loguru import logger
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperFullPlay import implFullPlayAction, generateMusicData
from HelperGetUserThing import implGetUser_
from MyConfig import testUid8
def implWipeTickets(userId: int, currentLoginTimestamp: int, currentLoginResult) -> str:
# Get User Charge
currentUserCharge = implGetUser_("Charge", userId)
currentUserChargeList = currentUserCharge["userChargeList"]
# All stock set to 0
for charge in currentUserChargeList:
charge["stock"] = 0
# example format
# {"userId":11088995,"length":16,"userChargeList":[{"chargeId":1,"stock":0,"purchaseDate":"2025-02-04 00:51:50","validDate":"2025-02-04 00:51:50","extNum1":0},{"chargeId":2,"stock":0,"purchaseDate":"2025-06-11 17:19:42","validDate":"2025-09-09 04:00:00","extNum1":0},{"chargeId":3,"stock":0,"purchaseDate":"2025-06-11 17:19:40","validDate":"2025-09-09 04:00:00","extNum1":0},{"chargeId":4,"stock":0,"purchaseDate":"2025-06-11 09:34:51","validDate":"2025-09-09 04:00:00","extNum1":0},{"chargeId":5,"stock":0,"purchaseDate":"2025-01-30 12:31:16","validDate":"2025-04-30 04:00:00","extNum1":0},{"chargeId":6,"stock":0,"purchaseDate":"2025-02-17 20:01:42","validDate":"2025-02-17 20:01:42","extNum1":0},{"chargeId":7,"stock":0,"purchaseDate":"2025-02-06 16:17:41","validDate":"2025-02-06 16:17:41","extNum1":0},{"chargeId":8,"stock":0,"purchaseDate":"2025-02-06 16:17:49","validDate":"2025-02-06 16:17:49","extNum1":0},{"chargeId":9,"stock":0,"purchaseDate":"2025-02-06 16:18:00","validDate":"2025-02-06 16:18:00","extNum1":0},{"chargeId":10001,"stock":1,"purchaseDate":"2025-06-11 17:19:51","validDate":"2025-09-09 04:00:00","extNum1":0},{"chargeId":10005,"stock":0,"purchaseDate":"2025-04-25 15:45:55","validDate":"2025-07-24 04:00:00","extNum1":0},{"chargeId":10105,"stock":0,"purchaseDate":"2025-04-25 15:46:00","validDate":"2025-07-24 04:00:00","extNum1":0},{"chargeId":10205,"stock":0,"purchaseDate":"2025-04-25 15:46:03","validDate":"2025-07-24 04:00:00","extNum1":0},{"chargeId":11001,"stock":0,"purchaseDate":"2025-01-08 20:43:05","validDate":"2025-04-08 04:00:00","extNum1":0},{"chargeId":30001,"stock":0,"purchaseDate":"2025-04-25 15:46:17","validDate":"2025-07-24 04:00:00","extNum1":0},{"chargeId":999999,"stock":0,"purchaseDate":"2025-02-06 23:03:14","validDate":"2025-02-06 23:03:14","extNum1":0}]}
musicData = generateMusicData()
userAllPatches = {
"upsertUserAll": {
# "userData": [{
# "lastRomVersion": romVersion,
# "lastDataVersion": dataVersion
# }],
"userChargeList": currentUserChargeList,
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1", # 1避免覆盖
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
if __name__ == "__main__":
userId = testUid8
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(implWipeTickets(userId, currentLoginTimestamp, loginResult))
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

156
backend/ActionLoginBonus.py Normal file
View File

@@ -0,0 +1,156 @@
# ログインボーナス!やったね!
# セガ秘 内部使用のみ(トレードマーク)
import rapidjson as json
from loguru import logger
import xml.etree.ElementTree as ET
from Config import (
loginBonusDBPath,
loginBonusDBPathFallback,
)
from MyConfig import testUid
from API_TitleServer import apiSDGB
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperFullPlay import implFullPlayAction, generateMusicData
class NoSelectedBonusError(Exception):
pass
def apiQueryLoginBonus(userId: int) -> str:
"""ログインボーナスを取得する API"""
data = json.dumps({"userId": int(userId), "nextIndex": 0, "maxCount": 2000})
return apiSDGB(data, "GetUserLoginBonusApi", userId)
def implLoginBonus(
userId: int, currentLoginTimestamp: int, currentLoginResult, bonusGenerateMode=1
):
"""
ログインボーナスデータをアップロードする
bonusGenerateMode は、ログインボーナスを生成する方法を指定します。
1: 選択したボーナスのみ MAX にする(選択したボーナスはないの場合は False を返す)
2: 全部 MAX にする
"""
musicData = generateMusicData()
# サーバーからログインボーナスデータを取得
data = json.dumps({"userId": int(userId), "nextIndex": 0, "maxCount": 2000})
UserLoginBonusResponse = json.loads(apiSDGB(data, "GetUserLoginBonusApi", userId))
# ログインボーナスリストを生成、それから処理してアップロード
UserLoginBonusList = UserLoginBonusResponse["userLoginBonusList"]
finalBonusList = generateLoginBonusList(UserLoginBonusList, bonusGenerateMode)
if not finalBonusList:
return False # ログインボーナスを選択していないから失敗
# UserAllのパッチ
userAllPatches = {
"upsertUserAll": {
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1", # 上書きしない
"userLoginBonusList": finalBonusList,
"isNewLoginBonusList": "0" * len(finalBonusList),
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
def generateLoginBonusList(UserLoginBonusList, generateMode=1):
"""
ログインボーナスリストを生成します。
generateMode は、ログインボーナスを生成する方法を指定します。
1: 選択したボーナスのみ MAX にする(選択したボーナスはないの場合は False を返す)
2: 全部 MAX にする
"""
# HDDから、ログインボーナスデータを読み込む
# アップデートがある場合、このファイルを更新する必要があります
# 必ず最新のデータを使用してください
try:
tree = ET.parse(loginBonusDBPath)
root = tree.getroot()
loginBonusIdList = [
int(item.find("id").text) for item in root.findall(".//StringID")
]
logger.debug(f"ログインボーナスIDリスト: {loginBonusIdList}")
except FileNotFoundError:
try:
tree = ET.parse(loginBonusDBPathFallback)
root = tree.getroot()
loginBonusIdList = [
int(item.find("id").text) for item in root.findall(".//StringID")
]
logger.debug(f"ログインボーナスIDリスト: {loginBonusIdList}")
except Exception:
raise FileNotFoundError("ログインボーナスデータベースを読み込めません")
# ログインボーナスの MAX POINT は5の場合があります
# その全部のボーナスIDをこのリストに追加してください
# 必ず最新のデータを使用してください
Bonus5Id = [12, 29, 30, 38, 43, 604, 611]
# UserBonusList から bonusId を取得
UserLoginBonusIdList = [item["bonusId"] for item in UserLoginBonusList]
# 存在しないボーナス
NonExistingBonuses = list(set(loginBonusIdList) - set(UserLoginBonusIdList))
logger.debug(f"存在しないボーナス: {NonExistingBonuses}")
bonusList = []
if generateMode == 1: # 選択したボーナスのみ MAX にする
for item in UserLoginBonusList:
if item["isCurrent"] and not item["isComplete"]:
point = 4 if item["bonusId"] in Bonus5Id else 9
data = {
"bonusId": item["bonusId"],
"point": point,
"isCurrent": True,
"isComplete": False,
}
bonusList.append(data)
if len(bonusList) == 0:
raise NoSelectedBonusError("選択したログインボーナスがありません")
elif generateMode == 2: # 全部 MAX にする
# 存在しているボーナスを追加
for item in UserLoginBonusList:
if not item["isComplete"]:
data = {
"bonusId": item["bonusId"],
"point": 4 if item["bonusId"] in Bonus5Id else 9,
"isCurrent": item["isCurrent"],
"isComplete": False,
}
bonusList.append(data)
elif item["bonusId"] == 999:
data = {
"bonusId": 999,
"point": (item["point"] // 10) * 10 + 9,
"isCurrent": item["isCurrent"],
"isComplete": False,
}
bonusList.append(data)
# 存在しないボーナスを追加
for bonusId in NonExistingBonuses:
data = {
"bonusId": bonusId,
"point": 4 if bonusId in Bonus5Id else 9,
"isCurrent": False,
"isComplete": False,
}
bonusList.append(data)
else:
raise SyntaxError("generateMode は 1 または 2 でなければなりません")
logger.debug(f"ログインボーナスリスト: {bonusList}")
return bonusList
if __name__ == "__main__":
# ログインボーナスデータをアップロードする
userId = testUid
currentLoginTimestamp = generateTimestamp()
currentLoginResult = apiLogin(currentLoginTimestamp, userId)
implLoginBonus(userId, currentLoginTimestamp, currentLoginResult, 2)
apiLogout(currentLoginTimestamp, userId)

View File

@@ -0,0 +1,99 @@
# 删除和上传成绩
from loguru import logger
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperFullPlay import implFullPlayAction
from MyConfig import testUid
def implDeleteMusicRecord(
userId: int,
currentLoginTimestamp: int,
currentLoginResult,
musicId: int,
levelId: int,
) -> str:
musicData = {
"musicId": musicId,
"level": levelId,
"playCount": 1,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0,
}
userAllPatches = {
"upsertUserAll": {
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "0", # 0为编辑即可删除掉成绩
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
def implUploadMusicRecord(
userId: int,
currentLoginTimestamp: int,
currentLoginResult,
musicId: int,
levelId: int,
achievement: int,
dxScore: int,
) -> str:
"""
VERY EARLY STAGE OF UPLOADING SCORES!!!! DO NOT USE THIS!!!!
上传成绩的参考实现。
"""
# 要上传的数据
musicData = {
"musicId": musicId,
"level": levelId,
"playCount": 1,
"achievement": achievement,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": dxScore,
"scoreRank": 0,
"extNum1": 0,
}
userAllPatches = {
"upsertUserAll": {
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1", # 0编辑 1插入
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
if __name__ == "__main__":
userId = testUid
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
musicId = 852 # 852 is tiamat
levelId = 3 # 3 is MASTER
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(
implDeleteMusicRecord(
userId, currentLoginTimestamp, loginResult, musicId, levelId
)
)
# logger.info(implUploadMusicRecord(userId, currentLoginTimestamp, loginResult, musicId, levelId, 1000000, 100))
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

View File

@@ -0,0 +1,71 @@
# 解锁一些东西的外部代码
from loguru import logger
from MyConfig import testUid8
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperUnlockThing import implUnlockThing
def implUnlockMultiItem(
itemKind: int,
userId: int,
currentLoginTimestamp: int,
currentLoginResult,
*itemIds: int,
) -> str:
if not itemIds:
logger.info("无操作,跳过处理!")
return
"""
发单个东西,比如搭档 10
"""
userItemList = [
{"itemKind": itemKind, "itemId": itemId, "stock": 1, "isValid": True}
for itemId in itemIds
]
unlockThingResult = implUnlockThing(
userItemList, userId, currentLoginTimestamp, currentLoginResult
)
return unlockThingResult
def implUnlockMusic(
musicToBeUnlocked: int, userId: int, currentLoginTimestamp: int, currentLoginResult
) -> str:
"""
解锁乐曲
"""
userItemList = [
{"itemKind": 5, "itemId": musicToBeUnlocked, "stock": 1, "isValid": True},
{"itemKind": 6, "itemId": musicToBeUnlocked, "stock": 1, "isValid": True},
]
unlockThingResult = implUnlockThing(
userItemList, userId, currentLoginTimestamp, currentLoginResult
)
return unlockThingResult
if __name__ == "__main__":
userId = int(input("type user id: ").strip() or "0") or testUid8
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
items = range(11, 33) # all partners
logger.info(
implUnlockMultiItem(
10,
userId,
currentLoginTimestamp,
loginResult,
*items,
)
)
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

View File

@@ -0,0 +1,199 @@
import requests
from loguru import logger
from HelperGetUserMusicDetail import getUserFullMusicDetail
from HelperMusicDB import getMusicTitle
class divingFishAuthFailError(Exception):
pass
class divingFishCommError(Exception):
pass
# 水鱼查分器的 API 地址
BASE_URL = "https://www.diving-fish.com/api/maimaidxprober"
# 水鱼查分器的成绩状态转换
COMBO_ID_TO_NAME = ["", "fc", "fcp", "ap", "app"]
SYNC_ID_TO_NAME = ["", "fs", "fsp", "fsd", "fsdp", "sync"]
def apiDivingFish(method: str, apiPath: str, importToken: str, data=None):
"""水鱼查分器的 API 通讯实现"""
headers = {"Import-Token": importToken}
if method == "POST":
headers["Content-Type"] = "application/json"
logger.info(f"水鱼查分器 API 请求:{method} {BASE_URL + apiPath}")
if method == "POST":
response = requests.post(
url=BASE_URL + apiPath,
json=data,
headers=headers,
)
elif method == "GET":
response = requests.get(
url=BASE_URL + apiPath,
headers=headers,
)
elif method == "DELETE":
response = requests.delete(
url=BASE_URL + apiPath,
headers=headers,
)
else:
logger.error(f"未知的请求方法:{method}")
raise ValueError(f"未知的请求方法:{method}")
logger.info(f"水鱼查分器请求结果:{response.status_code}")
logger.debug(f"水鱼查分器回应:{response.text}")
finalResponseTextDecode = response.text.encode("utf-8").decode("unicode_escape")
logger.debug(f"水鱼查分器回应解码后:{finalResponseTextDecode}")
match response.status_code:
case 200:
return response.json()
case 500:
raise divingFishAuthFailError
case _:
raise divingFishCommError
def getFishRecords(importToken: str) -> dict:
"""获取水鱼查分器的成绩"""
return apiDivingFish("GET", "/player/records", importToken)
def updateFishRecords(importToken: str, records: list[dict]) -> dict:
"""上传成绩到水鱼查分器"""
return apiDivingFish("POST", "/player/update_records", importToken, records)
def resetFishRecords(fishImportToken: str):
"""重置水鱼查分器的用户数据"""
return apiDivingFish("DELETE", "/player/delete_records", fishImportToken)
def getFishUserInfo(userQQ: int):
"""按QQ获取水鱼查分器的用户信息"""
return apiDivingFish("POST", "/query/player", "", {"qq": userQQ})
def maimaiUserMusicDetailToDivingFishFormat(userMusicDetailList) -> list:
"""舞萌的 UserMusicDetail 成绩格式转换成水鱼的格式"""
divingFishList = []
for currentMusicDetail in userMusicDetailList:
# musicId 大于 100000 属于宴谱,不计入
if currentMusicDetail["musicId"] >= 100000:
continue
# 获得歌名
currentMusicTitle = getMusicTitle(currentMusicDetail["musicId"])
# 如果数据库里未找到此歌曲
if currentMusicTitle == "R_ERR_MUSIC_ID_NOT_IN_DATABASE":
logger.warning(f"数据库无此歌曲 跳过: {currentMusicDetail['musicId']}")
continue
# 每一个乐曲都判断下是 DX 还是标准
if currentMusicDetail["musicId"] >= 10000:
notesType = "DX"
else:
notesType = "SD"
# 追加进列表
try:
divingFishList.append(
{
"achievements": (
currentMusicDetail["achievement"] / 10000
), # 水鱼的成绩是 float 而非舞萌的 int
"title": currentMusicTitle,
"type": notesType,
"level_index": currentMusicDetail["level"],
"fc": COMBO_ID_TO_NAME[currentMusicDetail["comboStatus"]],
"fs": SYNC_ID_TO_NAME[currentMusicDetail["syncStatus"]],
"dxScore": currentMusicDetail["deluxscoreMax"],
}
)
except Exception:
logger.error(f"无法将 UserMusic 翻译成水鱼格式: {currentMusicDetail}")
return divingFishList
def isVaildFishToken(importToken: str):
"""通过尝试获取一次成绩,检查水鱼查分器的 Token 是否有效
有效返回 True无效返回 False"""
result = apiDivingFish("GET", "/player/records", importToken)
logger.debug(f"水鱼查分器 Token 检查结果:{result}")
if result:
return True
return False
def implGetUserCurrentDXRating(userQQ: int):
"""获取用户当前的 DX RATING"""
try:
playerData = getFishUserInfo(userQQ)
playerRating = playerData["rating"]
logger.info(f"用户 {userQQ} 的 DX RATING 是 {playerRating}")
except Exception as e:
logger.warning(f"无法获取用户 {userQQ} 的 DX RATING: {e}")
return False
return playerRating
def implUserMusicToDivingFish(userId: int, fishImportToken: str):
"""上传所有成绩到水鱼的参考实现。
返回一个 int 的 ErrorCode。
0: Success
1: Get User Music Fail
2: Auth Fail
3: Comm Error
"""
logger.info("开始尝试上传舞萌成绩到水鱼查分器!")
try:
userFullMusicDetailList = getUserFullMusicDetail(userId)
logger.info("成功得到成绩!转换成水鱼格式..")
divingFishData = maimaiUserMusicDetailToDivingFishFormat(
userFullMusicDetailList
)
logger.info("转换成功!开始上传水鱼..")
except Exception as e:
logger.error(f"获取成绩失败!{e}")
return 1
try:
updateFishRecords(fishImportToken, divingFishData)
except divingFishAuthFailError:
logger.error("水鱼查分器认证失败!")
return 2
except divingFishCommError:
logger.error("水鱼查分器通讯失败!")
return 3
def generateDebugTestScore():
"""生成测试成绩"""
return [
{
"achievement": 1010000,
"comboStatus": 4,
"deluxscoreMax": 4026,
"level": 4,
"musicId": 834,
"syncStatus": 4,
},
{
"achievement": 1010000,
"comboStatus": 4,
"deluxscoreMax": 4200,
"level": 4,
"musicId": 11663,
"syncStatus": 4,
},
]
if __name__ == "__main__":
userId = int(input("userId: "))
fishImportToken = input("DivingFish Token: ")
implUserMusicToDivingFish(userId, fishImportToken)

126
backend/ChargeTicket.py Normal file
View File

@@ -0,0 +1,126 @@
from datetime import datetime, timedelta
# 倍票相关 API 的实现
import rapidjson as json
import pytz
from loguru import logger
from API_TitleServer import apiSDGB
from HelperGetUserThing import implGetUser_
from Config import (
clientId,
placeId,
regionId,
)
from MyConfig import testUid2
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperFullPlay import implFullPlayAction, generateMusicData
def implWipeTickets(userId: int, currentLoginTimestamp: int, currentLoginResult) -> str:
"""清空用户所有票的 API 请求器,返回 Json String。"""
# 先得到当前用户的 Charge 数据
currentUserCharge = implGetUser_("Charge", userId)
# 取得 List
currentUserChargeList = currentUserCharge["userChargeList"]
# 所有 stock 都置为 0
for charge in currentUserChargeList:
charge["stock"] = 0
musicData = generateMusicData()
userAllPatches = {
"upsertUserAll": {
"userChargeList": currentUserChargeList,
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1", # 1避免覆盖
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
def apiQueryTicket(userId: int) -> str:
"""查询已有票的 API 请求器,返回 Json String。"""
# 构建 Payload
data = json.dumps({"userId": userId})
# 发送请求
userdata_result = apiSDGB(data, "GetUserChargeApi", userId)
# 返回响应
return userdata_result
def apiBuyTicket(
userId: int, ticketType: int, price: int, playerRating: int, playCount: int
) -> str:
"""倍票购买 API 的请求器"""
nowTime = datetime.now(pytz.timezone("Asia/Shanghai"))
# 构造请求数据 Payload
data = json.dumps(
{
"userId": userId,
"userChargelog": {
"chargeId": ticketType,
"price": price,
"purchaseDate": nowTime.strftime("%Y-%m-%d %H:%M:%S.0"),
"playCount": playCount,
"playerRating": playerRating,
"placeId": placeId,
"regionId": regionId,
"clientId": clientId,
},
"userCharge": {
"chargeId": ticketType,
"stock": 1,
"purchaseDate": nowTime.strftime("%Y-%m-%d %H:%M:%S.0"),
"validDate": (nowTime + timedelta(days=90))
.replace(hour=4, minute=0, second=0)
.strftime("%Y-%m-%d %H:%M:%S"),
},
}
)
# 发送请求,返回最终得到的 Json String 回执
return apiSDGB(data, "UpsertUserChargelogApi", userId)
def implBuyTicket(userId: int, ticketType: int):
"""
购买倍票 API 的参考实现。
需要事先登录.
返回服务器响应的 Json string。
"""
# 先使用 GetUserData API 请求器,取得 rating 和 pc 数
currentUserData = implGetUser_("Data", userId)
if currentUserData:
playerRating = currentUserData["userData"]["playerRating"]
playCount = currentUserData["userData"].get("playCount", 0)
else:
return False
# 正式买票
getTicketResponseStr = apiBuyTicket(
userId, ticketType, ticketType - 1, playerRating, playCount
)
# 返回结果
return getTicketResponseStr
if __name__ == "__main__":
userId = testUid2
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(implBuyTicket(userId, 2)) # 购买倍票
# logger.info(apiQueryTicket(userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

16
backend/Config.py Normal file
View File

@@ -0,0 +1,16 @@
regionId = 13
regionName = "河南"
placeId = 2411
placeName = "智游星期六河南郑州店"
clientId = "A63E01E6154"
useProxy = False
proxyUrl = "http://100.104.133.113:33080"
loginBonusDBPath = "./Data/loginBonusDB.xml"
musicDBPath = "./Data/musicDB.json"
loginBonusDBPathFallback = "./maimaiDX-Api/Data/loginBonusDB.xml"
musicDBPathFallback = "./maimaiDX-Api/Data/musicDB.json"
# 日本精工,安全防漏

View File

@@ -0,0 +1,318 @@
<?xml version="1.0" encoding="utf-8"?>
<SerializeSortData xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<dataName>loginBonus</dataName>
<SortList>
<StringID>
<id>53</id>
<str>パートナー:ソルト(ぷりずむ)</str>
</StringID>
<StringID>
<id>48</id>
<str>パートナー:ラズ(ばでぃーず)</str>
</StringID>
<StringID>
<id>38</id>
<str>パートナー:ずんだもん</str>
</StringID>
<StringID>
<id>39</id>
<str>パートナー:乙姫(ばでぃーず)</str>
</StringID>
<StringID>
<id>40</id>
<str>パートナー:らいむっくま&れもんっくま(ばでぃーず)</str>
</StringID>
<StringID>
<id>34</id>
<str>パートナー:黒姫</str>
</StringID>
<StringID>
<id>24</id>
<str>パートナー:ラズ(ふぇすてぃばる)</str>
</StringID>
<StringID>
<id>25</id>
<str>パートナー:シフォン(ふぇすてぃばる)</str>
</StringID>
<StringID>
<id>26</id>
<str>パートナー:ソルト(ふぇすてぃばる)</str>
</StringID>
<StringID>
<id>19</id>
<str>パートナー:ちびみるく</str>
</StringID>
<StringID>
<id>20</id>
<str>パートナー:百合咲ミカ</str>
</StringID>
<StringID>
<id>8</id>
<str>パートナー:しゃま(ゆにばーす)</str>
</StringID>
<StringID>
<id>9</id>
<str>パートナー:みるく(ゆにばーす)</str>
</StringID>
<StringID>
<id>7</id>
<str>パートナー:乙姫(すぷらっしゅ)</str>
</StringID>
<StringID>
<id>1</id>
<str>パートナー:乙姫</str>
</StringID>
<StringID>
<id>2</id>
<str>パートナー:ラズ</str>
</StringID>
<StringID>
<id>3</id>
<str>パートナー:シフォン</str>
</StringID>
<StringID>
<id>4</id>
<str>パートナー:ソルト</str>
</StringID>
<StringID>
<id>5</id>
<str>パートナー:しゃま</str>
</StringID>
<StringID>
<id>6</id>
<str>パートナー:みるく</str>
</StringID>
<StringID>
<id>54</id>
<str>でらっくす譜面One Step Ahead</str>
</StringID>
<StringID>
<id>55</id>
<str>でらっくす譜面:天狗の落とし文 feat. </str>
</StringID>
<StringID>
<id>612</id>
<str>でらっくす譜面LANCE</str>
</StringID>
<StringID>
<id>613</id>
<str>でらっくす譜面HYP3RTRIBE</str>
</StringID>
<StringID>
<id>614</id>
<str>でらっくす譜面Imitation:Loud Lounge</str>
</StringID>
<StringID>
<id>49</id>
<str>でらっくす譜面:ハッピーシンセサイザ</str>
</StringID>
<StringID>
<id>50</id>
<str>でらっくす譜面sweet little sister</str>
</StringID>
<StringID>
<id>608</id>
<str>でらっくす譜面:御旗のもとに</str>
</StringID>
<StringID>
<id>609</id>
<str>でらっくす譜面:炎歌 -ほむらうた-</str>
</StringID>
<StringID>
<id>610</id>
<str>でらっくす譜面:華の集落、秋のお届け</str>
</StringID>
<StringID>
<id>605</id>
<str>でらっくす譜面oboro</str>
</StringID>
<StringID>
<id>606</id>
<str>でらっくす譜面:ナミダと流星</str>
</StringID>
<StringID>
<id>607</id>
<str>スタンダード譜面:渦状銀河のシンフォニエッタ</str>
</StringID>
<StringID>
<id>508</id>
<str>でらっくす譜面Latent Kingdom</str>
</StringID>
<StringID>
<id>41</id>
<str>でらっくす譜面:初音ミクの消失</str>
</StringID>
<StringID>
<id>42</id>
<str>でらっくす譜面:色は匂へど散りぬるを</str>
</StringID>
<StringID>
<id>601</id>
<str>でらっくす譜面BULK UP (GAME EXCLUSIVE EDIT)</str>
</StringID>
<StringID>
<id>602</id>
<str>でらっくす譜面Monochrome Rainbow</str>
</StringID>
<StringID>
<id>603</id>
<str>でらっくす譜面Selector</str>
</StringID>
<StringID>
<id>35</id>
<str>でらっくす譜面:深海少女</str>
</StringID>
<StringID>
<id>36</id>
<str>でらっくす譜面:ナイト・オブ・ナイツ</str>
</StringID>
<StringID>
<id>27</id>
<str>でらっくす譜面M.S.S.Planet</str>
</StringID>
<StringID>
<id>28</id>
<str>でらっくす譜面:響縁</str>
</StringID>
<StringID>
<id>501</id>
<str>スタンダード譜面Halcyon</str>
</StringID>
<StringID>
<id>502</id>
<str>スタンダード譜面:サンバランド</str>
</StringID>
<StringID>
<id>503</id>
<str>でらっくす譜面Starlight Disco</str>
</StringID>
<StringID>
<id>504</id>
<str>でらっくす譜面:火炎地獄</str>
</StringID>
<StringID>
<id>505</id>
<str>スタンダード譜面VIIIbit Explorer</str>
</StringID>
<StringID>
<id>506</id>
<str>でらっくす譜面Maxi</str>
</StringID>
<StringID>
<id>507</id>
<str>でらっくす譜面ケロ⑨destiny</str>
</StringID>
<StringID>
<id>21</id>
<str>でらっくす譜面:セツナトリップ</str>
</StringID>
<StringID>
<id>22</id>
<str>でらっくす譜面Grip &amp; Break down !!</str>
</StringID>
<StringID>
<id>17</id>
<str>でらっくす譜面:ゴーストルール</str>
</StringID>
<StringID>
<id>18</id>
<str>でらっくす譜面taboo tears you up</str>
</StringID>
<StringID>
<id>56</id>
<str>アイコンPRiSM</str>
</StringID>
<StringID>
<id>611</id>
<str>アイコンBUDDiES 乙姫&ラズ</str>
</StringID>
<StringID>
<id>43</id>
<str>アイコンBUDDiES</str>
</StringID>
<StringID>
<id>604</id>
<str>アイコンFESTiVAL ラズ&シフォン&ソルト</str>
</StringID>
<StringID>
<id>29</id>
<str>アイコンFESTiVAL</str>
</StringID>
<StringID>
<id>30</id>
<str>アイコンLia=Fail</str>
</StringID>
<StringID>
<id>12</id>
<str>アイコンUNiVERSE</str>
</StringID>
<StringID>
<id>57</id>
<str>ネームプレートQuiQ</str>
</StringID>
<StringID>
<id>51</id>
<str>ネームプレートSwift Swing</str>
</StringID>
<StringID>
<id>14</id>
<str>ネームプレート:はっぴー(ゆにばーす)</str>
</StringID>
<StringID>
<id>58</id>
<str>フレームValsqotch</str>
</StringID>
<StringID>
<id>52</id>
<str>フレームLatent Kingdom</str>
</StringID>
<StringID>
<id>44</id>
<str>フレームmystique as iris</str>
</StringID>
<StringID>
<id>45</id>
<str>フレームVeRForTe αRtE:VEiN</str>
</StringID>
<StringID>
<id>37</id>
<str>フレームTricolor⁂circuS</str>
</StringID>
<StringID>
<id>31</id>
<str>フレームHeavenly Blast</str>
</StringID>
<StringID>
<id>32</id>
<str>フレームsølips</str>
</StringID>
<StringID>
<id>33</id>
<str>フレームRainbow Rush Story</str>
</StringID>
<StringID>
<id>23</id>
<str>フレーム:ふたりでばかんすにゃ♪</str>
</StringID>
<StringID>
<id>15</id>
<str>フレーム:ここからはじまるプロローグ。</str>
</StringID>
<StringID>
<id>16</id>
<str>フレーム:モ゜ルモ゜ル</str>
</StringID>
<StringID>
<id>10</id>
<str>フレーム:黒姫</str>
</StringID>
<StringID>
<id>11</id>
<str>フレーム:百合咲ミカ</str>
</StringID>
<StringID>
<id>999</id>
<str>ちほー進行1.5倍チケット</str>
</StringID>
</SortList>
</SerializeSortData>

1597
backend/Data/musicDB.json Normal file

File diff suppressed because it is too large Load Diff

107
backend/ForceLogout.py Normal file
View File

@@ -0,0 +1,107 @@
# 解小黑屋实现
# 仍十分不完善,不建议使用
import time
from datetime import datetime
import rapidjson as json
from GetPreview import apiGetUserPreview
from HelperLogInOut import apiLogout
from loguru import logger
from MyConfig import testUid2
def isUserLoggedIn(userId):
isLogin = json.loads(apiGetUserPreview(userId, True))["isLogin"]
logger.debug(f"用户 {userId} 是否登录: {isLogin}")
return isLogin
def getHumanReadableTime(unixTime):
"""将 Unix 时间戳转换为人类可读的时间"""
# 减一个小时,因为舞萌貌似是 UTC+9
timestamp = int(unixTime) - 3600
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def getMaimaiUNIXTime(mmddhhmmss, year=2025):
"""
解析用户输入的 MMDDHHMMSS 格式的时间,返回 Unix 时间戳
时间会被推后一个小时,因为舞萌貌似是 UTC+9
"""
# date_time_str = f"{year}{mmddhhmmss}"
# 加上一个小时
date_time_str = (
f"{year}{mmddhhmmss[:4]}{int(mmddhhmmss[4:6]) + 1:02}{mmddhhmmss[6:]}"
)
date_time_obj = datetime.strptime(date_time_str, "%Y%m%d%H%M%S")
# 将 datetime 对象转换为 Unix 时间戳
unix_timestamp = int(time.mktime(date_time_obj.timetuple()))
logger.info(f"转换出了时间戳: {unix_timestamp}")
return unix_timestamp
def logOut(userId, Timestamp):
"""极其简单的登出实现,成功返回 True失败返回 False
注意:不会检查用户是否真的登出了,只会尝试登出"""
try:
if apiLogout(Timestamp, userId, True)["returnCode"] == 1:
# 成功送出了登出请求
logger.debug(f"已成功尝试登出用户 {userId}")
return True
except Exception:
logger.error(f"登出用户 {userId} 的时候发生了错误")
return False
def isCorrectTimestamp(timestamp, userId):
"""
动作:给定一个时间戳,用它尝试登出用户,然后检查用户是否成功登出。
如果用户成功登出,返回 True否则返回 False。
"""
if not logOut(userId, timestamp):
logger.error(f"用时间戳 {timestamp} 登出用户 {userId} 的时候发生了错误")
return False
isLoggedOut = not isUserLoggedIn(userId)
logger.debug(f"时间戳 {timestamp} 是否正确: {isLoggedOut}")
return isLoggedOut
def findCorrectTimestamp(timestamp, userId, max_attempts=600):
# 初始化偏移量
offset = 1
attempts = 0
while attempts < max_attempts:
# 尝试当前时间戳
currentTryTimestamp = timestamp
if isCorrectTimestamp(currentTryTimestamp, userId):
logger.info(f"正确的时间戳: {currentTryTimestamp}")
return currentTryTimestamp
# 增加偏移量尝试
currentTryTimestamp = timestamp + offset
if isCorrectTimestamp(currentTryTimestamp, userId):
logger.info(f"正确的时间戳(在给定时间以后): {currentTryTimestamp}")
return currentTryTimestamp
# 减少偏移量尝试
currentTryTimestamp = timestamp - offset
if isCorrectTimestamp(currentTryTimestamp, userId):
logger.info(f"正确的时间戳(在给定时间以前): {currentTryTimestamp}")
return currentTryTimestamp
# 增加尝试次数和偏移量
attempts += 2
offset += 1
logger.error(f"无法找到正确的时间戳,尝试次数超过了 {max_attempts}")
return None
if __name__ == "__main__":
human_time = "0207155500"
beginTimestamp = getMaimaiUNIXTime(human_time)
print(f"我们将开始用这个时间戳开始尝试: {beginTimestamp}")
correctTimestamp = findCorrectTimestamp(beginTimestamp, testUid2)

31
backend/GetAny.py Normal file
View File

@@ -0,0 +1,31 @@
# 获取用户简略预览数据的 API 实现,此 API 无需任何登录即可调取
from loguru import logger
import rapidjson as json
from API_TitleServer import apiSDGB
def apiGetAny(
userId,
apiName: str,
noLog: bool = False,
) -> str:
data = json.dumps({"userId": int(userId)})
preview_result = apiSDGB(data, apiName, userId, noLog)
return preview_result
# CLI 示例
if __name__ == "__main__":
userId = input("请输入用户 ID")
# userId = testUid8
# print(apiGetAny(userId, "GetUserRatingApi"))
# print(apiGetAny(userId, "GetUserPreviewApi"))
for type in ["course", "extend", "character", "activity", "charge", "option", "region"]:
try:
data = apiGetAny(userId, f"GetUser{type.title()}Api", noLog=True)
except Exception as e:
logger.error(f"failed when scraping {type}: {e}")
else:
print(f"{type}:", json.dumps(json.loads(data), ensure_ascii=False, indent=4))

18
backend/GetPreview.py Normal file
View File

@@ -0,0 +1,18 @@
# 获取用户简略预览数据的 API 实现,此 API 无需任何登录即可调取
import rapidjson as json
from API_TitleServer import apiSDGB
def apiGetUserPreview(userId, noLog: bool = False) -> str:
data = json.dumps({"userId": int(userId)})
preview_result = apiSDGB(data, "GetUserPreviewApi", userId, noLog)
return preview_result
# CLI 示例
if __name__ == "__main__":
userId = input("请输入用户 ID")
# userId = testUid8
print(apiGetUserPreview(userId))
print(apiSDGB("{}", "Ping", userId, False))

21
backend/GetPreview2024.py Normal file
View File

@@ -0,0 +1,21 @@
# 获取用户简略预览数据的 API 实现,此 API 无需任何登录即可调取
import rapidjson as json
from API_TitleServer import apiSDGB_2024, use2024Api
use2024Api()
def apiGetUserPreview(userId, noLog: bool = False) -> str:
data = json.dumps({"userId": int(userId)})
preview_result = apiSDGB_2024(data, "GetUserPreviewApi", userId, noLog)
return preview_result
# CLI 示例
if __name__ == "__main__":
userId = input("请输入用户 ID")
# userId = testUid8
# print(apiGetUserPreview(userId))
print(apiSDGB_2024("{}", "Ping", userId, False))

18
backend/GetRating.py Normal file
View File

@@ -0,0 +1,18 @@
# 获取用户简略预览数据的 API 实现,此 API 无需任何登录即可调取
import rapidjson as json
from API_TitleServer import apiSDGB
def apiGetUserPreview(userId, noLog: bool = False) -> str:
data = json.dumps({"userId": int(userId)})
preview_result = apiSDGB(data, "GetUserRatingApi", userId, noLog)
return preview_result
# CLI 示例
if __name__ == "__main__":
userId = input("请输入用户 ID")
# userId = testUid8
print(apiGetUserPreview(userId))
print(apiSDGB("{}", "Ping", userId, False))

34
backend/GetUserAll.py Normal file
View File

@@ -0,0 +1,34 @@
from loguru import logger
from HelperFullPlay import implFullPlayAction
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from MyConfig import testUid8
if __name__ == "__main__":
userId = int(input("type user id: ").strip() or "0") or testUid8
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
items = [23]
musicData = {
"musicId": 11538, # Amber Chronicle
"level": 0,
"playCount": 1,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0,
}
implFullPlayAction(
userId, currentLoginTimestamp, loginResult, musicData, {}, debugMode=True
)
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

30
backend/GetUserData.py Normal file
View File

@@ -0,0 +1,30 @@
# 获取用户简略预览数据的 API 实现,此 API 无需任何登录即可调取
from loguru import logger
import rapidjson as json
from API_TitleServer import apiSDGB
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from MyConfig import testUid8
def apiGetUserData(userId, noLog: bool = False) -> str:
data = json.dumps({"userId": int(userId)})
preview_result = apiSDGB(data, "GetUserDataApi", userId, noLog)
return preview_result
if __name__ == "__main__":
userId = int(input("type user id: ").strip() or "0") or testUid8
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(apiGetUserData(userId, noLog=False))
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

View File

@@ -0,0 +1,43 @@
GetUserCardApi
GetUserCharacterApi
GetUserChargeApi
GetUserCourseApi
GetUserDataApi
GetUserExtendApi
GetUserFavoriteApi
GetUserFriendSeasonRankingApi
GetUserGhostApi
GetUserItemApi
GetGameChargeApi
GetUserLoginBonusApi
GetGameEventApi
GetUserMapApi
GetGameNgMusicIdApi
GetUserMusicApi
GetGameRankingApi
GetGameSettingApi
GetUserOptionApi
GetUserPortraitApi
GetGameTournamentInfoApi
UserLogoutApi
GetUserPreviewApi
GetTransferFriendApi
GetUserRatingApi
GetUserActivityApi
GetUserRecommendRateMusicApi
GetUserRecommendSelectMusicApi
GetUserRegionApi
GetUserScoreRankingApi
UploadUserPhotoApi
UploadUserPlaylogApi
UploadUserPortraitApi
UpsertClientBookkeepingApi
UpsertClientSettingApi
UpsertClientTestmodeApi
UpsertClientUploadApi
UpsertUserAllApi
UpsertUserChargelogApi
UserLoginApi
Ping
GetUserFavoriteItemApi
GetGameNgWordListApi

132
backend/HelperFullPlay.py Normal file
View File

@@ -0,0 +1,132 @@
import rapidjson as json
from loguru import logger
from API_TitleServer import (
SDGBRequestError,
SDGBApiError,
apiSDGB,
calcPlaySpecial,
)
from HelperGetUserThing import implGetUser_
from HelperUploadUserPlayLog import apiUploadUserPlaylog
from HelperUserAll import generateFullUserAll
def generateMusicData():
"""生成一份占位的音乐数据"""
return {
"musicId": 834, # PANDORA PARADOXXX
"level": 4,
"playCount": 1,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0,
}
def applyUserAllPatches(userAll, patches):
"""
递归地将给定的补丁应用到用户数据的各个层次。
:param userAll: 原始用户数据
:param patches: 包含所有patch的字典
"""
for key, value in patches.items():
if (
isinstance(value, dict)
and key in userAll
and isinstance(userAll[key], dict)
):
# 如果patch的值是字典并且userAll中对应的key也是字典递归处理
applyUserAllPatches(userAll[key], value)
elif (
isinstance(value, list)
and key in userAll
and isinstance(userAll[key], list)
):
# 如果值是列表,进行详细的更新处理
for i, patch_item in enumerate(value):
if (
i < len(userAll[key])
and isinstance(patch_item, dict)
and isinstance(userAll[key][i], dict)
):
# 如果列表项是字典,更新字典中的字段
applyUserAllPatches(userAll[key][i], patch_item)
elif i >= len(userAll[key]):
# 如果patch的列表比userAll的列表长追加新的元素
userAll[key].append(patch_item)
else:
# 否则直接更新或添加key
userAll[key] = value
def implFullPlayAction(
userId: int,
currentLoginTimestamp: int,
currentLoginResult,
musicData,
userAllPatches,
debugMode=False,
):
"""
一份完整的上机实现,可以打 patch 来实现各种功能
需要在外部先登录并传入登录结果
"""
# 取得 UserData
currentUserData = implGetUser_("Data", userId)
currentUserData2 = currentUserData["userData"]
# 构建并上传一个游玩记录
currentUploadUserPlaylogApiResult = apiUploadUserPlaylog(
userId, musicData, currentUserData2, currentLoginResult["loginId"]
)
logger.debug(f"上传 UserPlayLog 结果: {currentUploadUserPlaylogApiResult}")
# 构建并上传 UserAll
retries = 0
while retries < 3:
# 计算一个特殊数
currentPlaySpecial = calcPlaySpecial()
# 生成出 UserAll
currentUserAll = generateFullUserAll(
userId,
currentLoginResult,
currentLoginTimestamp,
currentUserData2,
currentPlaySpecial,
)
# 应用参数里的补丁
applyUserAllPatches(currentUserAll, userAllPatches)
# 调试模式下直接输出数据
if debugMode:
logger.debug(
"调试模式:构建出的 UserAll 数据:"
+ json.dumps(currentUserAll, indent=4)
)
logger.info("Bye!")
return
# 建构 Json 数据
data = json.dumps(currentUserAll)
# 开始上传 UserAll
try:
currentUserAllResult = json.loads(apiSDGB(data, "UpsertUserAllApi", userId))
except SDGBRequestError:
logger.warning("上传 UserAll 出现 500. 重建数据.")
retries += 1
continue
except Exception:
raise SDGBApiError("邪门错误")
# 成功上传后退出循环
break
else: # 重试次数超过3次
raise SDGBRequestError
logger.info("上机:结果:" + str(currentUserAllResult))
return currentUserAllResult

View File

@@ -0,0 +1,63 @@
# 获取用户成绩的各种实现
import rapidjson as json
from loguru import logger
from HelperMusicDB import getMusicTitle
from API_TitleServer import apiSDGB
from MyConfig import testUid
# 日志设置
# log_level = "DEBUG"
# log_format = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS zz}</green> | <level>{level: <8}</level> | <yellow>Line {line: >4} ({file}):</yellow> <b>{message}</b>"
# logger.add(sys.stderr, level=log_level, format=log_format, colorize=True, backtrace=True, diagnose=True)
# logger.add("file.log", level=log_level, format=log_format, colorize=False, backtrace=True, diagnose=True)
def getUserMusicDetail(userId: int, nextIndex: int = 0, maxCount: int = 50) -> dict:
"""获取用户的成绩的API"""
data = json.dumps(
{"userId": int(userId), "nextIndex": nextIndex, "maxCount": maxCount}
)
return json.loads(apiSDGB(data, "GetUserMusicApi", userId))
def getUserFullMusicDetail(userId: int):
"""获取用户的全部成绩"""
currentUserMusicDetailList = []
nextIndex: int | None = None # 初始化 nextIndex
while nextIndex != 0 or nextIndex is None: # 只要还有nextIndex就一直获取获取
userMusicResponse = getUserMusicDetail(userId, nextIndex or 0)
nextIndex = userMusicResponse["nextIndex"]
logger.info(f"NextIndex: {nextIndex}")
# 处理已经没有 userMusicList 的情况
if not userMusicResponse["userMusicList"]:
break
# 只要还有 userMusicList 就一直加进去,直到全部获取完毕
for currentMusic in userMusicResponse["userMusicList"]:
for currentMusicDetail in currentMusic["userMusicDetailList"]:
if not currentMusicDetail["playCount"] > 0:
continue
currentUserMusicDetailList.append(currentMusicDetail)
return currentUserMusicDetailList
def parseUserFullMusicDetail(userFullMusicDetailList: list):
"""解析用户的全部成绩,给出一个迫真人类可读 list 套 dict"""
musicDetailList = []
for currentMusicDetail in userFullMusicDetailList:
musicDetailList.append(
{
"歌名": getMusicTitle(currentMusicDetail["musicId"]),
"难度": currentMusicDetail["level"],
"分数": currentMusicDetail["achievement"] / 10000,
"DX分数": currentMusicDetail["deluxscoreMax"],
}
)
return musicDetailList
if __name__ == "__main__":
userId = testUid
userFullMusicDetailList = getUserFullMusicDetail(userId)
parsedUserFullMusicDetail = parseUserFullMusicDetail(userFullMusicDetailList)
logger.info(parsedUserFullMusicDetail)

View File

@@ -0,0 +1,23 @@
# 获取用户数据的 API 实现
import rapidjson as json
from API_TitleServer import apiSDGB
def implGetUser_(thing: str, userId: int, noLog=False) -> dict:
"""获取用户某些数据的 API 实现,返回 Dict"""
# 获取 Json String
result = apiGetUserThing(userId, thing, noLog)
# 转换为 Dict
userthingDict = json.loads(result)
# 返回 Dict
return userthingDict
def apiGetUserThing(userId: int, thing: str, noLog=False) -> str:
"""获取用户数据的 API 请求器,返回 Json String"""
# 构建 Payload
data = json.dumps({"userId": userId})
# 发送请求
userthing_result = apiSDGB(data, "GetUser" + thing + "Api", userId, noLog)
# 返回响应
return userthing_result

83
backend/HelperLogInOut.py Normal file
View File

@@ -0,0 +1,83 @@
# 登录·登出实现
# 一般作为模块使用,但也可以作为 CLI 程序运行以强制登出账号。
import time
import random
import rapidjson as json
from loguru import logger
from API_TitleServer import apiSDGB
from Config import (
clientId,
placeId,
regionId,
)
from MyConfig import testUid
def apiLogin(timestamp: int, userId: int, noLog: bool = False) -> dict:
"""登录,返回 dict"""
data = json.dumps(
{
"userId": userId,
"accessCode": "",
"regionId": regionId,
"placeId": placeId,
"clientId": clientId,
"dateTime": timestamp,
"isContinue": False,
"genericFlag": 0,
}
)
login_result = json.loads(apiSDGB(data, "UserLoginApi", userId, noLog))
if not noLog:
logger.info("登录:结果:" + str(login_result))
return login_result
def apiLogout(timestamp: int, userId: int, noLog: bool = False) -> dict:
"""登出,返回 dict"""
data = json.dumps(
{
"userId": userId,
"accessCode": "",
"regionId": regionId,
"placeId": placeId,
"clientId": clientId,
"dateTime": timestamp,
"type": 1,
}
)
logout_result = json.loads(apiSDGB(data, "UserLogoutApi", userId, noLog))
if not noLog:
logger.info("登出:结果:" + str(logout_result))
return logout_result
def generateTimestampLegacy() -> int:
"""生成一个凑合用的时间戳"""
timestamp = int(time.time()) - 60
logger.info(f"生成时间戳: {timestamp}")
return timestamp
def generateTimestamp() -> int:
"""生成一个今天早上 10:00 随机偏移的时间戳"""
timestamp = int(
time.mktime(
time.strptime(time.strftime("%Y-%m-%d 10:00:00"), "%Y-%m-%d %H:%M:%S")
)
) + random.randint(-600, 600)
logger.info(f"生成时间戳: {timestamp}")
logger.info(
f"此时间戳对应的时间为: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(timestamp))}"
)
return timestamp
if __name__ == "__main__":
print("强制登出 CLI")
uid = testUid
timestamp = generateTimestamp()
apiLogout(int(timestamp), int(uid))

302
backend/HelperMisc.py Normal file
View File

@@ -0,0 +1,302 @@
# 杂项助手函数
# 主要用于当作模块使用的时候的一些生活质量提升
import rapidjson as json
from loguru import logger
from HelperGetUserThing import implGetUser_
import unicodedata
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from MyConfig import testUid
def numberToLetter(number):
"""
将数字转换为字母1-26 to A-Z
"""
if 1 <= number <= 26:
return chr(number + 64)
else:
return None
def maimaiVersionToHumanReadable(romVersion: str, dataVersion: str) -> str:
try:
romVersionList = romVersion.split(".")
dataVersionList = dataVersion.split(".")
except Exception as e:
logger.warning(f"无法解析版本号: {romVersion} {dataVersion},错误:{e}")
return "无效版本号:无法解析"
try:
romVersionList = [int(i) for i in romVersionList]
dataVersionList = [int(i) for i in dataVersionList]
except Exception as e:
logger.warning(f"无法解析版本号: {romVersion} {dataVersion},错误:{e}")
return "无效版本号:无法解读数字"
finalVersionList = []
finalVersionList.append(romVersionList[0])
# quirk
minorVer = max(romVersionList[1], dataVersionList[1])
if minorVer == 0:
finalVersionList.append("00")
else:
finalVersionList.append(minorVer)
finalVersionLetter = numberToLetter(max(romVersionList[2], dataVersionList[2]))
if finalVersionLetter:
finalVersionLetter = f"-{finalVersionLetter}"
else:
finalVersionLetter = ""
finalVersionList.append(finalVersionLetter)
if int(finalVersionList[1]) < 30:
versionStringPrefix = "CH"
else:
versionStringPrefix = "CN"
finalVersionString = f"{versionStringPrefix}{finalVersionList[0]}.{finalVersionList[1]}{finalVersionList[2]}"
return finalVersionString
levelIdDict = {"绿": 0, "": 1, "": 2, "": 3, "": 4, "": 5}
def getHalfWidthString(s):
"""全角转半角舞萌ID用"""
return unicodedata.normalize("NFKC", s)
def getHumanReadableLoginErrorCode(loginResult) -> str:
"""解析登录结果并且给出中文的报错解释"""
match loginResult["returnCode"]:
case 1:
return False
case 100:
return "❌ 用户正在上机游玩,请下机后再试,或等待 15 分钟。"
case 102:
return "⚠️ 请在微信公众号内点击一次获取新的二维码,然后再试。"
case 103:
return "❌ 试图登录的账号 UID 无效,请检查账号是否正确。"
case _:
return "❌ 登录失败!这不应该发生,请反馈此问题。错误详情:" + loginResult
def checkTechnologyUseCount(userId: int) -> int:
"""猜测账号是否用了科技0没用过其他为用过"""
userData1 = implGetUser_("Data", userId)
userData = userData1.get("userData", {})
userRegion = implGetUser_("Region", userId)
userRegionList = userRegion.get("userRegionList", [])
playCount = userData.get("playCount", 0)
allRegionPlayCount = 0
for region in userRegionList:
allRegionPlayCount += region.get("playCount", 0)
logger.info(
f"用户 {userId} 的总游玩次数: {playCount}, 各地区游玩次数: {allRegionPlayCount}"
)
# 计算全部的 Region 加起来的游玩次数是否和 playCount 对不上,对不上就是用了科技
# 返回差值
return playCount - allRegionPlayCount
def getFriendlyUserData(userId: int) -> str:
"""生成一个(相对)友好的UserData的人话"""
userData1 = implGetUser_("Data", userId)
userData = userData1.get("userData", {})
userRegion = implGetUser_("Region", userId)
banState = userData1.get("banState")
result = f"用户: {getHalfWidthString(userData.get('userName', '未知'))}\n"
result += f"DX RATING: {userData.get('playerRating', '未知')} "
result += f"B35: {userData.get('playerOldRating', '未知')} "
result += f"B15: {userData.get('playerNewRating', '未知')}\n"
result += f"总游戏次数: {userData.get('playCount', '未知')} "
result += f"当前版本游戏次数: {userData.get('currentPlayCount', '未知')}\n"
result += f"最近登录时间: {userData.get('lastLoginDate')} "
result += f"最近登录版本: {maimaiVersionToHumanReadable(userData.get('lastRomVersion'), userData.get('lastDataVersion'))} "
result += f"最近登录地区: {userData.get('lastRegionName', '未知')}\n"
result += f"注册日期: {userData.get('firstPlayDate')} "
result += f"注册版本: {maimaiVersionToHumanReadable(userData.get('firstRomVersion'), userData.get('firstDataVersion'))}\n"
result += f"封号状态(banState): {banState}\n"
try:
logger.info(userRegion)
result += getHumanReadableRegionData(userRegion)
except Exception as e:
result += f"地区数据获取失败:{e}\n"
return result
def getHumanReadableRegionData(userRegion: str) -> str:
"""生成一个人类可读的地区数据"""
userRegionList = userRegion.get("userRegionList")
logger.info(userRegionList)
result = ""
for region in userRegionList:
regionName = WAHLAP_REGIONS.get(region["regionId"], "未知")
playCount = region["playCount"]
created = region["created"]
result += f"\n{regionName} 游玩次数: {playCount} 首次游玩: {created}"
return result
def getHumanReadablePreview(preview_json_content: str) -> str:
"""简单,粗略地解释 Preview 的 Json String 为人话。"""
previewData = json.loads(preview_json_content)
userName = getHalfWidthString(previewData["userName"])
playerRating = previewData["playerRating"]
finalString = f"用户名:{userName}\nDX RATING{playerRating}\n"
return finalString
def getHumanReadableLoginBonusList(jsonString: str):
"""生成一个人类可读的 Login Bonus 的列表"""
data = json.loads(jsonString)
result = []
for bonus in data["userLoginBonusList"]:
if not bonus["isComplete"]: # 过滤已经集满的
line = f"BonusID {bonus['bonusId']} 已集 {bonus['point']}"
if bonus["isCurrent"]: # 如果是当前选中,追加标记
line += "(当前选中)"
result.append(line)
resultString = ""
for line in result: # 转成字符串
resultString += line + "\n"
return resultString
def getHumanReadableTicketList(jsonString: str):
"""生成一个人类可读的 UserCharge 的列表"""
data = json.loads(jsonString)
userId = data["userId"]
length = data["length"]
userChargeList = data["userChargeList"]
result = f"UID: {userId} 票槽大小: {length} 所有记录:"
for currentItem in userChargeList:
chargeId = currentItem["chargeId"]
stock = currentItem["stock"]
purchaseDate = currentItem["purchaseDate"]
validDate = currentItem["validDate"]
result += f"\nID: {chargeId} 持有: {stock}, 购买日期: {purchaseDate}, 有效期限: {validDate}"
return result
def getHumanReadableUserData(userData) -> str:
"""生成一个人类可读的 UserData 的数据(比较详细)"""
userId = userData.get("userId")
userData = userData.get("userData", {})
banState = userData.get("banState")
logger.debug(userData)
result = f"用户名: {userData.get('userName', '未知')} "
result += f"UID: {userId}\n"
result += f"当前 RATING: {userData.get('playerRating', '未知')} "
result += f"B35: {userData.get('playerOldRating', '未知')} "
result += f"B15: {userData.get('playerNewRating', '未知')} "
result += f"最高 RATING: {userData.get('highestRating', '未知')}\n"
result += f"级别段位: {userData.get('gradeRank', '未知')} "
result += f"段位认定: {userData.get('courseRank', '未知')} "
result += f"友人对战段位: {userData.get('classRank', '未知')}\n"
result += f"总游戏次数: {userData.get('playCount', '未知')} "
result += f"当前版本游戏次数: {userData.get('currentPlayCount', '未知')}\n"
result += f"总DX分: {userData.get('totalDeluxscore', '未知')} "
result += f"绿谱总DX分: {userData.get('totalBasicDeluxscore', '未知')} "
result += f"黄谱总DX分: {userData.get('totalAdvancedDeluxscore', '未知')} "
result += f"红谱总DX分: {userData.get('totalExpertDeluxscore', '未知')} "
result += f"紫谱总DX分: {userData.get('totalMasterDeluxscore', '未知')} "
result += f"白谱总DX分: {userData.get('totalReMasterDeluxscore', '未知')}\n"
result += f"总SYNC: {userData.get('totalSync', '未知')} "
result += f"绿谱总SYNC: {userData.get('totalBasicSync', '未知')} "
result += f"黄谱总SYNC: {userData.get('totalAdvancedSync', '未知')} "
result += f"红谱总SYNC: {userData.get('totalExpertSync', '未知')} "
result += f"紫谱总SYNC: {userData.get('totalMasterSync', '未知')} "
result += f"白谱总SYNC: {userData.get('totalReMasterSync', '未知')}\n"
result += f"总分: {userData.get('totalAchievement', '未知')} "
result += f"绿谱总分: {userData.get('totalBasicAchievement', '未知')} "
result += f"黄谱总分: {userData.get('totalAdvancedAchievement', '未知')} "
result += f"红谱总分: {userData.get('totalExpertAchievement', '未知')} "
result += f"紫谱总分: {userData.get('totalMasterAchievement', '未知')} "
result += f"白谱总分: {userData.get('totalReMasterAchievement', '未知')}\n"
result += f"活动事件日期: {userData.get('eventWatchedDate')}\n"
result += f"最后 ROM 版本: {userData.get('lastRomVersion', '未知')}\n"
result += f"最后数据版本: {userData.get('lastDataVersion', '未知')}\n"
result += f"最后登录时间: {userData.get('lastLoginDate')}\n"
result += f"最后游戏时间: {userData.get('lastPlayDate')}\n"
result += f"最后双人登录时间: {userData.get('lastPairLoginDate')}\n"
result += f"最后免费游戏时间: {userData.get('lastTrialPlayDate')}\n"
result += f"最后游戏花费: {userData.get('lastPlayCredit', '未知')}\n"
result += f"最后地区 ID: {userData.get('lastRegionId', '未知')}\n"
result += f"最后地区名称: {userData.get('lastRegionName', '未知')}\n"
result += f"最后选择功能票: {userData.get('lastSelectTicket', '未知')}\n"
result += f"最后一次段位认定: {userData.get('lastSelectCourse', '未知')}\n"
result += f"最后一次 Course 计数: {userData.get('lastCountCourse', '未知')}\n"
result += f"注册 ROM 版本: {userData.get('firstRomVersion', '未知')}\n"
result += f"注册数据版本: {userData.get('firstDataVersion', '未知')}\n"
result += f"注册日期: {userData.get('firstPlayDate')}\n"
result += f"总觉醒: {userData.get('totalAwake', '未知')}\n"
result += f"签到日期: {userData.get('dailyCourseBonusDate')}\n"
result += f"跑图存储距离: {userData.get('mapStock', '未知')}m\n"
result += f"封号状态: {banState}\n"
return result
WAHLAP_REGIONS = {
1: "北京",
2: "重庆",
3: "上海",
4: "天津",
5: "安徽",
6: "福建",
7: "甘肃",
8: "广东",
9: "贵州",
10: "海南",
11: "河北",
12: "黑龙江",
13: "河南",
14: "湖北",
15: "湖南",
16: "江苏",
17: "江西",
18: "吉林",
19: "辽宁",
20: "青海",
21: "陕西",
22: "山东",
23: "山西",
24: "四川",
25: "未知25",
26: "云南",
27: "浙江",
28: "广西",
29: "内蒙古",
30: "宁夏",
31: "新疆",
32: "西藏",
}
if __name__ == "__main__":
# test version string convert
print(maimaiVersionToHumanReadable("1.20.0", "1.0.0"))
print(maimaiVersionToHumanReadable("1.41.00", "1.40.11"))
print(maimaiVersionToHumanReadable("1.00.00", "1.00.00"))
userId = testUid
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(checkTechnologyUseCount(userId))
# logger.info(apiQueryTicket(userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

14
backend/HelperMusicDB.py Normal file
View File

@@ -0,0 +1,14 @@
from MusicDB import musicDB
from loguru import logger
def getMusicTitle(musicId: int) -> str:
"""从数据库获取音乐的标题"""
# logger.debug(f"查询歌名: {musicId}")
musicInfo = musicDB.get(musicId)
if not musicInfo:
logger.warning(f"数据库里未找到此歌曲: {musicId}")
return "R_ERR_MUSIC_ID_NOT_IN_DATABASE"
musicName = musicInfo.get("name")
# logger.debug(f"成功查询到歌名: {musicName}")
return musicName

View File

@@ -0,0 +1,97 @@
# 解锁东西的一个通用的助手,不可独立使用
from HelperFullPlay import implFullPlayAction
def implUnlockThing(
newUserItemList, userId: int, currentLoginTimestamp: int, currentLoginResult
) -> str:
musicData = {
"musicId": 11538, # Amber Chronicle
"level": 0,
"playCount": 1,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0,
}
userAllPatches = {
"upsertUserAll": {
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1",
"userItemList": newUserItemList,
"isNewItemList": "1" * len(newUserItemList),
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
itemKindDict = {
"PLATE": 1, # 姓名框
"TITLE": 2, # 称号
"ICON": 3, # 头像
"MUSIC": 5, # 歌
"MUSIC_MASTER": 6, # 紫谱
"MUSIC_RE_MASTER": 7, # 白谱
"CHARACTER": 9, # 旅行伙伴
"PARTNER": 10, # 搭档
"FRAME": 11, # 背景板
"TICKET": 12, # 功能票
# "PRESENT": 4, #
# "MUSIC_STRONG": 8, #
}
itemKindzhCNDict = {
"姓名框": "PLATE",
"称号": "TITLE",
"头像": "ICON",
"": "MUSIC",
"紫谱": "MUSIC_MASTER",
"白谱": "MUSIC_RE_MASTER",
"旅行伙伴": "CHARACTER",
"搭档": "PARTNER",
"背景板": "FRAME",
"功能票": "TICKET",
# "礼物": "PRESENT",
# "STRONG": "MUSIC_STRONG",
}
# Splash = 2020
# UNiVERSE = 2022
# FESTiVAL = 2023
# BUDDiES = 2024
# PRiSM = 2025
partnerList = {
"1": "迪拉熊",
"17": "青柠熊&柠檬熊",
"29": "青柠熊柠檬熊2024",
"11": "乙姫",
"18": "乙姫Splash",
"28": "乙姫2024",
"12": "拉兹",
"23": "拉兹2023",
"30": "拉兹 (BUDDiES)",
"13": "雪纺",
"24": "雪纺2023",
"14": "莎露朵",
"25": "莎露朵2023",
"31": "莎露朵 (PRiSM)",
"15": "夏玛",
"19": "夏玛UNiVERSE",
"16": "咪璐库",
"21": "小咪璐库",
"32": "咪璐库 (PRiSM)",
"20": "咪璐库UNiVERSE",
"22": "百合咲美香",
"26": "黒姫",
"27": "俊达萌",
"33": "超天酱",
}
for id, partner in partnerList.items():
print()

View File

@@ -0,0 +1,153 @@
# 上传一个占位用的游玩记录的 API 实现
import rapidjson as json
import pytz
import time
import random
from datetime import datetime
from loguru import logger
from API_TitleServer import apiSDGB
from Config import (
placeId,
placeName,
)
def apiUploadUserPlaylog(
userId: int, musicDataToBeUploaded, currentUserData2, loginId: int
) -> str:
"""
上传一个 UserPlayLog。
注意:成绩为随机的空成绩,只用作占位
返回 Json String。"""
# 构建一个 PlayLog
data = json.dumps(
{
"userId": int(userId),
"userPlaylogList": [
{
"userId": 0,
"orderId": 0,
"playlogId": loginId,
"version": 1051000,
"placeId": placeId,
"placeName": placeName,
"loginDate": int(time.time()), # 似乎和登录timestamp不同
"playDate": datetime.now(pytz.timezone("Asia/Shanghai")).strftime(
"%Y-%m-%d"
),
"userPlayDate": datetime.now(
pytz.timezone("Asia/Shanghai")
).strftime("%Y-%m-%d %H:%M:%S")
+ ".0",
"type": 0,
"musicId": int(musicDataToBeUploaded["musicId"]),
"level": int(musicDataToBeUploaded["level"]),
"trackNo": 1,
"vsMode": 0,
"vsUserName": "",
"vsStatus": 0,
"vsUserRating": 0,
"vsUserAchievement": 0,
"vsUserGradeRank": 0,
"vsRank": 0,
"playerNum": 1,
"playedUserId1": 0,
"playedUserName1": "",
"playedMusicLevel1": 0,
"playedUserId2": 0,
"playedUserName2": "",
"playedMusicLevel2": 0,
"playedUserId3": 0,
"playedUserName3": "",
"playedMusicLevel3": 0,
"characterId1": currentUserData2["charaSlot"][0],
"characterLevel1": random.randint(1000, 6500),
"characterAwakening1": 5,
"characterId2": currentUserData2["charaSlot"][1],
"characterLevel2": random.randint(1000, 6500),
"characterAwakening2": 5,
"characterId3": currentUserData2["charaSlot"][2],
"characterLevel3": random.randint(1000, 6500),
"characterAwakening3": 5,
"characterId4": currentUserData2["charaSlot"][3],
"characterLevel4": random.randint(1000, 6500),
"characterAwakening4": 5,
"characterId5": currentUserData2["charaSlot"][4],
"characterLevel5": random.randint(1000, 6500),
"characterAwakening5": 5,
"achievement": int(musicDataToBeUploaded["achievement"]),
"deluxscore": int(musicDataToBeUploaded["deluxscoreMax"]),
"scoreRank": int(musicDataToBeUploaded["scoreRank"]),
"maxCombo": 0,
"totalCombo": random.randint(700, 900),
"maxSync": 0,
"totalSync": 0,
"tapCriticalPerfect": 0,
"tapPerfect": 0,
"tapGreat": 0,
"tapGood": 0,
"tapMiss": random.randint(1, 10),
"holdCriticalPerfect": 0,
"holdPerfect": 0,
"holdGreat": 0,
"holdGood": 0,
"holdMiss": random.randint(1, 15),
"slideCriticalPerfect": 0,
"slidePerfect": 0,
"slideGreat": 0,
"slideGood": 0,
"slideMiss": random.randint(1, 15),
"touchCriticalPerfect": 0,
"touchPerfect": 0,
"touchGreat": 0,
"touchGood": 0,
"touchMiss": random.randint(1, 15),
"breakCriticalPerfect": 0,
"breakPerfect": 0,
"breakGreat": 0,
"breakGood": 0,
"breakMiss": random.randint(1, 15),
"isTap": True,
"isHold": True,
"isSlide": True,
"isTouch": True,
"isBreak": True,
"isCriticalDisp": True,
"isFastLateDisp": True,
"fastCount": 0,
"lateCount": 0,
"isAchieveNewRecord": True,
"isDeluxscoreNewRecord": True,
"comboStatus": 0,
"syncStatus": 0,
"isClear": False,
"beforeRating": currentUserData2["playerRating"],
"afterRating": currentUserData2["playerRating"],
"beforeGrade": 0,
"afterGrade": 0,
"afterGradeRank": 1,
"beforeDeluxRating": currentUserData2["playerRating"],
"afterDeluxRating": currentUserData2["playerRating"],
"isPlayTutorial": False,
"isEventMode": False,
"isFreedomMode": False,
"playMode": 0,
"isNewFree": False,
"trialPlayAchievement": -1,
"extNum1": 0,
"extNum2": 0,
"extNum4": 3020,
"extBool1": False,
"extBool2": False,
}
],
}
)
# 发送请求
result = apiSDGB(data, "UploadUserPlaylogListApi", userId)
logger.info("上传游玩记录:结果:" + str(result))
# 返回响应
return result

270
backend/HelperUserAll.py Normal file
View File

@@ -0,0 +1,270 @@
# UserAll 有关的一些辅助函数
import pytz
from datetime import datetime
from loguru import logger
from HelperGetUserThing import implGetUser_
from HelperGetUserMusicDetail import getUserMusicDetail
from Config import (
clientId,
placeName,
placeId,
regionId,
regionName,
)
def isNewMusicType(userId, musicId, level) -> str:
"""判断这首 musicId 在 isNewMusicDetailList 应该填什么
0: Edit
1: Insert
一次只能处理一首歌,所以返回值是 str
未完工,仅供测试
"""
userMusicDetailList = getUserMusicDetail(userId, musicId, 1)["userMusicList"][0][
"userMusicDetailList"
]
logger.info(userMusicDetailList)
try:
if (
userMusicDetailList[0]["musicId"] == musicId
and userMusicDetailList[0]["level"] == level
):
logger.info(f"We think {musicId} Level {level} should use EDIT.")
return "0"
except Exception:
return "1"
def generateFullUserAll(
userId,
currentLoginResult,
currentLoginTimestamp,
currentUserData2,
currentPlaySpecial,
):
"""从服务器取得必要的数据并构建一个比较完整的 UserAll"""
# 先构建一个基础 UserAll
currentUserAll = generateUserAllData(
userId,
currentLoginResult,
currentLoginTimestamp,
currentUserData2,
currentPlaySpecial,
)
# 然后从服务器取得必要的数据
currentUserExtend = implGetUser_("Extend", userId, True)
currentUserOption = implGetUser_("Option", userId, True)
currentUserRating = implGetUser_("Rating", userId, True)
currentUserActivity = implGetUser_("Activity", userId, True)
currentUserCharge = implGetUser_("Charge", userId, True)
currentUserMissionData = implGetUser_("MissionData", userId, True)
# 把这些数据都追加进去
currentUserAll["upsertUserAll"]["userExtend"] = [currentUserExtend["userExtend"]]
currentUserAll["upsertUserAll"]["userOption"] = [currentUserOption["userOption"]]
currentUserAll["upsertUserAll"]["userRatingList"] = [
currentUserRating["userRating"]
]
currentUserAll["upsertUserAll"]["userActivityList"] = [
currentUserActivity["userActivity"]
]
currentUserAll["upsertUserAll"]["userChargeList"] = currentUserCharge[
"userChargeList"
]
currentUserAll["upsertUserAll"]["userWeeklyData"] = currentUserMissionData[
"userWeeklyData"
]
# 完事
return currentUserAll
def generateUserAllData(
userId,
currentLoginResult,
currentLoginTimestamp,
currentUserData2,
currentPlaySpecial,
):
"""构建一个非常基础的 UserAll 数据,必须手动填充一些数据"""
data = {
"userId": userId,
"playlogId": currentLoginResult["loginId"],
"isEventMode": False,
"isFreePlay": False,
"upsertUserAll": {
"userData": [
{
"accessCode": "",
"userName": currentUserData2["userName"],
"isNetMember": 1,
"point": currentUserData2["point"],
"totalPoint": currentUserData2["totalPoint"],
"iconId": currentUserData2["iconId"],
"plateId": currentUserData2["plateId"],
"titleId": currentUserData2["titleId"],
"partnerId": currentUserData2["partnerId"],
"frameId": currentUserData2["frameId"],
"selectMapId": currentUserData2["selectMapId"],
"totalAwake": currentUserData2["totalAwake"],
"gradeRating": currentUserData2["gradeRating"],
"musicRating": currentUserData2["musicRating"],
"playerRating": currentUserData2["playerRating"],
"highestRating": currentUserData2["highestRating"],
"gradeRank": currentUserData2["gradeRank"],
"classRank": currentUserData2["classRank"],
"courseRank": currentUserData2["courseRank"],
"charaSlot": currentUserData2["charaSlot"],
"charaLockSlot": currentUserData2["charaLockSlot"],
"contentBit": currentUserData2["contentBit"],
"playCount": currentUserData2["playCount"],
"currentPlayCount": currentUserData2["currentPlayCount"],
"renameCredit": 0,
"mapStock": currentUserData2["mapStock"],
"eventWatchedDate": currentUserData2["eventWatchedDate"],
"lastGameId": "SDGB",
"lastRomVersion": currentUserData2["lastRomVersion"],
"lastDataVersion": currentUserData2["lastDataVersion"],
# "lastLoginDate": currentLoginResult['lastLoginDate'], # sb
"lastLoginDate": currentUserData2["lastLoginDate"], # 等待测试
"lastPlayDate": datetime.now(
pytz.timezone("Asia/Shanghai")
).strftime("%Y-%m-%d %H:%M:%S")
+ ".0",
"lastPlayCredit": 1,
"lastPlayMode": 0,
"lastPlaceId": placeId,
"lastPlaceName": placeName,
"lastAllNetId": 0,
"lastRegionId": regionId,
"lastRegionName": regionName,
"lastClientId": clientId,
"lastCountryCode": "CHN",
"lastSelectEMoney": 0,
"lastSelectTicket": 0,
"lastSelectCourse": currentUserData2["lastSelectCourse"],
"lastCountCourse": 0,
"firstGameId": "SDGB",
"firstRomVersion": currentUserData2["firstRomVersion"],
"firstDataVersion": currentUserData2["firstDataVersion"],
"firstPlayDate": currentUserData2["firstPlayDate"],
"compatibleCmVersion": currentUserData2["compatibleCmVersion"],
"dailyBonusDate": currentUserData2["dailyBonusDate"],
"dailyCourseBonusDate": currentUserData2["dailyCourseBonusDate"],
"lastPairLoginDate": currentUserData2["lastPairLoginDate"],
"lastTrialPlayDate": currentUserData2["lastTrialPlayDate"],
"playVsCount": 0,
"playSyncCount": 0,
"winCount": 0,
"helpCount": 0,
"comboCount": 0,
"totalDeluxscore": currentUserData2["totalDeluxscore"],
"totalBasicDeluxscore": currentUserData2["totalBasicDeluxscore"],
"totalAdvancedDeluxscore": currentUserData2[
"totalAdvancedDeluxscore"
],
"totalExpertDeluxscore": currentUserData2["totalExpertDeluxscore"],
"totalMasterDeluxscore": currentUserData2["totalMasterDeluxscore"],
"totalReMasterDeluxscore": currentUserData2[
"totalReMasterDeluxscore"
],
"totalSync": currentUserData2["totalSync"],
"totalBasicSync": currentUserData2["totalBasicSync"],
"totalAdvancedSync": currentUserData2["totalAdvancedSync"],
"totalExpertSync": currentUserData2["totalExpertSync"],
"totalMasterSync": currentUserData2["totalMasterSync"],
"totalReMasterSync": currentUserData2["totalReMasterSync"],
"totalAchievement": currentUserData2["totalAchievement"],
"totalBasicAchievement": currentUserData2["totalBasicAchievement"],
"totalAdvancedAchievement": currentUserData2[
"totalAdvancedAchievement"
],
"totalExpertAchievement": currentUserData2[
"totalExpertAchievement"
],
"totalMasterAchievement": currentUserData2[
"totalMasterAchievement"
],
"totalReMasterAchievement": currentUserData2[
"totalReMasterAchievement"
],
"playerOldRating": currentUserData2["playerOldRating"],
"playerNewRating": currentUserData2["playerNewRating"],
"banState": 0,
"friendRegistSkip": currentUserData2["friendRegistSkip"],
"dateTime": currentLoginTimestamp,
}
],
"userExtend": [], # 需要填上
"userOption": [], # 需要填上
"userGhost": [],
"userCharacterList": [],
"userMapList": [],
"userLoginBonusList": [],
"userRatingList": [], # 需要填上
"userItemList": [], # 可选,但经常要填上
"userMusicDetailList": [], # 需要填上
"userCourseList": [],
"userFriendSeasonRankingList": [],
"userChargeList": [], # 需要填上
"userFavoriteList": [],
"userActivityList": [], # 需要填上
"userMissionDataList": [],
"userWeeklyData": [], # 应该需要填上
"userGamePlaylogList": [
{
"playlogId": currentLoginResult["loginId"],
"version": "1.51.00",
"playDate": datetime.now(pytz.timezone("Asia/Shanghai")).strftime(
"%Y-%m-%d %H:%M:%S"
)
+ ".0",
"playMode": 0,
"useTicketId": -1,
"playCredit": 1,
"playTrack": 1,
"clientId": clientId,
"isPlayTutorial": False,
"isEventMode": False,
"isNewFree": False,
"playCount": currentUserData2["playCount"],
"playSpecial": currentPlaySpecial,
"playOtherUserId": 0,
}
],
"user2pPlaylog": {
"userId1": 0,
"userId2": 0,
"userName1": "",
"userName2": "",
"regionId": 0,
"placeId": 0,
"user2pPlaylogDetailList": [],
},
"userIntimateList": [],
"userShopItemStockList": [],
"userGetPointList": [],
"userTradeItemList": [],
"userFavoritemusicList": [],
"userKaleidxScopeList": [],
"isNewCharacterList": "",
"isNewMapList": "",
"isNewLoginBonusList": "",
"isNewItemList": "",
"isNewMusicDetailList": "", # 可选但经常要填上
"isNewCourseList": "0",
"isNewFavoriteList": "",
"isNewFriendSeasonRankingList": "",
"isNewUserIntimateList": "",
"isNewFavoritemusicList": "",
"isNewKaleidxScopeList": "",
},
}
return data

23
backend/MusicDB.py Normal file
View File

@@ -0,0 +1,23 @@
import rapidjson as json
from Config import *
from typing import Dict, Union
# 定义音乐数据库的类型注解
MusicDBType = Dict[int, Dict[str, Union[int, str]]]
# 将 '__all__' 用于模块导出声明
__all__ = ["musicDB"]
# 读取并解析 JSON 文件
try:
with open(musicDBPath, "r", encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
try:
with open(musicDBPathFallback, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
raise FileNotFoundError("musicDB.json 文件不存在!")
# 将 JSON 数据转换为指定格式的字典
musicDB: MusicDBType = {int(k): v for k, v in data.items()}

2
backend/README.md Normal file
View File

@@ -0,0 +1,2 @@
# maimai DX API
API of maimai DX CN(SDGB).

View File

@@ -0,0 +1,33 @@
# 解密国服 Auth Lite 的包
# 仅测试过 PowerOn 请求,其他包不确定是否适用
# 仅适用国服 DX2024其他未测试
# 完全 Standalone不依赖于其他文件
import base64
from Crypto.Cipher import AES
from urllib.parse import unquote
from Crypto.Util.Padding import unpad
# 密钥从 HDD 提取
LITE_AUTH_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
LITE_AUTH_IV = bytes.fromhex('00000000000000000000000000000000')
# 填入你抓包得到的想解密的数据的 base64 编码
base64String = "N0PyrjawH/7qA8A28y++3txHDsKuAs5+nib751QiNlJYigvwldPaG7xd0WYXvgqlWY16JIy38GQ8+M4ttaWRNfpWy9l29pC2h2abd4VGhIeWGLbOjc2Bthqhibui76vi4dW+05TsPiyXbOsqHFzScvdByKUtZUobZgrnr/WW+YqRIUdw/ZHBmKBY81JivnVH9AkEyCCP9xubYMjDqi65WhDpcrdMk5nUjHq/O7R1eXr12Es9gXDUruy/H4M7eMt+4kFSDCGpLSFwAEDhba6rpOz0n588nfvXXFlZ+a3ZsZSBYAJPBZ795Ck8ZDIYnEMWMV5nk6qPc2HiBF9ZZw88FlATGC8NqsTSjGX6JJXWDApUaSF5obXMu4LTmMMr0KDt2fQ6VQPkLnTgJ6tsJv1iAQvtcZ9ymn3I4S0XWXGmEq8r7XE7D+pnSJyjUn7bSXb6HOzCQtQc9XYmIbylS2sNkiDXywrxVgmiAXc4Ta8M9aNUb+81QrKj6qqC06DzdYQNBFRxb78X3nGYECEmKBsxIOR7Mf/ZqWYtA28Ob9H5CCUmjLvUaoQ+htJMHfQ9fvvevfu+FxtlJXfw+3UQDiQ1xvSZe2NMCgkLOuwqZ5/5PyoAV9MKzRXT4hBzDoiAIt7bzOH9JcNJkjUtLAjXbnwN6M6zUKpgMK4WYeCwUffNy21GbLVtfIxZZbVhK8A6Ni7j"
def auth_lite_decrypt(ciphertext: bytes) -> str:
# 解密并去除填充
cipher = AES.new(LITE_AUTH_KEY, AES.MODE_CBC, LITE_AUTH_IV)
decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size)
# 提取内容并解码
content = decrypted_data[16:] # 去除头部的16字节
return content.decode('utf-8').strip()
# 解码 base64
decodedData = base64.b64decode(base64String)
# 解密数据
decryptedData = auth_lite_decrypt(decodedData)
print(decryptedData)
# 解码 URL 编码
print(unquote(decryptedData))

View File

@@ -0,0 +1,74 @@
# 解密从 HDD 抓包得到的数据
# 兼容 PRiSM 和 CN 2024
# 完全 Standalone不依赖于其他文件
import base64
import zlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad, pad
# CN 2024
AES_KEY_SDGB_1_40 = "n7bx6:@Fg_:2;5E89Phy7AyIcpxEQ:R@"
AES_IV_SDGB_1_40 = ";;KjR1C3hgB1ovXa"
# 国际服 PRiSM
AES_KEY_SDGA_1_50 = "A;mv5YUpHBK3YxTy5KB^[;5]C2AL50Bq"
AES_IV_SDGA_1_50 = "9FM:sd9xA91X14v]"
AES_KEY_SDGB_1_50 = "a>32bVP7v<63BVLkY[xM>daZ1s9MBP<R"
AES_IV_SDGB_1_50 = "d6xHIKq]1J]Dt^ue"
class AESPKCS7:
# 实现了 maimai 通讯所用的 AES 加密的类
def __init__(self, key: str, iv: str):
self.key = key.encode('utf-8')
self.iv = iv.encode('utf-8')
self.mode = AES.MODE_CBC
# 加密
def encrypt(self, content: str) -> bytes:
cipher = AES.new(self.key, self.mode, self.iv)
content_padded = pad(content.encode(), AES.block_size)
encrypted_bytes = cipher.encrypt(content_padded)
return encrypted_bytes
# 解密
def decrypt(self, encrypted_content: bytes) -> str:
cipher = AES.new(self.key, self.mode, self.iv)
decrypted_padded = cipher.decrypt(encrypted_content)
decrypted = unpad(decrypted_padded, AES.block_size)
return decrypted
def main_sdga():
# 填入你的想解密的数据的 base64 编码
base64_encoded_data = "KSGm2qo7qVHz1wrK15PckYC5/kLjKcTtEXOgHeHt1Xn6DPdo3pltoPLADHpe8+Wq"
aes = AESPKCS7(AES_KEY_SDGA_1_50, AES_IV_SDGA_1_50)
# 首先解码 base64
decodedData = base64.b64decode(base64_encoded_data)
# 然后解密数据PRiSM 是先压缩再加密which is 正确做法)
decryptedData = aes.decrypt(decodedData)
# 解压数据
decompressedData = zlib.decompress(decryptedData)
print(str(decompressedData))
def main_sdgb140():
# 填入你的想解密的数据的 base64 编码
base64_encoded_data = "eJyrTVvpuGwCR32OdodwtVXZ7/Ofmfhin7k/K61q3XNoad1rAPGwECU="
aes = AESPKCS7(AES_KEY_SDGB_1_40, AES_IV_SDGB_1_40)
# 首先解码 base64
decodedData = base64.b64decode(base64_encoded_data)
# 然后解压数据CN 2024 是加密后再压缩(纯傻逼
decompressedData = zlib.decompress(decodedData)
# 最后解密数据
decryptedData = aes.decrypt(decompressedData)
print(str(decryptedData))
if __name__ == "__main__":
main_sdga()
main_sdgb140()

View File

@@ -0,0 +1,78 @@
# 舞萌DX AimeDB 服务器模拟实现
# 适用于舞萌DX 2024
# 理论可用于 HDD 登号等(这种情况下自行修改 hosts
# SGWCMAID111111111111AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
## 配置
# 0 返回本地生成的假结果
# 1 原样返回官方服务器的结果
useOfficialServer = 0
from loguru import logger
# 解析命令行参数,作为用户 ID
import argparse
try:
parser = argparse.ArgumentParser(description="舞萌DX AimeDB 服务器模拟实现")
#parser.add_argument("userId", type=int, help="用户 ID")
#args = parser.parse_args()
#DUMMY_USER_ID = args.userId
raise Exception("未传入用户 ID")
except Exception as e:
logger.warning(f"{e},未传入用户 ID使用默认值")
DUMMY_USER_ID = 1
from fastapi import (
FastAPI,
Request
)
from fastapi.responses import (
PlainTextResponse,
JSONResponse
)
import rapidjson as json
import uvicorn
from loguru import logger
# 将当前目录的父目录加入到 sys.path 中
import sys
from pathlib import Path
current_dir = Path(__file__).resolve().parent
parent_dir = current_dir.parent
sys.path.append(str(parent_dir))
from API_AimeDB import implAimeDB, calcSEGAAimeDBAuthKey
app = FastAPI()
@app.post('/qrcode/api/alive_check')
async def qrcode_alive_check_api():
return PlainTextResponse('alive')
@app.post('/wc_aime/api/alive_check')
async def wc_aime_alive_check_api():
return PlainTextResponse('alive')
@app.post('/wc_aime/api/get_data')
async def get_data_dummy_api(request: Request):
gotRequest = json.loads((await request.body()).decode())
if useOfficialServer == 0:
fakeTimestamp = str(int(gotRequest['timestamp'])+3)
currentResponse = {
'errorID': 0,
'key': calcSEGAAimeDBAuthKey(str(DUMMY_USER_ID), fakeTimestamp),
'timestamp': fakeTimestamp,
'userID': DUMMY_USER_ID
}
logger.info(f"返回假结果: {currentResponse}")
return JSONResponse(currentResponse)
elif useOfficialServer == 1:
# 发给真正的 AimeDB
realAimeDBResponse = implAimeDB(gotRequest['qrCode'], True)
return JSONResponse(json.loads(realAimeDBResponse))
else:
pass
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=80)

View File

@@ -0,0 +1,69 @@
# 舞萌DX Auth-Lite 服务器模拟实现
# 仅实现了 /net/initialize 接口,用来处理 PowerOn
# NOT FINISHED: ONLY A DUMMY IMPLEMENTATION FOR TESTING
# Contact me if you have more information about this
from fastapi import (
FastAPI,
Request
)
from fastapi.responses import (
HTMLResponse
)
import uvicorn
import httpx
from loguru import logger
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
# 从 HDD 提取
LITE_AUTH_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
LITE_AUTH_IV = bytes.fromhex('00000000000000000000000000000000')
def auth_lite_encrypt(plaintext: str) -> bytes:
# 构造数据16字节头 + 16字节0前缀 + 明文
header = bytes(16)
content = bytes(16) + plaintext.encode('utf-8')
data = header + content
# 填充并加密
padded_data = pad(data, AES.block_size)
cipher = AES.new(LITE_AUTH_KEY, AES.MODE_CBC, LITE_AUTH_IV)
return cipher.encrypt(padded_data)
def auth_lite_decrypt(ciphertext: bytes) -> str:
# 解密并去除填充
cipher = AES.new(LITE_AUTH_KEY, AES.MODE_CBC, LITE_AUTH_IV)
decrypted_data = unpad(cipher.decrypt(ciphertext), AES.block_size)
# 提取内容并解码
content = decrypted_data[16:] # 去除头部的16字节
return content.decode('utf-8').strip()
def apiOfficialServer(encryptedString: str):
url = "http://at.sys-allnet.cn/net/initialize"
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "SDGB;Windows/Lite"
}
data = encryptedString
response = httpx.post(url, headers=headers, data=data)
return response.content
app = FastAPI()
USE_OFFICIAL_SERVER = 1
@app.post('/net/initialize')
async def get_data_dummy_api(request: Request):
gotRequest = (await request.body())
if USE_OFFICIAL_SERVER == 1:
decrypted = auth_lite_decrypt(gotRequest)
officialResponse = apiOfficialServer(auth_lite_encrypt(decrypted))
logger.info(auth_lite_decrypt(officialResponse))
return HTMLResponse(officialResponse)
else:
# todo
pass
if __name__ == '__main__':
uvicorn.run(app, host="0.0.0.0", port=80)

View File

@@ -0,0 +1,6 @@
# Never Gonna Give You Up
# Never Gonna Let You Down
# Never Gonna Run Around And Desert You
# Never Gonna Make You Cry
# Never Gonna Say Goodbye
# Never Gonna Tell A Lie And Hurt You

View File

@@ -0,0 +1,51 @@
# 感谢伟大的 Diving-Fish 让我被迫直面恐惧
import xml.dom.minidom as minidom
from pathlib import Path
import rapidjson as json
from loguru import logger
def makeMusicDBJson():
'''
从 HDD 的文件来生成 music_db.json
推荐的是如果要国服用 那就用国际服的文件来生成
免得国服每次更新还要重新生成太麻烦
'''
# 记得改
A000_DIR = Path('H:\PRiSM\Package\Sinmai_Data\StreamingAssets\A000')
OPTION_DIR = Path('H:\PRiSM\Package\Sinmai_Data\StreamingAssets')
music_db: dict[str, dict[str, str | int]] = {}
DEST_PATH = Path('../Data/musicDB.json')
music_folders = [f for f in (A000_DIR / 'music').iterdir() if f.is_dir()]
for option_dir in OPTION_DIR.iterdir():
if (option_dir / 'music').exists():
music_folders.extend([f for f in (option_dir / 'music').iterdir() if f.is_dir()])
for folder in music_folders:
xml_path = (folder / 'Music.xml')
if xml_path.exists():
xml = minidom.parse(xml_path.as_posix())
data = xml.getElementsByTagName('MusicData')[0]
music_id = data.getElementsByTagName('name')[0].getElementsByTagName('id')[0].firstChild.data
music_name = data.getElementsByTagName('name')[0].getElementsByTagName('str')[0].firstChild.data
music_version = data.getElementsByTagName('AddVersion')[0].getElementsByTagName('id')[0].firstChild.data
music_db[music_id] = {
"name": music_name,
"version": int(music_version)
}
logger.debug(f'Found {len(music_db)} music data')
serialized = '{\n'
sorted_keys = sorted(music_db.keys(), key=lambda x: int(x))
for key in sorted_keys:
value = music_db[key]
serialized += f' "{key}": {json.dumps(value, ensure_ascii=False)},\n'
serialized = serialized[:-2] + '\n}'
with open(DEST_PATH, 'w', encoding='utf-8') as f:
f.write(serialized)
if __name__ == '__main__':
makeMusicDBJson()
print('Done.')

113
backend/Standalone/UI.py Normal file
View File

@@ -0,0 +1,113 @@
import sys
import rapidjson as json
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QLineEdit,
QTextEdit,
QPushButton,
QLabel,
QHBoxLayout,
)
from PyQt6.QtCore import Qt
# 将当前目录的父目录加入到 sys.path 中
from pathlib import Path
current_dir = Path(__file__).resolve().parent
parent_dir = current_dir.parent
sys.path.append(str(parent_dir))
from API_TitleServer import *
def sendRequest(requestText: str, apiNameText: str, uid: int) -> str:
try:
data = json.loads(requestText)
data = json.dumps(data)
except Exception:
return "给出的输入不是有效的 JSON"
try:
result = apiSDGB(data, apiNameText, uid)
except Exception as e:
return "请求失败:" + str(e)
return result
class ApiTester(QMainWindow):
def __init__(self):
super().__init__()
# 主窗口设定
self.setWindowTitle("舞萌DX 2024 API 测试器")
self.resize(640, 400)
# 布局
mainWidget = QWidget()
self.setCentralWidget(mainWidget)
MainLayout = QVBoxLayout(mainWidget)
# 目标 API 输入框布局
TargetAPILayout = QHBoxLayout()
# API 输入框
self.TargetAPIInputBox = QLineEdit()
self.TargetAPIInputBox.setPlaceholderText("指定 API")
TargetAPILayout.addWidget(self.TargetAPIInputBox)
# API 后缀标签
TargetAPILabel = QLabel("MaimaiChn")
TargetAPILayout.addWidget(TargetAPILabel)
# 添加到主布局
MainLayout.addLayout(TargetAPILayout)
# UA额外信息输入框
self.AgentExtraInputBox = QLineEdit()
self.AgentExtraInputBox.setPlaceholderText("指定附加信息(UID或狗号)")
MainLayout.addWidget(self.AgentExtraInputBox)
# 请求输入框
self.RequestInputBox = QTextEdit()
self.RequestInputBox.setPlaceholderText("此处填入请求")
MainLayout.addWidget(self.RequestInputBox)
# 发送按钮
SendRequestButton = QPushButton("发送!")
SendRequestButton.clicked.connect(self.prepareRequest)
MainLayout.addWidget(SendRequestButton)
# 响应输出框
self.ResponseTextBox = QTextEdit()
self.ResponseTextBox.setPlaceholderText("此处显示输出")
self.ResponseTextBox.setReadOnly(True)
MainLayout.addWidget(self.ResponseTextBox)
# 布局设定
MainLayout.setContentsMargins(5, 5, 5, 5)
MainLayout.setSpacing(5)
MainLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
def prepareRequest(self):
# 发送请求用
try:
RequestDataString = self.RequestInputBox.toPlainText()
TargetAPIString = self.TargetAPIInputBox.text()
AgentExtraString = int(self.AgentExtraInputBox.text())
except Exception:
self.ResponseTextBox.setPlainText("输入无效")
return
Result = sendRequest(RequestDataString, TargetAPIString, AgentExtraString)
# 显示出输出
self.ResponseTextBox.setPlainText(Result)
if __name__ == "__main__":
app = QApplication(sys.argv)
# Set proper style for each OS
# if sys.platform == "win32":
# app.setStyle("windowsvista")
# else:
# app.setStyle("Fusion")
window = ApiTester()
window.show()
sys.exit(app.exec())

55
backend/_Special.py Normal file
View File

@@ -0,0 +1,55 @@
# 纯纯测试用
from loguru import logger
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from HelperFullPlay import implFullPlayAction, generateMusicData
from MyConfig import testUid8
def implChangeVersionNumber(
userId: int,
currentLoginTimestamp: int,
currentLoginResult,
dataVersion="1.40.09",
romVersion="1.41.00",
) -> str:
musicData = generateMusicData()
userAllPatches = {
"upsertUserAll": {
"userData": [
{
"lastRomVersion": romVersion,
"lastDataVersion": dataVersion,
"playerRating": 114514,
}
],
"userMusicDetailList": [musicData],
"isNewMusicDetailList": "1", # 1避免覆盖
}
}
result = implFullPlayAction(
userId, currentLoginTimestamp, currentLoginResult, musicData, userAllPatches
)
return result
if __name__ == "__main__":
userId = testUid8
currentLoginTimestamp = generateTimestamp()
loginResult = apiLogin(currentLoginTimestamp, userId)
if loginResult["returnCode"] != 1:
logger.info("登录失败")
exit()
try:
logger.info(
implChangeVersionNumber(
userId, currentLoginTimestamp, loginResult, "1.00.00", "1.00.00"
)
)
logger.info(apiLogout(currentLoginTimestamp, userId))
finally:
logger.info(apiLogout(currentLoginTimestamp, userId))
# logger.warning("Error")

554
backend/main.py Normal file
View File

@@ -0,0 +1,554 @@
import uvicorn
import json
import os
from datetime import datetime
from fastapi import FastAPI, Body
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from typing import Annotated
# 创建logs目录如果不存在
if not os.path.exists("logs"):
os.makedirs("logs")
# 配置日志
logger.add("logs/app_{time}.log", rotation="100 MB", retention="30 days")
logger.add("logs/error_{time}.log", level="ERROR", rotation="100 MB", retention="90 days")
# API Imports
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from GetUserData import apiGetUserData
from HelperUserAll import generateFullUserAll
from HelperGetUserMusicDetail import getUserFullMusicDetail
from Best50_To_Diving_Fish import implUserMusicToDivingFish
from GetPreview import apiGetUserPreview
from ActionLoginBonus import implLoginBonus
from ActionUnlockItem import implUnlockMultiItem
from ActionScoreRecord import implUploadMusicRecord, implDeleteMusicRecord
from ChargeTicket import implBuyTicket, implWipeTickets
from GetAny import apiGetAny
from API_AuthLiteDelivery import getRawDelivery, parseRawDelivery, getUpdateIniFromURL, parseUpdateIni
from API_AimeDB import implGetUID
from APILogger import api_logger
import uvicorn
import json
import os
from datetime import datetime
from fastapi import FastAPI, Body
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from typing import Annotated
# 创建logs目录如果不存在
if not os.path.exists("logs"):
os.makedirs("logs")
# 配置日志
logger.add("logs/app_{time}.log", rotation="100 MB", retention="30 days")
logger.add("logs/error_{time}.log", level="ERROR", rotation="100 MB", retention="90 days")
# API Imports
from HelperLogInOut import apiLogin, apiLogout, generateTimestamp
from GetUserData import apiGetUserData
from HelperUserAll import generateFullUserAll
from HelperGetUserMusicDetail import getUserFullMusicDetail
from Best50_To_Diving_Fish import implUserMusicToDivingFish
from GetPreview import apiGetUserPreview
from ActionLoginBonus import implLoginBonus
from ActionUnlockItem import implUnlockMultiItem
from ActionScoreRecord import implUploadMusicRecord, implDeleteMusicRecord
from ChargeTicket import implBuyTicket, implWipeTickets
from GetAny import apiGetAny
from API_AuthLiteDelivery import getRawDelivery, parseRawDelivery, getUpdateIniFromURL, parseUpdateIni
from API_AimeDB import implGetUID
from APILogger import api_logger
from MyConfig import testUid8
app = FastAPI()
# 日志中间件
@app.middleware("http")
async def log_requests(request, call_next):
# 记录请求开始时间
start_time = datetime.now()
# 记录请求信息
logger.info(f"Request: {request.method} {request.url} - Headers: {dict(request.headers)}")
# 处理请求
response = await call_next(request)
# 记录响应信息和处理时间
process_time = (datetime.now() - start_time).total_seconds()
logger.info(f"Response: {response.status_code} - Process Time: {process_time}s")
return response
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api")
async def root():
return {"message": "Welcome to the maimaiDX API"}
@app.get("/api/music")
async def get_music():
try:
with open("Data/musicDB.json", "r", encoding="utf-8") as f:
music_data_obj = json.load(f)
# Convert the object to a list of objects
music_list = [
{
"id": int(music_id),
"name": details.get("name"),
"version": details.get("version")
}
for music_id, details in music_data_obj.items()
]
return music_list
except FileNotFoundError:
return {"error": "Music database not found."}
except Exception as e:
return {"error": str(e)}
@app.get("/api/user/all")
async def get_user_all(userId: int = None):
if userId is None:
userId = testUid8
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Get basic user data
user_data_str = apiGetUserData(userId, noLog=True)
user_data = json.loads(user_data_str)
if "userData" not in user_data:
return {"error": "GetUserData failed", "details": user_data}
current_user_data2 = user_data["userData"]
# 3. Generate the full UserAll object
full_user_all = generateFullUserAll(
userId,
login_result,
timestamp,
current_user_data2,
{}
)
# 4. Logout
apiLogout(timestamp, userId)
return full_user_all
except Exception as e:
logger.error(f"An error occurred in get_user_all: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.get("/api/user/music_results")
async def get_user_music_results(userId: int):
if not userId:
return {"error": "userId is required"}
try:
# This might be slow as it fetches all pages
full_music_details = getUserFullMusicDetail(userId)
return {"userMusicResults": full_music_details}
except Exception as e:
logger.error(f"An error occurred in get_user_music_results: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/user/upload_to_diving_fish")
async def upload_to_diving_fish(
data: dict
):
userId = data.get("userId")
fishImportToken = data.get("fishImportToken")
if not userId or not fishImportToken:
return {"error": "userId and fishImportToken are required"}
try:
result_code = implUserMusicToDivingFish(userId, fishImportToken)
error_map = {
0: "Success",
1: "Failed to get user music from game server.",
2: "Diving Fish authentication failed. Check your token.",
3: "Communication error with Diving Fish server."
}
return {
"resultCode": result_code,
"message": error_map.get(result_code, "Unknown error")
}
except Exception as e:
logger.error(f"An error occurred in upload_to_diving_fish: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.get("/api/user/preview")
async def get_user_preview(userId: int):
if not userId:
return {"error": "userId is required"}
try:
preview_data_str = apiGetUserPreview(userId, noLog=True)
return json.loads(preview_data_str)
except Exception as e:
logger.error(f"An error occurred in get_user_preview: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/claim_login_bonus")
async def claim_login_bonus(data: dict):
userId = data.get("userId")
if not userId:
return {"error": "userId is required"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Claim bonus
# Using mode 2 to claim all available bonuses
bonus_result = implLoginBonus(userId, timestamp, login_result, bonusGenerateMode=2)
# 3. Logout
apiLogout(timestamp, userId)
if bonus_result:
return {"message": "Login bonus action completed successfully.", "details": bonus_result}
else:
return {"message": "No applicable login bonus found or action failed."}
except Exception as e:
logger.error(f"An error occurred in claim_login_bonus: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/unlock_item")
async def unlock_item(data: dict):
userId = data.get("userId")
itemKind = data.get("itemKind")
itemIds_str = data.get("itemIds") # Expecting a comma-separated string of integers
if not all([userId, itemKind is not None, itemIds_str]):
return {"error": "userId, itemKind, and a string of itemIds are required"}
try:
itemIds = [int(i.strip()) for i in itemIds_str.split(",")]
except ValueError:
return {"error": "itemIds must be a comma-separated string of numbers"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Unlock items
unlock_result = implUnlockMultiItem(
itemKind, userId, timestamp, login_result, *itemIds
)
# 3. Logout
apiLogout(timestamp, userId)
return {"message": "Unlock action completed.", "details": unlock_result}
except Exception as e:
logger.error(f"An error occurred in unlock_item: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/upload_score")
async def upload_score(data: dict):
userId = data.get("userId")
musicId = data.get("musicId")
levelId = data.get("levelId")
achievement = data.get("achievement")
dxScore = data.get("dxScore")
if not all([userId, musicId, levelId is not None, achievement is not None, dxScore is not None]):
return {"error": "userId, musicId, levelId, achievement, and dxScore are required"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Upload score
upload_result = implUploadMusicRecord(
userId, timestamp, login_result, musicId, levelId, achievement, dxScore
)
# 3. Logout
apiLogout(timestamp, userId)
return {"message": "Score upload action completed.", "details": upload_result}
except Exception as e:
logger.error(f"An error occurred in upload_score: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/delete_score")
async def delete_score(data: dict):
userId = data.get("userId")
musicId = data.get("musicId")
levelId = data.get("levelId")
if not all([userId, musicId, levelId is not None]):
return {"error": "userId, musicId, and levelId are required"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Delete score
delete_result = implDeleteMusicRecord(
userId, timestamp, login_result, musicId, levelId
)
# 3. Logout
apiLogout(timestamp, userId)
return {"message": "Score delete action completed.", "details": delete_result}
except Exception as e:
logger.error(f"An error occurred in delete_score: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/buy_ticket")
async def buy_ticket(data: dict):
userId = data.get("userId")
ticketType = data.get("ticketType")
if not all([userId, ticketType is not None]):
return {"error": "userId and ticketType are required"}
try:
ticketType = int(ticketType)
except ValueError:
return {"error": "ticketType must be a number"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Buy ticket
buy_result = implBuyTicket(userId, ticketType)
# 3. Logout
apiLogout(timestamp, userId)
return {"message": "Buy ticket action completed.", "details": json.loads(buy_result)}
except Exception as e:
logger.error(f"An error occurred in buy_ticket: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/force_logout")
async def force_logout(data: dict):
userId = data.get("userId")
if not userId:
return {"error": "userId is required"}
try:
# Attempt to logout with the current timestamp
timestamp = generateTimestamp()
logout_result = apiLogout(timestamp, userId, noLog=True)
return {"message": "Force logout attempt sent.", "details": logout_result}
except Exception as e:
logger.error(f"An error occurred in force_logout: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/action/wipe_tickets")
async def wipe_tickets(data: dict):
userId = data.get("userId")
if not userId:
return {"error": "userId is required"}
timestamp = None
try:
# 1. Login
timestamp = generateTimestamp()
login_result = apiLogin(timestamp, userId)
if login_result.get("returnCode") != 1:
return {"error": "Login failed", "details": login_result}
# 2. Wipe tickets
wipe_result = implWipeTickets(userId, timestamp, login_result)
# 3. Logout
apiLogout(timestamp, userId)
return {"message": "Wipe tickets action completed.", "details": wipe_result}
except Exception as e:
logger.error(f"An error occurred in wipe_tickets: {e}")
if timestamp and userId:
try:
apiLogout(timestamp, userId)
except Exception as logout_e:
logger.error(f"Error during cleanup logout: {logout_e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
@app.get("/api/get_auth_lite_delivery")
async def get_auth_lite_delivery():
try:
raw_delivery_str = getRawDelivery()
update_links = parseRawDelivery(raw_delivery_str)
return {"updateLinks": update_links}
except Exception as e:
logger.error(f"An error occurred in get_auth_lite_delivery: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.get("/api/get_any")
async def get_any(userId: int, apiName: str):
if not all([userId, apiName]):
return {"error": "userId and apiName are required"}
try:
result_str = apiGetAny(userId, apiName, noLog=True)
return json.loads(result_str)
except Exception as e:
logger.error(f"An error occurred in get_any for {apiName}: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/parse_update_ini")
async def parse_update_ini(data: dict):
url = data.get("url")
if not url:
return {"error": "URL is required"}
try:
ini_text = getUpdateIniFromURL(url)
parsed_data = parseUpdateIni(ini_text)
return {"parsedData": parsed_data}
except Exception as e:
logger.error(f"An error occurred parsing INI from {url}: {e}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.post("/api/aime_scan")
async def aime_scan(data: dict):
qr_content = data.get("qrContent")
if not qr_content:
api_logger.log_warning("Aime scan failed: qrContent is required")
return {"error": "qrContent is required"}
try:
api_logger.log_info(f"Aime scan request received for QR content: {qr_content[:20]}...")
result = implGetUID(qr_content)
api_logger.log_info(f"Aime scan completed successfully for QR content: {qr_content[:20]}...")
if "errorID" in result:
api_logger.log_warning(f"Aime scan returned error: {result['errorID']}")
elif "userID" in result:
api_logger.log_info(f"Aime scan returned user ID: {result['userID']}")
return result
except Exception as e:
api_logger.log_error(f"An error occurred in aime_scan: {str(e)}")
return {"error": "An unexpected error occurred.", "details": str(e)}
@app.get("/api/logs")
async def get_logs():
"""获取日志文件内容"""
try:
import os
import glob
from datetime import datetime
# 获取最新的日志文件
log_files = glob.glob("logs/app_*.log")
if not log_files:
return {"logs": []}
# 按修改时间排序,获取最新的日志文件
latest_log_file = max(log_files, key=os.path.getmtime)
# 读取日志文件内容
with open(latest_log_file, "r", encoding="utf-8") as f:
lines = f.readlines()
# 解析日志行
logs = []
for line in lines[-100:]: # 只返回最后100行
# 尝试解析loguru格式的日志
try:
# 格式: 2023-05-15 10:30:45.123 | INFO | message
parts = line.strip().split(" | ")
if len(parts) >= 3:
timestamp_str = parts[0]
level = parts[1].strip()
message = " | ".join(parts[2:])
# 解析时间戳
timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S.%f")
logs.append({
"timestamp": timestamp.isoformat(),
"level": level,
"message": message
})
except Exception:
# 如果解析失败,将整行作为消息
logs.append({
"timestamp": datetime.now().isoformat(),
"level": "INFO",
"message": line.strip()
})
return {"logs": logs}
except Exception as e:
api_logger.log_error(f"Failed to read logs: {str(e)}")
return {"error": "Failed to read logs", "details": str(e)}

15
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,15 @@
[project]
name = "maimaidx-api"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"datetime>=5.5",
"httpx>=0.28.1",
"loguru>=0.7.3",
"pycryptodome>=3.23.0",
"python-rapidjson>=1.21",
"pytz>=2025.2",
"requests>=2.32.4",
]

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
datetime>=5.5
httpx>=0.28.1
loguru>=0.7.3
pycryptodome>=3.23.0
python-rapidjson>=1.21
pytz>=2025.2
requests>=2.32.4

317
backend/uv.lock generated Normal file
View File

@@ -0,0 +1,317 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "certifi"
version = "2025.7.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "datetime"
version = "5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytz" },
{ name = "zope-interface" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/66/e284b9978fede35185e5d18fb3ae855b8f573d8c90a56de5f6d03e8ef99e/DateTime-5.5.tar.gz", hash = "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3", size = 63671, upload-time = "2024-03-21T07:26:50.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/78/8e382b8cb4346119e2e04270b6eb4a01c5ee70b47a8a0244ecdb157204f7/DateTime-5.5-py3-none-any.whl", hash = "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120", size = 52649, upload-time = "2024-03-21T07:26:47.849Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "maimaidx-api"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "datetime" },
{ name = "httpx" },
{ name = "loguru" },
{ name = "pycryptodome" },
{ name = "python-rapidjson" },
{ name = "pytz" },
{ name = "requests" },
]
[package.metadata]
requires-dist = [
{ name = "datetime", specifier = ">=5.5" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pycryptodome", specifier = ">=3.23.0" },
{ name = "python-rapidjson", specifier = ">=1.21" },
{ name = "pytz", specifier = ">=2025.2" },
{ name = "requests", specifier = ">=2.32.4" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
]
[[package]]
name = "python-rapidjson"
version = "1.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/da/f041c85e7852ddb87dd59e34fdbfebf27defc6e4dfecbc0486ab30553dfd/python_rapidjson-1.21.tar.gz", hash = "sha256:4d0dd9cf1fcb6f4bf79ee606e6e9be4cfa598f273b91338a6974b6a99309a1e6", size = 238903, upload-time = "2025-07-10T06:30:43.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/c8/52735f227a9c662df8e4292af2b13960f0351795b0115f9fe95b3bfcfe62/python_rapidjson-1.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:354ac34828a242eec1896901fabe59d14adab7a5054d09a8bdaed4c6ee510b22", size = 220850, upload-time = "2025-07-10T07:19:00.767Z" },
{ url = "https://files.pythonhosted.org/packages/c2/83/f02aa1c9ea2594f05dd8c6c51e2ecea398a462118c7f4ddc004a135096f0/python_rapidjson-1.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ddd846b095fad357e4e4c5071e874f9c100c2e5b9765abcc7d71254820251c20", size = 210311, upload-time = "2025-07-10T07:19:02.27Z" },
{ url = "https://files.pythonhosted.org/packages/2f/60/b6a4026fd144fe80db1d6f7cb78191ed2f27fb46c69c719b3b63a8542072/python_rapidjson-1.21-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7faf4211354c1db9b21934d723f3c7db1d611ec3013458dc2fe1a732aa100d3e", size = 1679351, upload-time = "2025-07-10T07:19:04.419Z" },
{ url = "https://files.pythonhosted.org/packages/35/7e/73ca21eb527fbe27b267ea04b9047fdfb4a524b2ce2565a082d84cdee7e9/python_rapidjson-1.21-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46ea95e9292260fd8174966eca05fad08b43ca0f5df1ccfab9796edb0868d8eb", size = 1722281, upload-time = "2025-07-10T07:19:06.637Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9c/23da2d95b0b4de46695b57a593763ddb8e7f8a786c2793be89fc3dad957e/python_rapidjson-1.21-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1cac86399cfbaf24e72aef6eed1114180a72ba0242c7153f89d874a81fb83c6", size = 1719283, upload-time = "2025-07-10T07:19:07.958Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c1/52de9e4c137ccc98116c16835d9a708436d58ba51ba448e238ce08938d5b/python_rapidjson-1.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a4afbf54eb60dc56d4c2b6fac5d24709e8a5928baaeff0669c00950961c7a65", size = 2530062, upload-time = "2025-07-10T07:19:09.302Z" },
{ url = "https://files.pythonhosted.org/packages/7f/16/08d24136b5895157f1fdc75669796283d5116336c0b1c27fa73de260980f/python_rapidjson-1.21-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e8d4d1285c697325ad1b5300866176d190625bbdd85dce963300fad5bfaf166", size = 2635763, upload-time = "2025-07-10T07:19:10.764Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a3/08aecff00e26e5dbb8cc8e5b2372b6f9ada28fcf09623e6de62f08ed2b99/python_rapidjson-1.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1525778bc9b9cee9b8f3058e0435f0e4ff03e2d8c3883893ebcaf58680316964", size = 2635419, upload-time = "2025-07-10T07:19:12.791Z" },
{ url = "https://files.pythonhosted.org/packages/38/21/bd4e0cda3eada4d99562c1b5bcd2cb54588df17d63ab95b8b76af3d93826/python_rapidjson-1.21-cp312-cp312-win32.whl", hash = "sha256:b5717ddb9788ca00e0f97546cbdd3cfd0c25e52ab3bfed0951c7898e7f37cc60", size = 128528, upload-time = "2025-07-10T07:19:14.61Z" },
{ url = "https://files.pythonhosted.org/packages/e2/91/70f9083930de93e7ce9ccebd8bd598a5737829452c03c258ea862146bbfa/python_rapidjson-1.21-cp312-cp312-win_amd64.whl", hash = "sha256:409704e52ad25df661265dbcb3a512a2146c98f72f362adc157e9b595704e1be", size = 148421, upload-time = "2025-07-10T07:19:16.127Z" },
{ url = "https://files.pythonhosted.org/packages/6d/82/9f1ffd805d6c4b2329046f88b59eec077ea5b2a1894ec23f8fcf22f53e0f/python_rapidjson-1.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:863208f50b848b8e10aefcf0da2a67ce38a1044a2ebf83d6355f16f0c7865d4f", size = 220853, upload-time = "2025-07-10T07:19:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/21/d8/9de439a33cef3d1a7672028abcc1a3c8d0c9982f03a73b9cc71055325ee3/python_rapidjson-1.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:608ade2fff2788821f25e70af4b1bfbf9ab2b2dc8ad47c91616935c5d31e3b72", size = 210314, upload-time = "2025-07-10T07:19:19.292Z" },
{ url = "https://files.pythonhosted.org/packages/da/93/dbee2d13a1619747652b8c8a921cb8a2e7309c0a142867b4954341c76632/python_rapidjson-1.21-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf6377c4e9636e20b403776585903d1ff52613a419c5f8453032146f0422634", size = 1679359, upload-time = "2025-07-10T07:19:21.086Z" },
{ url = "https://files.pythonhosted.org/packages/a1/56/06535489e561f6c2a035a5fa8d3d2ecc97c647fb4e1625a8cb7a1db46e6b/python_rapidjson-1.21-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:74cba817cd9b16a3a54e7ddb3b59980c338c9a28900124502215dcba5db5a276", size = 1722183, upload-time = "2025-07-10T07:19:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/67/0b/7c27a2473edfb9d40eb65e5a225dc2e833a5e355f228502aa19c2bdf6679/python_rapidjson-1.21-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3194bde069c3f03f3c425de7037cca37d337b6c7ac30f42cd2f17e15f1c4da3a", size = 1718857, upload-time = "2025-07-10T07:19:24.369Z" },
{ url = "https://files.pythonhosted.org/packages/17/0a/0db35ac49212505c347cdc353ddebd2b4085bfc02090baae552e7b5908b4/python_rapidjson-1.21-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b663932dc7548567028fb735bf4092b50dc373822dc9756a9ade6513541013de", size = 2530213, upload-time = "2025-07-10T07:19:26.062Z" },
{ url = "https://files.pythonhosted.org/packages/02/6c/4972fb83f0419654d3083e0b4881f607cbf0829d64da43b7d2a3a21480a0/python_rapidjson-1.21-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fcb094ecca3d892f1dbd318251579ef66e2b2f06a2a24414ad71c31931d7ffff", size = 2636100, upload-time = "2025-07-10T07:19:27.744Z" },
{ url = "https://files.pythonhosted.org/packages/3e/08/da452b2bd67f88b8232e48f2341f3320ce9315f0bdfed1ca769b0b389acf/python_rapidjson-1.21-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f4cdb4f77b08b7ae121b7ca27ed2d57dcce17f8cd9f1d4593c954c982aba18ba", size = 2635274, upload-time = "2025-07-10T07:19:29.174Z" },
{ url = "https://files.pythonhosted.org/packages/18/2b/b81e89c01ec25078a2326051b691443c90797da0d21e1b6c862128de5238/python_rapidjson-1.21-cp313-cp313-win32.whl", hash = "sha256:be63c0ef87bf26059ee77f5de21d84a1be3659cb6baa8484c237ffe17a8beb56", size = 128530, upload-time = "2025-07-10T07:19:30.743Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/e92189d6142860f949b9679c915a6a3752ccc2345a5f80a3995233c45f9f/python_rapidjson-1.21-cp313-cp313-win_amd64.whl", hash = "sha256:38307a2ef4fddbdc18806a796201fe668c30e60de7cd795943a3f7fd39535c8b", size = 148424, upload-time = "2025-07-10T07:19:32.212Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "requests"
version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]
[[package]]
name = "zope-interface"
version = "7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/93/9210e7606be57a2dfc6277ac97dcc864fd8d39f142ca194fdc186d596fda/zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe", size = 252960, upload-time = "2024-11-28T08:45:39.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/0b/c7516bc3bad144c2496f355e35bd699443b82e9437aa02d9867653203b4a/zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7", size = 208959, upload-time = "2024-11-28T08:47:47.788Z" },
{ url = "https://files.pythonhosted.org/packages/a2/e9/1463036df1f78ff8c45a02642a7bf6931ae4a38a4acd6a8e07c128e387a7/zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465", size = 209357, upload-time = "2024-11-28T08:47:50.897Z" },
{ url = "https://files.pythonhosted.org/packages/07/a8/106ca4c2add440728e382f1b16c7d886563602487bdd90004788d45eb310/zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89", size = 264235, upload-time = "2024-11-28T09:18:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ca/57286866285f4b8a4634c12ca1957c24bdac06eae28fd4a3a578e30cf906/zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54", size = 259253, upload-time = "2024-11-28T08:48:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/96/08/2103587ebc989b455cf05e858e7fbdfeedfc3373358320e9c513428290b1/zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d", size = 264702, upload-time = "2024-11-28T08:48:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/5f/c7/3c67562e03b3752ba4ab6b23355f15a58ac2d023a6ef763caaca430f91f2/zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5", size = 212466, upload-time = "2024-11-28T08:49:14.397Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3b/e309d731712c1a1866d61b5356a069dd44e5b01e394b6cb49848fa2efbff/zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98", size = 208961, upload-time = "2024-11-28T08:48:29.865Z" },
{ url = "https://files.pythonhosted.org/packages/49/65/78e7cebca6be07c8fc4032bfbb123e500d60efdf7b86727bb8a071992108/zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d", size = 209356, upload-time = "2024-11-28T08:48:33.297Z" },
{ url = "https://files.pythonhosted.org/packages/11/b1/627384b745310d082d29e3695db5f5a9188186676912c14b61a78bbc6afe/zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c", size = 264196, upload-time = "2024-11-28T09:18:17.584Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f6/54548df6dc73e30ac6c8a7ff1da73ac9007ba38f866397091d5a82237bd3/zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398", size = 259237, upload-time = "2024-11-28T08:48:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/b6/66/ac05b741c2129fdf668b85631d2268421c5cd1a9ff99be1674371139d665/zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b", size = 264696, upload-time = "2024-11-28T08:48:41.161Z" },
{ url = "https://files.pythonhosted.org/packages/0a/2f/1bccc6f4cc882662162a1158cda1a7f616add2ffe322b28c99cb031b4ffc/zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd", size = 212472, upload-time = "2024-11-28T08:49:56.587Z" },
]

8
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

39
frontend/README.md Normal file
View File

@@ -0,0 +1,39 @@
# temp-vue-project
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

22
frontend/eslint.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>maimaiDX-API web tools</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5707
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "temp-vue-project",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.12.2",
"element-plus": "^2.11.2",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.0.4"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

105
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,105 @@
<template>
<el-container class="main-layout">
<el-aside :width="isCollapsed ? '64px' : '200px'" class="main-aside">
<el-menu
default-active="/profile"
class="el-menu-vertical-demo"
:collapse="isCollapsed"
router
>
<div class="header-container">
<span v-if="!isCollapsed">SDGB API TOOLS</span>
</div>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/music">
<el-icon><Headset /></el-icon>
<template #title>乐曲列表</template>
</el-menu-item>
<el-menu-item index="/profile">
<el-icon><User /></el-icon>
<template #title>用户中心</template>
</el-menu-item>
<el-menu-item index="/aime-db">
<el-icon><CreditCard /></el-icon>
<template #title>Aime卡扫描</template>
</el-menu-item>
<el-menu-item index="/auth-lite-delivery">
<el-icon><Link /></el-icon>
<template #title>更新链接</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>系统日志</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="main-header">
<el-icon @click="isCollapsed = !isCollapsed" class="collapse-icon">
<component :is="isCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
<span>SDGB 1.51</span>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { HomeFilled, Headset, User, Fold, Expand, Link, CreditCard, Document } from '@element-plus/icons-vue'
const isCollapsed = ref(false)
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.main-layout {
height: 100vh;
}
.main-aside {
transition: width 0.3s;
background-color: #fff;
border-right: 1px solid #e6e6e6;
}
.el-menu {
border-right: none;
}
.header-container {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
}
.main-header {
display: flex;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e6e6e6;
}
.collapse-icon {
font-size: 24px;
cursor: pointer;
margin-right: 20px;
}
.el-main {
background-color: #f4f7fa;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
padding: 20px;
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import logger, { getLogBuffer } from '@/utils/logger'
const logs = ref<any[]>(getLogBuffer())
const filterLevel = ref('ALL')
const filterText = ref('')
// 过滤后的日志
const filteredLogs = computed(() => {
let filtered = logs.value
// 按级别过滤
if (filterLevel.value !== 'ALL') {
filtered = filtered.filter(log => log.level === filterLevel.value)
}
// 按文本过滤
if (filterText.value) {
const searchText = filterText.value.toLowerCase()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(searchText) ||
(log.data && JSON.stringify(log.data).toLowerCase().includes(searchText))
)
}
// 按时间倒序排列
return filtered.slice().reverse()
})
// 刷新日志
function refreshLogs() {
logs.value = getLogBuffer()
}
// 清空日志
function clearLogs() {
logger.clearLogBuffer()
logs.value = []
}
// 获取日志级别对应的标签类型
function getTagType(level: string) {
switch (level) {
case 'ERROR': return 'danger'
case 'WARN': return 'warning'
case 'INFO': return 'success'
case 'DEBUG': return 'info'
default: return 'info'
}
}
// 格式化时间戳
function formatTimestamp(timestamp: string) {
return new Date(timestamp).toLocaleString()
}
</script>
<template>
<div class="log-viewer">
<div class="toolbar">
<el-input
v-model="filterText"
placeholder="搜索日志..."
clearable
style="width: 200px; margin-right: 10px;"
/>
<el-select
v-model="filterLevel"
placeholder="选择级别"
style="width: 120px; margin-right: 10px;"
>
<el-option label="全部" value="ALL" />
<el-option label="DEBUG" value="DEBUG" />
<el-option label="INFO" value="INFO" />
<el-option label="WARN" value="WARN" />
<el-option label="ERROR" value="ERROR" />
</el-select>
<el-button @click="refreshLogs">刷新</el-button>
<el-button @click="clearLogs">清空</el-button>
</div>
<el-table :data="filteredLogs" stripe style="width: 100%" height="400">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ formatTimestamp(scope.row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getTagType(scope.row.level)">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
<el-table-column label="数据" width="100">
<template #default="scope">
<el-popover
v-if="scope.row.data"
placement="left"
:width="300"
trigger="hover"
>
<template #reference>
<el-button size="small" type="primary" link>查看</el-button>
</template>
<pre>{{ JSON.stringify(scope.row.data, null, 2) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.log-viewer {
width: 100%;
}
.toolbar {
margin-bottom: 15px;
display: flex;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

20
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import MusicView from '../views/MusicView.vue'
import ProfileView from '../views/ProfileView.vue'
import AuthLiteDeliveryView from '../views/AuthLiteDeliveryView.vue'
import AimeDBView from '../views/AimeDBView.vue'
import LogsView from '../views/LogsView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/music',
name: 'music',
component: MusicView
},
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/aime-db',
name: 'aime-db',
component: AimeDBView
},
{
path: '/auth-lite-delivery',
name: 'auth-lite-delivery',
component: AuthLiteDeliveryView
},
{
path: '/logs',
name: 'logs',
component: LogsView
}
]
})
export default router

View File

@@ -0,0 +1,143 @@
// 日志级别枚举
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3
};
// 当前日志级别
let currentLogLevel = LogLevel.INFO;
// 日志缓冲区
let logBuffer = [];
const MAX_BUFFER_SIZE = 1000;
// 设置日志级别
export function setLogLevel(level) {
currentLogLevel = level;
}
// 获取当前时间戳
function getTimestamp() {
return new Date().toISOString();
}
// 写入日志到缓冲区
function writeToBuffer(level, message, data = null) {
const logEntry = {
timestamp: getTimestamp(),
level: Object.keys(LogLevel).find(key => LogLevel[key] === level),
message: message,
data: data
};
logBuffer.push(logEntry);
// 保持缓冲区大小在限制内
if (logBuffer.length > MAX_BUFFER_SIZE) {
logBuffer = logBuffer.slice(-MAX_BUFFER_SIZE);
}
// 在控制台输出日志
switch (level) {
case LogLevel.DEBUG:
console.debug(`[DEBUG] ${message}`, data);
break;
case LogLevel.INFO:
console.info(`[INFO] ${message}`, data);
break;
case LogLevel.WARN:
console.warn(`[WARN] ${message}`, data);
break;
case LogLevel.ERROR:
console.error(`[ERROR] ${message}`, data);
break;
}
}
// Debug级别日志
export function logDebug(message, data = null) {
if (currentLogLevel <= LogLevel.DEBUG) {
writeToBuffer(LogLevel.DEBUG, message, data);
}
}
// Info级别日志
export function logInfo(message, data = null) {
if (currentLogLevel <= LogLevel.INFO) {
writeToBuffer(LogLevel.INFO, message, data);
}
}
// Warn级别日志
export function logWarn(message, data = null) {
if (currentLogLevel <= LogLevel.WARN) {
writeToBuffer(LogLevel.WARN, message, data);
}
}
// Error级别日志
export function logError(message, data = null) {
if (currentLogLevel <= LogLevel.ERROR) {
writeToBuffer(LogLevel.ERROR, message, data);
}
}
// 获取日志缓冲区
export function getLogBuffer() {
return [...logBuffer]; // 返回副本
}
// 清空日志缓冲区
export function clearLogBuffer() {
logBuffer = [];
}
// 将日志保存到文件
export function saveLogsToFile() {
const logs = getLogBuffer();
const logText = logs.map(entry =>
`${entry.timestamp} [${entry.level}] ${entry.message} ${entry.data ? JSON.stringify(entry.data) : ''}`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `frontend-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// API请求日志
export function logApiRequest(method, url, data = null) {
logInfo(`API Request: ${method} ${url}`, data);
}
// API响应日志
export function logApiResponse(status, data = null) {
if (status >= 400) {
logError(`API Response: ${status}`, data);
} else {
logInfo(`API Response: ${status}`, data);
}
}
export default {
LogLevel,
setLogLevel,
logDebug,
logInfo,
logWarn,
logError,
getLogBuffer,
clearLogBuffer,
saveLogsToFile,
logApiRequest,
logApiResponse
};

View File

@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const qrContent = ref('')
const isLoading = ref(false)
const scanResult = ref<any>(null)
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
async function scanQRCode() {
if (!qrContent.value) {
ElMessage.error('请输入二维码内容')
return
}
isLoading.value = true
scanResult.value = null
try {
const { data } = await axios.post(`${apiBaseUrl.value}/aime_scan`, { qrContent: qrContent.value })
scanResult.value = data
if (data.errorID) {
ElMessage.error(`扫描失败: 错误代码 ${data.errorID}`)
} else {
ElMessage.success('二维码扫描成功')
}
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`扫描失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
function clearResult() {
scanResult.value = null
}
</script>
<template>
<div class="aime-db-view">
<el-card header="AimeDB 二维码扫描" style="width: 100%;">
<el-form :inline="true" @submit.prevent="scanQRCode">
<el-form-item label="二维码内容">
<el-input
v-model="qrContent"
placeholder="请输入Aime卡二维码内容"
clearable
style="width: 300px;"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="scanQRCode"
:loading="isLoading"
>
扫描
</el-button>
<el-button @click="clearResult">清空结果</el-button>
</el-form-item>
</el-form>
<el-card v-if="scanResult" header="扫描结果" style="margin-top: 20px;">
<div v-if="scanResult.errorID">
<el-alert
title="扫描失败"
:description="`错误代码: ${scanResult.errorID}`"
type="error"
show-icon
/>
</div>
<div v-else>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ scanResult.userID }}</el-descriptions-item>
<el-descriptions-item label="Aime卡ID">{{ scanResult.aimeID }}</el-descriptions-item>
<el-descriptions-item label="访问代码">{{ scanResult.accessCode }}</el-descriptions-item>
<el-descriptions-item label="芯片ID">{{ scanResult.chipID }}</el-descriptions-item>
<el-descriptions-item label="返回代码">{{ scanResult.returnCode }}</el-descriptions-item>
</el-descriptions>
</div>
<el-divider />
<h3>原始数据</h3>
<pre>{{ JSON.stringify(scanResult, null, 2) }}</pre>
</el-card>
</el-card>
</div>
</template>
<style scoped>
.aime-db-view {
padding: 20px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const authLiteDeliveryData = ref<any>(null)
const isLoading = ref(false)
const parsedIniData = ref<any>(null) // New ref for parsed INI data
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
async function getAuthLiteDelivery() {
isLoading.value = true
parsedIniData.value = null; // Clear previous parsed data
try {
const { data } = await axios.get(`${apiBaseUrl.value}/get_auth_lite_delivery`)
if (data.error) throw new Error(data.error)
authLiteDeliveryData.value = data
ElMessage.success('已获取 AuthLiteDelivery 信息')
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`获取失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
async function parseIni(url: string) {
isLoading.value = true
parsedIniData.value = null; // Clear previous parsed data
try {
const { data } = await axios.post(`${apiBaseUrl.value}/parse_update_ini`, { url })
if (data.error) throw new Error(data.error)
parsedIniData.value = data.parsedData
ElMessage.success('INI 文件解析成功')
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`INI 文件解析失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="auth-lite-delivery-view">
<el-card header="AuthLiteDelivery 信息获取" style="width: 100%;">
<el-form :inline="true" @submit.prevent="getAuthLiteDelivery">
<el-form-item>
<el-button type="primary" @click="getAuthLiteDelivery" :loading="isLoading">获取 AuthLiteDelivery</el-button>
</el-form-item>
</el-form>
<el-card v-if="authLiteDeliveryData" header="AuthLiteDelivery 结果" style="margin-top: 20px;">
<p>更新链接列表:</p>
<el-space wrap>
<div v-for="(link, index) in authLiteDeliveryData.updateLinks" :key="index" class="link-item">
<el-link :href="link" target="_blank">{{ link }}</el-link>
<el-button type="text" @click="parseIni(link)" :loading="isLoading">解析</el-button>
</div>
</el-space>
</el-card>
<el-card v-if="parsedIniData" header="解析后的 INI 内容" style="margin-top: 20px;">
<el-descriptions :column="1" border>
<el-descriptions-item label="游戏描述">{{ parsedIniData.game_desc }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ parsedIniData.release_time }}</el-descriptions-item>
<el-descriptions-item label="主更新包">{{ parsedIniData.main_file }}</el-descriptions-item>
<el-descriptions-item label="可选更新包">
<ul v-if="parsedIniData.optional_files && parsedIniData.optional_files.length">
<li v-for="(file, idx) in parsedIniData.optional_files" :key="idx">{{ file }}</li>
</ul>
<span v-else></span>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h3>原始解析数据 (完整)</h3>
<pre>{{ JSON.stringify(parsedIniData, null, 2) }}</pre>
</el-card>
</el-card>
</div>
</template>
<style scoped>
.auth-lite-delivery-view {
padding: 20px;
}
.link-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div class="home">
<h1>舞萌DX 2025(SDGB 1.51)API网页工具</h1>
<p>本工具仅供学习交流使用请勿用于非法用途</p>
<p>目前已经实现的功能</p>
<p>用户所有信息查看需要获取二维码</p>
<p>用户信息预览免获取二维码</p>
<p>领取登录奖励等等</p>
<img src='https://i.mji.rip/2025/09/18/5bdc72c934da195de76f2df4677a26d3.png'></img>
</div>
</template>
<style scoped>
.home {
padding: 20px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import LogViewer from '@/components/LogViewer.vue'
import logger, { getLogBuffer, saveLogsToFile } from '@/utils/logger'
const backendLogs = ref<any[]>([])
const frontendLogs = ref<any[]>(getLogBuffer())
const isLoading = ref(false)
const autoRefresh = ref(false)
const refreshInterval = ref<NodeJS.Timeout | null>(null)
const apiBaseUrl = `http://${window.location.hostname}:8000/api`
// 获取后端日志
async function fetchBackendLogs() {
isLoading.value = true
try {
logger.logInfo('Fetching backend logs')
const { data } = await axios.get(`${apiBaseUrl}/logs`)
if (data.error) {
throw new Error(data.error)
}
backendLogs.value = data.logs || []
ElMessage.success('后端日志获取成功')
} catch (e: any) {
logger.logError('Failed to fetch backend logs', e)
ElMessage.error('获取后端日志失败: ' + (e.response?.data?.detail || e.message))
// 使用模拟数据
backendLogs.value = [
{ timestamp: new Date().toISOString(), level: 'INFO', message: 'Backend started successfully' },
{ timestamp: new Date().toISOString(), level: 'INFO', message: 'Aime scan request received' },
{ timestamp: new Date().toISOString(), level: 'WARN', message: 'QR content validation failed' }
]
} finally {
isLoading.value = false
}
}
// 刷新所有日志
function refreshLogs() {
frontendLogs.value = getLogBuffer()
fetchBackendLogs()
}
// 开始自动刷新
function startAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
}
refreshInterval.value = setInterval(() => {
refreshLogs()
}, 5000) // 每5秒刷新一次
}
// 停止自动刷新
function stopAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
}
// 切换自动刷新
function toggleAutoRefresh() {
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// 保存日志到文件
function saveLogs() {
saveLogsToFile()
ElMessage.success('日志已保存到文件')
}
// 组件挂载时的操作
onMounted(() => {
refreshLogs()
})
// 组件卸载时的操作
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<template>
<div class="logs-view">
<el-card header="系统日志" style="width: 100%;">
<div class="toolbar">
<el-button type="primary" @click="refreshLogs" :loading="isLoading">刷新日志</el-button>
<el-checkbox v-model="autoRefresh" @change="toggleAutoRefresh">自动刷新</el-checkbox>
<el-button @click="clearFrontendLogs">清空前端日志</el-button>
<el-button @click="saveLogs">保存日志到文件</el-button>
</div>
<el-tabs type="border-card">
<el-tab-pane label="前端日志">
<el-table :data="frontendLogs" stripe style="width: 100%" height="500">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ new Date(scope.row.timestamp).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="scope">
<el-tag :type="scope.row.level === 'ERROR' ? 'danger' :
scope.row.level === 'WARN' ? 'warning' :
scope.row.level === 'INFO' ? 'success' : 'info'">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
<el-table-column prop="data" label="数据" width="200">
<template #default="scope">
<el-popover
v-if="scope.row.data"
placement="left"
:width="300"
trigger="hover"
>
<template #reference>
<el-button size="small">查看数据</el-button>
</template>
<pre>{{ JSON.stringify(scope.row.data, null, 2) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="后端日志">
<el-table :data="backendLogs" stripe style="width: 100%" height="500">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ new Date(scope.row.timestamp).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="scope">
<el-tag :type="scope.row.level === 'ERROR' ? 'danger' :
scope.row.level === 'WARN' ? 'warning' :
scope.row.level === 'INFO' ? 'success' : 'info'">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
.logs-view {
padding: 20px;
}
.toolbar {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const musicList = ref<any[]>([])
const isLoading = ref(true)
const searchTerm = ref('')
const apiBaseUrl = computed(() => `http://${window.location.hostname}:8000/api`)
const filteredMusic = computed(() => {
if (!searchTerm.value) {
return musicList.value
}
return musicList.value.filter(music =>
music.name.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
onMounted(async () => {
try {
const { data } = await axios.get(`${apiBaseUrl.value}/music`)
if (!Array.isArray(data)) {
throw new Error('Invalid data format received from API.')
}
musicList.value = data
if (data.length === 0) {
ElMessage.warning('API returned empty music list.')
}
} catch (e: any) {
ElMessage.error(`无法加载乐曲列表: ${e.message}`)
console.error(e)
} finally {
isLoading.value = false
}
})
</script>
<template>
<div class="music-view">
<el-card header="乐曲列表">
<el-input
v-model="searchTerm"
placeholder="搜索歌曲名"
clearable
style="margin-bottom: 20px;"
/>
<el-table :data="filteredMusic" stripe v-loading="isLoading" height="75vh" empty-text="没有数据">
<el-table-column prop="id" label="ID" width="100" sortable />
<el-table-column prop="name" label="标题" sortable />
<el-table-column prop="version" label="版本" width="120" sortable />
</el-table>
</el-card>
</div>
</template>
<style scoped>
.music-view {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import logger from '@/utils/logger'
// --- API Configuration ---
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
// --- State Management ---
const inputUserId = ref('')
const isLoading = ref(false)
const userData = ref<any>(null)
const previewData = ref<any>(null)
const musicResults = ref<any[] | null>(null)
const anyData = ref<any>(null)
// AimeDB Scan State
const qrContent = ref('')
const isScanning = ref(false)
// Action-specific state
const fishImportToken = ref('')
const itemKind = ref('')
const itemIds = ref('')
const ticketType = ref('')
const apiName = ref('')
const scoreForm = reactive({
musicId: '',
levelId: '',
achievement: '',
dxScore: ''
})
// --- Generic Action Wrapper ---
async function performAction(action: () => Promise<void>, confirmOptions?: { title: string; message: string }) {
if (!inputUserId.value) {
ElMessage.error('请先输入用户ID');
logger.logWarn('Perform action failed: User ID is empty');
return;
}
if (confirmOptions) {
try {
await ElMessageBox.confirm(confirmOptions.message, confirmOptions.title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
ElMessage.info('操作已取消');
logger.logInfo('Action cancelled by user');
return;
}
}
isLoading.value = true;
try {
await action();
} catch (e: any) {
const message = e.response?.data?.detail || e.message;
logger.logError('Action failed', { error: message, stack: e.stack });
ElMessage.error(message);
console.error(e);
} finally {
isLoading.value = false;
}
}
// --- AimeDB Scan Function ---
async function scanAimeQR() {
if (!qrContent.value) {
ElMessage.error('请输入二维码内容')
logger.logWarn('Aime scan failed: QR content is empty')
return
}
logger.logInfo('Starting Aime QR scan', { qrContent: qrContent.value.substring(0, 20) + '...' })
isScanning.value = true
try {
const requestData = { qrContent: qrContent.value }
logger.logApiRequest('POST', '/api/aime_scan', requestData)
const { data } = await axios.post(`${apiBaseUrl.value}/aime_scan`, requestData)
logger.logApiResponse(200, data)
if (data.errorID) {
logger.logWarn('Aime scan failed', { errorID: data.errorID })
ElMessage.error(`扫描失败: 错误代码 ${data.errorID}`)
} else if (data.userID) {
inputUserId.value = data.userID
logger.logInfo('Aime scan successful', { userID: data.userID })
ElMessage.success('二维码扫描成功用户ID已填充')
} else {
logger.logWarn('Aime scan returned no userID')
ElMessage.error('扫描成功但未返回有效用户ID')
}
} catch (e: any) {
logger.logError('Aime scan failed with exception', e)
const message = e.response?.data?.detail || e.message
ElMessage.error(`扫描失败: ${message}`)
console.error(e)
} finally {
isScanning.value = false
}
}
// --- API Functions ---
const fetchFullProfile = () => {
if (!inputUserId.value) {
ElMessage.error('请输入用户ID');
logger.logWarn('Fetch full profile failed: User ID is empty');
return;
}
logger.logInfo('Fetching full profile', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/all?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
userData.value = data.upsertUserAll.userData[0];
previewData.value = null;
musicResults.value = null;
ElMessage.success('已加载完整用户信息');
logger.logInfo('Successfully loaded full profile', { userId: inputUserId.value });
});
};
const fetchPreview = () => {
if (!inputUserId.value) {
ElMessage.error('请输入用户ID');
logger.logWarn('Fetch preview failed: User ID is empty');
return;
}
logger.logInfo('Fetching user preview', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/preview?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
previewData.value = data;
userData.value = null;
musicResults.value = null;
ElMessage.success('已加载用户预览信息');
logger.logInfo('Successfully loaded user preview', { userId: inputUserId.value });
});
};
const fetchScores = () => {
logger.logInfo('Fetching user scores', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/music_results?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
musicResults.value = data.userMusicResults;
ElMessage.success(`已找到 ${musicResults.value?.length || 0} 条分数记录`);
logger.logInfo('Successfully loaded user scores', { userId: inputUserId.value, count: musicResults.value?.length || 0 });
})};
// --- Action Functions ---
const claimBonus = () => {
logger.logInfo('Claiming login bonus', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/action/claim_login_bonus`;
const requestData = { userId: inputUserId.value };
logger.logApiRequest('POST', url, requestData);
const { data } = await axios.post(url, requestData);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
ElMessage.success(data.message);
logger.logInfo('Successfully claimed login bonus', { userId: inputUserId.value });
})};
const doUnlockItem = () => {
logger.logInfo('Unlocking item', { userId: inputUserId.value, itemKind: itemKind.value, itemIds: itemIds.value });
performAction(async () => {
if (!itemKind.value || !itemIds.value) throw new Error('道具种类和ID不能为空');
const payload = { userId: inputUserId.value, itemKind: itemKind.value, itemIds: itemIds.value };
const url = `${apiBaseUrl.value}/action/unlock_item`;
logger.logApiRequest('POST', url, payload);
const { data } = await axios.post(url, payload);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
ElMessage.success(data.message);
logger.logInfo('Successfully unlocked item', { userId: inputUserId.value, itemKind: itemKind.value });
})};
const uploadToFish = () => performAction(async () => {
if (!fishImportToken.value) throw new Error('水鱼查分器导入Token不能为空')
const payload = { userId: inputUserId.value, fishImportToken: fishImportToken.value }
const { data } = await axios.post(`${apiBaseUrl.value}/user/upload_to_diving_fish`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const buyTicket = () => performAction(async () => {
if (!ticketType.value) throw new Error('票券类型不能为空')
const payload = { userId: inputUserId.value, ticketType: ticketType.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/buy_ticket`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const forceLogout = () => performAction(async () => {
const { data } = await axios.post(`${apiBaseUrl.value}/action/force_logout`, { userId: inputUserId.value })
if (data.error) throw new Error(data.error)
ElMessage.info(data.message)
}, { title: '确认强制登出', message: '这会尝试将用户从当前登录的机台上强制登出,确定吗?' })
const wipeTickets = () => performAction(async () => {
const { data } = await axios.post(`${apiBaseUrl.value}/action/wipe_tickets`, { userId: inputUserId.value })
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
}, { title: '确认清空票券', message: '此操作会清空该用户的所有票券,确定吗?' })
const uploadScore = () => performAction(async () => {
const { musicId, levelId, achievement, dxScore } = scoreForm
if (!musicId || !levelId || !achievement || !dxScore) throw new Error('上传分数所需的所有字段均不能为空')
const payload = { ...scoreForm, userId: inputUserId.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/upload_score`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const deleteScore = () => performAction(async () => {
const { musicId, levelId } = scoreForm
if (!musicId || !levelId) throw new Error('删除分数需要乐曲ID和难度ID')
const payload = { musicId, levelId, userId: inputUserId.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/delete_score`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
}, { title: '确认删除分数', message: `确定要删除乐曲 ${scoreForm.musicId} (难度 ${scoreForm.levelId}) 的分数记录吗?` })
const getAny = () => performAction(async () => {
if (!apiName.value) throw new Error('API名称不能为空')
const { data } = await axios.get(`${apiBaseUrl.value}/get_any?userId=${inputUserId.value}&apiName=${apiName.value}`)
if (data.error) throw new Error(data.error)
anyData.value = data
ElMessage.success(`已成功调用 ${apiName.value}`)
})
</script>
<template>
<el-space direction="vertical" alignment="start" :size="20" style="width: 100%;">
<el-card header="用户选择" style="width: 100%;">
<el-form :inline="true" @submit.prevent="fetchFullProfile">
<el-form-item label="用户ID">
<el-input v-model="inputUserId" placeholder="请输入用户ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchFullProfile" :loading="isLoading">获取完整信息</el-button>
<el-button @click="fetchPreview" :loading="isLoading">获取预览</el-button>
</el-form-item>
</el-form>
<el-divider />
<h3>Aime卡扫描</h3>
<el-form :inline="true" @submit.prevent="scanAimeQR">
<el-form-item label="二维码内容">
<el-input v-model="qrContent" placeholder="请输入Aime卡二维码内容" clearable style="width: 300px;" />
</el-form-item>
<el-form-item>
<el-button type="success" @click="scanAimeQR" :loading="isScanning">扫描并填充用户ID</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="card-container">
<el-card v-if="userData" header="完整用户信息" class="data-card">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ userData.userName }}</el-descriptions-item>
<el-descriptions-item label="Rating">{{ userData.playerRating }}</el-descriptions-item>
<el-descriptions-item label="最高Rating">{{ userData.highestRating }}</el-descriptions-item>
<el-descriptions-item label="游玩次数">{{ userData.playCount }}</el-descriptions-item>
<el-descriptions-item label="封禁状态">{{ userData.banState }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="previewData" header="用户预览" class="data-card">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ previewData.userName }}</el-descriptions-item>
<el-descriptions-item label="Rating">{{ previewData.playerRating }}</el-descriptions-item>
<el-descriptions-item label="是否在线">{{ previewData.isLogin }}</el-descriptions-item>
<el-descriptions-item label="封禁状态">{{ previewData.banState }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="inputUserId" header="玩家操作" class="data-card">
<el-collapse accordion>
<el-collapse-item title="通用操作" name="1">
<el-button type="success" @click="claimBonus" :loading="isLoading">领取登录奖励</el-button>
</el-collapse-item>
<el-collapse-item title="道具管理" name="2">
<p>解锁道具 (例如: 种类 10 为搭档)</p>
<el-form :inline="true" @submit.prevent="doUnlockItem">
<el-form-item><el-input v-model="itemKind" placeholder="道具种类" /></el-form-item>
<el-form-item><el-input v-model="itemIds" placeholder="道具ID (英文逗号分隔)" /></el-form-item>
<el-form-item><el-button type="warning" @click="doUnlockItem" :loading="isLoading">解锁</el-button></el-form-item>
</el-form>
</el-collapse-item>
<el-collapse-item title="票券管理" name="3">
<el-form :inline="true" @submit.prevent="buyTicket">
<el-form-item><el-input v-model="ticketType" placeholder="票券种类" /></el-form-item>
<el-form-item><el-button type="primary" @click="buyTicket" :loading="isLoading">购买</el-button></el-form-item>
</el-form>
</el-collapse-item>
<el-collapse-item title="分数管理" name="4">
<el-tabs type="border-card">
<el-tab-pane label="上传分数">
<el-form :inline="true" @submit.prevent="uploadScore">
<el-form-item><el-input v-model="scoreForm.musicId" placeholder="乐曲ID" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.levelId" placeholder="难度 (0-4)" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.achievement" placeholder="达成率 (例如: 1005000)" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.dxScore" placeholder="DX分数" /></el-form-item>
<el-form-item><el-button type="primary" @click="uploadScore" :loading="isLoading">上传</el-button></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="删除分数">
<el-form :inline="true" @submit.prevent="deleteScore">
<el-form-item><el-input v-model="scoreForm.musicId" placeholder="乐曲ID" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.levelId" placeholder="难度 (0-4)" /></el-form-item>
<el-form-item><el-button type="danger" @click="deleteScore" :loading="isLoading">删除</el-button></el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-collapse-item>
<el-collapse-item title="高级/危险操作" name="5">
<el-form :inline="true" @submit.prevent="uploadToFish">
<el-form-item label="上传至水鱼"><el-input v-model="fishImportToken" type="password" placeholder="水鱼查分器Token" show-password /></el-form-item>
<el-form-item><el-button type="info" @click="uploadToFish" :loading="isLoading">上传</el-button></el-form-item>
</el-form>
<el-divider />
<el-form :inline="true" @submit.prevent="getAny">
<el-form-item label="通用接口调用"><el-input v-model="apiName" placeholder="API名称 (例如: GetUserChargeApi)" /></el-form-item>
<el-form-item><el-button type="info" @click="getAny" :loading="isLoading">调用</el-button></el-form-item>
</el-form>
<el-card v-if="anyData" header="Result" style="margin-top: 10px;">
<pre>{{ JSON.stringify(anyData, null, 2) }}</pre>
</el-card>
<el-divider />
<el-button type="danger" @click="wipeTickets" :loading="isLoading">清空所有票券</el-button>
<el-button type="danger" @click="forceLogout" :loading="isLoading">强制登出</el-button>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<el-card v-if="inputUserId" header="分数详情" style="width: 100%;">
<el-button @click="fetchScores" :loading="isLoading">获取所有分数</el-button>
<el-table v-if="musicResults" :data="musicResults" stripe style="width: 100%; margin-top: 20px;" height="400">
<el-table-column prop="musicId" label="乐曲ID" sortable />
<el-table-column prop="level" label="难度" sortable />
<el-table-column label="达成率" sortable>
<template #default="scope">
{{ (scope.row.achievement / 10000).toFixed(4) }}%
</template>
</el-table-column>
<el-table-column prop="playCount" label="游玩次数" sortable />
</el-table>
</el-card>
</el-space>
</template>
<style scoped>
.card-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.data-card {
width: 100%;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.data-card {
width: 450px;
}
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
hmr: {
overlay: false,
},
},
})