Files
Astral/api/chime.py
T
Txiaochen 1636a79141 feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)
当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。

- api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿
- 上传成绩链路已真机跑通,报文与真机抓包逐字节一致
- 开发文档见 docs 分支;source/ 与 logs/ 不入库
2026-10-06 14:42:06 +08:00

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
--------------------------------------------------------------------------------
协议依据
--------------------------------------------------------------------------------
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
strQRData, strTitleKey, serverURLIndex)
strGameID = "MAID"
strChipID = KeychipID
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
strTitleKey= "SDGB"
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
EraseHashIdentifier() : [20:] 64 位载荷
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
KeychipID = AMDaemon.System.KeychipId.Value
TitleKey = AMDaemon.System.GameId
GameID = "MAID"
docs/舞萌DX_扫码登录协议技术规范.md
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import ParseQRCode
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
# 也可以直接传二维码 URL
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
"""
from __future__ import annotations
import hashlib
import http.client
import json
import re
import ssl
from datetime import datetime
from typing import Tuple
from urllib.parse import urlparse
from config import (
AIMEDB_HOST,
AIMEDB_PATH,
AIMEDB_PORT,
KEYCHIP_ID,
QR_OPEN_GAME_ID,
QR_SIGN_KEY,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from .log_setup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
# =============================================================================
QR_PREFIX = "SGWC" # Chime.WechatID
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
#: 默认机台 chipID
#:
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
#: 而游戏服务器那边用的是短值:
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
#:
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
#: docs/舞萌DX_扫码登录协议技术规范.md §3.4 的抓包验证向量也是
#: chipID = "XXXX-XXXXXXXXXXX"。
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
DEFAULT_CHIP_ID = KEYCHIP_ID
# =============================================================================
# 内部工具
# =============================================================================
def _extract_qr_from_url(url: str) -> str:
"""
从二维码 URL 提取完整 84 字符串。
URL 形如:
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
return QR_PREFIX + match.group(1)
def _normalize_qr(qr_input: str) -> str:
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
qr = (qr_input or "").strip()
if qr.startswith("http://") or qr.startswith("https://"):
log_debug("输入是 URL,先提取二维码字符串")
qr = _extract_qr_from_url(qr)
if len(qr) != QR_TOTAL_LEN:
raise ValueError(
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
)
if not qr.startswith(QR_PREFIX):
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
if qr[4:8] != QR_GAME_ID:
raise ValueError(
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
)
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
log_debug(
"二维码校验通过:timestamp=" + qr[8:20]
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
)
return qr
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
"""
构造请求体。
签名(技术规范 §4.1):
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
★ 拼接里不含 qrCode、不含 titlekey
qrCode 字段(CCommQRCode.EraseHashIdentifier):
qr_string[20:] 即 64 位载荷
返回:
(body_bytes, timestamp, key)
"""
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
body_dict = {
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
return body_bytes, timestamp, key
def _send(body_bytes: bytes) -> dict:
"""
发送 HTTPS POST 并解析 JSON 响应。
★ 请求头只放 Connection / User-Agent / Content-Length。
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
"""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
"Connection": "Keep-Alive",
"User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes)),
# ★ 此处刻意不加 Content-Type
}
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
try:
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
resp = conn.getresponse()
text = resp.read().decode("utf-8")
status = resp.status
finally:
conn.close()
log_response(status=status, body=text, api_name="aimedb/get_data")
try:
return json.loads(text)
except Exception as exc:
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
def _parse(resp: dict) -> Tuple[int, str]:
"""解析响应,返回 (user_id, token)。"""
error_id = resp.get("errorID", -1)
user_id = resp.get("userID", -1)
token = resp.get("token", "") or ""
if error_id != 0:
raise RuntimeError(
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
)
if user_id == -1 or not token:
raise RuntimeError(
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
)
return int(user_id), str(token)
# =============================================================================
# 公开 API
# =============================================================================
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
"""
解析二维码并换取 userId 与 token。
参数:
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 UserPreview / UserLogin 等
异常:
ValueError: 二维码格式不合法
RuntimeError: 服务器返回错误或网络异常
"""
log_info("解析二维码登录:chip_id=" + chip_id)
qr_string = _normalize_qr(qr_input)
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
log_request(
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
body={
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
},
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes))},
api_name="aimedb/get_data",
)
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
resp = _send(body_bytes)
user_id, token = _parse(resp)
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
+ str(len(token)) + ")")
return user_id, token
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
return 1
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
print("")
print("user_id =", user_id)
print("token =", token)
return 0
except Exception as exc:
log_error("扫码登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())