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

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" },
]