first jiwoos commit

This commit is contained in:
minjiu
2026-05-02 11:12:13 +09:00
commit 296adf3073
30 changed files with 9403 additions and 0 deletions

709
webapp/app.py Normal file
View File

@@ -0,0 +1,709 @@
from __future__ import annotations
import json
import os
import shutil
import re
import subprocess
import sys
import threading
import traceback
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
APP_DIR = Path(__file__).resolve().parent
ROOT_DIR = APP_DIR.parent
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from register_game_playwright import normalize_team_name
JOBS_DIR = ROOT_DIR / "jobs"
LOGS_DIR = ROOT_DIR / "logs"
OUTPUT_DIR = ROOT_DIR / "output"
TEAM_OPTIONS = [
{"value": "HT", "label": "KIA"},
{"value": "KT", "label": "KT"},
{"value": "LG", "label": "LG"},
{"value": "NC", "label": "NC"},
{"value": "OB", "label": "두산"},
{"value": "LT", "label": "롯데"},
{"value": "SK", "label": "SSG"},
{"value": "SS", "label": "삼성"},
{"value": "WO", "label": "키움"},
{"value": "HH", "label": "한화"},
]
GAME_TYPE_OPTIONS = [
{"value": "regular", "label": "정규경기"},
{"value": "wildcard", "label": "와일드카드"},
{"value": "semi_playoff", "label": "준PO"},
{"value": "playoff", "label": "PO"},
{"value": "korean_series", "label": "한국시리즈"},
]
GAME_TYPE_CODE_MAP = {
"regular": None,
"wildcard": "4444",
"semi_playoff": "3333",
"playoff": "5555",
"korean_series": "7777",
}
JOBS_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
def now_iso() -> str:
return datetime.now().isoformat(timespec="seconds")
def safe_name(value: str) -> str:
return "".join(char for char in value if char.isalnum() or char in {"-", "_"})
def job_path(job_id: str) -> Path:
return JOBS_DIR / f"{job_id}.json"
def log_path(job_id: str) -> Path:
return LOGS_DIR / f"{job_id}.log"
def load_job(job_id: str) -> dict[str, Any]:
path = job_path(job_id)
if not path.exists():
raise FileNotFoundError(job_id)
return json.loads(path.read_text(encoding="utf-8"))
def save_job(data: dict[str, Any]) -> None:
job_id = data["job_id"]
job_path(job_id).write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def list_jobs(limit: int = 30) -> list[dict[str, Any]]:
jobs: list[dict[str, Any]] = []
for path in sorted(JOBS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
jobs.append(json.loads(path.read_text(encoding="utf-8")))
except Exception:
continue
if len(jobs) >= limit:
break
return jobs
def tail_log_preview(job_id: str) -> str:
path = log_path(job_id)
if not path.exists():
return "로그 없음"
try:
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
except Exception:
return "로그 읽기 실패"
if not lines:
return "로그 없음"
for line in reversed(lines):
if line.startswith("[") and ("START " in line or "END " in line):
continue
return line[:96]
return lines[-1][:96]
def enrich_job(job: dict[str, Any]) -> dict[str, Any]:
enriched = dict(job)
enriched["log_preview"] = tail_log_preview(job["job_id"])
return enriched
def get_active_job() -> dict[str, Any] | None:
for job in list_jobs(limit=200):
if job.get("status") in {"queued", "running"}:
return enrich_job(job)
return None
def has_active_job() -> bool:
return get_active_job() is not None
def list_recent_reports(limit: int = 12) -> list[dict[str, Any]]:
reports: list[dict[str, Any]] = []
for path in sorted(OUTPUT_DIR.glob("*_report.json"), key=lambda p: p.stat().st_mtime, reverse=True):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
game_info = payload.get("game_info") or {}
reports.append(
{
"game_id": payload.get("game_id") or path.name.replace("_report.json", ""),
"date": game_info.get("date") or "-",
"home_team": game_info.get("home_team") or "-",
"away_team": game_info.get("away_team") or "-",
"start_time": game_info.get("start_time") or "-",
"path": str(path),
}
)
if len(reports) >= limit:
break
return reports
def resolve_report_path(game_id: str, report_path: str | None) -> Path:
if report_path:
return Path(report_path)
return OUTPUT_DIR / f"{game_id}_report.json"
def requires_report(job_type: str) -> bool:
return job_type == "register_basic"
def validate_job_request(job_type: str, game_id: str, manager_mode: str, manager_game_no: str, report_path: str, inning_no: str) -> str | None:
if not game_id:
return "경기 식별 정보가 올바르지 않습니다. game_id 또는 날짜/팀 조합을 확인하세요."
if manager_mode == "manual" and job_type in {"lineup", "record", "finish", "post_update"} and not manager_game_no:
return "관리자 게임번호 직접 입력을 선택했다면 게임번호를 입력하세요."
def get_inn_val(s: str) -> float | None:
if not s: return None
m = re.match(r"^(\d+)([TB]?)$", s.upper().strip())
if not m: return None
num = int(m.group(1))
half = 0.5 if m.group(2) == "B" else 0.0
return num + half
if job_type == "record":
if not inning_no:
return "경기기록 입력은 이닝을 선택해야 합니다."
if inning_no != "all":
if "-" in inning_no:
start_str, end_str = inning_no.split("-", 1)
s_val = get_inn_val(start_str)
e_val = get_inn_val(end_str)
if s_val is None or e_val is None:
return f"이닝 범위 형식이 올바르지 않습니다 (예: 3B-9B). 입력값: {inning_no}"
if not (1.0 <= s_val <= e_val <= 15.5):
return "이닝 범위가 올바르지 않습니다 (1회 초 ~ 15회 말)."
else:
val = get_inn_val(inning_no)
if val is None:
return f"이닝 형식이 올바르지 않습니다 (예: 5T). 입력값: {inning_no}"
if not (1.0 <= val <= 15.5):
return "이닝은 1회부터 15회까지만 선택할 수 있습니다."
if requires_report(job_type):
resolved = resolve_report_path(game_id, report_path)
# 자동화 작업들은 첫 단계에서 리포트를 생성하므로 사전 파일 체크를 하지 않음
# "수동 모드"나 특수한 경우가 아니면 거의 모든 자동화 버튼을 허용
automated_jobs = {"lineup", "record", "register_basic", "post_update", "finish", "compare", "video_review"}
if job_type not in automated_jobs and not resolved.exists():
return f"리포트 파일이 없습니다: {resolved}"
return None
def normalize_team_key(value: str) -> str:
raw = (value or "").strip()
if not raw:
return ""
return raw.casefold()
def team_label_from_code(team_code: str) -> str:
for option in TEAM_OPTIONS:
if option["value"] == team_code:
return option["label"]
return team_code
def team_alias_keys(value: str) -> set[str]:
raw = (value or "").strip()
if not raw:
return set()
normalized = normalize_team_name(raw)
return {raw.casefold(), normalized.casefold()}
def compose_game_id_from_form(form) -> str:
def clean(v: str) -> str:
return "".join((v or "").split())
game_type = clean(form.get("game_type") or "regular")
game_date = clean(form.get("game_date"))
away_code = clean(form.get("away_team_code")).upper()
home_code = clean(form.get("home_team_code")).upper()
dh_raw = clean(form.get("doubleheader_no") or "0")
if not game_date or not away_code or not home_code:
return ""
pure_date = game_date.replace("-", "")
year = pure_date[:4]
month = pure_date[4:6]
day = pure_date[6:8]
type_code = GAME_TYPE_CODE_MAP.get(game_type)
if not type_code or game_type == "regular":
type_code = year
dh = "0"
if dh_raw.isdigit():
dh = str(max(0, min(9, int(dh_raw))))
# 네이버 표준 ID: [연도/타입][월][일][원정][홈][더블헤더][연도]
# 예: 2026 04 14 LT LG 0 2026
return f"{type_code}{month}{day}{away_code}{home_code}{dh}{year}"
def resolve_game_id_from_form(form) -> str:
mode = (form.get("game_id_mode") or "direct").strip()
raw_id = ""
if mode == "direct":
raw_id = (form.get("game_id") or "").strip()
else:
raw_id = compose_game_id_from_form(form)
# 영문/숫자만 남기고 한글 및 특수문자 제거
return "".join(re.findall(r"[A-Za-z0-9]", raw_id))
def build_generated_report_paths(game_id: str, job_type: str, inning_no: str | None = None) -> tuple[Path, Path]:
if job_type == "lineup":
stem = f"{game_id}_lineup_report"
elif job_type == "record" and inning_no and inning_no != "all":
# 범위를 파일명에 표시 (예: 5-9 -> inning_5-9)
safe_inning = safe_name(inning_no)
stem = f"{game_id}_inning_{safe_inning}_report"
elif job_type == "record":
stem = f"{game_id}_full_record_report"
elif job_type == "finish":
stem = f"{game_id}_finish_report"
elif job_type == "post_update":
stem = f"{game_id}_post_report"
elif job_type == "compare":
stem = f"{game_id}_compare_report"
elif job_type == "video_review":
stem = f"{game_id}_review_report"
else:
stem = f"{game_id}_report"
return OUTPUT_DIR / f"{stem}.json", OUTPUT_DIR / f"{stem}.txt"
def build_command(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> list[str]:
python_exe = sys.executable
cmd = [python_exe]
if job_type == "register_basic":
cmd.extend([str(ROOT_DIR / "register_game_basic_playwright.py"), "--game-id", game_id, "--close"])
elif job_type == "lineup":
cmd.extend([str(ROOT_DIR / "lineup_only_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "record":
cmd.extend([str(ROOT_DIR / "record_game_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "finish":
cmd.extend([str(ROOT_DIR / "finish_game_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "post_update":
cmd.extend([str(ROOT_DIR / "update_game_post_playwright.py"), "--game-id", game_id, "--close"])
if manager_game_no:
cmd.extend(["--manager-game-no", manager_game_no])
elif job_type == "compare":
cmd.extend([str(ROOT_DIR / "compare_history_with_report.py"), "--game-id", game_id])
elif job_type == "video_review":
cmd.extend([str(ROOT_DIR / "video_review_playwright.py"), "--game-id", game_id, "--review-only", "--close"])
else:
raise ValueError(f"지원하지 않는 작업 타입입니다: {job_type}")
if report_path:
cmd.extend(["--report-path", report_path])
return cmd
def build_job_steps(job: dict[str, Any]) -> list[list[str]]:
job_type = job["type"]
game_id = job["game_id"]
manager_game_no = job.get("manager_game_no") or None
report_path = job.get("report_path") or None
inning_no = job.get("inning_no") or None
python_exe = sys.executable
if job_type == "video_review":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "register_basic":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "lineup":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--lineup-only",
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "record":
report_command = [
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
]
if inning_no and inning_no != "all":
if "-" in inning_no:
try:
# 1T-9B 같은 형식 처리
start_val, end_val = inning_no.split("-", 1)
report_command.extend(["--start-inning", start_val, "--end-inning", end_val])
except ValueError:
pass
else:
report_command.extend(["--inning", str(inning_no)])
return [
report_command,
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type in {"finish", "post_update"}:
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
if job_type == "compare":
return [
[
python_exe,
str(ROOT_DIR / "game_report.py"),
"--game-id",
game_id,
"--output-json",
report_path,
"--output-txt",
str(Path(report_path).with_suffix(".txt")),
],
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
]
return [build_command(job_type, game_id, manager_game_no, report_path, inning_no)]
def run_job(job_id: str) -> None:
job = load_job(job_id)
job["status"] = "running"
job["started_at"] = now_iso()
save_job(job)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "utf-8"
env["JOB_ID"] = job_id
log_file = log_path(job_id)
with log_file.open("a", encoding="utf-8") as log:
steps = build_job_steps(job)
log.write(f"[{now_iso()}] START steps={len(steps)}\n")
log.flush()
try:
return_code = 0
for step_index, command in enumerate(steps, start=1):
log.write(f"[{now_iso()}] STEP {step_index}/{len(steps)} {' '.join(command)}\n")
log.flush()
process = subprocess.Popen(
command,
cwd=str(ROOT_DIR),
stdout=log,
stderr=log,
text=True,
env=env,
)
return_code = process.wait()
if return_code != 0:
break
job = load_job(job_id)
job["finished_at"] = now_iso()
job["return_code"] = return_code
if return_code == 0:
job["status"] = "success"
job["error"] = None
else:
job["status"] = "failed"
job["error"] = f"프로세스 종료 코드: {return_code}"
save_job(job)
log.write(f"[{now_iso()}] END return_code={return_code}\n")
except Exception as exc:
job = load_job(job_id)
job["status"] = "failed"
job["finished_at"] = now_iso()
job["error"] = str(exc)
save_job(job)
log.write(f"[{now_iso()}] EXCEPTION {exc}\n")
log.write(traceback.format_exc())
def create_job(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> dict[str, Any]:
normalized_game_id = safe_name(game_id) or "unknown"
job_id = f"{normalized_game_id}-{job_type}-{uuid.uuid4().hex[:8]}"
if report_path:
report_path_value = report_path
elif job_type in {"lineup", "record", "compare", "video_review"}:
report_path_value = str(build_generated_report_paths(game_id, job_type, inning_no)[0])
else:
report_path_value = str(OUTPUT_DIR / f"{game_id}_report.json")
try:
from db_logging import start_job as db_start_job
db_start_job(job_id=job_id, game_id=game_id, start_inning=inning_no or "")
except Exception as e:
print(f"DB logging init error: {e}")
job = {
"job_id": job_id,
"type": job_type,
"game_id": game_id,
"manager_game_no": manager_game_no or "",
"report_path": report_path_value,
"inning_no": inning_no or "",
"status": "queued",
"created_at": now_iso(),
"started_at": None,
"finished_at": None,
"log_file": str(log_path(job_id)),
"error": None,
"return_code": None,
}
save_job(job)
thread = threading.Thread(target=run_job, args=(job_id,), daemon=True)
thread.start()
return job
app = Flask(__name__, template_folder="templates", static_folder="static")
@app.get("/")
def index():
jobs = [enrich_job(job) for job in list_jobs()]
active_job = get_active_job()
recent_reports = list_recent_reports()
error = request.args.get("error", "").strip()
message = request.args.get("message", "").strip()
def split_inning(val: str):
if not val: return "1", "T"
m = re.match(r"^(\d+)([TB])$", val.upper())
if m: return m.group(1), m.group(2)
return val, "T"
start_num, start_half = split_inning(request.args.get("start_inning") or "1T")
end_num, end_half = split_inning(request.args.get("end_inning") or "9B")
defaults = {
"game_id_mode": (request.args.get("game_id_mode") or "parse").strip(),
"game_id": (request.args.get("game_id") or "").strip(),
"game_type": (request.args.get("game_type") or "regular").strip(),
"game_date": (request.args.get("game_date") or "").strip(),
"home_team_code": (request.args.get("home_team_code") or "").strip(),
"away_team_code": (request.args.get("away_team_code") or "").strip(),
"doubleheader_no": (request.args.get("doubleheader_no") or "0").strip(),
"manager_mode": (request.args.get("manager_mode") or "auto").strip(),
"manager_game_no": (request.args.get("manager_game_no") or "").strip(),
"report_path": (request.args.get("report_path") or "").strip(),
"inning_no": (request.args.get("inning_no") or "1T-9B").strip(),
"start_inning_num": start_num,
"start_inning_half": start_half,
"end_inning_num": end_num,
"end_inning_half": end_half,
}
return render_template(
"index.html",
jobs=jobs,
defaults=defaults,
error=error,
message=message,
active_job=active_job,
recent_reports=recent_reports,
team_options=TEAM_OPTIONS,
game_type_options=GAME_TYPE_OPTIONS,
)
@app.post("/jobs/<job_type>")
def start_job(job_type: str):
if job_type not in {"register_basic", "lineup", "record", "finish", "post_update", "compare", "video_review"}:
abort(404)
game_id = resolve_game_id_from_form(request.form)
manager_mode = (request.form.get("manager_mode") or "auto").strip()
manager_game_no = (request.form.get("manager_game_no") or "").strip() if manager_mode == "manual" else ""
report_path = (request.form.get("report_path") or "").strip()
inning_no = (request.form.get("inning_no") or "").strip()
redirect_params = {
"game_id_mode": (request.form.get("game_id_mode") or "direct").strip(),
"game_id": (request.form.get("game_id") or "").strip(),
"game_type": (request.form.get("game_type") or "regular").strip(),
"game_date": (request.form.get("game_date") or "").strip(),
"home_team_code": (request.form.get("home_team_code") or "").strip(),
"away_team_code": (request.form.get("away_team_code") or "").strip(),
"doubleheader_no": (request.form.get("doubleheader_no") or "0").strip(),
"manager_mode": manager_mode,
"manager_game_no": manager_game_no,
"report_path": report_path,
"inning_no": inning_no,
"start_inning": (request.form.get("start_inning") or "1").strip(),
"end_inning": (request.form.get("end_inning") or "9").strip(),
}
validation_error = validate_job_request(job_type, game_id, manager_mode, manager_game_no, report_path, inning_no)
if validation_error:
return redirect(url_for("index", error=validation_error, **redirect_params))
if has_active_job():
return redirect(url_for("index", error="실행 중인 작업이 있습니다. 완료 후 다시 시도하세요.", **redirect_params))
create_job(
job_type=job_type,
game_id=game_id,
manager_game_no=None if job_type == "register_basic" else (manager_game_no or None),
report_path=report_path or None,
inning_no=inning_no or None,
)
return redirect(url_for("index", **redirect_params))
def clear_files(paths: list[Path]) -> int:
deleted = 0
for path in paths:
if path.exists() and path.is_file():
path.unlink()
deleted += 1
return deleted
@app.post("/maintenance/clear-logs")
def clear_logs():
deleted = clear_files(list(LOGS_DIR.glob("*.log")))
return redirect(url_for("index", message=f"로그 {deleted}개를 삭제했습니다."))
@app.post("/maintenance/clear-jobs")
def clear_jobs():
deleted = clear_files(list(JOBS_DIR.glob("*.json")))
return redirect(url_for("index", message=f"작업 상태 {deleted}개를 삭제했습니다."))
@app.post("/maintenance/clear-reports")
def clear_reports():
targets = list(OUTPUT_DIR.glob("*_report.json")) + list(OUTPUT_DIR.glob("*_report.txt"))
deleted = clear_files(targets)
return redirect(url_for("index", message=f"리포트 {deleted}개를 삭제했습니다."))
@app.get("/db-logs/<job_id>")
def view_db_logs(job_id: str):
try:
from db_logging import get_combined_logs
logs = get_combined_logs(job_id)
except Exception as e:
return f"DB 로드 실패: {e}", 500
return render_template("logs.html", job_id=job_id, logs=logs)
@app.post("/maintenance/clear-runtime-profiles")
def clear_runtime_profiles():
runtime_dirs = [path for path in ROOT_DIR.glob("playwright-user-data-runtime-*") if path.is_dir()]
deleted = 0
for path in runtime_dirs:
try:
shutil.rmtree(path, ignore_errors=False)
deleted += 1
except Exception:
continue
return redirect(url_for("index", message=f"런타임 프로필 {deleted}개를 삭제했습니다."))
@app.get("/api/dashboard")
def dashboard_api():
jobs = [enrich_job(job) for job in list_jobs()]
active_job = get_active_job()
recent_reports = list_recent_reports()
return jsonify({
"jobs": jobs,
"active_job": active_job,
"recent_reports": recent_reports
})
@app.get("/jobs")
def jobs_api():
return jsonify(list_jobs())
@app.get("/jobs/<job_id>")
def job_detail(job_id: str):
try:
job = load_job(job_id)
except FileNotFoundError:
abort(404)
return render_template("job.html", job=job)
@app.get("/jobs/<job_id>/status")
def job_status(job_id: str):
try:
return jsonify(load_job(job_id))
except FileNotFoundError:
abort(404)
@app.get("/jobs/<job_id>/log")
def job_log(job_id: str):
path = log_path(job_id)
if not path.exists():
abort(404)
return path.read_text(encoding="utf-8"), 200, {"Content-Type": "text/plain; charset=utf-8"}
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)