first jiwoos commit
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
|
||||
logs/
|
||||
jobs/
|
||||
output/
|
||||
playwright-user-data/
|
||||
playwright-user-data-runtime-*/
|
||||
|
||||
baseball_logs.db
|
||||
*.log
|
||||
|
||||
.gemini/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.vscode/
|
||||
.idea/
|
||||
test_file/
|
||||
58
browser_launch.py
Normal file
58
browser_launch.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Error, Playwright
|
||||
|
||||
|
||||
IGNORE_PATTERNS = shutil.ignore_patterns(
|
||||
"Singleton*",
|
||||
"LOCK",
|
||||
"lockfile",
|
||||
"Crashpad*",
|
||||
"BrowserMetrics*",
|
||||
)
|
||||
|
||||
|
||||
def launch_browser_context(
|
||||
playwright: Playwright,
|
||||
user_data_dir: str,
|
||||
channel: str,
|
||||
headless: bool,
|
||||
):
|
||||
launch_kwargs = {
|
||||
"channel": channel,
|
||||
"headless": headless,
|
||||
"args": ["--start-maximized"],
|
||||
"no_viewport": True,
|
||||
}
|
||||
source_dir = Path(user_data_dir)
|
||||
try:
|
||||
return playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=str(source_dir),
|
||||
**launch_kwargs,
|
||||
)
|
||||
except Error as exc:
|
||||
message = str(exc)
|
||||
if "Target page, context or browser has been closed" not in message:
|
||||
raise
|
||||
|
||||
fallback_dir = source_dir.parent / f"{source_dir.name}-runtime-{int(time.time())}"
|
||||
if fallback_dir.exists():
|
||||
shutil.rmtree(fallback_dir, ignore_errors=True)
|
||||
fallback_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source_dir.exists():
|
||||
shutil.copytree(
|
||||
source_dir,
|
||||
fallback_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=IGNORE_PATTERNS,
|
||||
)
|
||||
|
||||
return playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=str(fallback_dir),
|
||||
**launch_kwargs,
|
||||
)
|
||||
196
compare_history_with_report.py
Normal file
196
compare_history_with_report.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from record_game_playwright import PITCH_RESULT_LABEL_MAP, infer_batter_result_label, infer_runner_action_label
|
||||
from register_game_playwright import DEFAULT_GAME_ID, DEFAULT_REPORT_DIR, load_report
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="history.txt와 report.json의 기록 시퀀스를 비교합니다.")
|
||||
parser.add_argument("--game-id", default=DEFAULT_GAME_ID)
|
||||
parser.add_argument("--report-path")
|
||||
parser.add_argument("--history-path", default="history.txt")
|
||||
parser.add_argument("--output-json")
|
||||
parser.add_argument("--output-txt")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def report_path_from_args(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def output_paths(args: argparse.Namespace) -> tuple[Path, Path]:
|
||||
if args.output_json:
|
||||
json_path = Path(args.output_json)
|
||||
else:
|
||||
json_path = Path("output") / f"{args.game_id}_history_compare.json"
|
||||
if args.output_txt:
|
||||
txt_path = Path(args.output_txt)
|
||||
else:
|
||||
txt_path = Path("output") / f"{args.game_id}_history_compare.txt"
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
txt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return json_path, txt_path
|
||||
|
||||
|
||||
def normalize_name(text: str) -> str:
|
||||
text = (text or "").replace("*", "").strip()
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_entry(text: str) -> str:
|
||||
text = normalize_name(text)
|
||||
text = text.replace(" - ", "-")
|
||||
text = re.sub(r"\s+", "", text)
|
||||
return text
|
||||
|
||||
|
||||
def batter_name(batter_text: str) -> str:
|
||||
match = re.search(r"\d+번타자\s+(.+)$", batter_text or "")
|
||||
return normalize_name(match.group(1) if match else (batter_text or ""))
|
||||
|
||||
|
||||
def runner_name(runner_text: str) -> str:
|
||||
match = re.search(r"[123]루주자\s+(.+?)\s*:", runner_text or "")
|
||||
if match:
|
||||
return normalize_name(match.group(1))
|
||||
match = re.search(r"주자\s+(.+?)\s*:", runner_text or "")
|
||||
return normalize_name(match.group(1) if match else "")
|
||||
|
||||
|
||||
def history_entries_from_text(raw: str) -> list[str]:
|
||||
collapsed = re.sub(r"\r?\n+", "", raw.strip())
|
||||
if not collapsed:
|
||||
return []
|
||||
collapsed = re.sub(r"(?=(?:타자|[123]루주자)\s*:)", "\n", collapsed)
|
||||
return [line.strip() for line in collapsed.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def pitch_label(pitch: dict[str, Any]) -> str | None:
|
||||
result_text = (pitch.get("pitchResultText") or "").strip()
|
||||
if result_text == "타격":
|
||||
return None
|
||||
return PITCH_RESULT_LABEL_MAP.get(result_text, result_text or None)
|
||||
|
||||
|
||||
def expected_entries(report: dict[str, Any]) -> list[str]:
|
||||
entries: list[str] = []
|
||||
for half in report.get("game_contents") or []:
|
||||
for event in half.get("events") or []:
|
||||
if event.get("event_type") != "at_bat":
|
||||
continue
|
||||
batter = batter_name(event.get("batter") or "")
|
||||
pitches = event.get("pitches") or []
|
||||
for pitch in pitches:
|
||||
label = pitch_label(pitch)
|
||||
if label:
|
||||
entries.append(f"타자 : {batter} - {label}")
|
||||
for runner_event in pitch.get("runnerEvents") or []:
|
||||
from_base = runner_event.get("fromBase")
|
||||
label = infer_runner_action_label(event, runner_event)
|
||||
name = runner_name(runner_event.get("text") or "")
|
||||
if from_base and name and label:
|
||||
entries.append(f"{from_base}루주자 : {name} - {label}")
|
||||
result = event.get("result") or {}
|
||||
result_label = infer_batter_result_label(result, event)
|
||||
if batter and result_label:
|
||||
entries.append(f"타자 : {batter} - {result_label}")
|
||||
for runner_event in event.get("runnerEvents") or []:
|
||||
from_base = runner_event.get("fromBase")
|
||||
label = infer_runner_action_label(event, runner_event)
|
||||
name = runner_name(runner_event.get("text") or "")
|
||||
if from_base and name and label:
|
||||
entries.append(f"{from_base}루주자 : {name} - {label}")
|
||||
return entries
|
||||
|
||||
|
||||
def compare_sequences(expected: list[str], actual: list[str]) -> dict[str, Any]:
|
||||
expected_norm = [normalize_entry(item) for item in expected]
|
||||
actual_norm = [normalize_entry(item) for item in actual]
|
||||
mismatch_index = None
|
||||
mismatch = None
|
||||
for index, (left, right) in enumerate(zip(expected_norm, actual_norm)):
|
||||
if left != right:
|
||||
mismatch_index = index
|
||||
mismatch = {
|
||||
"index": index,
|
||||
"expected": expected[index],
|
||||
"actual": actual[index],
|
||||
}
|
||||
break
|
||||
missing = []
|
||||
extra = []
|
||||
if len(expected) > len(actual):
|
||||
missing = expected[len(actual):]
|
||||
elif len(actual) > len(expected):
|
||||
extra = actual[len(expected):]
|
||||
return {
|
||||
"expected_count": len(expected),
|
||||
"actual_count": len(actual),
|
||||
"matches_exactly": expected_norm == actual_norm,
|
||||
"first_mismatch": mismatch,
|
||||
"missing_tail": missing[:50],
|
||||
"extra_tail": extra[:50],
|
||||
}
|
||||
|
||||
|
||||
def build_text_summary(result: dict[str, Any], expected: list[str], actual: list[str]) -> str:
|
||||
lines = [
|
||||
f"expected_count: {result['expected_count']}",
|
||||
f"actual_count: {result['actual_count']}",
|
||||
f"matches_exactly: {result['matches_exactly']}",
|
||||
]
|
||||
mismatch = result.get("first_mismatch")
|
||||
if mismatch:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
f"first_mismatch_index: {mismatch['index']}",
|
||||
f"expected: {mismatch['expected']}",
|
||||
f"actual: {mismatch['actual']}",
|
||||
]
|
||||
)
|
||||
if result.get("missing_tail"):
|
||||
lines.append("")
|
||||
lines.append("missing_tail:")
|
||||
lines.extend(f"- {item}" for item in result["missing_tail"])
|
||||
if result.get("extra_tail"):
|
||||
lines.append("")
|
||||
lines.append("extra_tail:")
|
||||
lines.extend(f"- {item}" for item in result["extra_tail"])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
report = load_report(report_path_from_args(args))
|
||||
history_path = Path(args.history_path)
|
||||
raw_history = history_path.read_text(encoding="utf-8")
|
||||
actual = history_entries_from_text(raw_history)
|
||||
expected = expected_entries(report)
|
||||
result = compare_sequences(expected, actual)
|
||||
payload = {
|
||||
"game_id": report.get("game_id") or args.game_id,
|
||||
"history_path": str(history_path),
|
||||
"report_path": str(report_path_from_args(args)),
|
||||
"comparison": result,
|
||||
"expected_preview": expected[:200],
|
||||
"actual_preview": actual[:200],
|
||||
}
|
||||
json_path, txt_path = output_paths(args)
|
||||
json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
txt_path.write_text(build_text_summary(result, expected, actual), encoding="utf-8")
|
||||
print(f"비교 완료: {json_path}")
|
||||
print(f"비교 요약: {txt_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
db_logging.py
Normal file
159
db_logging.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import sqlite3
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path(__file__).parent / "baseball_logs.db"
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
# execution_jobs 테이블: 전체 경기 기록 세션 관리
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS execution_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
game_id TEXT NOT NULL,
|
||||
inning_range TEXT,
|
||||
status TEXT DEFAULT 'RUNNING',
|
||||
start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
end_time TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# pitch_logs 테이블: 투구 개별 트랜잭션 기록
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS pitch_logs (
|
||||
pitch_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT,
|
||||
inning TEXT,
|
||||
batter TEXT,
|
||||
pitch_no INTEGER,
|
||||
target_value TEXT,
|
||||
selected_value TEXT,
|
||||
is_success INTEGER,
|
||||
error_code TEXT,
|
||||
error_detail TEXT,
|
||||
duration REAL,
|
||||
log_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(job_id) REFERENCES execution_jobs(job_id)
|
||||
)
|
||||
""")
|
||||
# event_logs 테이블: 선수 교체 및 기타 주요 이벤트 기록
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS event_logs (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT,
|
||||
inning TEXT,
|
||||
event_type TEXT,
|
||||
target_player TEXT,
|
||||
actual_player TEXT,
|
||||
is_success INTEGER,
|
||||
error_msg TEXT,
|
||||
log_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(job_id) REFERENCES execution_jobs(job_id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def start_job(job_id: str, game_id: str, start_inning: str = "", end_inning: str = "") -> str:
|
||||
init_db()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
inning_range = "ALL"
|
||||
if start_inning and end_inning:
|
||||
inning_range = f"{start_inning}-{end_inning}"
|
||||
elif start_inning:
|
||||
inning_range = f"From {start_inning}"
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO execution_jobs (job_id, game_id, inning_range) VALUES (?, ?, ?)",
|
||||
(job_id, game_id, inning_range)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return job_id
|
||||
|
||||
def finish_job(job_id: str, status: str = "COMPLETED"):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE execution_jobs SET status = ?, end_time = ? WHERE job_id = ?",
|
||||
(status, datetime.datetime.now(), job_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def log_pitch(job_id: str, inning: str, batter: str, pitch_no: int, target_value: str, selected_value: str, is_success: bool, error_code: str = "", error_detail: str = "", duration: float = 0.0):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO pitch_logs
|
||||
(job_id, inning, batter, pitch_no, target_value, selected_value, is_success, error_code, error_detail, duration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(job_id, inning, batter, pitch_no, target_value, selected_value, 1 if is_success else 0, error_code, error_detail, duration)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def log_event(job_id: str, inning: str, event_type: str, target_player: str, actual_player: str, is_success: bool, error_msg: str = ""):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO event_logs
|
||||
(job_id, inning, event_type, target_player, actual_player, is_success, error_msg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(job_id, inning, event_type, target_player, actual_player, 1 if is_success else 0, error_msg)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_pitch_logs(job_id: str) -> list[dict]:
|
||||
init_db()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM pitch_logs WHERE job_id = ? ORDER BY pitch_id ASC", (job_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_event_logs(job_id: str) -> list[dict]:
|
||||
init_db()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM event_logs WHERE job_id = ? ORDER BY event_id ASC", (job_id,))
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_combined_logs(job_id: str) -> list[dict]:
|
||||
"""투구 로그와 이벤트 로그를 합쳐서 시간순으로 반환"""
|
||||
init_db()
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 1. 투구 로그 (type='pitch')
|
||||
cursor.execute("""
|
||||
SELECT 'pitch' as type, inning, batter as target_name,
|
||||
'[' || pitch_no || '구] ' || target_value as action_desc,
|
||||
selected_value as actual_desc, is_success, error_detail as error_msg, log_time
|
||||
FROM pitch_logs WHERE job_id = ?
|
||||
""", (job_id,))
|
||||
pitches = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# 2. 이벤트 로그 (type='event')
|
||||
cursor.execute("""
|
||||
SELECT 'event' as type, inning, event_type as target_name,
|
||||
target_player as action_desc, actual_player as actual_desc,
|
||||
is_success, error_msg, log_time
|
||||
FROM event_logs WHERE job_id = ?
|
||||
""", (job_id,))
|
||||
events = [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
combined = pitches + events
|
||||
# 시간순 정렬 (log_time 기준)
|
||||
combined.sort(key=lambda x: x['log_time'])
|
||||
return combined
|
||||
90
finish_game_playwright.py
Normal file
90
finish_game_playwright.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Error, Playwright, sync_playwright
|
||||
|
||||
from browser_launch import launch_browser_context
|
||||
from record_game_playwright import fill_game_end_pitching, open_game_status_page, submit_game_end
|
||||
from register_game_playwright import DEFAULT_BASE_URL, DEFAULT_GAME_ID, DEFAULT_REPORT_DIR, load_report
|
||||
|
||||
|
||||
TARGET_GAME_ID = DEFAULT_GAME_ID
|
||||
TARGET_MANAGER_GAME_NO = ""
|
||||
TARGET_REPORT_PATH = ""
|
||||
TARGET_SAVE = True
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="게임기록 화면에서 게임종료 팝업만 처리합니다."
|
||||
)
|
||||
parser.add_argument("--game-id", default=TARGET_GAME_ID, help="예: 20260404LGWO02026")
|
||||
parser.add_argument("--report-path", help="기본값: output/<game_id>_report.json")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="관리자 사이트 기본 URL")
|
||||
parser.add_argument(
|
||||
"--manager-game-no",
|
||||
default=(TARGET_MANAGER_GAME_NO or None),
|
||||
help="관리자 게임번호. 있으면 해당 행의 게임기록 버튼으로 진입",
|
||||
)
|
||||
parser.add_argument("--user-data-dir", default="playwright-user-data", help="Chromium 사용자 데이터 폴더")
|
||||
parser.add_argument("--channel", default="chrome", help="브라우저 채널. 예: chrome, msedge")
|
||||
parser.add_argument("--headless", action="store_true", help="헤드리스 모드")
|
||||
parser.add_argument("--save", dest="save", action="store_true", help="게임종료 버튼까지 클릭")
|
||||
parser.add_argument("--no-save", dest="save", action="store_false", help="게임종료 팝업 입력까지만")
|
||||
parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음")
|
||||
parser.set_defaults(save=TARGET_SAVE)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_report_path(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
if TARGET_REPORT_PATH:
|
||||
return Path(TARGET_REPORT_PATH)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace) -> None:
|
||||
report = load_report(resolve_report_path(args))
|
||||
browser = launch_browser_context(
|
||||
playwright=playwright,
|
||||
user_data_dir=args.user_data_dir,
|
||||
channel=args.channel,
|
||||
headless=args.headless,
|
||||
)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
try:
|
||||
open_game_status_page(page, args.base_url, report, args.manager_game_no)
|
||||
fill_game_end_pitching(page, report)
|
||||
if args.save:
|
||||
submit_game_end(page)
|
||||
|
||||
if args.close:
|
||||
browser.close()
|
||||
return
|
||||
|
||||
try:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Error as exc:
|
||||
if "Target page, context or browser has been closed" not in str(exc):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
with sync_playwright() as playwright:
|
||||
run(playwright, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1090
game_report.py
Normal file
1090
game_report.py
Normal file
File diff suppressed because it is too large
Load Diff
1
history.txt
Normal file
1
history.txt
Normal file
File diff suppressed because one or more lines are too long
107
lineup_only_playwright.py
Normal file
107
lineup_only_playwright.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Error, Playwright, sync_playwright
|
||||
|
||||
from browser_launch import launch_browser_context
|
||||
from register_game_playwright import (
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_GAME_ID,
|
||||
DEFAULT_REPORT_DIR,
|
||||
fill_lineup_form,
|
||||
load_report,
|
||||
open_edit_page,
|
||||
)
|
||||
|
||||
|
||||
# 직접 수정해서 쓰는 기본값
|
||||
TARGET_GAME_ID = "20260404NCHT02026"
|
||||
TARGET_MANAGER_GAME_NO = "11078"
|
||||
TARGET_REPORT_PATH = ""
|
||||
TARGET_SAVE = True
|
||||
TARGET_CLOSE = True
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="기존 경기 수정 화면에서 라인업만 자동 입력하고 저장 직전에서 멈춥니다."
|
||||
)
|
||||
parser.add_argument("--game-id", default=TARGET_GAME_ID, help="예: 20250425LTOB02025")
|
||||
parser.add_argument("--report-path", help="기본값: output/<game_id>_report.json")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="관리자 사이트 기본 URL")
|
||||
parser.add_argument(
|
||||
"--manager-game-no",
|
||||
default=(TARGET_MANAGER_GAME_NO or None),
|
||||
help="관리자 게임번호. 있으면 해당 행의 수정 버튼으로 진입",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-data-dir",
|
||||
default="playwright-user-data",
|
||||
help="로그인 세션을 유지할 Chromium 사용자 데이터 폴더",
|
||||
)
|
||||
parser.add_argument("--channel", default="chrome", help="브라우저 채널. 예: chrome, msedge")
|
||||
parser.add_argument("--headless", action="store_true", help="헤드리스 모드")
|
||||
parser.add_argument("--save", dest="save", action="store_true", help="라인업 저장 버튼까지 클릭")
|
||||
parser.add_argument("--no-save", dest="save", action="store_false", help="저장 직전까지만 입력")
|
||||
parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음")
|
||||
parser.add_argument("--no-close", dest="close", action="store_false", help="작업 후 브라우저를 유지")
|
||||
parser.set_defaults(save=TARGET_SAVE, close=TARGET_CLOSE)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_report_path(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
if TARGET_REPORT_PATH:
|
||||
return Path(TARGET_REPORT_PATH)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace) -> None:
|
||||
report = load_report(resolve_report_path(args))
|
||||
browser = launch_browser_context(
|
||||
playwright=playwright,
|
||||
user_data_dir=args.user_data_dir,
|
||||
channel=args.channel,
|
||||
headless=args.headless,
|
||||
)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
try:
|
||||
open_edit_page(page, args.base_url, report, args.manager_game_no)
|
||||
fill_lineup_form(page, report)
|
||||
if args.save:
|
||||
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
|
||||
page.locator("#lineupWriteBtn").click()
|
||||
page.wait_for_timeout(1000)
|
||||
print("라인업 입력 완료")
|
||||
|
||||
if args.close:
|
||||
browser.close()
|
||||
print("라인업 작업 종료")
|
||||
return
|
||||
|
||||
try:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Error as exc:
|
||||
if "Target page, context or browser has been closed" not in str(exc):
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
with sync_playwright() as playwright:
|
||||
run(playwright, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
main.py
Normal file
7
main.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from webapp.app import app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)
|
||||
32
mapping_overrides/README.md
Normal file
32
mapping_overrides/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Mapping Overrides
|
||||
|
||||
이 폴더는 `네이버 야구 텍스트 -> 관리자 사이트 입력 라벨` 매핑을 사람이 직접 검토하고 수정하기 쉽게 정리한 참조용 폴더입니다.
|
||||
|
||||
목적:
|
||||
- 자동입력 로직에서 쓰는 주요 매핑을 한 곳에 모아 보기 쉽게 관리
|
||||
- 새로운 예외 케이스가 생겼을 때 사용자가 직접 규칙을 추가
|
||||
- 코드 수정 전에 어떤 라벨이 어떤 의미인지 확인
|
||||
|
||||
현재 상태:
|
||||
- 이 폴더의 파일들은 "수정/검토용 참조 데이터"입니다.
|
||||
- 아직 모든 스크립트가 이 파일을 직접 읽는 구조는 아닙니다.
|
||||
- 즉, 지금은 사람이 안전하게 확인/편집하기 위한 기준 문서 역할입니다.
|
||||
- 표준 스키마는 [`standard_schema.md`](./standard_schema.md)를 따른다.
|
||||
|
||||
파일 설명:
|
||||
- `pitch_result_map.json`
|
||||
- 투구 결과 텍스트 매핑
|
||||
- `batter_result_map.json`
|
||||
- 타자 결과 타입/텍스트 매핑
|
||||
- `runner_event_map.json`
|
||||
- 주루 이벤트 매핑
|
||||
- `review_item_map.json`
|
||||
- 합의판정 항목/최종결과 매핑
|
||||
- `special_rules.md`
|
||||
- 특수 예외 규칙 정리
|
||||
|
||||
권장 사용법:
|
||||
1. 실제로 잘못 입력된 사례를 먼저 적습니다.
|
||||
2. 어떤 텍스트가 들어왔는지 확인합니다.
|
||||
3. 이 폴더의 JSON/문서에 원하는 매핑 규칙을 먼저 정리합니다.
|
||||
4. 그 다음 코드에 반영합니다.
|
||||
167
mapping_overrides/batter_result_map.json
Normal file
167
mapping_overrides/batter_result_map.json
Normal file
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"by_type": {
|
||||
"walk": "포볼",
|
||||
"intentional_walk": "고의사구",
|
||||
"strikeout": "스윙 스트라이크-아웃",
|
||||
"hit_by_pitch": "몸에 맞는 볼",
|
||||
"single": "1루타",
|
||||
"double": "2루타",
|
||||
"triple": "3루타",
|
||||
"home_run": "홈런",
|
||||
"sacrifice_fly": "희생 플라이",
|
||||
"sacrifice_bunt": "희생 번트",
|
||||
"reach_on_error": "수비실책",
|
||||
"reach_on_fielder_choice": "야수선택",
|
||||
"bunt_hit": "번트안타",
|
||||
"double_play": "병살-아웃",
|
||||
"out": "아웃"
|
||||
},
|
||||
"by_text_priority": [
|
||||
{
|
||||
"contains_all_in_runner_text": ["보크", "진루"],
|
||||
"label": "보크-볼"
|
||||
},
|
||||
{
|
||||
"when_runner_event_type": "wild_pitch_advance",
|
||||
"label": "폭투-볼"
|
||||
},
|
||||
{
|
||||
"when_runner_event_type": "wild_pitch_advance",
|
||||
"when_last_pitch_contains_any": ["스트라이크", "헛스윙"],
|
||||
"label": "폭투-스트라이크"
|
||||
},
|
||||
{
|
||||
"when_runner_event_type": "passed_ball_advance",
|
||||
"label": "포일-볼"
|
||||
},
|
||||
{
|
||||
"when_runner_event_type": "passed_ball_advance",
|
||||
"when_last_pitch_contains_any": ["스트라이크", "헛스윙"],
|
||||
"label": "포일-스트라이크"
|
||||
},
|
||||
{
|
||||
"contains": "번트 아웃",
|
||||
"label": "번트-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "병살",
|
||||
"label": "병살-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "트리플-아웃",
|
||||
"label": "트리플-아웃"
|
||||
},
|
||||
{
|
||||
"contains_all": ["낫아웃", "폭투"],
|
||||
"label": "폭투 낫아웃 진루"
|
||||
},
|
||||
{
|
||||
"contains_all": ["낫아웃", "포일"],
|
||||
"label": "포일 낫아웃 진루"
|
||||
},
|
||||
{
|
||||
"contains": "낫아웃",
|
||||
"label": "스트라이크-낫아웃"
|
||||
},
|
||||
{
|
||||
"contains": "병살",
|
||||
"label": "병살-아웃"
|
||||
},
|
||||
{
|
||||
"contains_any": ["파울희생플라이", "파울 희생플라이"],
|
||||
"label": "희생 플라이"
|
||||
},
|
||||
{
|
||||
"contains": "파울플라이",
|
||||
"label": "파울플라이-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "희생 플라이",
|
||||
"label": "희생 플라이"
|
||||
},
|
||||
{
|
||||
"contains": "희생 번트",
|
||||
"label": "희생 번트"
|
||||
},
|
||||
{
|
||||
"contains_any": ["1루타 후 주루아웃", "1루타 후 주자아웃"],
|
||||
"label": "1루타 후 주루아웃"
|
||||
},
|
||||
{
|
||||
"contains_any": ["2루타 후 주루아웃", "2루타 후 주자아웃"],
|
||||
"label": "2루타 후 주루아웃"
|
||||
},
|
||||
{
|
||||
"contains_any": ["3루타 후 주루아웃", "3루타 후 주자아웃"],
|
||||
"label": "3루타 후 주루아웃"
|
||||
},
|
||||
{
|
||||
"contains": "몸에 맞는 타구",
|
||||
"label": "몸에 맞는 타구"
|
||||
},
|
||||
{
|
||||
"contains": "수비방해",
|
||||
"label": "수비방해"
|
||||
},
|
||||
{
|
||||
"contains": "루킹스트라이크-아웃",
|
||||
"label": "루킹스트라이크-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "스윙 스트라이크-아웃",
|
||||
"label": "스윙 스트라이크-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "스트라이크-아웃",
|
||||
"label": "스트라이크-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "파울희생플라이-아웃",
|
||||
"label": "파울희생플라이-아웃"
|
||||
},
|
||||
{
|
||||
"contains": "인필드플라이",
|
||||
"label": "인필드플라이"
|
||||
},
|
||||
{
|
||||
"contains_all": ["땅볼출루", "실책"],
|
||||
"label": "땅볼출루-실책"
|
||||
},
|
||||
{
|
||||
"contains": "땅볼출루",
|
||||
"label": "땅볼출루(무안타)"
|
||||
},
|
||||
{
|
||||
"contains_any": ["내야안타", "내야 안타"],
|
||||
"label": "내야안타"
|
||||
},
|
||||
{
|
||||
"contains": "야수선택",
|
||||
"label": "야수선택"
|
||||
},
|
||||
{
|
||||
"contains": "포일-낫아웃",
|
||||
"label": "포일-낫아웃"
|
||||
},
|
||||
{
|
||||
"contains": "폭투-스트라이크",
|
||||
"label": "폭투-스트라이크"
|
||||
},
|
||||
{
|
||||
"contains": "번트-파울-아웃",
|
||||
"label": "번트-파울-아웃"
|
||||
},
|
||||
{
|
||||
"contains_any": ["기타아웃", "기타 아웃"],
|
||||
"label": "기타아웃"
|
||||
},
|
||||
{
|
||||
"contains": "몸에 맞는 볼",
|
||||
"label": "몸에 맞는 볼"
|
||||
},
|
||||
{
|
||||
"contains": "고의사구",
|
||||
"label": "고의사구"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
mapping_overrides/pitch_result_map.json
Normal file
10
mapping_overrides/pitch_result_map.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"볼": "볼",
|
||||
"스트라이크": "스트라이크(루킹)",
|
||||
"헛스윙": "헛스윙(스트라이크)",
|
||||
"번트헛스윙": "번트시도-스트라이크",
|
||||
"파울": "파울",
|
||||
"번트파울": "번트-파울",
|
||||
"고의사구": "고의사구",
|
||||
"자동 고의사구": "고의사구"
|
||||
}
|
||||
47
mapping_overrides/review_item_map.json
Normal file
47
mapping_overrides/review_item_map.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"item_rules": [
|
||||
{
|
||||
"contains": "홈런",
|
||||
"item": "홈런타구 페어 파울",
|
||||
"result_group": ["페어", "파울"]
|
||||
},
|
||||
{
|
||||
"contains_any": ["내야타구 페어", "외야타구 페어", "페어/파울", "파울/페어"],
|
||||
"item": "외야타구 페어 파울",
|
||||
"result_group": ["페어", "파울"]
|
||||
},
|
||||
{
|
||||
"contains_any": ["세이프", "아웃", "태그", "견제", "도루"],
|
||||
"item": "포수/태그플레이 아웃/세이프",
|
||||
"result_group": ["아웃", "세이프"]
|
||||
},
|
||||
{
|
||||
"contains_any": ["포구", "바운드", "노바운드", "캐치"],
|
||||
"item": "야수의 포구",
|
||||
"result_group": ["아웃", "세이프"]
|
||||
},
|
||||
{
|
||||
"contains_any": ["몸에 맞는 공", "몸에 맞는 볼"],
|
||||
"item": "몸에 맞는 공",
|
||||
"result_group": ["인정", "불인정"]
|
||||
},
|
||||
{
|
||||
"contains_any": ["체크스윙", "헛스윙"],
|
||||
"item": "헛스윙",
|
||||
"result_group": ["인정", "불인정"]
|
||||
},
|
||||
{
|
||||
"contains": "파울",
|
||||
"item": "파울",
|
||||
"result_group": ["인정", "불인정"]
|
||||
},
|
||||
{
|
||||
"fallback": true,
|
||||
"item": "기타",
|
||||
"result_group": ["인정", "불인정"]
|
||||
}
|
||||
],
|
||||
"check_swing_rule": {
|
||||
"description": "체크스윙은 헛스윙 항목으로 처리한다. 최종 판정이 스윙이면 인정, 아니면 불인정이다."
|
||||
}
|
||||
}
|
||||
134
mapping_overrides/runner_event_map.json
Normal file
134
mapping_overrides/runner_event_map.json
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"default_by_type": {
|
||||
"advance": "일반 진루",
|
||||
"score": "일반 진루",
|
||||
"steal": "도루성공",
|
||||
"steal_fail": "도루실패아웃",
|
||||
"force_out": "포스아웃",
|
||||
"tag_out": "태그아웃",
|
||||
"error_advance": "수비 실책",
|
||||
"wild_pitch_advance": "폭투-진루성공",
|
||||
"passed_ball_advance": "기타 진루"
|
||||
},
|
||||
"priority_rules": [
|
||||
{
|
||||
"contains": "견제-세이프",
|
||||
"label": "견제-세이프"
|
||||
},
|
||||
{
|
||||
"contains": "견제사 아웃",
|
||||
"label": "견제 아웃"
|
||||
},
|
||||
{
|
||||
"contains": "도루사",
|
||||
"label": "도루사"
|
||||
},
|
||||
{
|
||||
"contains": "도실-세이프",
|
||||
"label": "도실-세이프"
|
||||
},
|
||||
{
|
||||
"contains_all": ["보크", "진루"],
|
||||
"label": "보크 진루"
|
||||
},
|
||||
{
|
||||
"contains": "보크",
|
||||
"label": "보크"
|
||||
},
|
||||
{
|
||||
"when_batter_result_contains_any": ["라인드라이브", "직선타"],
|
||||
"event_type_in": ["force_out", "tag_out"],
|
||||
"label": "베이스 터치 아웃"
|
||||
},
|
||||
{
|
||||
"contains": "몸에 맞는 타구",
|
||||
"label": "몸에 맞는 타구"
|
||||
},
|
||||
{
|
||||
"contains": "견제",
|
||||
"label": "견제 에러"
|
||||
},
|
||||
{
|
||||
"contains_all": ["도루", "실책"],
|
||||
"label": "도루성공&실책"
|
||||
},
|
||||
{
|
||||
"contains_all": ["이중도루 실패", "아웃"],
|
||||
"label": "도루실패아웃"
|
||||
},
|
||||
{
|
||||
"contains_all": ["실책", "무진루"],
|
||||
"label": "실책-무진루"
|
||||
},
|
||||
{
|
||||
"contains": "진루 방해",
|
||||
"label": "진루 방해"
|
||||
},
|
||||
{
|
||||
"contains": "보크 진루",
|
||||
"label": "보크 진루"
|
||||
},
|
||||
{
|
||||
"contains": "무관심도루",
|
||||
"label": "무관심도루"
|
||||
},
|
||||
{
|
||||
"contains": "도루 저지 에러",
|
||||
"label": "도루 저지 에러"
|
||||
},
|
||||
{
|
||||
"contains_all": ["이중도루 실패", "진루"],
|
||||
"label": "기타 진루"
|
||||
},
|
||||
{
|
||||
"contains_all": ["포일", "진루"],
|
||||
"label": "기타 진루"
|
||||
},
|
||||
{
|
||||
"contains_all": ["낫아웃", "폭투"],
|
||||
"label": "폭투 낫아웃 진루"
|
||||
},
|
||||
{
|
||||
"contains_all": ["낫아웃", "포일"],
|
||||
"label": "포일 낫아웃 진루"
|
||||
},
|
||||
{
|
||||
"when_batter_result_type": "walk",
|
||||
"event_type_in": ["advance", "score"],
|
||||
"label": "볼넷 진루"
|
||||
},
|
||||
{
|
||||
"contains": "실책",
|
||||
"event_type_in": ["advance", "score", "error_advance"],
|
||||
"label": "수비 실책"
|
||||
},
|
||||
{
|
||||
"contains": "사전 출발",
|
||||
"label": "사전 출발"
|
||||
},
|
||||
{
|
||||
"contains": "베이스 실수",
|
||||
"label": "베이스 실수"
|
||||
},
|
||||
{
|
||||
"contains": "수비방해",
|
||||
"label": "수비방해"
|
||||
},
|
||||
{
|
||||
"contains": "몸에 맞는 타구",
|
||||
"label": "몸에 맞는 타구"
|
||||
},
|
||||
{
|
||||
"contains_any": ["기타아웃", "기타 아웃"],
|
||||
"label": "기타아웃"
|
||||
},
|
||||
{
|
||||
"contains_any": ["기타세이프", "기타 세이프"],
|
||||
"label": "기타 세이프"
|
||||
},
|
||||
{
|
||||
"contains_any": ["기타진루", "기타 진루"],
|
||||
"label": "기타 진루"
|
||||
}
|
||||
]
|
||||
}
|
||||
107
mapping_overrides/special_rules.md
Normal file
107
mapping_overrides/special_rules.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Special Rules
|
||||
|
||||
이 문서는 JSON으로 넣기 애매한 예외 규칙을 적는 곳입니다.
|
||||
|
||||
## 병살
|
||||
- 타자 결과는 `병살-아웃`
|
||||
- 첫 번째 팝업:
|
||||
- 타자 결과 텍스트의 수비 시퀀스만 사용
|
||||
- 마지막 포스아웃/주자아웃 팝업:
|
||||
- `runner_event.text`의 수비 시퀀스만 사용
|
||||
|
||||
## 파울플라이
|
||||
- 파울라인 밖 좌표로 찍는다.
|
||||
- 기준 파울라인:
|
||||
- `(5, 50)` ~ `(50, 100)`
|
||||
- `(95, 50)` ~ `(50, 100)`
|
||||
- 파울플라이는 이 직선 밖이어야 한다.
|
||||
|
||||
## 일반 플라이
|
||||
- 일반 플라이는 파울라인 안쪽이어야 한다.
|
||||
- 즉 `좌익수/중견수/우익수`, `좌중간/우중간`, `중전` 쪽 플라이는 위의 파울라인 두 직선 안에 들어가야 한다.
|
||||
- 파울플라이와 일반 플라이는 좌표 규칙이 다르다.
|
||||
|
||||
## 파울희생플라이
|
||||
- 결과 라벨은 `희생 플라이`
|
||||
- 위치만 파울플라이처럼 처리
|
||||
|
||||
## 낫아웃
|
||||
- 일반 낫아웃: `스트라이크-낫아웃`
|
||||
- 폭투 낫아웃: `폭투 낫아웃 진루`
|
||||
- 포일 낫아웃: `포일 낫아웃 진루`
|
||||
|
||||
## 번트 헛스윙
|
||||
- 네이버 텍스트 `번트헛스윙` / `번트 헛스윙` 은 리포트에서 `BS` 로 저장한다.
|
||||
- 사이트에서는 `번트시도-스트라이크` 로 입력한다.
|
||||
|
||||
## 보크
|
||||
- `보크 진루`는 그대로 `보크 진루`
|
||||
- `보크 스트라이크`는 한 번에 처리하지 않는다.
|
||||
1. `보크` 입력
|
||||
2. 입력완료
|
||||
3. `스트라이크` 입력
|
||||
|
||||
## 포일/폭투/이중도루 실패 진루
|
||||
- 포일이나 이중도루 실패 시의 추가 진루는 `기타 진루`
|
||||
- 폭투는 `폭투-진루성공`
|
||||
- 주자가 폭투로 진루하는 상황에서는 마지막 투구 결과가 `볼`이면 `폭투-볼`, `스트라이크/헛스윙`이면 `폭투-스트라이크`로 처리한다.
|
||||
- 주자가 포일로 진루하는 상황에서는 마지막 투구 결과가 `볼`이면 `포일-볼`, `스트라이크/헛스윙`이면 `포일-스트라이크`로 처리한다.
|
||||
- 주자가 보크로 진루하는 상황에서는 마지막 투구 결과가 `볼`이면 `보크-볼`, `스트라이크/헛스윙`이면 `보크` 입력 후 추가로 스트라이크를 따로 입력한다.
|
||||
|
||||
## 라인드라이브 아웃
|
||||
- 타자 결과가 라인드라이브/직선타 아웃이면
|
||||
- 주자 아웃은 `포스아웃`, `태그아웃`보다 `베이스 터치 아웃` 우선
|
||||
|
||||
## 몸에 맞는 타구
|
||||
- 라벨은 `몸에 맞는 타구`
|
||||
- 타구 위치는 `1루수` 쪽
|
||||
- 타구 종류는 `땅볼`
|
||||
|
||||
## 실책 팝업
|
||||
- 포구 실책: 실책 선수 1회 클릭
|
||||
- 송구/악송구 실책: 같은 선수 2회 클릭
|
||||
|
||||
## 도루
|
||||
- 일반 도루: `도루성공`
|
||||
- 견제 실책 도루: `견제 에러`
|
||||
- 도루 후 실책 추가 진루: `도루성공&실책`
|
||||
|
||||
## 화면에 실제 보이는 주루/아웃 관련 라벨
|
||||
- `견제-세이프`
|
||||
- `견제 아웃`
|
||||
- `도루사`
|
||||
- `도실-세이프`
|
||||
- `실책-무진루`
|
||||
- `진루 방해`
|
||||
- `보크`
|
||||
- `도루 저지 에러`
|
||||
- `베이스 터치 아웃`
|
||||
- `베이스 실수`
|
||||
- `사전 출발`
|
||||
- `기타 세이프`
|
||||
- `기타아웃`
|
||||
|
||||
## 화면에 실제 보이는 타자 결과 라벨
|
||||
- `번트-아웃`
|
||||
- `병살-아웃`
|
||||
- `트리플-아웃`
|
||||
- `1루타 후 주루아웃`
|
||||
- `2루타 후 주루아웃`
|
||||
- `3루타 후 주루아웃`
|
||||
- `몸에 맞는 타구`
|
||||
- `수비방해`
|
||||
- `루킹스트라이크-아웃`
|
||||
- `스트라이크-아웃`
|
||||
- `포일-낫아웃`
|
||||
- `폭투-스트라이크`
|
||||
- `번트-파울-아웃`
|
||||
- `땅볼출루(무안타)`
|
||||
- `내야안타`
|
||||
- `야수선택`
|
||||
|
||||
## 야수 -> 투수 교체 문장
|
||||
- `야수 A : 투수 B (으)로 교체`는 잘못 붙은 네이버 텍스트일 가능성이 높다.
|
||||
- 보통 의미:
|
||||
- `A`는 `지명타자` 이동
|
||||
- `B`는 현재 투수 교체
|
||||
- 이 경우 `merged_pitcher_substitution`처럼 별도 취급한다.
|
||||
175
mapping_overrides/standard_schema.md
Normal file
175
mapping_overrides/standard_schema.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Standard Mapping Schema
|
||||
|
||||
이 문서는 `mapping_overrides` 폴더의 매핑 파일을 하나의 공통 형태로 정리하기 위한 기준입니다.
|
||||
|
||||
목표:
|
||||
- 매핑 파일마다 다른 키 이름과 구조를 줄인다
|
||||
- 단순 치환, 우선순위 룰, 그룹 선택 규칙을 같은 방식으로 읽게 한다
|
||||
- 코드 적용 전 사람이 검토하기 쉬운 형태를 유지한다
|
||||
|
||||
## 공통 원칙
|
||||
|
||||
- 모든 파일은 `schema_version`을 가진다.
|
||||
- 모든 규칙은 가능한 한 `ordered` 목록으로 표현한다.
|
||||
- 먼저 매칭되는 규칙이 우선한다.
|
||||
- 최종 저장값은 항상 표준값 1개다.
|
||||
- 예외 규칙은 일반 규칙보다 뒤가 아니라 우선순위 숫자로 조정한다.
|
||||
|
||||
## 공통 헤더
|
||||
|
||||
모든 JSON 매핑 파일은 아래 공통 헤더를 가진다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"kind": "alias_map",
|
||||
"id": "team_name_map",
|
||||
"description": "팀명 별칭을 표준 팀명으로 정규화한다",
|
||||
"field": "team_name"
|
||||
}
|
||||
```
|
||||
|
||||
필드 의미:
|
||||
- `schema_version`: 스키마 버전
|
||||
- `kind`: 파일 타입
|
||||
- `id`: 사람이 읽는 식별자
|
||||
- `description`: 파일 목적 설명
|
||||
- `field`: 이 매핑이 적용되는 대상 필드
|
||||
|
||||
## 스키마 종류
|
||||
|
||||
### 1. `alias_map`
|
||||
|
||||
단순 문자열 치환용 스키마다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"kind": "alias_map",
|
||||
"id": "game_type_map",
|
||||
"field": "game_type",
|
||||
"entries": [
|
||||
{ "source": "와일드카드", "target": "wildcard" },
|
||||
{ "source": "준플레이오프", "target": "semi_playoff" },
|
||||
{ "source": "플레이오프", "target": "playoff" },
|
||||
{ "source": "한국시리즈", "target": "korean_series" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
권장 사용처:
|
||||
- 팀명
|
||||
- 경기 구분
|
||||
- 구장명
|
||||
- 투구 결과처럼 문자열이 1:1로 대응되는 경우
|
||||
|
||||
### 2. `ordered_rule_map`
|
||||
|
||||
우선순위가 필요한 문자열 룰용 스키마다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"kind": "ordered_rule_map",
|
||||
"id": "batter_result_map",
|
||||
"field": "batter_result",
|
||||
"default": { "target": "아웃" },
|
||||
"rules": [
|
||||
{
|
||||
"priority": 100,
|
||||
"when": {
|
||||
"text_contains_any": ["병살", "더블플레이"]
|
||||
},
|
||||
"then": { "target": "병살-아웃" }
|
||||
},
|
||||
{
|
||||
"priority": 90,
|
||||
"when": {
|
||||
"result_type_in": ["walk"]
|
||||
},
|
||||
"then": { "target": "포볼" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
권장 조건 키:
|
||||
- `text_contains`
|
||||
- `text_contains_any`
|
||||
- `text_contains_all`
|
||||
- `event_type_in`
|
||||
- `result_type_in`
|
||||
- `last_pitch_contains_any`
|
||||
- `role_in`
|
||||
|
||||
권장 결과 키:
|
||||
- `target`: 표준 출력값
|
||||
- `group`: 선택 그룹 이름
|
||||
- `note`: 사람이 읽는 설명
|
||||
|
||||
### 3. `group_map`
|
||||
|
||||
판독/선택형 UI처럼 결과 그룹이 따로 필요한 규칙용 스키마다.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"kind": "group_map",
|
||||
"id": "review_item_map",
|
||||
"field": "review_item",
|
||||
"items": [
|
||||
{
|
||||
"priority": 100,
|
||||
"when": { "text_contains_any": ["홈런"] },
|
||||
"item": "홈런타구 페어 파울",
|
||||
"result_group": ["페어", "파울"]
|
||||
},
|
||||
{
|
||||
"priority": 10,
|
||||
"fallback": true,
|
||||
"item": "기타",
|
||||
"result_group": ["인정", "불인정"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
권장 사용처:
|
||||
- 합의판정 항목
|
||||
- 결과 버튼 2개 이상이 붙는 UI
|
||||
|
||||
### 4. `special_rules`
|
||||
|
||||
JSON으로 표현하기 어려운 예외는 문서로 남긴다.
|
||||
|
||||
권장 항목:
|
||||
- 조건
|
||||
- 예외 사유
|
||||
- UI 처리 순서
|
||||
- 금지 규칙
|
||||
|
||||
## 현재 파일의 표준화 방향
|
||||
|
||||
- `pitch_result_map.json`
|
||||
- `alias_map`으로 정리
|
||||
- `batter_result_map.json`
|
||||
- `ordered_rule_map`으로 정리
|
||||
- `runner_event_map.json`
|
||||
- `ordered_rule_map`으로 정리
|
||||
- `review_item_map.json`
|
||||
- `group_map`으로 정리
|
||||
- `special_rules.md`
|
||||
- `special_rules` 문서로 유지
|
||||
|
||||
## 우선순위 규칙
|
||||
|
||||
- 숫자가 큰 규칙이 먼저다.
|
||||
- 같은 priority면 위에서 아래 순서가 우선이다.
|
||||
- `fallback: true`는 마지막 규칙만 허용한다.
|
||||
|
||||
## 표준값 규칙
|
||||
|
||||
- 저장값은 항상 하나의 표준 문자열이어야 한다.
|
||||
- 표준 문자열은 코드에서 다시 해석하지 않아도 되도록 짧고 일관되게 유지한다.
|
||||
- 별칭, 공백, 한국어/영문 혼용은 입력 단계에서만 흡수한다.
|
||||
|
||||
3002
record_game_playwright.py
Normal file
3002
record_game_playwright.py
Normal file
File diff suppressed because it is too large
Load Diff
1367
record_gdate.json
Normal file
1367
record_gdate.json
Normal file
File diff suppressed because it is too large
Load Diff
116
register_game_basic_playwright.py
Normal file
116
register_game_basic_playwright.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Error, Playwright, sync_playwright
|
||||
|
||||
from browser_launch import launch_browser_context
|
||||
from register_game_playwright import (
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_GAME_ID,
|
||||
DEFAULT_REPORT_DIR,
|
||||
load_report,
|
||||
normalize_game_type,
|
||||
normalize_stadium_name,
|
||||
normalize_team_name,
|
||||
open_create_page,
|
||||
select_by_label,
|
||||
split_time,
|
||||
)
|
||||
|
||||
|
||||
TARGET_GAME_ID = DEFAULT_GAME_ID
|
||||
TARGET_REPORT_PATH = ""
|
||||
TARGET_SAVE = True
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="신규 등록 화면에서 경기 기본 정보만 입력합니다."
|
||||
)
|
||||
parser.add_argument("--game-id", default=TARGET_GAME_ID, help="예: 20260404LGWO02026")
|
||||
parser.add_argument("--report-path", help="기본값: output/<game_id>_report.json")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="관리자 사이트 기본 URL")
|
||||
parser.add_argument("--user-data-dir", default="playwright-user-data", help="Chromium 사용자 데이터 폴더")
|
||||
parser.add_argument("--channel", default="chrome", help="브라우저 채널. 예: chrome, msedge")
|
||||
parser.add_argument("--headless", action="store_true", help="헤드리스 모드")
|
||||
parser.add_argument("--save", dest="save", action="store_true", help="저장 버튼까지 클릭")
|
||||
parser.add_argument("--no-save", dest="save", action="store_false", help="저장 직전까지만 입력")
|
||||
parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음")
|
||||
parser.set_defaults(save=TARGET_SAVE)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_report_path(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
if TARGET_REPORT_PATH:
|
||||
return Path(TARGET_REPORT_PATH)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def fill_basic_game_form(page, report: dict) -> None:
|
||||
game_info = report["game_info"]
|
||||
season_label = f"{game_info['season']} 프로야구"
|
||||
game_type_label = normalize_game_type(game_info["game_type"])
|
||||
stadium_label = normalize_stadium_name(game_info["stadium"])
|
||||
home_team_label = normalize_team_name(game_info["home_team"])
|
||||
away_team_label = normalize_team_name(game_info["away_team"])
|
||||
start_hour, start_minute = split_time(game_info["start_time"])
|
||||
|
||||
select_by_label(page, "#season_id", season_label)
|
||||
select_by_label(page, "#gameType", game_type_label)
|
||||
select_by_label(page, "#stadium_id", stadium_label)
|
||||
page.locator("#gameDate").fill(game_info["date"])
|
||||
select_by_label(page, "#startH", start_hour)
|
||||
select_by_label(page, "#startM", start_minute)
|
||||
select_by_label(page, "#homeTeam_id", home_team_label)
|
||||
select_by_label(page, "#awayTeam_id", away_team_label)
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace) -> None:
|
||||
report = load_report(resolve_report_path(args))
|
||||
browser = launch_browser_context(
|
||||
playwright=playwright,
|
||||
user_data_dir=args.user_data_dir,
|
||||
channel=args.channel,
|
||||
headless=args.headless,
|
||||
)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
try:
|
||||
open_create_page(page, args.base_url)
|
||||
fill_basic_game_form(page, report)
|
||||
if args.save:
|
||||
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
|
||||
page.locator("#gameWriteBtn").click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
if args.close:
|
||||
browser.close()
|
||||
return
|
||||
|
||||
try:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Error as exc:
|
||||
if "Target page, context or browser has been closed" not in str(exc):
|
||||
raise
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
with sync_playwright() as playwright:
|
||||
run(playwright, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
486
register_game_playwright.py
Normal file
486
register_game_playwright.py
Normal file
@@ -0,0 +1,486 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Error, Page, Playwright, TimeoutError, sync_playwright
|
||||
|
||||
from browser_launch import launch_browser_context
|
||||
|
||||
|
||||
DEFAULT_GAME_ID = "20250425LTOB02025"
|
||||
DEFAULT_REPORT_DIR = Path("output")
|
||||
DEFAULT_BASE_URL = "http://58.229.253.168:8089"
|
||||
|
||||
TEAM_NAME_MAP = {
|
||||
"키움": "Hero",
|
||||
"키움 히어로즈": "Hero",
|
||||
"Hero": "Hero",
|
||||
}
|
||||
|
||||
GAME_TYPE_MAP = {
|
||||
"와일드카드": "와일드카드 결정전",
|
||||
}
|
||||
POSITION_NUMBER_MAP = {
|
||||
"투수": "1",
|
||||
"포수": "2",
|
||||
"1루수": "3",
|
||||
"2루수": "4",
|
||||
"3루수": "5",
|
||||
"유격수": "6",
|
||||
"좌익수": "7",
|
||||
"중견수": "8",
|
||||
"우익수": "9",
|
||||
"지명타자": "10",
|
||||
}
|
||||
|
||||
STADIUM_NAME_MAP = {
|
||||
"고척": "고척돔",
|
||||
"고척스카이돔": "고척돔",
|
||||
"잠실": "잠실",
|
||||
"대구 삼성 라이온즈 파크": "대구라팍",
|
||||
"대구라이온즈파크": "대구라팍",
|
||||
"대구 라팍": "대구라팍",
|
||||
"대구삼성라이온즈파크": "대구라팍",
|
||||
"수원 케이티 위즈 파크": "수원",
|
||||
"수원KT위즈파크": "수원",
|
||||
"수원kt위즈파크": "수원",
|
||||
"창원NC파크": "창원",
|
||||
"창원 nc 파크": "창원",
|
||||
"창원 NC 파크": "창원",
|
||||
"대전 한화생명 볼파크": "대전",
|
||||
"대전한화생명볼파크": "대전",
|
||||
"대전 한화생명 이글스파크": "한밭(~2024)",
|
||||
"대전한화생명이글스파크": "한밭(~2024)",
|
||||
"인천": "문학",
|
||||
"인천 SSG 랜더스필드": "문학",
|
||||
"인천SSG랜더스필드": "문학",
|
||||
"문학": "문학",
|
||||
"광주-기아 챔피언스 필드": "광주",
|
||||
"광주 기아 챔피언스 필드": "광주",
|
||||
"광주KIA챔피언스필드": "광주",
|
||||
"광주 kia 챔피언스 필드": "광주",
|
||||
"사직야구장": "사직",
|
||||
"사직": "사직",
|
||||
"울산문수야구장": "울산",
|
||||
"울산 문수야구장": "울산",
|
||||
"울산": "울산",
|
||||
"포항야구장": "포항",
|
||||
"포항": "포항",
|
||||
"마산야구장": "마산",
|
||||
"마산": "마산",
|
||||
"군산월명야구장": "군산",
|
||||
"군산": "군산",
|
||||
"청주야구장": "청주",
|
||||
"청주": "청주",
|
||||
"잠실야구장": "잠실",
|
||||
"목동야구장": "목동",
|
||||
"목동": "목동",
|
||||
"무등야구장": "무등",
|
||||
"무등": "무등",
|
||||
"대구시민야구장": "대구",
|
||||
"대구": "대구",
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="신규 등록으로 경기 생성 후 수정 화면에서 라인업까지 자동 입력합니다."
|
||||
)
|
||||
parser.add_argument("--game-id", default=DEFAULT_GAME_ID, help="예: 20250425NCSS02025")
|
||||
parser.add_argument(
|
||||
"--report-path",
|
||||
help="기본값: output/<game_id>_report.json",
|
||||
)
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="관리자 사이트 기본 URL")
|
||||
parser.add_argument("--manager-game-no", help="관리자 게임번호. 있으면 해당 행의 수정 버튼으로 진입")
|
||||
parser.add_argument(
|
||||
"--user-data-dir",
|
||||
default="playwright-user-data",
|
||||
help="로그인 세션을 유지할 Chromium 사용자 데이터 폴더",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--channel",
|
||||
default="chrome",
|
||||
help="브라우저 채널. 예: chrome, msedge",
|
||||
)
|
||||
parser.add_argument("--end-time", help="종료시간 HH:MM 형식. 예: 21:47")
|
||||
parser.add_argument("--end-hour", help="종료시간 시. 예: 22")
|
||||
parser.add_argument("--end-minute", help="종료시간 분. 예: 15")
|
||||
parser.add_argument("--attendance", help="관중 수. 예: 12500")
|
||||
parser.add_argument("--headless", action="store_true", help="헤드리스 모드")
|
||||
parser.add_argument("--save", dest="save", action="store_true", help="라인업 저장 버튼까지 클릭")
|
||||
parser.add_argument("--no-save", dest="save", action="store_false", help="저장 직전까지만 입력")
|
||||
parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음")
|
||||
parser.set_defaults(save=False)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_report(report_path: Path) -> dict[str, Any]:
|
||||
return json.loads(report_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def normalize_team_name(name: str) -> str:
|
||||
return TEAM_NAME_MAP.get(name, name)
|
||||
|
||||
|
||||
def normalize_game_type(name: str) -> str:
|
||||
return GAME_TYPE_MAP.get(name, name)
|
||||
|
||||
|
||||
def normalize_stadium_name(name: str) -> str:
|
||||
return STADIUM_NAME_MAP.get(name, name)
|
||||
|
||||
|
||||
def split_time(iso_time: str | None) -> tuple[str, str]:
|
||||
if not iso_time:
|
||||
return "00", "00"
|
||||
dt = datetime.fromisoformat(iso_time)
|
||||
return f"{dt.hour:02d}", f"{dt.minute:02d}"
|
||||
|
||||
|
||||
def resolve_end_time(game_info: dict[str, Any], args: argparse.Namespace) -> tuple[str, str]:
|
||||
if args.end_time:
|
||||
hour_text, minute_text = args.end_time.split(":", 1)
|
||||
return f"{int(hour_text):02d}", f"{int(minute_text):02d}"
|
||||
|
||||
if args.end_hour is not None and args.end_minute is not None:
|
||||
return f"{int(args.end_hour):02d}", f"{int(args.end_minute):02d}"
|
||||
|
||||
if game_info.get("end_time"):
|
||||
return split_time(game_info["end_time"])
|
||||
|
||||
raise ValueError("종료시간이 없습니다. --end-time HH:MM 또는 --end-hour/--end-minute를 지정하세요.")
|
||||
|
||||
|
||||
def get_report_path(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def select_by_label(page: Page, selector: str, label: str) -> None:
|
||||
loc = page.locator(selector)
|
||||
try:
|
||||
loc.click(timeout=1000)
|
||||
except Exception:
|
||||
pass
|
||||
loc.select_option(label=label)
|
||||
loc.dispatch_event("change")
|
||||
|
||||
|
||||
def normalize_number_text(number: str | int | None) -> str:
|
||||
text = str(number or "").strip()
|
||||
digits = "".join(char for char in text if char.isdigit())
|
||||
if not digits:
|
||||
return ""
|
||||
return str(int(digits))
|
||||
|
||||
|
||||
def normalize_player_name_text(name: str | None) -> str:
|
||||
text = (name or "").replace("*", "").strip()
|
||||
text = re.sub(r"\([^)]*\)\s*$", "", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
def normalize_option_player_text(text: str) -> tuple[str, str]:
|
||||
stripped = " ".join(text.split())
|
||||
matched = re.match(r"^(.*?)\s*\[(\d+)번\]$", stripped)
|
||||
if matched:
|
||||
return normalize_player_name_text(matched.group(1)), normalize_number_text(matched.group(2))
|
||||
return normalize_player_name_text(stripped), ""
|
||||
|
||||
|
||||
def infer_option_role_hint(text: str) -> str:
|
||||
stripped = " ".join(text.split())
|
||||
matched = re.search(r"\(([^)]*)\)\s*(?:\[\d+번\])?$", stripped)
|
||||
if not matched:
|
||||
return ""
|
||||
hint = matched.group(1).strip()
|
||||
if hint == "투":
|
||||
return "pitcher"
|
||||
if hint == "타":
|
||||
return "batter"
|
||||
return ""
|
||||
|
||||
|
||||
def infer_target_role_hint(position_name: str | None) -> str:
|
||||
if position_name == "투수":
|
||||
return "pitcher"
|
||||
return "batter"
|
||||
|
||||
|
||||
def get_select_options(page: Page, selector: str) -> list[dict[str, str]]:
|
||||
return page.locator(selector).evaluate(
|
||||
"""(el) => [...el.options].map(option => ({
|
||||
value: option.value,
|
||||
text: option.textContent.trim()
|
||||
}))"""
|
||||
)
|
||||
|
||||
|
||||
def select_player_option(
|
||||
page: Page,
|
||||
selector: str,
|
||||
player_name: str,
|
||||
player_number: str | None,
|
||||
position_name: str | None = None,
|
||||
) -> None:
|
||||
options = get_select_options(page, selector)
|
||||
target_number = normalize_number_text(player_number)
|
||||
normalized_player_name = normalize_player_name_text(player_name)
|
||||
target_role_hint = infer_target_role_hint(position_name)
|
||||
name_matches = []
|
||||
role_filtered_matches = []
|
||||
|
||||
for option in options:
|
||||
option_name, option_number = normalize_option_player_text(option["text"])
|
||||
if option_name != normalized_player_name:
|
||||
continue
|
||||
name_matches.append(option)
|
||||
option_role_hint = infer_option_role_hint(option["text"])
|
||||
role_matches = (
|
||||
not target_role_hint
|
||||
or not option_role_hint
|
||||
or option_role_hint == target_role_hint
|
||||
)
|
||||
if role_matches:
|
||||
role_filtered_matches.append(option)
|
||||
if target_number and option_number == target_number:
|
||||
if not option_role_hint or option_role_hint == target_role_hint:
|
||||
page.locator(selector).select_option(value=option["value"])
|
||||
return
|
||||
|
||||
if len(role_filtered_matches) == 1:
|
||||
page.locator(selector).select_option(value=role_filtered_matches[0]["value"])
|
||||
return
|
||||
|
||||
if len(name_matches) == 1:
|
||||
page.locator(selector).select_option(value=name_matches[0]["value"])
|
||||
return
|
||||
|
||||
normalized_options = [normalize_option_player_text(option["text"]) for option in options]
|
||||
similar_options = [
|
||||
option["text"]
|
||||
for option, (option_name, option_number) in zip(options, normalized_options)
|
||||
if normalized_player_name in option_name or option_name in normalized_player_name or (target_number and option_number == target_number)
|
||||
]
|
||||
if not similar_options:
|
||||
similar_options = [option["text"] for option in options]
|
||||
raise ValueError(
|
||||
f"{selector}에서 선수 '{player_name}' #{player_number} 옵션을 찾지 못했습니다. "
|
||||
f"후보: {', '.join(similar_options[:10])}"
|
||||
)
|
||||
|
||||
|
||||
def select_position_option(page: Page, selector: str, position_name: str) -> None:
|
||||
position_value = POSITION_NUMBER_MAP.get(position_name)
|
||||
if not position_value:
|
||||
raise ValueError(f"포지션 매핑이 없습니다: {position_name}")
|
||||
page.locator(selector).select_option(value=position_value)
|
||||
|
||||
|
||||
def build_lineup_entries(lineups: dict[str, Any], team_key: str) -> list[tuple[int, dict[str, Any]]]:
|
||||
team_lineup = lineups[team_key]
|
||||
entries = [(0, team_lineup["starter_pitcher"])]
|
||||
entries.extend((int(player["bat_order"]), player) for player in team_lineup["players"])
|
||||
return entries
|
||||
|
||||
|
||||
def fill_game_form(page: Page, report: dict[str, Any], args: argparse.Namespace) -> None:
|
||||
game_info = report["game_info"]
|
||||
|
||||
season_label = f"{game_info['season']} 프로야구"
|
||||
game_type_label = normalize_game_type(game_info["game_type"])
|
||||
stadium_label = normalize_stadium_name(game_info["stadium"])
|
||||
home_team_label = normalize_team_name(game_info["home_team"])
|
||||
away_team_label = normalize_team_name(game_info["away_team"])
|
||||
start_hour, start_minute = split_time(game_info["start_time"])
|
||||
end_hour, end_minute = resolve_end_time(game_info, args)
|
||||
attendance_source = args.attendance if args.attendance is not None else game_info.get("attendance")
|
||||
attendance = None
|
||||
if attendance_source is not None:
|
||||
attendance = "".join(char for char in str(attendance_source) if char.isdigit())
|
||||
|
||||
select_by_label(page, "#season_id", season_label)
|
||||
select_by_label(page, "#gameType", game_type_label)
|
||||
select_by_label(page, "#stadium_id", stadium_label)
|
||||
if attendance is not None:
|
||||
page.locator("#spectatorCnt").fill(attendance)
|
||||
|
||||
page.locator("#gameDate").fill(game_info["date"])
|
||||
select_by_label(page, "#startH", start_hour)
|
||||
select_by_label(page, "#startM", start_minute)
|
||||
page.locator("#endH").select_option(value=str(int(end_hour)))
|
||||
select_by_label(page, "#endM", end_minute)
|
||||
|
||||
select_by_label(page, "#homeTeam_id", home_team_label)
|
||||
select_by_label(page, "#awayTeam_id", away_team_label)
|
||||
|
||||
umpires = game_info["umpires"]
|
||||
page.locator("#homeplate_umpire").fill(umpires["chief"] or "")
|
||||
page.locator("#base_umpire1").fill(umpires["first_base"] or "")
|
||||
page.locator("#base_umpire2").fill(umpires["second_base"] or "")
|
||||
page.locator("#base_umpire3").fill(umpires["third_base"] or "")
|
||||
select_by_label(page, "#gameType", game_type_label)
|
||||
|
||||
|
||||
def fill_lineup_form(page: Page, report: dict[str, Any]) -> None:
|
||||
lineups = report["lineups"]
|
||||
team_selector_map = {
|
||||
"home_team": "home",
|
||||
"away_team": "away",
|
||||
}
|
||||
|
||||
for team_key, prefix in team_selector_map.items():
|
||||
for order, player in build_lineup_entries(lineups, team_key):
|
||||
if not player:
|
||||
continue
|
||||
player_selector = f"#{prefix}_player_id_{order}"
|
||||
defense_selector = f"#{prefix}_defense_no_{order}"
|
||||
select_player_option(page, player_selector, player["name"], player.get("number"), player.get("position"))
|
||||
select_position_option(page, defense_selector, player["position"])
|
||||
|
||||
|
||||
def find_edit_href(page: Page, report: dict[str, Any], manager_game_no: str | None) -> str:
|
||||
game_info = report["game_info"]
|
||||
target_date = game_info["date"]
|
||||
target_stadium = normalize_stadium_name(game_info["stadium"])
|
||||
target_home_team = normalize_team_name(game_info["home_team"])
|
||||
target_away_team = normalize_team_name(game_info["away_team"])
|
||||
|
||||
rows: list[dict[str, Any]] = page.locator("table.gclist tr").evaluate_all(
|
||||
"""(rows) => rows.slice(1).map((row) => {
|
||||
const cells = [...row.cells].map((cell) => cell.innerText.trim());
|
||||
const editLink = [...row.querySelectorAll('a')].find((anchor) => anchor.textContent.trim() === '수정');
|
||||
return {
|
||||
gameNo: cells[0] || '',
|
||||
date: cells[1] || '',
|
||||
gameType: cells[2] || '',
|
||||
stadium: cells[3] || '',
|
||||
homeTeam: cells[4] || '',
|
||||
awayTeam: cells[5] || '',
|
||||
href: editLink ? editLink.getAttribute('href') : '',
|
||||
};
|
||||
})"""
|
||||
)
|
||||
|
||||
if manager_game_no:
|
||||
matched = next((row for row in rows if row["gameNo"] == str(manager_game_no)), None)
|
||||
if not matched or not matched["href"]:
|
||||
raise ValueError(f"관리자 게임번호 {manager_game_no} 행의 수정 링크를 찾지 못했습니다.")
|
||||
return matched["href"]
|
||||
|
||||
candidates = [
|
||||
row
|
||||
for row in rows
|
||||
if row["href"]
|
||||
and row["date"] == target_date
|
||||
and normalize_stadium_name(row["stadium"]) == target_stadium
|
||||
and normalize_team_name(row["homeTeam"]) == target_home_team
|
||||
and normalize_team_name(row["awayTeam"]) == target_away_team
|
||||
]
|
||||
if not candidates:
|
||||
raise ValueError("목록에서 일치하는 경기 행을 찾지 못했습니다.")
|
||||
return candidates[0]["href"]
|
||||
|
||||
|
||||
def open_edit_page(page: Page, base_url: str, report: dict[str, Any], manager_game_no: str | None) -> None:
|
||||
if manager_game_no:
|
||||
page.goto(f"{base_url}/manager/game/write?id={manager_game_no}", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("#gameFrm", timeout=10000)
|
||||
page.wait_for_selector("#home_player_id_1", timeout=10000)
|
||||
return
|
||||
|
||||
page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("table.gclist", timeout=10000)
|
||||
edit_href = find_edit_href(page, report, manager_game_no)
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.locator(f"a[href='{edit_href}']").first.click()
|
||||
page.wait_for_selector("#gameFrm", timeout=10000)
|
||||
page.wait_for_selector("#home_player_id_1", timeout=10000)
|
||||
|
||||
|
||||
def open_create_page(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded")
|
||||
page.wait_for_selector("table.gclist", timeout=10000)
|
||||
with page.expect_navigation(wait_until="domcontentloaded"):
|
||||
page.locator("a").filter(has_text="신규 등록").first.click()
|
||||
page.wait_for_selector("#gameFrm", timeout=10000)
|
||||
|
||||
|
||||
def create_game(page: Page, report: dict[str, Any], args: argparse.Namespace) -> None:
|
||||
open_create_page(page, args.base_url)
|
||||
fill_game_form(page, report, args)
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#gameWriteBtn").click()
|
||||
page.wait_for_url(f"{args.base_url}/manager/game/list", timeout=10000)
|
||||
|
||||
|
||||
def update_game_header(page: Page, report: dict[str, Any], args: argparse.Namespace) -> None:
|
||||
fill_game_form(page, report, args)
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#gameUpdateBtn").click()
|
||||
try:
|
||||
page.wait_for_url(f"{args.base_url}/manager/game/list", timeout=10000)
|
||||
except TimeoutError:
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace, report: dict[str, Any]) -> None:
|
||||
browser = launch_browser_context(
|
||||
playwright=playwright,
|
||||
user_data_dir=args.user_data_dir,
|
||||
channel=args.channel,
|
||||
headless=args.headless,
|
||||
)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
try:
|
||||
create_game(page, report, args)
|
||||
open_edit_page(page, args.base_url, report, args.manager_game_no)
|
||||
update_game_header(page, report, args)
|
||||
open_edit_page(page, args.base_url, report, args.manager_game_no)
|
||||
fill_lineup_form(page, report)
|
||||
|
||||
if args.save:
|
||||
page.on("dialog", lambda dialog: dialog.accept())
|
||||
page.locator("#lineupWriteBtn").click()
|
||||
try:
|
||||
page.wait_for_url(f"{args.base_url}/manager/game/list", timeout=10000)
|
||||
except TimeoutError:
|
||||
pass
|
||||
|
||||
if args.close:
|
||||
browser.close()
|
||||
else:
|
||||
try:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Error as exc:
|
||||
if "Target page, context or browser has been closed" not in str(exc):
|
||||
raise
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
report_path = get_report_path(args)
|
||||
report = load_report(report_path)
|
||||
|
||||
with sync_playwright() as playwright:
|
||||
run(playwright, args, report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
18
requirements.txt
Normal file
18
requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
anyio==4.12.1
|
||||
blinker==1.9.0
|
||||
certifi==2026.2.25
|
||||
click==8.3.2
|
||||
colorama==0.4.6
|
||||
Flask==3.1.3
|
||||
greenlet==3.3.2
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
playwright==1.58.0
|
||||
pyee==13.0.1
|
||||
typing_extensions==4.15.0
|
||||
Werkzeug==3.1.8
|
||||
142
update_and_run.bat
Normal file
142
update_and_run.bat
Normal file
@@ -0,0 +1,142 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "APP_URL=http://127.0.0.1:5000"
|
||||
set "PYTHON_EXE=python"
|
||||
|
||||
echo ==================================================
|
||||
echo [0/7] Python 버전 확인
|
||||
echo ==================================================
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
py -3 --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [오류] Python 또는 py launcher를 찾지 못했습니다.
|
||||
echo Python 3.10 이상을 설치한 뒤 다시 시도하세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
set "PYTHON_EXE=py -3"
|
||||
)
|
||||
|
||||
for /f "delims=" %%V in ('%PYTHON_EXE% -c "import sys; print(\"[정보] Python {}.{}.{}\".format(*sys.version_info[:3])); raise SystemExit(0 if sys.version_info >= (3, 10) else 1)" 2^>nul') do echo %%V
|
||||
if errorlevel 1 (
|
||||
echo [경고] Python 3.10 미만입니다. 동작은 할 수 있지만 권장하지 않습니다.
|
||||
)
|
||||
|
||||
echo ==================================================
|
||||
echo [1/7] Git 최신 코드 가져오기
|
||||
echo ==================================================
|
||||
if exist .git (
|
||||
for /f "delims=" %%B in ('git branch --show-current 2^>nul') do set "BRANCH=%%B"
|
||||
if not defined BRANCH set "BRANCH=main"
|
||||
echo [정보] 현재 브랜치: !BRANCH!
|
||||
git pull --rebase --autostash origin !BRANCH!
|
||||
if errorlevel 1 (
|
||||
echo [경고] git pull 실패. 원격 저장소 또는 네트워크를 확인하세요.
|
||||
echo 로컬 파일로 계속 진행합니다.
|
||||
) else (
|
||||
echo [완료] 최신 코드 반영 완료
|
||||
)
|
||||
) else (
|
||||
echo [경고] .git 폴더가 없습니다. git pull 없이 로컬 파일로 진행합니다.
|
||||
)
|
||||
|
||||
echo ==================================================
|
||||
echo [2/7] .venv 확인
|
||||
echo ==================================================
|
||||
if not exist .venv\Scripts\python.exe (
|
||||
echo [정보] .venv가 없어서 새로 생성합니다.
|
||||
%PYTHON_EXE% -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo [오류] .venv 생성 실패
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
) else (
|
||||
echo [완료] .venv가 이미 존재합니다.
|
||||
)
|
||||
|
||||
echo ==================================================
|
||||
echo [3/7] 가상환경 활성화
|
||||
echo ==================================================
|
||||
call .venv\Scripts\activate.bat
|
||||
if errorlevel 1 (
|
||||
echo [오류] 가상환경 활성화 실패
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [완료] 가상환경 활성화됨: !VIRTUAL_ENV!
|
||||
|
||||
echo ==================================================
|
||||
echo [4/7] pip 업그레이드
|
||||
echo ==================================================
|
||||
python -m pip install --upgrade pip
|
||||
if errorlevel 1 (
|
||||
echo [오류] pip 업그레이드 실패
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo ==================================================
|
||||
echo [5/7] 의존성 설치
|
||||
echo ==================================================
|
||||
if not exist requirements.txt (
|
||||
echo [오류] requirements.txt가 없습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
python -m pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo [오류] requirements.txt 설치 실패
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [완료] 의존성 설치 완료
|
||||
|
||||
echo ==================================================
|
||||
echo [6/7] Playwright 브라우저 설치
|
||||
echo ==================================================
|
||||
python -m playwright install
|
||||
if errorlevel 1 (
|
||||
echo [경고] Playwright install이 실패했습니다.
|
||||
echo 이미 설치되어 있으면 계속 사용합니다.
|
||||
)
|
||||
|
||||
echo ==================================================
|
||||
echo [7/7] Flask 서버 실행
|
||||
echo ==================================================
|
||||
if not exist main.py (
|
||||
echo [오류] main.py를 찾을 수 없습니다.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
start "BaseBall Flask Server" cmd /k "%PYTHON_EXE% main.py"
|
||||
|
||||
echo [정보] 서버가 올라올 때까지 기다립니다...
|
||||
set "READY=0"
|
||||
for /l %%I in (1,1,60) do (
|
||||
powershell -NoProfile -Command "try { $r = Invoke-WebRequest -Uri '%APP_URL%' -UseBasicParsing -TimeoutSec 2; if ($r.StatusCode -ge 200) { exit 0 } } catch { exit 1 }" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
set "READY=1"
|
||||
goto :server_ready
|
||||
)
|
||||
timeout /t 1 /nobreak >nul
|
||||
)
|
||||
|
||||
:server_ready
|
||||
if "!READY!"=="1" (
|
||||
echo [완료] 서버가 시작되었습니다. 브라우저를 엽니다.
|
||||
start "" "%APP_URL%"
|
||||
) else (
|
||||
echo [경고] 서버 시작 확인 시간이 초과되었습니다.
|
||||
echo http://127.0.0.1:5000 를 직접 열어보세요.
|
||||
)
|
||||
|
||||
echo.
|
||||
echo 업데이트와 실행이 끝났습니다.
|
||||
echo 서버 창은 별도로 열려 있습니다.
|
||||
pause
|
||||
129
update_game_post_playwright.py
Normal file
129
update_game_post_playwright.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import Error, Playwright, sync_playwright
|
||||
|
||||
from browser_launch import launch_browser_context
|
||||
from register_game_playwright import (
|
||||
DEFAULT_BASE_URL,
|
||||
DEFAULT_GAME_ID,
|
||||
DEFAULT_REPORT_DIR,
|
||||
load_report,
|
||||
open_edit_page,
|
||||
resolve_end_time,
|
||||
)
|
||||
|
||||
|
||||
TARGET_GAME_ID = DEFAULT_GAME_ID
|
||||
TARGET_MANAGER_GAME_NO = ""
|
||||
TARGET_REPORT_PATH = ""
|
||||
TARGET_END_TIME = ""
|
||||
TARGET_ATTENDANCE = ""
|
||||
TARGET_SAVE = True
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="기존 경기 수정 화면에서 경기 후 메타 정보만 입력합니다."
|
||||
)
|
||||
parser.add_argument("--game-id", default=TARGET_GAME_ID, help="예: 20260404LGWO02026")
|
||||
parser.add_argument("--report-path", help="기본값: output/<game_id>_report.json")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="관리자 사이트 기본 URL")
|
||||
parser.add_argument(
|
||||
"--manager-game-no",
|
||||
default=(TARGET_MANAGER_GAME_NO or None),
|
||||
help="관리자 게임번호. 있으면 해당 행의 수정 버튼으로 진입",
|
||||
)
|
||||
parser.add_argument("--user-data-dir", default="playwright-user-data", help="Chromium 사용자 데이터 폴더")
|
||||
parser.add_argument("--channel", default="chrome", help="브라우저 채널. 예: chrome, msedge")
|
||||
parser.add_argument("--end-time", default=(TARGET_END_TIME or None), help="종료시간 HH:MM")
|
||||
parser.add_argument("--attendance", default=(TARGET_ATTENDANCE or None), help="관중 수")
|
||||
parser.add_argument("--headless", action="store_true", help="헤드리스 모드")
|
||||
parser.add_argument("--save", dest="save", action="store_true", help="수정 버튼까지 클릭")
|
||||
parser.add_argument("--no-save", dest="save", action="store_false", help="저장 직전까지만 입력")
|
||||
parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음")
|
||||
parser.set_defaults(save=TARGET_SAVE)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_report_path(args: argparse.Namespace) -> Path:
|
||||
if args.report_path:
|
||||
return Path(args.report_path)
|
||||
if TARGET_REPORT_PATH:
|
||||
return Path(TARGET_REPORT_PATH)
|
||||
return DEFAULT_REPORT_DIR / f"{args.game_id}_report.json"
|
||||
|
||||
|
||||
def fill_post_game_form(page, report: dict, args: argparse.Namespace) -> None:
|
||||
game_info = report["game_info"]
|
||||
end_hour, end_minute = resolve_end_time(
|
||||
game_info,
|
||||
argparse.Namespace(
|
||||
end_time=args.end_time,
|
||||
end_hour=None,
|
||||
end_minute=None,
|
||||
),
|
||||
)
|
||||
|
||||
attendance_source = args.attendance if args.attendance is not None else game_info.get("attendance")
|
||||
attendance = "".join(char for char in str(attendance_source or "") if char.isdigit())
|
||||
if attendance:
|
||||
page.locator("#spectatorCnt").fill(attendance)
|
||||
|
||||
page.locator("#endH").select_option(value=str(int(end_hour)))
|
||||
page.locator("#endM").select_option(label=end_minute)
|
||||
|
||||
umpires = game_info["umpires"]
|
||||
page.locator("#homeplate_umpire").fill(umpires["chief"] or "")
|
||||
page.locator("#base_umpire1").fill(umpires["first_base"] or "")
|
||||
page.locator("#base_umpire2").fill(umpires["second_base"] or "")
|
||||
page.locator("#base_umpire3").fill(umpires["third_base"] or "")
|
||||
|
||||
|
||||
def run(playwright: Playwright, args: argparse.Namespace) -> None:
|
||||
report = load_report(resolve_report_path(args))
|
||||
browser = launch_browser_context(
|
||||
playwright=playwright,
|
||||
user_data_dir=args.user_data_dir,
|
||||
channel=args.channel,
|
||||
headless=args.headless,
|
||||
)
|
||||
page = browser.pages[0] if browser.pages else browser.new_page()
|
||||
|
||||
try:
|
||||
open_edit_page(page, args.base_url, report, args.manager_game_no)
|
||||
fill_post_game_form(page, report, args)
|
||||
if args.save:
|
||||
page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
|
||||
page.locator("#gameUpdateBtn").click()
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
if args.close:
|
||||
browser.close()
|
||||
return
|
||||
|
||||
try:
|
||||
page.wait_for_timeout(3600 * 1000)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Error as exc:
|
||||
if "Target page, context or browser has been closed" not in str(exc):
|
||||
raise
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
with sync_playwright() as playwright:
|
||||
run(playwright, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
15
verify_runner_sync.py
Normal file
15
verify_runner_sync.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import json
|
||||
|
||||
path = 'output/20260408HHSK02026_report.json'
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
for gc in data.get('game_contents', []):
|
||||
for ev in gc.get('events', []):
|
||||
if ev.get('batter') and '오재원' in ev.get('batter'):
|
||||
print(f"At-bat: {ev.get('batter')}")
|
||||
for p in ev.get('pitches', []):
|
||||
print(f" Pitch {p.get('pitchNo')}: {p.get('pitchResultText')}")
|
||||
if p.get('runnerEvents'):
|
||||
for rev in p.get('runnerEvents'):
|
||||
print(f" Runner Event: {rev.get('text')}")
|
||||
print(f" At-bat Runner Events: {ev.get('runnerEvents')}")
|
||||
2
webapp/__init__.py
Normal file
2
webapp/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Web application package for the baseball automation project."""
|
||||
|
||||
709
webapp/app.py
Normal file
709
webapp/app.py
Normal 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)
|
||||
419
webapp/static/style.css
Normal file
419
webapp/static/style.css
Normal file
@@ -0,0 +1,419 @@
|
||||
:root {
|
||||
--bg: #f4f7f3;
|
||||
--panel: #ffffff;
|
||||
--ink: #1d2421;
|
||||
--muted: #66736d;
|
||||
--line: #dbe4dc;
|
||||
--primary: #1f6b53;
|
||||
--primary-soft: rgba(31, 107, 83, 0.12);
|
||||
--danger: #b34131;
|
||||
--warn: #9d6a12;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(31, 107, 83, 0.06), transparent 20%),
|
||||
linear-gradient(180deg, #f9fbf8 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(980px, calc(100% - 24px));
|
||||
margin: 24px auto 40px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 10px 28px rgba(27, 33, 29, 0.06);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
background: linear-gradient(135deg, rgba(31, 107, 83, 0.08), rgba(235, 242, 237, 0.9));
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.section-head h2,
|
||||
.section-head h1 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.hero p,
|
||||
.helper,
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#game-id-preview {
|
||||
font-family: Consolas, "SFMono-Regular", monospace;
|
||||
font-size: 20px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
background: #fff3f1;
|
||||
color: var(--danger);
|
||||
border: 1px solid #efc6c0;
|
||||
}
|
||||
|
||||
.notice.success {
|
||||
background: #f0faf4;
|
||||
color: var(--primary);
|
||||
border: 1px solid #c6e1d1;
|
||||
}
|
||||
|
||||
.notice.warn {
|
||||
background: #fff8e7;
|
||||
color: var(--warn);
|
||||
border: 1px solid #ecd6a7;
|
||||
}
|
||||
|
||||
.job-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-two,
|
||||
.grid-parse {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-parse {
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
label.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
label span {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
border: 1px solid #c9d4cb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 4px var(--primary-soft);
|
||||
}
|
||||
|
||||
.flatpickr-input[readonly] {
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flatpickr-calendar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 18px 40px rgba(32, 37, 35, 0.14);
|
||||
font-family: "Pretendard", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
}
|
||||
|
||||
.flatpickr-months {
|
||||
background: linear-gradient(135deg, rgba(31, 107, 83, 0.10), rgba(255,255,255,0.95));
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
.flatpickr-weekdays {
|
||||
background: #f7faf5;
|
||||
}
|
||||
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.startRange,
|
||||
.flatpickr-day.endRange {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.flatpickr-day.today {
|
||||
border-color: #cb7f1a;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.section-head.compact {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.action-strip {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.inline-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.inline-field label {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn,
|
||||
button.btn {
|
||||
border: 1px solid #d7e0d8;
|
||||
border-radius: 999px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #eff4ef;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
background: #f8e8e5;
|
||||
border-color: #efc6c0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn.warning {
|
||||
background: #fff3e0;
|
||||
border-color: #ffcc80;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-left: 6px solid var(--primary);
|
||||
}
|
||||
|
||||
.status-running {
|
||||
border-left: 6px solid #c9831e;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
border-left: 6px solid var(--danger);
|
||||
}
|
||||
|
||||
.status-queued {
|
||||
border-left: 6px solid #7e8b83;
|
||||
}
|
||||
|
||||
.job-body {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-preview-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-preview-link:hover,
|
||||
.text-link:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page {
|
||||
width: min(100% - 16px, 100%);
|
||||
margin: 16px auto 28px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.grid-two,
|
||||
.grid-parse {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inning Range Selection Styles */
|
||||
.inning-range-group {
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
max-width: none !important;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.inning-select-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
flex: 0 0 160px;
|
||||
}
|
||||
|
||||
.inning-split {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inning-split select {
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.inning-split select:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.inning-split select:last-child {
|
||||
flex: 0 0 62px;
|
||||
}
|
||||
|
||||
.inning-select-item.checkbox-item {
|
||||
/* ... */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.inning-select-item.checkbox-item label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.inning-select-item.checkbox-item input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
422
webapp/templates/index.html
Normal file
422
webapp/templates/index.html
Normal file
@@ -0,0 +1,422 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>야구 자동화</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="panel hero">
|
||||
<div class="hero-copy">
|
||||
<h1>야구 자동화</h1>
|
||||
<p>경기 ID를 직접 넣거나 날짜와 팀을 고른 뒤, 라인업 또는 특정 이닝만 바로 입력합니다.</p>
|
||||
</div>
|
||||
<div class="preview-box">
|
||||
<span class="preview-label">현재 저장 대상 경기 ID</span>
|
||||
<strong id="game-id-preview">{{ defaults.game_id or '-' }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<section class="panel notice error">{{ error }}</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<form class="job-form" method="post" id="main-job-form" action="{{ url_for('start_job', job_type='lineup') }}">
|
||||
<div class="section-head compact">
|
||||
<h2>경기 선택</h2>
|
||||
<span class="helper">라인업은 자동으로 라인업 전용 리포트를 만들고, 경기기록은 선택한 이닝만 담아 실행합니다.</span>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<label>
|
||||
<span>경기 ID 입력 방식</span>
|
||||
<select name="game_id_mode" id="game_id_mode">
|
||||
<option value="direct" {% if defaults.game_id_mode == 'direct' %}selected{% endif %}>직접 입력</option>
|
||||
<option value="composed" {% if defaults.game_id_mode == 'composed' %}selected{% endif %}>날짜/팀 조합</option>
|
||||
<option value="parse" {% if defaults.game_id_mode == 'parse' %}selected{% endif %}>텍스트 붙여넣기</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>관리자 게임번호</span>
|
||||
<select name="manager_mode" id="manager_mode">
|
||||
<option value="auto" {% if defaults.manager_mode == 'auto' %}selected{% endif %}>자동 찾기</option>
|
||||
<option value="manual" {% if defaults.manager_mode == 'manual' %}selected{% endif %}>직접 입력</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label data-mode="direct">
|
||||
<span>경기 ID</span>
|
||||
<input name="game_id" placeholder="예: 20260404LGWO02026" value="{{ defaults.game_id }}">
|
||||
</label>
|
||||
|
||||
<div class="grid-two" data-mode="composed">
|
||||
<label>
|
||||
<span>경기 구분</span>
|
||||
<select name="game_type" id="game_type">
|
||||
{% for option in game_type_options %}
|
||||
<option value="{{ option.value }}" {% if defaults.game_type == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>경기 날짜</span>
|
||||
<input class="date-input" type="text" name="game_date" id="game_date" placeholder="날짜 선택" value="{{ defaults.game_date }}">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid-two" data-mode="composed">
|
||||
<label>
|
||||
<span>어웨이팀</span>
|
||||
<select name="away_team_code" id="away_team_code">
|
||||
<option value="">선택</option>
|
||||
{% for team in team_options %}
|
||||
<option value="{{ team.value }}" {% if defaults.away_team_code == team.value %}selected{% endif %}>{{ team.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>홈팀</span>
|
||||
<select name="home_team_code" id="home_team_code">
|
||||
<option value="">선택</option>
|
||||
{% for team in team_options %}
|
||||
<option value="{{ team.value }}" {% if defaults.home_team_code == team.value %}selected{% endif %}>{{ team.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label data-mode="composed">
|
||||
<span>더블헤더 순번</span>
|
||||
<select name="doubleheader_no" id="doubleheader_no">
|
||||
<option value="0" {% if defaults.doubleheader_no == '0' %}selected{% endif %}>0: 더블헤더 아님</option>
|
||||
<option value="1" {% if defaults.doubleheader_no == '1' %}selected{% endif %}>1: 더블헤더 1차전</option>
|
||||
<option value="2" {% if defaults.doubleheader_no == '2' %}selected{% endif %}>2: 더블헤더 2차전</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="grid-parse" data-mode="parse">
|
||||
<label class="wide">
|
||||
<span>기록 사이트 텍스트</span>
|
||||
<input type="text" id="parse_text" placeholder="예: 11115 2026-04-11 정규경기 대전 한화 KIA" autocomplete="off">
|
||||
</label>
|
||||
<label>
|
||||
<span>더블헤더</span>
|
||||
<select name="doubleheader_no_parse" id="doubleheader_no_parse">
|
||||
<option value="0">일반</option>
|
||||
<option value="1">DH 1차전</option>
|
||||
<option value="2">DH 2차전</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label data-manager-mode="manual">
|
||||
<span>관리자 게임번호</span>
|
||||
<input name="manager_game_no" placeholder="예: 11080" value="{{ defaults.manager_game_no }}">
|
||||
</label>
|
||||
|
||||
<div class="action-strip">
|
||||
<div class="inline-field inning-range-group">
|
||||
<div class="inning-select-item">
|
||||
<label>시작</label>
|
||||
<div class="inning-split">
|
||||
<select id="start_inning_num">
|
||||
{% for i in range(1, 13) %}
|
||||
<option value="{{ i }}" {% if defaults.start_inning_num == i|string %}selected{% endif %}>{{ i }}회</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="start_inning_half">
|
||||
<option value="T" {% if defaults.start_inning_half == 'T' %}selected{% endif %}>초</option>
|
||||
<option value="B" {% if defaults.start_inning_half == 'B' %}selected{% endif %}>말</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inning-select-item">
|
||||
<label>종료</label>
|
||||
<div class="inning-split">
|
||||
<select id="end_inning_num">
|
||||
{% for i in range(1, 13) %}
|
||||
<option value="{{ i }}" {% if defaults.end_inning_num == i|string %}selected{% endif %}>{{ i }}회</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select id="end_inning_half">
|
||||
<option value="T" {% if defaults.end_inning_half == 'T' %}selected{% endif %}>초</option>
|
||||
<option value="B" {% if defaults.end_inning_half == 'B' %}selected{% endif %}>말</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inning-select-item checkbox-item">
|
||||
<label>
|
||||
<input type="checkbox" id="all_innings_check"> 전체
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="inning_no" id="inning_no" value="{{ defaults.inning_no }}">
|
||||
<div class="button-row">
|
||||
<button class="btn primary" type="submit" formaction="{{ url_for('start_job', job_type='lineup') }}" title="선택한 경기의 라인업 전용 리포트를 자동 생성한 뒤 라인업만 입력합니다.">라인업 입력</button>
|
||||
<button class="btn danger" type="submit" formaction="{{ url_for('start_job', job_type='record') }}" title="선택한 이닝만 담긴 리포트를 자동 생성한 뒤 그 이닝의 경기기록만 입력합니다.">경기기록 입력</button>
|
||||
<button class="btn warning" type="submit" formaction="{{ url_for('start_job', job_type='video_review') }}" title="경기의 모든 합의판정(비디오 판독) 내역을 모아서 한꺼번에 등록합니다.">합의판정 등록</button>
|
||||
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='register_basic') }}" title="신규 경기 등록 화면에서 기본 경기 정보만 입력합니다.">경기 등록</button>
|
||||
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='post_update') }}" title="전체 리포트를 자동 생성한 뒤 관중 수, 종료시간, 심판 정보를 입력합니다.">경기 후 정보</button>
|
||||
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='finish') }}" title="전체 리포트를 자동 생성한 뒤 게임종료 팝업에서 승패/홀드/세이브/블론세이브를 입력합니다.">경기 마무리</button>
|
||||
<button class="btn" type="submit" formaction="{{ url_for('start_job', job_type='compare') }}" title="전체 리포트를 자동 생성한 뒤 history.txt와 비교해서 불일치 리포트를 만듭니다.">기록 비교</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="helper">
|
||||
경기 ID 형식은 `경기구분4 + 월일4 + 어웨이2 + 홈2 + 더블헤더1 + 연도4`입니다.
|
||||
정규경기는 앞 4자리에 연도를 쓰고, 와일드카드 `4444`, 준PO `3333`, PO `5555`, 한국시리즈 `7777`을 씁니다.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>최근 작업</h2>
|
||||
<a class="text-link" href="{{ url_for('jobs_api') }}" target="_blank" rel="noopener">JSON 보기</a>
|
||||
</div>
|
||||
<div id="job-list-container" class="job-list">
|
||||
{% if jobs %}
|
||||
{% for job in jobs %}
|
||||
<article class="job-card status-{{ job.status }}">
|
||||
<div class="job-top">
|
||||
<strong>{{ job.type }}</strong>
|
||||
<span class="status">{{ job.status }}</span>
|
||||
</div>
|
||||
<div class="job-body">
|
||||
<div>경기 ID: {{ job.game_id }}</div>
|
||||
<div>이닝: {{ job.inning_no or '-' }}</div>
|
||||
<div>게임번호: {{ job.manager_game_no or '-' }}</div>
|
||||
<div>생성: {{ job.created_at }}</div>
|
||||
</div>
|
||||
<div class="job-actions">
|
||||
<a class="text-link log-preview-link" href="{{ url_for('job_detail', job_id=job.job_id) }}">{{ job.log_preview }}</a>
|
||||
<a class="text-link" href="{{ url_for('job_log', job_id=job.job_id) }}" target="_blank" rel="noopener">로그</a>
|
||||
<a class="text-link" href="{{ url_for('view_db_logs', job_id=job.job_id) }}" target="_blank" rel="noopener">DB 상세로그</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="empty">아직 작업이 없습니다.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>편의 기능</h2>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<form method="post" action="{{ url_for('clear_logs') }}">
|
||||
<button class="btn" type="submit">로그 초기화</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('clear_jobs') }}">
|
||||
<button class="btn" type="submit">작업 상태 초기화</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('clear_reports') }}">
|
||||
<button class="btn" type="submit">리포트 초기화</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('clear_runtime_profiles') }}">
|
||||
<button class="btn" type="submit">런타임 프로필 초기화</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ko.js"></script>
|
||||
<script>
|
||||
const modeSelect = document.getElementById('game_id_mode');
|
||||
const managerModeSelect = document.getElementById('manager_mode');
|
||||
const gameTypeSelect = document.getElementById('game_type');
|
||||
const dateInput = document.getElementById('game_date');
|
||||
const awayTeamCode = document.getElementById('away_team_code');
|
||||
const homeTeamCode = document.getElementById('home_team_code');
|
||||
const dhInput = document.getElementById('doubleheader_no');
|
||||
const dhInputParse = document.getElementById('doubleheader_no_parse');
|
||||
const directGameIdInput = document.querySelector('input[name="game_id"]');
|
||||
const parseInput = document.getElementById('parse_text');
|
||||
const managerGameNoInput = document.querySelector('input[name="manager_game_no"]');
|
||||
const preview = document.getElementById('game-id-preview');
|
||||
|
||||
flatpickr(dateInput, {
|
||||
locale: 'ko',
|
||||
dateFormat: 'Y-m-d',
|
||||
allowInput: true,
|
||||
disableMobile: true,
|
||||
defaultDate: dateInput?.value || null
|
||||
});
|
||||
|
||||
function syncModeVisibility() {
|
||||
const mode = modeSelect.value;
|
||||
document.querySelectorAll('[data-mode]').forEach((node) => {
|
||||
node.style.display = node.getAttribute('data-mode') === mode ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function syncManagerModeVisibility() {
|
||||
const mode = managerModeSelect.value;
|
||||
document.querySelectorAll('[data-manager-mode]').forEach((node) => {
|
||||
node.style.display = node.getAttribute('data-manager-mode') === mode ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function buildComposedId() {
|
||||
const rawDate = dateInput.value || '';
|
||||
const away = awayTeamCode.value || '';
|
||||
const home = homeTeamCode.value || '';
|
||||
const gameType = gameTypeSelect.value || 'regular';
|
||||
const mode = modeSelect.value;
|
||||
const dh = (mode === 'parse' ? dhInputParse.value : dhInput.value) || '0';
|
||||
if (!rawDate || !away || !home) return '-';
|
||||
const [year, month, day] = rawDate.split('-');
|
||||
const typeMap = {
|
||||
regular: year,
|
||||
wildcard: '4444',
|
||||
semi_playoff: '3333',
|
||||
playoff: '5555',
|
||||
korean_series: '7777',
|
||||
};
|
||||
return `${typeMap[gameType] || year}${month}${day}${away}${home}${dh}${year}`;
|
||||
}
|
||||
|
||||
function syncPreview() {
|
||||
const mode = modeSelect.value;
|
||||
preview.textContent = mode === 'direct'
|
||||
? (directGameIdInput.value.trim() || '-')
|
||||
: buildComposedId();
|
||||
}
|
||||
|
||||
function handleParse() {
|
||||
const text = parseInput.value.trim();
|
||||
if (!text) return;
|
||||
// 탭이나 공백으로 분리
|
||||
const parts = text.split(/\t|\s{2,}/).map((s) => s.trim()).filter(Boolean);
|
||||
if (parts.length < 5) return;
|
||||
|
||||
const teamMap = { 'KIA': 'HT', '한화': 'HH', '두산': 'OB', '롯데': 'LT', 'SSG': 'SK', '삼성': 'SS', '키움': 'WO', 'Hero': 'WO', 'LG': 'LG', 'NC': 'NC', 'KT': 'KT', '상무': 'SM' };
|
||||
const typeMap = { '정규경기': 'regular', '와일드카드': 'wildcard', '준플레이오프': 'semi_playoff', '플레이오프': 'playoff', '한국시리즈': 'korean_series', '시범경기': 'exhibition' };
|
||||
|
||||
// 1. 관리자 게임번호 (보통 첫 번째 숫자 뭉치)
|
||||
if (/^\d+$/.test(parts[0])) {
|
||||
managerGameNoInput.value = parts[0];
|
||||
managerModeSelect.value = 'manual';
|
||||
syncManagerModeVisibility();
|
||||
}
|
||||
|
||||
// 2. 날짜 찾기 (YYYY-MM-DD 형식)
|
||||
const datePart = parts.find(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
|
||||
if (datePart) {
|
||||
dateInput.value = datePart;
|
||||
if (dateInput._flatpickr) dateInput._flatpickr.setDate(datePart);
|
||||
}
|
||||
|
||||
// 3. 경기 타입 찾기
|
||||
for (const [key, val] of Object.entries(typeMap)) {
|
||||
if (text.includes(key)) {
|
||||
game_type.value = val;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 팀 찾기 (원정 vs 홈 구분)
|
||||
// 네이버 일정 텍스트는 보통 [시간] [종류] [장소] [홈팀] [원정팀] 순서인 경우가 많음
|
||||
// 여기서는 텍스트에 포함된 팀 이름들을 순서대로 추출
|
||||
const foundTeams = [];
|
||||
const words = text.split(/[\s\t]+/);
|
||||
words.forEach(word => {
|
||||
for (const [name, code] of Object.entries(teamMap)) {
|
||||
if (word.includes(name) && !foundTeams.some(t => t.code === code)) {
|
||||
foundTeams.push({ name, code });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (foundTeams.length >= 2) {
|
||||
// 보통 일정 텍스트 상 먼저 나오는 팀이 홈팀인 경우가 많으므로 (네이버 기준)
|
||||
homeTeamCode.value = foundTeams[0].code;
|
||||
awayTeamCode.value = foundTeams[1].code;
|
||||
}
|
||||
|
||||
syncPreview();
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard');
|
||||
const data = await response.json();
|
||||
const container = document.getElementById('job-list-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = data.jobs.length ? data.jobs.map((job) => `
|
||||
<article class="job-card status-${job.status}">
|
||||
<div class="job-top"><strong>${job.type}</strong><span class="status">${job.status}</span></div>
|
||||
<div class="job-body">
|
||||
<div>경기 ID: ${job.game_id}</div>
|
||||
<div>이닝: ${job.inning_no || '-'}</div>
|
||||
<div>게임번호: ${job.manager_game_no || '-'}</div>
|
||||
<div>생성: ${job.created_at}</div>
|
||||
</div>
|
||||
<div class="job-actions">
|
||||
<a class="text-link log-preview-link" href="/jobs/${job.job_id}">${job.log_preview}</a>
|
||||
<a class="text-link" href="/jobs/${job.job_id}/log" target="_blank" rel="noopener">로그</a>
|
||||
<a class="text-link" href="/db-logs/${job.job_id}" target="_blank" rel="noopener">DB 상세로그</a>
|
||||
</div>
|
||||
</article>
|
||||
`).join('') : '<p class="empty">아직 작업이 없습니다.</p>';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const startInningNum = document.getElementById('start_inning_num');
|
||||
const startInningHalf = document.getElementById('start_inning_half');
|
||||
const endInningNum = document.getElementById('end_inning_num');
|
||||
const endInningHalf = document.getElementById('end_inning_half');
|
||||
const inningNoHidden = document.getElementById('inning_no');
|
||||
const allInningsCheck = document.getElementById('all_innings_check');
|
||||
|
||||
function syncInningNo() {
|
||||
if (allInningsCheck.checked) {
|
||||
inningNoHidden.value = 'all';
|
||||
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(el => el.disabled = true);
|
||||
} else {
|
||||
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(el => el.disabled = false);
|
||||
const start = startInningNum.value + startInningHalf.value;
|
||||
const end = endInningNum.value + endInningHalf.value;
|
||||
if (start === end) {
|
||||
inningNoHidden.value = start;
|
||||
} else {
|
||||
inningNoHidden.value = `${start}-${end}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[startInningNum, startInningHalf, endInningNum, endInningHalf].forEach(node => {
|
||||
node.addEventListener('change', syncInningNo);
|
||||
});
|
||||
allInningsCheck.addEventListener('change', syncInningNo);
|
||||
|
||||
// 초기 상태 반영
|
||||
if (inningNoHidden.value === 'all') {
|
||||
allInningsCheck.checked = true;
|
||||
}
|
||||
syncInningNo();
|
||||
|
||||
modeSelect.addEventListener('change', syncModeVisibility);
|
||||
managerModeSelect.addEventListener('change', syncManagerModeVisibility);
|
||||
[gameTypeSelect, dateInput, awayTeamCode, homeTeamCode, dhInput, dhInputParse, directGameIdInput, modeSelect].forEach((node) => {
|
||||
node?.addEventListener('input', syncPreview);
|
||||
node?.addEventListener('change', syncPreview);
|
||||
});
|
||||
parseInput?.addEventListener('input', handleParse);
|
||||
|
||||
syncModeVisibility();
|
||||
syncManagerModeVisibility();
|
||||
syncPreview();
|
||||
setInterval(refreshDashboard, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
40
webapp/templates/job.html
Normal file
40
webapp/templates/job.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>작업 상세</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h1>작업 상세</h1>
|
||||
<a class="text-link" href="{{ url_for('index') }}">처음으로</a>
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div><strong>작업 ID</strong><span>{{ job.job_id }}</span></div>
|
||||
<div><strong>타입</strong><span>{{ job.type }}</span></div>
|
||||
<div><strong>상태</strong><span>{{ job.status }}</span></div>
|
||||
<div><strong>경기 ID</strong><span>{{ job.game_id }}</span></div>
|
||||
<div><strong>게임번호</strong><span>{{ job.manager_game_no or '-' }}</span></div>
|
||||
<div><strong>리포트 경로</strong><span>{{ job.report_path }}</span></div>
|
||||
<div><strong>생성</strong><span>{{ job.created_at }}</span></div>
|
||||
<div><strong>시작</strong><span>{{ job.started_at or '-' }}</span></div>
|
||||
<div><strong>종료</strong><span>{{ job.finished_at or '-' }}</span></div>
|
||||
<div><strong>에러</strong><span>{{ job.error or '-' }}</span></div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<a class="btn" href="{{ url_for('job_status', job_id=job.job_id) }}" target="_blank" rel="noopener">상태 JSON</a>
|
||||
<a class="btn primary" href="{{ url_for('job_log', job_id=job.job_id) }}" target="_blank" rel="noopener">로그 보기</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% if job.status in ['queued', 'running'] %}
|
||||
<script>
|
||||
setTimeout(() => window.location.reload(), 7000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
134
webapp/templates/logs.html
Normal file
134
webapp/templates/logs.html
Normal file
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>통합 실행 로그: {{ job_id }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='filename=' + 'style.css') if false else url_for('static', filename='style.css') }}">
|
||||
<style>
|
||||
body { padding: 20px; font-family: 'Inter', sans-serif; background-color: #f8faf9; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
h2 { margin-top: 20px; margin-bottom: 20px; font-weight: 700; color: #1a1c1a; }
|
||||
|
||||
.toolbar { background: white; padding: 15px 20px; border-radius: 12px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
||||
.filter-group { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
|
||||
th, td { padding: 14px 18px; border-bottom: 1px solid #edf2ef; font-size: 14px; text-align: left; }
|
||||
th { background: #f2f5f3; color: #5f6661; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 12px; }
|
||||
|
||||
tr.fail-row { background-color: #fff5f5; }
|
||||
tr.fail-row:hover { background-color: #ffebeb; }
|
||||
tr:hover { background-color: #fcfdfc; }
|
||||
|
||||
.badge { display: inline-block; padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: 700; text-transform: uppercase; }
|
||||
.badge-pitch { background: #e3f2fd; color: #1976d2; }
|
||||
.badge-event { background: #f3e5f5; color: #7b1fa2; }
|
||||
|
||||
.status-tag { display: flex; align-items: center; gap: 6px; font-weight: 600; }
|
||||
.status-success { color: #2e7d32; }
|
||||
.status-fail { color: #d32f2f; }
|
||||
|
||||
.error-msg { color: #d32f2f; font-size: 13px; font-weight: 500; font-family: monospace; background: rgba(211, 47, 47, 0.05); padding: 4px 8px; border-radius: 4px; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.back-btn { display: inline-block; padding: 10px 16px; background: #fff; border: 1px solid #d0d7d3; border-radius: 8px; text-decoration: none; color: #1a1c1a; font-weight: 600; transition: all 0.2s; }
|
||||
.back-btn:hover { background: #f9f9f9; transform: translateX(-4px); }
|
||||
|
||||
/* Toggle Switch */
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 24px; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e0; transition: .4s; border-radius: 24px; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: #f56565; }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="{{ url_for('index') }}" class="back-btn">← 대시보드로 돌아가기</a>
|
||||
</div>
|
||||
|
||||
<div class="hero" style="margin-bottom: 30px;">
|
||||
<h1 style="font-size: 28px; color: #1a1c1a; margin-bottom: 8px;">경기 통합 액션 로그</h1>
|
||||
<p style="color: #5f6661;">Job ID: <code style="background: #e9ecef; padding: 2px 6px; border-radius: 4px;">{{ job_id }}</code></p>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="filter-group">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="failFilter">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span style="font-weight: 600; color: #4a5568;">실패한 로그만 보기</span>
|
||||
</div>
|
||||
<div style="color: #718096; font-size: 14px;">
|
||||
총 <strong>{{ logs|length }}</strong>개의 액션이 기록됨
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="logTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="80">유형</th>
|
||||
<th width="60">이닝</th>
|
||||
<th width="150">대상(타자/교체)</th>
|
||||
<th>동작 상세</th>
|
||||
<th>실제 입력/결과</th>
|
||||
<th width="100">상태</th>
|
||||
<th>에러/비고</th>
|
||||
<th width="100">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if logs %}
|
||||
{% for log in logs %}
|
||||
<tr class="log-row {% if not log.is_success %}fail-row{% endif %}" data-success="{{ log.is_success }}">
|
||||
<td>
|
||||
<span class="badge badge-{{ log.type }}">{{ '투구' if log.type == 'pitch' else '교체' }}</span>
|
||||
</td>
|
||||
<td>{{ log.inning }}</td>
|
||||
<td style="font-weight: 600;">{{ log.target_name }}</td>
|
||||
<td>{{ log.action_desc }}</td>
|
||||
<td>{{ log.actual_desc or '-' }}</td>
|
||||
<td>
|
||||
<div class="status-tag status-{{ 'success' if log.is_success else 'fail' }}">
|
||||
{{ '✔ 성공' if log.is_success else '✖ 실패' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if log.error_msg %}
|
||||
<div class="error-msg">{{ log.error_msg }}</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="color: #718096; font-size: 12px;">{{ log.log_time.split(' ')[1] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; padding: 40px; color: #a0aec0;">기록된 로그가 없습니다.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const failFilter = document.getElementById('failFilter');
|
||||
const logRows = document.querySelectorAll('.log-row');
|
||||
|
||||
failFilter.addEventListener('change', function() {
|
||||
const showOnlyFail = this.checked;
|
||||
logRows.forEach(row => {
|
||||
const isSuccess = row.getAttribute('data-success') === 'True' || row.getAttribute('data-success') === '1';
|
||||
if (showOnlyFail) {
|
||||
row.style.display = isSuccess ? 'none' : '';
|
||||
} else {
|
||||
row.style.display = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user