first jiwoos commit

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

22
.gitignore vendored Normal file
View 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
View 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,
)

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
history.txt Normal file

File diff suppressed because one or more lines are too long

107
lineup_only_playwright.py Normal file
View 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
View 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)

View 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. 그 다음 코드에 반영합니다.

View 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": "고의사구"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"볼": "볼",
"스트라이크": "스트라이크(루킹)",
"헛스윙": "헛스윙(스트라이크)",
"번트헛스윙": "번트시도-스트라이크",
"파울": "파울",
"번트파울": "번트-파울",
"고의사구": "고의사구",
"자동 고의사구": "고의사구"
}

View 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": "체크스윙은 헛스윙 항목으로 처리한다. 최종 판정이 스윙이면 인정, 아니면 불인정이다."
}
}

View 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": "기타 진루"
}
]
}

View 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`처럼 별도 취급한다.

View 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

File diff suppressed because it is too large Load Diff

1367
record_gdate.json Normal file

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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

View 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
View 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
View File

@@ -0,0 +1,2 @@
"""Web application package for the baseball automation project."""

709
webapp/app.py Normal file
View File

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

419
webapp/static/style.css Normal file
View 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
View 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
View 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
View 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">&larr; 대시보드로 돌아가기</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>