feat: 增加 systemd 定时执行与断点续跑
- backend/server.py: 信令 WebSocket 被远端关闭或视频流停滞超过阈值时自动拆除会话, 由 finally 释放网易云端实例,避免会话空挂、后续 /start 一直拿到 503 - bin/maa-online-daily-run: 新增日常任务包装脚本,等待 API 就绪、启动云游戏、 停滞看门狗(连续 ScreencapFailed 或 Disconnected)、失败即释放云会话并最多重试 3 次 - bin/maa-resume-tasks.py: 新增断点续跑规划器,重试时只运行未出现 Completed 的任务, 全部完成则直接判定成功,不再开启新会话;StartUp 永不跳过 - systemd/: 新增 server/daily/update 服务与定时器模板,每天 04:00 起随机 0-2 小时执行 - bin/maa-online-install-systemd: 渲染 __ROOT__ 并安装、启用上述单元 - README.md, user_config/README.md: 补充自动定时执行、断点续跑与已知限制说明 - .gitignore: 忽略生成的 config/tasks/daily-resume.toml
This commit is contained in:
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a resume task file from partially finished MAA daily attempts.
|
||||
|
||||
A failed daily attempt still completes some tasks (their progress lives on the
|
||||
game server), so re-running the whole daily file on a retry wastes cloud-game
|
||||
minutes. This helper reads the previous attempts' MAA logs, finds which task
|
||||
types already reported "<Type> Completed", and writes a task file containing
|
||||
only the remaining tasks. The daily runner (bin/maa-online-daily-run) calls it
|
||||
automatically before every retry.
|
||||
|
||||
Usage:
|
||||
maa-resume-tasks.py <log> [<log> ...] [--source <daily.toml>] [--out <file>]
|
||||
|
||||
Output (stdout, last line):
|
||||
RESUME_TASKS=<name1>,<name2> remaining tasks written to --out
|
||||
NONE nothing left to do
|
||||
ERROR:<reason> could not build a resume file (caller should
|
||||
fall back to the full daily run)
|
||||
|
||||
StartUp is intentionally never skipped: every /start yields a fresh cloud
|
||||
instance, and the global `startup = true` flag guarantees the game is launched
|
||||
and sitting on the main screen before the remaining tasks run.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(os.environ.get("MAA_ONLINE_ROOT") or Path(__file__).resolve().parent.parent)
|
||||
DEFAULT_SOURCE = Path(os.environ.get("MAA_ONLINE_DAILY_SOURCE", ROOT / "config/tasks/daily.toml"))
|
||||
DEFAULT_OUT = Path(os.environ.get("MAA_ONLINE_RESUME_OUT", ROOT / "config/tasks/daily-resume.toml"))
|
||||
|
||||
TASK_HEADER = re.compile(r"^\[\[tasks\]\]\s*$")
|
||||
TYPE_RE = re.compile(r'^type\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
|
||||
NAME_RE = re.compile(r'^name\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
|
||||
COMPLETED_RE = re.compile(r"^.*\b(\w+) Completed\s*$", re.MULTILINE)
|
||||
|
||||
# Task types that are never skipped even when a completion marker is present:
|
||||
# a retry always reconnects to a fresh cloud instance and must relaunch/verify
|
||||
# the game before the remaining tasks can run.
|
||||
NEVER_SKIP = {"StartUp"}
|
||||
|
||||
|
||||
def split_source(text: str) -> tuple[str, list[str]]:
|
||||
"""Return (prelude, task blocks).
|
||||
|
||||
A block owns the contiguous comment lines directly above its [[tasks]]
|
||||
header, so a "# 1. xxx" section comment travels with its task instead of
|
||||
being left behind in the prelude. Blocks keep their exact formatting.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
header_indexes = [i for i, line in enumerate(lines) if TASK_HEADER.match(line)]
|
||||
if not header_indexes:
|
||||
raise ValueError("no [[tasks]] blocks found in source file")
|
||||
|
||||
starts = []
|
||||
for index in header_indexes:
|
||||
start = index
|
||||
while start - 1 >= 0 and lines[start - 1].lstrip().startswith("#"):
|
||||
start -= 1
|
||||
starts.append(start)
|
||||
|
||||
prelude = "".join(lines[: starts[0]])
|
||||
blocks = []
|
||||
for position, start in enumerate(starts):
|
||||
end = starts[position + 1] if position + 1 < len(starts) else len(lines)
|
||||
blocks.append("".join(lines[start:end]).strip("\n") + "\n")
|
||||
return prelude, blocks
|
||||
|
||||
|
||||
def completed_types(log_text: str) -> set[str]:
|
||||
return set(COMPLETED_RE.findall(log_text))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("logs", nargs="+", help="previous attempts' MAA log files")
|
||||
parser.add_argument("--source", default=str(DEFAULT_SOURCE))
|
||||
parser.add_argument("--out", default=str(DEFAULT_OUT))
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source)
|
||||
if not source.is_file():
|
||||
print(f"ERROR:source task file not found: {source}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
prelude, blocks = split_source(source.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"ERROR:{exc}")
|
||||
return 0
|
||||
|
||||
done: set[str] = set()
|
||||
for log in args.logs:
|
||||
log_path = Path(log)
|
||||
if not log_path.is_file():
|
||||
continue
|
||||
try:
|
||||
done |= completed_types(log_path.read_text(encoding="utf-8", errors="replace"))
|
||||
except OSError as exc:
|
||||
print(f"ERROR:cannot read previous log {log}: {exc}")
|
||||
return 0
|
||||
|
||||
kept: list[str] = []
|
||||
kept_names: list[str] = []
|
||||
for block in blocks:
|
||||
match = TYPE_RE.search(block)
|
||||
if not match:
|
||||
continue
|
||||
task_type = match.group(1)
|
||||
if task_type in done and task_type not in NEVER_SKIP:
|
||||
continue
|
||||
kept.append(block)
|
||||
name_match = NAME_RE.search(block)
|
||||
kept_names.append(name_match.group(1) if name_match else task_type)
|
||||
|
||||
if not kept:
|
||||
print("NONE")
|
||||
return 0
|
||||
|
||||
header = (
|
||||
"# AUTO-GENERATED by bin/maa-resume-tasks.py -- do not edit.\n"
|
||||
f"# Source: {source}\n"
|
||||
f"# Already completed (skipped): {', '.join(sorted(done)) or 'none'}\n\n"
|
||||
)
|
||||
out = Path(args.out)
|
||||
try:
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(
|
||||
header + prelude.rstrip("\n") + "\n\n" + "\n".join(kept), encoding="utf-8"
|
||||
)
|
||||
except OSError as exc:
|
||||
print(f"ERROR:cannot write resume file: {exc}")
|
||||
return 0
|
||||
|
||||
print(f"RESUME_TASKS={','.join(kept_names)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user