Files
Astral/api/LogSetup.py
T
Txiaochen 25ac522391 v0 plus附加功能
- 新增 unlockmusic.py:解锁(解禁)歌曲 + 解禁 Master / Re:Master 难度,整个流程复用 upsert.py 的 run()
- 买票前置闸门 api/ChargeGuard.py:必须先跑过 GetUserCharge,且同进程只允许买一次
- buy_ticket.py 票源改为 GetUserCharge,不再调用 GetGameCharge
- UpsertUserAll 与买票的冷却统一为 15 秒
- api/ 文件名统一 PascalCase(文件名 = 函数名)
- 项目改名 Astral,README 加 Logo、鸣谢与开发者说明
- 自检 284 项
2026-10-17 17:39:55 +08:00

705 lines
23 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 -*-
"""
LogSetup.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 GetUserPreview(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)