feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)

当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。

- api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿
- 上传成绩链路已真机跑通,报文与真机抓包逐字节一致
- 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
Txiaochen
2026-10-06 14:42:06 +08:00
commit 1636a79141
51 changed files with 10965 additions and 0 deletions
+704
View File
@@ -0,0 +1,704 @@
# -*- coding: utf-8 -*-
"""
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
INFO (默认) 只展示 api 脚本的运行情况和结果:
=== ParseQRCode 开始运行 ===
[接口说明] ...
[调用参数] ...
[运行结果] ...
=== ParseQRCode 运行结束 ===
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
-> 请求 GetUserPreviewApi
URL : https://...
请求头 : {...}
请求体 : {...JSON 全文...}
<- 响应 GetUserPreviewApi
状态码 : 200
响应体 : {...JSON 全文...}
【模式从哪来】
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
日常只改 config.py 一行:
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
【日志文件(两种形式同时存在、同时写入)】
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
下一次运行主脚本时被覆盖
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
【主脚本怎么用】
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
统一套上了日志外壳(见 autowrap)。
【api 脚本怎么用】
from api import log_info, log_debug, log_request, log_response
def UserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
raw = _http_post(...)
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
return parsed
"""
from __future__ import annotations
import atexit
import functools
import json
import os
import platform
import time
from datetime import datetime
from typing import Any, Callable, Iterable, Optional
# =============================================================================
# 常量与全局状态
# =============================================================================
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
# 项目根目录(本文件在 api/ 下,根目录是上一级)
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
_SEPARATOR = "=" * 66
_INDENT = " "
# 全局状态
_session_started = False
_session_script_name = ""
_current_level = LEVEL_INFO
_value_max = 0
_date_log_path: Optional[str] = None
_latest_log_path: Optional[str] = None
_date_log_file = None
_latest_log_file = None
# 被自动跟踪的 api 函数名(供横幅展示)
_tracked_api_names: list = []
# 本次会话实际调用过的 api(按首次调用顺序)
_called_apis: list = []
# =============================================================================
# 模式判定
# =============================================================================
def _normalize_level(level: Any) -> str:
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
if level is None:
return LEVEL_INFO
text = str(level).strip().lower()
if text in ("debug", "dbg", "verbose", "详细", "调试"):
return LEVEL_DEBUG
return LEVEL_INFO
def _config_level() -> str:
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
try:
import config # type: ignore
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
except Exception:
return LEVEL_INFO
def _config_value_max() -> int:
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
try:
import config # type: ignore
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
except Exception:
return 0
def get_level() -> str:
"""返回当前日志模式('info' / 'debug')。"""
return _current_level
def is_debug() -> bool:
"""当前是否 DEBUG 模式。"""
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
def set_level(level: Any) -> str:
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
global _current_level
_current_level = _normalize_level(level)
return _current_level
# =============================================================================
# 日志文件
# =============================================================================
def _ensure_logs_dir() -> None:
os.makedirs(_LOGS_DIR, exist_ok=True)
def _get_date_log_path() -> str:
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
def _get_latest_log_path() -> str:
return os.path.join(_LOGS_DIR, "latest.log")
def _write_files(message: str) -> None:
"""把一段文本写进两个日志文件(每行带时间戳)。"""
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
lines = message.splitlines() or [""]
payload = "".join(timestamp + line + "\n" for line in lines)
for handle in (_date_log_file, _latest_log_file):
if handle:
try:
handle.write(payload)
handle.flush()
except Exception:
pass
def _emit(message: str = "", *, console: bool = True) -> None:
"""统一出口:控制台 + 两个日志文件。"""
if console:
try:
print(message)
except Exception:
try:
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
except Exception:
pass
_write_files(message)
def _close_log_files() -> None:
global _date_log_file, _latest_log_file
for name in ("_date_log_file", "_latest_log_file"):
handle = globals()[name]
if handle:
try:
handle.close()
except Exception:
pass
globals()[name] = None
# =============================================================================
# 自动跟踪:给 api 函数套日志外壳
# =============================================================================
#
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
# 调用方不必自己写装饰器,也不必自己埋点。
#
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
# 拿到的已经是包装后的函数。
def _describe(func: Callable) -> str:
"""取函数 docstring 首行,作为「[接口说明]」。"""
doc = (func.__doc__ or "").strip()
if not doc:
return ""
return doc.splitlines()[0].strip()
def _make_tracked(func: Callable) -> Callable:
"""生成带日志外壳的包装函数。"""
name = func.__name__
summary = _describe(func)
@functools.wraps(func)
def tracked(*args, **kwargs) -> Any:
_note_api_call(name)
_emit("")
_emit("=== " + name + " 开始运行 ===")
if summary:
_emit(_INDENT + "[接口说明] " + summary)
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
if args:
if is_debug():
_emit(_INDENT + "[位置参数]")
_emit(_fmt_pretty(list(args)))
else:
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
if kwargs:
shown = {k: v for k, v in kwargs.items() if v is not None}
if shown:
if is_debug():
_emit(_INDENT + "[关键字参数]")
_emit(_fmt_pretty(shown))
else:
_emit(_INDENT + "[关键字参数] "
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
t0 = time.monotonic()
try:
result = func(*args, **kwargs)
except BaseException as exc:
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
+ str(exc) + "(耗时 " + str(ms) + " ms)")
_emit("=== " + name + " 运行结束 ===")
_emit("")
raise
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
+ "(耗时 " + str(ms) + " ms)")
if is_debug() and result is not None:
_emit(_INDENT + "[返回数据]")
_emit(_fmt_pretty(result))
_emit("=== " + name + " 运行结束 ===")
_emit("")
return result
tracked.__mai_logged__ = True # type: ignore[attr-defined]
tracked.__mai_original__ = func # type: ignore[attr-defined]
return tracked
def autowrap(namespace: dict, names: Iterable[str]) -> list:
"""
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
参数:
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
names: 要包装的函数名序列
返回:
实际完成包装的函数名列表
"""
wrapped = []
for name in names:
obj = namespace.get(name)
if obj is None or not callable(obj):
continue
if getattr(obj, "__mai_logged__", False):
wrapped.append(name)
continue
try:
namespace[name] = _make_tracked(obj)
wrapped.append(name)
except Exception:
continue
for n in wrapped:
if n not in _tracked_api_names:
_tracked_api_names.append(n)
return wrapped
def _note_api_call(name: str) -> None:
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
if name not in _called_apis:
_called_apis.append(name)
def get_called_apis() -> list:
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
return list(_called_apis)
# =============================================================================
# 会话:初始化 / 收尾
# =============================================================================
def setup_logging(script_name: str, level: Any = None) -> str:
"""
初始化日志会话(主脚本开头调用一次)。
做三件事:
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
3. 打印会话横幅
参数:
script_name: 脚本名,显示在横幅上
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
返回:
实际生效的模式('info' / 'debug')
"""
global _session_started, _session_script_name, _value_max
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
set_level(level if level is not None else _config_level())
_value_max = _config_value_max()
if _session_started:
return _current_level
_ensure_logs_dir()
_session_script_name = script_name
_date_log_path = _get_date_log_path()
_latest_log_path = _get_latest_log_path()
_called_apis.clear()
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
_print_banner(script_name)
_session_started = True
return _current_level
def _print_banner(script_name: str) -> None:
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
lines = [
_SEPARATOR,
_INDENT + "日志会话开始 —— " + script_name,
_SEPARATOR,
_INDENT * 2 + "主脚本 : " + script_name,
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
_INDENT * 2 + "日志模式 : " + mode_text,
_INDENT * 2 + "Python : " + platform.python_version()
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
_INDENT * 2 + "工作目录 : " + os.getcwd(),
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
+ " 个(自动跟踪,主脚本无需写日志代码)",
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
_SEPARATOR,
]
_emit("\n".join(lines))
def teardown_logging() -> None:
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
global _session_started
if not _session_started:
return
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
if _called_apis:
for i, name in enumerate(_called_apis, 1):
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
else:
lines.append(_INDENT * 2 + "(未调用任何 api)")
lines.append(_SEPARATOR)
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
lines.append(_SEPARATOR)
_emit("\n".join(lines))
_close_log_files()
_session_started = False
# =============================================================================
# 值格式化
# =============================================================================
def _truncate(text: str) -> str:
if _value_max and len(text) > _value_max:
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
return text
def _fmt(value: Any) -> str:
"""把一个值渲染成紧凑单行文本。"""
if isinstance(value, str):
return _truncate(value)
if value is None or isinstance(value, (int, float, bool)):
return repr(value)
if isinstance(value, (dict, list, tuple)):
try:
return _truncate(json.dumps(value, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
if hasattr(value, "__dict__"):
try:
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
return _truncate(json.dumps(data, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
return _truncate(repr(value))
def _to_plain(value: Any) -> Any:
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
if value is None or isinstance(value, (int, float, bool, str)):
return value
if isinstance(value, dict):
return {k: _to_plain(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_plain(v) for v in value]
if hasattr(value, "__dataclass_fields__"):
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _fmt_pretty(value: Any, indent: str = " ") -> str:
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
data = _to_plain(value)
if isinstance(data, str):
stripped = data.strip()
if stripped[:1] in ("{", "["):
try:
data = json.loads(stripped)
except Exception:
return "\n".join(indent + line for line in data.splitlines())
try:
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
except Exception:
text = repr(data)
if _value_max and len(text) > _value_max:
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
return "\n".join(indent + line for line in text.splitlines())
def _fmt_result(result: Any) -> str:
"""结果的紧凑摘要(INFO 用)。"""
if isinstance(result, list):
return "list(" + str(len(result)) + " 项)"
if isinstance(result, tuple):
return "tuple(" + str(len(result)) + " 项)"
if isinstance(result, dict):
return "dict(" + str(len(result)) + " 键)"
if isinstance(result, str):
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
if hasattr(result, "__dataclass_fields__"):
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
return repr(result)
# =============================================================================
# 流程日志(INFO 可见)
# =============================================================================
def log_info(message: str) -> None:
"""普通信息 —— 相当于「会进日志文件的 print」。"""
_emit(str(message))
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
if index and total:
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
elif index:
_emit("[步骤 " + str(index) + "] " + title)
else:
_emit("[步骤] " + title)
def log_result(message: Any) -> None:
"""记一条运行结果。"""
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
def log_warning(message: str) -> None:
"""警告(不影响流程,但需要留意)。"""
_emit("[警告] " + str(message))
def log_error(message: str) -> None:
"""错误。"""
_emit("[错误] " + str(message))
def log_section(title: str) -> None:
"""记一个分节(带分隔线),用于主脚本分段。"""
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
"""
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
log_field("user_id", 12345)
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
"""
if debug_only and not is_debug():
return
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
_emit(name + ":")
_emit(_fmt_pretty(value))
return
_emit(name + ": " + _fmt(value))
def log_debug(message: Any) -> None:
"""调试信息 —— 仅 DEBUG 模式输出。"""
if not is_debug():
return
_emit("[调试] " + _fmt(message))
# =============================================================================
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
# =============================================================================
def log_request(
url: str = "",
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
) -> None:
"""
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
参数:
url: 完整请求地址
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
headers: 请求头(dict)
api_name: 接口名
"""
if not is_debug():
return
_emit("")
_emit("-> 请求 " + api_name if api_name else "-> 请求")
if url:
_emit(" URL : " + str(url))
if headers:
_emit(" 请求头 : " + _fmt(headers))
if body is not None:
_emit(" 请求体 :")
_emit(_fmt_pretty(body))
def log_response(
status: Any = None,
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
elapsed_ms: Optional[int] = None,
) -> None:
"""
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
参数:
status: HTTP 状态码
body: 响应体(JSON 字符串 / dict / VO)
headers: 响应头(dict)
api_name: 接口名
elapsed_ms: 耗时(毫秒)
"""
if not is_debug():
return
_emit("")
_emit("<- 响应 " + api_name if api_name else "<- 响应")
if status is not None:
_emit(" 状态码 : " + str(status))
if elapsed_ms is not None:
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
if headers:
_emit(" 响应头 : " + _fmt(headers))
if body is not None:
_emit(" 响应体 :")
_emit(_fmt_pretty(body))
def log_payload(label: str, data: Any) -> None:
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
if not is_debug():
return
_emit("")
_emit(" " + label + " :")
_emit(_fmt_pretty(data))
# =============================================================================
# 兼容:装饰器与 logger 对象
# =============================================================================
def api_log(func: Callable) -> Callable:
"""
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
"""
if getattr(func, "__mai_logged__", False):
return func
return _make_tracked(func)
class _Logger:
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
def info(self, message: str) -> None:
log_info(message)
def warning(self, message: str) -> None:
log_warning(message)
def error(self, message: str) -> None:
log_error(message)
def debug(self, message: Any) -> None:
log_debug(message)
def step(self, title: str, **kwargs) -> None:
log_step(title, **kwargs)
def result(self, message: Any) -> None:
log_result(message)
_logger_instance = _Logger()
def get_logger() -> _Logger:
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
return _logger_instance
# =============================================================================
# 退出时自动收尾
# =============================================================================
def _auto_teardown() -> None:
try:
if _session_started:
teardown_logging()
else:
_close_log_files()
except Exception:
pass
atexit.register(_auto_teardown)