commit 296adf3073c31fb26f622c6b67d682b5e79f6ad0 Author: minjiu Date: Sat May 2 11:12:13 2026 +0900 first jiwoos commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b35af69 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/browser_launch.py b/browser_launch.py new file mode 100644 index 0000000..e818b36 --- /dev/null +++ b/browser_launch.py @@ -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, + ) diff --git a/compare_history_with_report.py b/compare_history_with_report.py new file mode 100644 index 0000000..1c38507 --- /dev/null +++ b/compare_history_with_report.py @@ -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() diff --git a/db_logging.py b/db_logging.py new file mode 100644 index 0000000..811f9d5 --- /dev/null +++ b/db_logging.py @@ -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 diff --git a/finish_game_playwright.py b/finish_game_playwright.py new file mode 100644 index 0000000..009cf8d --- /dev/null +++ b/finish_game_playwright.py @@ -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/_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() diff --git a/game_report.py b/game_report.py new file mode 100644 index 0000000..36a3860 --- /dev/null +++ b/game_report.py @@ -0,0 +1,1090 @@ +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from datetime import datetime +from pathlib import Path +import re +from typing import Any + +import httpx + + +DEFAULT_GAME_ID = "20260414LTLG02026" +MAX_INNING = 20 + +TEAM_CODE_MAP = { + "HH": "한화", + "HT": "KIA", + "KT": "KT", + "LG": "LG", + "LT": "롯데", + "NC": "NC", + "OB": "두산", + "SK": "SSG", + "SS": "삼성", + "WO": "키움", +} + +HEADERS = { + "User-Agent": "Mozilla/5.0", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "ko-KR,ko;q=0.9", + "Origin": "https://m.sports.naver.com", + "x-sports-backend": "kotlin", +} + +SKIP_OPTION_TYPES = {0, 8, 98, 99} +HIDDEN_EVENT_TEXTS = {"투수 투수판 이탈"} +CHANGE_KEYWORDS = ("(으)로 교체", "수비위치 변경") +RESULT_LABELS = {"W": "승리투수", "L": "패전투수", "H": "홀드", "S": "세이브"} +HIDDEN_EVENT_TEXTS.update({"코칭스태프 마운드 방문", "포수 마운드 방문"}) +GAME_TYPE_MAP = { + "kbo_r": "정규경기", + "wildcard": "와일드카드", + "wc": "와일드카드", + "semi_playoff": "준플레이오프", + "semi_po": "준플레이오프", + "playoff": "플레이오프", + "po": "플레이오프", + "korean_series": "한국시리즈", + "ks": "한국시리즈", +} +KBO_SR_ID_CANDIDATES = { + "정규경기": ["0", "1", "2", "3", "4", "5", "7", "8", "9"], + "와일드카드": ["3", "0", "1", "2", "4", "5", "7", "8", "9"], + "준플레이오프": ["4", "0", "1", "2", "3", "5", "7", "8", "9"], + "플레이오프": ["5", "0", "1", "2", "3", "4", "7", "8", "9"], + "한국시리즈": ["7", "0", "1", "2", "3", "4", "5", "8", "9"], +} + +REVIEW_RESULT_GROUPS = { + "홈런타구 페어 파울": ("페어", "파울"), + "외야타구 페어 파울": ("페어", "파울"), + "포수/태그플레이 아웃/세이프": ("아웃", "세이프"), + "야수의 포구": ("아웃", "세이프"), + "몸에 맞는 공": ("인정", "불인정"), + "파울": ("인정", "불인정"), + "헛스윙": ("인정", "불인정"), + "기타": ("인정", "불인정"), +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="라인업, 이닝별 타석 로그, 투수 결과를 json/txt 파일로 저장합니다." + ) + parser.add_argument("--game-id", default=DEFAULT_GAME_ID, help="예: 20250425NCSS02025") + parser.add_argument("--output-dir", default="output", help="저장할 폴더 경로") + parser.add_argument("--start-inning", help="시작 이닝 (예: 1, 1T(초), 1B(말))") + parser.add_argument("--end-inning", help="종료 이닝 (예: 9, 9B)") + parser.add_argument("--lineup-only", action="store_true", help="라인업만 포함한 리포트를 생성합니다.") + parser.add_argument("--output-json", help="JSON 저장 경로를 직접 지정합니다.") + return parser.parse_args() + + +def request_json(client: httpx.Client, url: str) -> dict[str, Any]: + response = client.get(url) + response.raise_for_status() + return response.json() + + +def option_seqno(option: dict[str, Any]) -> int: + return int(option.get("seqno", -1)) + + +def relay_seqno(relay: dict[str, Any]) -> int: + seqnos = [option_seqno(option) for option in relay.get("textOptions", []) if option.get("seqno") is not None] + return min(seqnos) if seqnos else -1 + + +def get_team_names(game_id: str, game_info: dict[str, Any] | None = None) -> tuple[str, str]: + if game_info: + return game_info["awayTeamName"], game_info["homeTeamName"] + away_code = game_id[8:10] + home_code = game_id[10:12] + return TEAM_CODE_MAP.get(away_code, away_code), TEAM_CODE_MAP.get(home_code, home_code) + + +def get_starting_pitcher(pitchers: list[dict[str, Any]]) -> dict[str, Any] | None: + if not pitchers: + return None + return min(pitchers, key=lambda pitcher: pitcher.get("seqno", 999)) + + +def get_starting_batters(batters: list[dict[str, Any]]) -> list[dict[str, Any]]: + starters_by_order: dict[int, dict[str, Any]] = {} + + for batter in sorted(batters, key=lambda item: (item.get("batOrder", 999), item.get("seqno", 999))): + bat_order = batter.get("batOrder") + if bat_order is None or bat_order in starters_by_order: + continue + starters_by_order[bat_order] = batter + + return [starters_by_order[order] for order in sorted(starters_by_order)] + + +def format_player_line(player: dict[str, Any]) -> str: + number = player.get("backnum") or "-" + handedness = player.get("hitType") or player.get("hittype") or "-" + position = player.get("posName") or player.get("pos") or "투수" + name = player.get("name") or "-" + return f"{name} (#{number}, {position}, {handedness})" + + +def build_lineup_team(team_name: str, lineup: dict[str, Any]) -> dict[str, Any]: + starter_pitcher = get_starting_pitcher(lineup.get("pitcher", [])) + starting_batters = get_starting_batters(lineup.get("batter", [])) + return { + "team_name": team_name, + "starter_pitcher": { + "name": starter_pitcher.get("name"), + "position": "투수", + "number": starter_pitcher.get("backnum"), + } + if starter_pitcher + else None, + "players": [ + { + "bat_order": batter.get("batOrder"), + "name": batter.get("name"), + "position": batter.get("posName"), + "number": batter.get("backnum"), + } + for batter in starting_batters + ], + } + + +def build_preview_lineup_team(team_name: str, preview_lineup: dict[str, Any] | None) -> dict[str, Any] | None: + if not preview_lineup: + return None + + full_lineup = preview_lineup.get("fullLineUp") or [] + starter_pitcher = next( + ( + player + for player in full_lineup + if player.get("positionName") == "선발투수" or int(player.get("batorder", 0) or 0) == 0 + ), + None, + ) + batters = sorted( + (player for player in full_lineup if int(player.get("batorder", 0) or 0) > 0), + key=lambda player: int(player.get("batorder", 99) or 99), + ) + + return { + "team_name": team_name, + "starter_pitcher": { + "name": starter_pitcher.get("playerName"), + "position": "투수", + "number": starter_pitcher.get("backnum"), + } + if starter_pitcher + else None, + "players": [ + { + "bat_order": int(player.get("batorder")), + "name": player.get("playerName"), + "position": player.get("positionName"), + "number": player.get("backnum"), + } + for player in batters + ], + } + + +def build_lineup_summary( + game_id: str, + game_info: dict[str, Any], + relay_data: dict[str, Any], + preview_data: dict[str, Any] | None = None, +) -> dict[str, Any]: + away_name, home_name = get_team_names(game_id, game_info) + away_preview = build_preview_lineup_team(away_name, (preview_data or {}).get("awayTeamLineUp")) + home_preview = build_preview_lineup_team(home_name, (preview_data or {}).get("homeTeamLineUp")) + return { + "away_team": away_preview or build_lineup_team(away_name, relay_data["awayLineup"]), + "home_team": home_preview or build_lineup_team(home_name, relay_data["homeLineup"]), + } + + +def infer_game_type(game_info: dict[str, Any]) -> str: + round_code = str(game_info.get("roundCode") or "").lower() + round_name = str(game_info.get("roundName") or "").strip() + if round_name: + return round_name + for key, label in GAME_TYPE_MAP.items(): + if key in round_code: + return label + return "정규경기" + + +def derive_umpires(record_data: dict[str, Any]) -> dict[str, str | None]: + umpire_record = next((item for item in record_data.get("etcRecords", []) if item.get("how") == "심판"), None) + names = umpire_record.get("result", "").split() if umpire_record else [] + return { + "chief": names[0] if len(names) > 0 else None, + "first_base": names[1] if len(names) > 1 else None, + "second_base": names[2] if len(names) > 2 else None, + "third_base": names[3] if len(names) > 3 else None, + } + + +def to_kbo_game_id(game_id: str) -> str: + return f"{game_id[:12]}0" + + +def build_iso_datetime(game_date: str | None, hhmm: str | None) -> str | None: + if not game_date or not hhmm: + return None + time_text = hhmm.strip() + if not time_text or ":" not in time_text: + return None + hour_text, minute_text = time_text.split(":", 1) + try: + dt = datetime.fromisoformat(f"{game_date}T{int(hour_text):02d}:{int(minute_text):02d}:00") + except ValueError: + return None + return dt.isoformat() + + +def fetch_kbo_review_meta(client: httpx.Client, game_id: str, game_info: dict[str, Any]) -> dict[str, Any]: + game_type = infer_game_type(game_info) + candidates = KBO_SR_ID_CANDIDATES.get(game_type, KBO_SR_ID_CANDIDATES["정규경기"]) + kbo_game_id = to_kbo_game_id(game_id) + + for sr_id in candidates: + response = client.post( + "https://www.koreabaseball.com/ws/Schedule.asmx/GetScoreBoardScroll", + data={ + "leId": "1", + "srId": sr_id, + "seasonId": str(game_info.get("seasonYear") or ""), + "gameId": kbo_game_id, + }, + ) + response.raise_for_status() + payload = response.json() + if str(payload.get("code")) != "100": + continue + if not any(payload.get(key) for key in ("END_TM", "START_TM", "USE_TM", "CROWD_CN")): + continue + return payload + + return {} + + +def build_game_info(game_info: dict[str, Any], record_data: dict[str, Any], review_meta: dict[str, Any]) -> dict[str, Any]: + end_time = build_iso_datetime(game_info.get("gameDate"), review_meta.get("END_TM")) + return { + "date": game_info.get("gameDate"), + "stadium": game_info.get("stadium"), + "start_time": game_info.get("gameDateTime"), + "end_time": end_time, + "season": game_info.get("seasonYear"), + "game_type": infer_game_type(game_info), + "home_team": game_info.get("homeTeamName"), + "away_team": game_info.get("awayTeamName"), + "attendance": review_meta.get("CROWD_CN"), + "umpires": derive_umpires(record_data), + } + + +def get_half_inning_title(relays: list[dict[str, Any]], inning: int, home_or_away: int) -> str: + for relay in relays: + for option in relay.get("textOptions", []): + if option.get("type") == 0: + return option.get("text", "").strip() + half_label = "초" if home_or_away == 0 else "말" + return f"{inning}회{half_label}" + + +def get_batter_title(relay: dict[str, Any], options: list[dict[str, Any]]) -> str: + batter_title = next((option.get("text", "").strip() for option in options if option.get("type") == 8), "") + if batter_title: + return batter_title + + title = (relay.get("title") or "").strip() + if title and "공격" not in title and not title.startswith("="): + return title + return "" + + +def format_pitch_text(option: dict[str, Any]) -> str: + text = option.get("text", "").strip() + speed = str(option.get("speed") or "").strip() + stuff = str(option.get("stuff") or "").strip() + details = [] + if speed: + details.append(f"{speed}km") + if stuff: + details.append(stuff) + return f"{text} ({', '.join(details)})" if details else text + + +def classify_pitch_result(text: str, code: str | None) -> str: + normalized = text.replace(" ", "") + if any(key in normalized for key in ("번트헛스윙", "헛스윙번트", "번트시도스트라이크")): + return "BS" + if any(key in normalized for key in ("번트파울", "번트파울.")): + return "BF" + if code in {"BS", "BF", "B", "T", "S", "F", "H"}: + return code + if code and code != "V": + return code + mapping = { + "번트 헛스윙": "BS", # Bunt Strike + "번트헛스윙": "BS", + "번트 파울": "BF", # Bunt Foul + "번트파울": "BF", + "볼": "B", + "스트라이크": "T", + "헛스윙": "S", + "파울": "F", + "타격": "H", + } + for key, value in mapping.items(): + if key in text: + return value + return "" + + +def classify_result_type(text: str) -> str: + clean_text = text.replace(" ", "") + if "낫아웃" in clean_text: + return "strikeout_not_out" + if "고의사구" in text: + return "intentional_walk" + if "볼넷" in text: + return "walk" + if "삼진" in text: + return "strikeout" + if any(k in text for k in ["몸에 맞는 볼", "몸에 맞는 공", "사구", "헤드샷"]): + return "hit_by_pitch" + if "홈런" in text: + return "home_run" + + # 1/2/3루타 기본 감지 (runner event에 의해 확장될 수 있음) + if "3루타" in text: + return "triple" + if "2루타" in text: + return "double" + if "번트안타" in text: + return "bunt_hit" + if "1루타" in text or "내야안타" in text: + return "single" + + if "실책" in text and "출루" in text: + return "reach_on_error" + if "야수선택" in text: + return "reach_on_fielder_choice" + if "땅볼로 출루" in text or "땅볼출루" in text: + return "reach_on_grounder" + if "희생번트" in text: + return "sacrifice_bunt" + if "희생플라이" in text: + return "sacrifice_fly" + if "병살타" in text: + return "double_play" + if any(k in text for k in ["플라이 아웃", "땅볼 아웃", "인필드플라이 아웃", "라인드라이브 아웃", "직선타 아웃", "라인드라이브", "직선타"]): + return "out" + return "play" + + +def parse_runner_event(text: str) -> dict[str, Any]: + event_type = "runner_event" + if "도루" in text: + if "실패" in text: + event_type = "steal_fail" + else: + event_type = "steal" + elif "홈인" in text: + event_type = "score" + elif "포스아웃" in text: + event_type = "force_out" + elif "견제사" in text: + event_type = "pickoff_out" + elif "태그아웃" in text: + event_type = "tag_out" + elif "실책" in text: + event_type = "error_advance" + elif "폭투" in text: + event_type = "wild_pitch_advance" + elif "포일" in text: + event_type = "passed_ball_advance" + elif "진루" in text: + event_type = "advance" + + from_base = None + to_base = None + for label, base in (("1루주자", 1), ("2루주자", 2), ("3루주자", 3), ("1루", 1), ("2루", 2), ("3루", 3)): + if label in text and from_base is None: + from_base = base + for label, base in (("1루까지", 1), ("2루까지", 2), ("3루까지", 3)): + if label in text: + to_base = base + if "홈인" in text: + to_base = 4 + + runner_name = text.split(" : ", 1)[0].replace("1루주자 ", "").replace("2루주자 ", "").replace("3루주자 ", "").replace("대주자 ", "").strip() + + extra_advance = 0 + if "주자의 재치로" in text and from_base is not None and to_base is not None: + extra_advance = max(0, to_base - from_base) + + # KBO 매니저 사이트 버튼 매핑용 라벨 (명시적 기록) + clean_text = text.replace(" ", "") + if "실책으로" in clean_text: + action_label = "수비 실책" + elif "도루" in clean_text: + action_label = "도루성공" if "실패" not in clean_text else "도루시도 아웃" + elif "폭투" in clean_text: + action_label = "폭투-진루성공" + elif "포일" in clean_text: + action_label = "포일-진루성공" + elif "태그" in clean_text: + action_label = "태그아웃" + elif "포스" in clean_text: + action_label = "포스아웃" + elif "견제" in clean_text: + action_label = "견제 아웃" + elif any(k in clean_text for k in ["볼넷", "포볼", "고의사구", "몸에맞는", "사구"]): + action_label = "볼넷 진루" + else: + action_label = "일반 진루" + + return { + "type": event_type, + "runner": runner_name, + "fromBase": from_base, + "toBase": to_base, + "extra_advance": extra_advance, + "text": text, + "action_label": action_label + } + + +def normalize_review_result_token(token: str, review_item: str) -> str | None: + token = token.strip() + if not token: + return None + if review_item in {"홈런타구 페어 파울", "외야타구 페어 파울"}: + if "페어" in token: + return "페어" + if "파울" in token: + return "파울" + elif review_item in {"포수/태그플레이 아웃/세이프", "야수의 포구"}: + if "아웃" in token: + return "아웃" + if "세이프" in token: + return "세이프" + elif review_item == "헛스윙": + # 반드시 "노스윙"을 먼저 체크해야 함 ("노스윙"에도 "스윙"이 포함되어 있으므로) + if "불인정" in token or "노스윙" in token or "공포" in token: + return "노스윙" + if "스윙" in token or "인정" in token: + return "스윙" + else: + if "불인정" in token: + return "불인정" + if "인정" in token: + return "인정" + return None + + +def infer_review_item(detail_text: str) -> str: + if "체크스윙" in detail_text or "스윙" in detail_text: + return "헛스윙" + if "홈런" in detail_text: + return "홈런타구 페어 파울" + if "페어" in detail_text or "파울" in detail_text: + return "외야타구 페어 파울" + if "태그" in detail_text or "견제" in detail_text or "도루" in detail_text or "아웃" in detail_text or "세이프" in detail_text or "타구 관련" in detail_text: + return "포수/태그플레이 아웃/세이프" + if "포구" in detail_text or "노바운드" in detail_text or "바운드" in detail_text: + return "야수의 포구" + if "몸에 맞" in detail_text: + return "몸에 맞는 공" + return "기타" + + +def parse_review_event(text: str) -> dict[str, Any]: + inning_match = re.search(r"(\d+)회(초|말)", text) + request_team_match = re.search(r"([가-힣A-Za-z]+)요청\s*(?:비디오 판독|합의 판정)", text) + detail_match = re.search(r"(?:비디오 판독|합의 판정):\s*(.+?)\s*([가-힣][가-힣\s]*)→([가-힣][가-힣\s]*)\s*$", text) + + detail_text = detail_match.group(1).strip() if detail_match else text + review_item = infer_review_item(detail_text) + before_result = normalize_review_result_token(detail_match.group(2), review_item) if detail_match else None + after_result = normalize_review_result_token(detail_match.group(3), review_item) if detail_match else None + timing = "before_pitch" if "초구 전" in text else "after_pitch" + + return { + "type": "video_review", + "text": text, + "requestInningLabel": f"{inning_match.group(1)}{'초' if inning_match.group(2) == '초' else '말'}" if inning_match else None, + "requestTeam": request_team_match.group(1) if request_team_match else None, + "reviewItem": review_item, + "beforeResult": before_result, + "finalResult": after_result, + "isSuccess": "성공" if before_result and after_result and before_result != after_result else "실패", + "timing": timing, + } + + +def extract_change_actor(text: str) -> tuple[str | None, str | None, str]: + lhs = text.split(" : ", 1)[0].strip() + if "번타자 " in lhs: + order_match = re.search(r"(\d+)번타자\s+(.+)$", lhs) + if order_match: + return "batter", order_match.group(1), order_match.group(2).strip() + for role in ("대타", "대주자", "1루주자", "2루주자", "3루주자", "주자", "투수", "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"): + if lhs.startswith(role + " "): + return role, None, lhs[len(role):].strip() + return None, None, lhs + + +def is_merged_pitcher_substitution(actor_role: str | None, in_role: str | None) -> bool: + field_roles = {"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"} + return actor_role in field_roles and in_role == "투수" + + +def parse_change_event(text: str) -> dict[str, Any]: + event: dict[str, Any] = { + "event_type": "change", + "change_type": "position_change" if "수비위치 변경" in text else "substitution", + "text": text, + } + actor_role, batter_order, actor_name = extract_change_actor(text) + event["actor_role"] = actor_role + event["actor_name"] = actor_name + if batter_order: + event["bat_order"] = int(batter_order) + + if "수비위치 변경" in text: + to_position = text.split(" : ", 1)[1].split("(으)로", 1)[0].strip() + event["player_name"] = actor_name + event["to_position"] = to_position + return event + + rhs = text.split(" : ", 1)[1].split("(으)로 교체", 1)[0].strip() + in_role, _, in_name = extract_change_actor(rhs) + event["out_player"] = actor_name + event["in_player"] = in_name + event["in_role"] = in_role + if is_merged_pitcher_substitution(actor_role, in_role): + event["change_type"] = "merged_pitcher_substitution" + event["player_name"] = actor_name + event["to_position"] = "지명타자" + event["pitcher_in_player"] = in_name + return event + if in_role in {"투수", "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수", "대타", "대주자"}: + event["to_position"] = in_role if in_role not in {"대타", "대주자"} else None + return event + + +def merge_runner_events(runner_events: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: dict[str, dict[str, Any]] = {} + for r in runner_events: + name = r.get("runner") + if not name: + continue + if name in merged: + merged[name]["type"] = r.get("type", merged[name]["type"]) + merged[name]["text"] += f" / {r.get('text', '')}" + if r.get("toBase"): + merged[name]["toBase"] = r["toBase"] + if r.get("extra_advance"): + merged[name]["extra_advance"] = r["extra_advance"] + if "태그아웃" in r.get("text", "") or r.get("type") == "tag_out": + merged[name]["type"] = "tag_out" + else: + merged[name] = r + return list(merged.values()) + + +def build_relay_events(relay: dict[str, Any]) -> list[dict[str, Any]]: + """하나의 릴레이 블록을 분석하여, 투구 번호 리셋 등을 감지해 여러 개의 타석/교체 이벤트 리스트로 반환합니다.""" + options = sorted(relay.get("textOptions", []), key=option_seqno) + + # 1. 세그먼트 분리 (pitchNum 1이 새로 나오면 타자가 바뀐 것) + segments: list[list[dict[str, Any]]] = [] + current_segment: list[dict[str, Any]] = [] + + for opt in options: + opt_type = opt.get("type") + # 투구가 1구인데 이미 현재 세그먼트에 투구가 들어있다면 새로운 타자 세그먼트 시작 + if opt_type == 1 and opt.get("pitchNum") == 1: + if any(o.get("type") == 1 for o in current_segment): + segments.append(current_segment) + current_segment = [] + current_segment.append(opt) + if current_segment: + segments.append(current_segment) + + # 2. 각 세그먼트별로 이벤트 객체 생성 + results = [] + relay_batter_title = get_batter_title(relay, options) + + for i, seg_options in enumerate(segments): + seg_changes = [] + seg_event_texts = [] + seg_pitches = [] + seg_runner_events = [] + seg_review_events = [] + seg_extra_events = [] + seg_result_text = None + + # 해당 세그먼트만의 타자 이름 찾기 + seg_batter_name = None + # 우선 type 8(타자 제목) 옵션이 있는지 확인 + seg_batter_name = next((o.get("text", "").strip() for o in seg_options if o.get("type") == 8), None) + + for opt in seg_options: + ot = opt.get("type") + txt = opt.get("text", "").strip() + if not txt or ot in SKIP_OPTION_TYPES: + continue + if txt in HIDDEN_EVENT_TEXTS: + continue + if any(k in txt for k in CHANGE_KEYWORDS): + seg_changes.append(parse_change_event(txt)) + continue + + if ot == 1: + seg_event_texts.append(format_pitch_text(opt)) + seg_pitches.append({ + "pitchNo": opt.get("pitchNum"), + "pitchResult": classify_pitch_result(txt, opt.get("pitchResult")), + "pitchResultText": txt.replace(f"{opt.get('pitchNum')}구 ", "", 1), + "speedKmh": int(opt["speed"]) if opt.get("speed") not in (None, "") else None, + "pitchType": opt.get("stuff"), + "runnerEvents": [], + }) + continue + + if ot == 14: + if seg_pitches: + seg_pitches[-1]["runnerEvents"].append(parse_runner_event(txt)) + else: + seg_runner_events.append(parse_runner_event(txt)) + continue + if ot == 24: + seg_runner_events.append(parse_runner_event(txt)) + continue + + seg_event_texts.append(txt) + if "비디오 판독" in txt or "합의 판정" in txt: + seg_review_events.append(parse_review_event(txt)) + elif "체크스윙" in txt: + seg_extra_events.append({"type": "appeal_or_judgement", "text": txt}) + elif any(r in txt for r in ["1루주자", "2루주자", "3루주자", "대주자", "도루", "홈인", "포스아웃"]) or ("진루" in txt and "출루" not in txt): + seg_runner_events.append(parse_runner_event(txt)) + else: + seg_result_text = txt + # 결과 텍스트에서 타자 이름 추론 (예: "고종욱 : 좌익수 플라이 아웃") + if " : " in txt and seg_batter_name is None: + name_part = txt.split(" : ", 1)[0].strip() + if name_part and len(name_part) < 10: # 너무 긴 문장은 제외 + seg_batter_name = name_part + + # 최종 타자 명칭 결정 + if not seg_batter_name: + # 첫 세그먼트면 릴레이 전체 타이틀 사용, 아니면 직전 대타 정보 등에서 유추 (일단 타이틀로 보정) + seg_batter_name = relay_batter_title if i == 0 else "" + + # 주자 이벤트 병합 + for p in seg_pitches: + p["runnerEvents"] = merge_runner_events(p["runnerEvents"]) + seg_merged_runner_events = merge_runner_events(seg_runner_events) + + # 타자 결과 객체 + res_obj = None + if seg_result_text: + base_type = classify_result_type(seg_result_text) + res_obj = {"type": base_type, "text": seg_result_text} + + # 타자 본인의 이후 주루 정보 확인 (주루아웃/실책진루 등) + b_name = seg_batter_name.split()[-1] if seg_batter_name else "" + final_runners = [] + for r in seg_merged_runner_events: + if b_name and r.get("runner") == b_name: + # 안타성 타구인 경우 주루 결과에 따라 타입 확장 + if base_type in {"single", "double", "triple"}: + r_type = r.get("type", "") + # 1) 주루 아웃인 경우 + if r_type in {"tag_out", "force_out", "steal_fail", "pickoff_out"}: + res_obj["type"] = f"{base_type}_runner_out" + # 2) 실책으로 인한 추가 진루인 경우 + elif r_type == "error_advance": + res_obj["type"] = f"{base_type}_error_advance" + + if r.get("toBase"): res_obj["toBase"] = r["toBase"] + if r.get("extra_advance"): res_obj["extra_advance"] = r["extra_advance"] + else: + final_runners.append(r) + seg_merged_runner_events = final_runners + + # 세그먼트 결과 조립 + if seg_changes: + results.extend(seg_changes) + + if seg_event_texts: + full_txt = f"{seg_batter_name} : " + ", ".join(seg_event_texts) if seg_batter_name else ", ".join(seg_event_texts) + results.append({ + "event_type": "at_bat", + "batter": seg_batter_name, + "rawText": full_txt, + "pitches": seg_pitches, + "result": res_obj, + "runnerEvents": seg_merged_runner_events, + "reviewEvents": seg_review_events, + "extraEvents": seg_extra_events, + "changes": [] # 세그먼트 내부에서는 별도로 다룸 + }) + + return results + + +def build_half_inning(inning: int, home_or_away: int, relays: list[dict[str, Any]]) -> dict[str, Any]: + title = get_half_inning_title(relays, inning, home_or_away) + raw_events: list[dict[str, Any]] = [] + + for relay in sorted(relays, key=relay_seqno): + new_events = build_relay_events(relay) + raw_events.extend(new_events) + + merged_events: list[dict[str, Any]] = [] + for event in raw_events: + if not merged_events or event.get("event_type") != "at_bat": + merged_events.append(event) + continue + + prev = merged_events[-1] + if prev.get("event_type") != "at_bat": + merged_events.append(event) + continue + + # 병합 조건: 투구 번호가 1보다 크고 타자 이름이 같거나 유사한 경우 + current_pitches = event.get("pitches") or [] + first_pitch_no = current_pitches[0].get("pitchNo", 0) if current_pitches else 0 + + is_same_batter = (prev.get("batter") == event.get("batter")) + + if first_pitch_no > 1 or is_same_batter: + # 병합 수행 + prev["pitches"].extend(current_pitches) + if event.get("result"): + prev["result"] = event["result"] + if event.get("rawText"): + # "이름 : " 중복 제거하며 병합 + current_txt = event["rawText"] + if " : " in current_txt: current_txt = current_txt.split(" : ", 1)[1] + prev["rawText"] += " / " + current_txt + + prev["runnerEvents"].extend(event.get("runnerEvents") or []) + prev["reviewEvents"].extend(event.get("reviewEvents") or []) + prev["extraEvents"].extend(event.get("extraEvents") or []) + continue + + merged_events.append(event) + + return { + "inning": inning, + "half": "top" if home_or_away == 0 else "bottom", + "title": title, + "events": merged_events, + } + + +def parse_inning_value(val: Any, default: float) -> float: + if val is None: + return default + s = str(val).upper().strip() + if not s: + return default + + # 1T, 1B 등 초/말 구분 처리 + m = re.match(r"^(\d+)([TB]?)$", s) + if not m: + try: + return float(s) + except: + return default + + num = int(m.group(1)) + suffix = m.group(2) + if suffix == "T": + return float(num) # .0 + if suffix == "B": + return num + 0.5 + return float(num) + +def collect_inning_data(client: httpx.Client, game_id: str, start_inning_val: str | None = None, end_inning_val: str | None = None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + innings: list[dict[str, Any]] = [] + raw_relays: list[dict[str, Any]] = [] + + start_score = parse_inning_value(start_inning_val, 0.0) + end_score = parse_inning_value(end_inning_val, 99.0) + + for inning in range(1, MAX_INNING + 1): + url = f"https://api-gw.sports.naver.com/schedule/games/{game_id}/relay?inning={inning}" + try: + payload = request_json(client, url) + except Exception: + break + + relays = payload.get("result", {}).get("textRelayData", {}).get("textRelays", []) + if not relays: + break + + grouped: dict[int, list[dict[str, Any]]] = defaultdict(list) + for relay in relays: + grouped[int(relay.get("homeOrAway", -1))].append(relay) + raw_relays.append(relay) + + for home_or_away in (0, 1): + half_relays = grouped.get(home_or_away, []) + if not half_relays: + continue + + # 현재 이닝의 수치화 (0: 초=.0, 1: 말=.5) + current_score = inning + (0.5 if home_or_away == 1 else 0.0) + if current_score < start_score or current_score > end_score: + continue + + innings.append(build_half_inning(inning, home_or_away, half_relays)) + + return innings, raw_relays + + +def extract_pitching_summary(record_data: dict[str, Any]) -> dict[str, list[str]]: + summary = {"승리투수": [], "패전투수": [], "홀드": [], "세이브": []} + for pitcher in record_data.get("pitchingResult", []): + label = RESULT_LABELS.get(pitcher.get("wls")) + if not label: + continue + summary[label].append(pitcher["name"]) + return summary + + +def collect_score_timeline(raw_relays: list[dict[str, Any]]) -> list[dict[str, Any]]: + timeline: list[dict[str, Any]] = [] + + for relay in raw_relays: + for option in relay.get("textOptions", []): + state = option.get("currentGameState") or {} + if not state: + continue + timeline.append( + { + "seqno": option.get("seqno"), + "home_score": int(state.get("homeScore", 0)), + "away_score": int(state.get("awayScore", 0)), + } + ) + + timeline.sort(key=lambda item: item["seqno"]) + return timeline + + +def collect_blown_saves(raw_relays: list[dict[str, Any]], away_name: str, home_name: str) -> list[str]: + timeline = collect_score_timeline(raw_relays) + blown_save_pitchers: list[str] = [] + + pitcher_entries: list[dict[str, Any]] = [] + for relay in raw_relays: + inning = int(relay.get("inn", 0) or 0) + if inning < 7: + continue + + batting_side = int(relay.get("homeOrAway", -1)) + pitcher_team = "home" if batting_side == 0 else "away" + pitcher_team_name = home_name if pitcher_team == "home" else away_name + + for option in relay.get("textOptions", []): + if option.get("type") != 2: + continue + player_change = option.get("playerChange") or {} + in_player = player_change.get("inPlayer") or {} + if in_player.get("playerPos") != "투수": + continue + state = option.get("currentGameState") or {} + pitcher_entries.append( + { + "name": in_player.get("playerName"), + "team": pitcher_team, + "team_name": pitcher_team_name, + "entry_seqno": option.get("seqno"), + "home_score": int(state.get("homeScore", 0)), + "away_score": int(state.get("awayScore", 0)), + } + ) + + for entry in pitcher_entries: + team_score = entry["home_score"] if entry["team"] == "home" else entry["away_score"] + opp_score = entry["away_score"] if entry["team"] == "home" else entry["home_score"] + if team_score <= opp_score: + continue + + for state in timeline: + if state["seqno"] <= entry["entry_seqno"]: + continue + current_team_score = state["home_score"] if entry["team"] == "home" else state["away_score"] + current_opp_score = state["away_score"] if entry["team"] == "home" else state["home_score"] + if current_team_score <= current_opp_score: + blown_save_pitchers.append(entry["name"]) + break + + return sorted(set(blown_save_pitchers)) + + +def build_pitcher_section(record_data: dict[str, Any], raw_relays: list[dict[str, Any]], away_name: str, home_name: str) -> dict[str, list[str]]: + summary = extract_pitching_summary(record_data) + summary["블론세이브"] = collect_blown_saves(raw_relays, away_name, home_name) + return summary + + +def render_lineup_text(lineup_summary: dict[str, Any]) -> list[str]: + lines = ["[라인업]"] + for team_key in ("away_team", "home_team"): + team = lineup_summary[team_key] + lines.append(f"[{team['team_name']}]") + if team["starter_pitcher"]: + pitcher = team["starter_pitcher"] + lines.append(f"선발투수: {pitcher['name']} (#{pitcher['number']}, {pitcher['position']})") + for player in team["players"]: + lines.append(f"{player['bat_order']}번: {player['name']} (#{player['number']}, {player['position']})") + lines.append("") + if lines[-1] == "": + lines.pop() + return lines + + +def render_innings_text(innings: list[dict[str, Any]]) -> list[str]: + lines = ["[이닝별 타석 로그]"] + for half_inning in innings: + lines.append(f"[{half_inning['title']}]") + for event in half_inning["events"]: + if event["event_type"] == "at_bat": + lines.append(event["rawText"]) + else: + lines.append(event["text"]) + lines.append("") + if lines[-1] == "": + lines.pop() + return lines + + +def render_pitcher_text(pitcher_section: dict[str, list[str]]) -> list[str]: + lines = ["[투수 결과]"] + for label in ("승리투수", "패전투수", "홀드", "세이브", "블론세이브"): + names = pitcher_section.get(label, []) + lines.append(f"{label}: {', '.join(names) if names else '-'}") + return lines + + +def render_text(report: dict[str, Any]) -> str: + sections = [ + render_lineup_text(report["lineups"]), + render_innings_text(report["game_contents"]), + render_pitcher_text(report["pitching_summary"]), + ] + return "\n\n".join("\n".join(section) for section in sections) + + +def save_outputs(report: dict[str, Any], output_dir: Path, output_json: Path | None = None) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + game_id = report["game_id"] + json_path = output_json or (output_dir / f"{game_id}_report.json") + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + return json_path + + +def filter_report(report: dict[str, Any], inning: str | None = None, lineup_only: bool = False, start_inning: str | None = None, end_inning: str | None = None) -> dict[str, Any]: + filtered = json.loads(json.dumps(report, ensure_ascii=False)) + if lineup_only: + filtered["game_contents"] = [] + filtered["pitching_summary"] = { + "승리투수": [], + "패전투수": [], + "홀드": [], + "세이브": [], + "블론세이브": [], + } + return filtered + + start_v = parse_inning_value(start_inning, 0.0) + end_v = parse_inning_value(end_inning, 99.0) + + if inning is not None: + iv = parse_inning_value(inning, 0.0) + start_v = iv + end_v = iv + 0.5 + + filtered["game_contents"] = [ + half_inning for half_inning in filtered.get("game_contents", []) + if start_v <= (float(half_inning.get("inning") or 0) + (0.5 if half_inning.get("half") == "bottom" else 0.0)) <= end_v + ] + return filtered + + +def build_report(game_id: str, start_inning: str | None = None, end_inning: str | None = None) -> dict[str, Any]: + # game_id 정제: 한글, 공백, 하이픈 등 제거하여 순수 API 규격만 남김 + game_id = "".join(re.findall(r"[A-Za-z0-9]", game_id)) + + with httpx.Client(headers=HEADERS, timeout=20.0) as client: + relay_payload = request_json(client, f"https://api-gw.sports.naver.com/schedule/games/{game_id}/relay") + record_payload = request_json(client, f"https://api-gw.sports.naver.com/schedule/games/{game_id}/record?fields=all") + game_payload = request_json(client, f"https://api-gw.sports.naver.com/schedule/games/{game_id}") + preview_payload = request_json(client, f"https://api-gw.sports.naver.com/schedule/games/{game_id}/preview") + + relay_data = relay_payload["result"]["textRelayData"] + record_data = record_payload["result"]["recordData"] + game_info = game_payload["result"]["game"] + preview_data = preview_payload["result"].get("previewData") or {} + review_meta = fetch_kbo_review_meta(client, game_id, game_info) + + lineup_summary = build_lineup_summary(game_id, game_info, relay_data, preview_data) + innings, raw_relays = collect_inning_data(client, game_id, start_inning_val=start_inning, end_inning_val=end_inning) + pitcher_section = build_pitcher_section( + record_data, + raw_relays, + lineup_summary["away_team"]["team_name"], + lineup_summary["home_team"]["team_name"], + ) + + return { + "game_id": game_id, + "game_info": build_game_info(game_info, record_data, review_meta), + "lineups": lineup_summary, + "game_contents": innings, + "pitching_summary": pitcher_section, + } + + +def main() -> None: + args = parse_args() + # game_id 정제 (알파벳+숫자만 추출) + cleaned_id = "".join(re.findall(r"[A-Za-z0-9]", args.game_id)) + report = build_report(cleaned_id, start_inning=args.start_inning, end_inning=args.end_inning) + filtered_report = filter_report( + report, + inning=None, + lineup_only=args.lineup_only, + start_inning=args.start_inning, + end_inning=args.end_inning + ) + save_outputs( + filtered_report, + Path(args.output_dir), + output_json=Path(args.output_json) if args.output_json else None, + ) + + +if __name__ == "__main__": + main() diff --git a/history.txt b/history.txt new file mode 100644 index 0000000..97a279e --- /dev/null +++ b/history.txt @@ -0,0 +1 @@ +타자 : 김민우 - 볼타자 : 김민우 - 파울타자 : 김민우 - 파울타자 : 김민우 - 1루타1루주자 : 김민우 - 일반 진루타자 : 장민석 - 희생 번트타자 : 유한준 - 볼타자 : 유한준 - 볼타자 : 유한준 - 헛스윙(스트라이크)타자 : 유한준 - 볼타자 : 유한준 - 포볼타자 : 알드리지 - 파울타자 : 알드리지 - 스트라이크(루킹)타자 : 알드리지 - 볼타자 : 알드리지 - 볼타자 : 알드리지 - 파울플라이-아웃타자 : 박병호 - 스트라이크(루킹)타자 : 박병호 - 볼타자 : 박병호 - 스트라이크(루킹)타자 : 박병호 - 스윙 스트라이크-아웃타자 : 김상수 - 볼타자 : 김상수 - 아웃타자 : 박한이 - 볼타자 : 박한이 - 볼타자 : 박한이 - 볼타자 : 박한이 - 스트라이크(루킹)타자 : 박한이 - 1루타타자 : 박석민 - 스트라이크(루킹)타자 : 박석민 - 스트라이크(루킹)타자 : 박석민 - 루킹스트라이크-아웃타자 : 최형우 - 볼타자 : 최형우 - 아웃타자 : 강정호 - 볼타자 : 강정호 - 스트라이크(루킹)타자 : 강정호 - 스트라이크(루킹)타자 : 강정호 - 스윙 스트라이크-아웃타자 : 송지만 - 스트라이크(루킹)타자 : 송지만 - 스트라이크(루킹)타자 : 송지만 - 루킹스트라이크-아웃타자 : 허도환 - 헛스윙(스트라이크)타자 : 허도환 - 파울타자 : 허도환 - 볼타자 : 허도환 - 스윙 스트라이크-아웃타자 : 채태인 - 내야안타타자 : 강봉규 - 볼타자 : 강봉규 - 아웃타자 : 조동찬 - 볼타자 : 조동찬 - 볼타자 : 조동찬 - 스트라이크(루킹)타자 : 조동찬 - 볼타자 : 조동찬 - 스트라이크(루킹)1루주자 : 채태인 - 일반 진루타자 : 조동찬 - 1루타타자 : 진갑용* - 스트라이크(루킹)3루주자 : 채태인 - 일반 진루1루주자 : 조동찬 - 일반 진루타자 : 진갑용* - 1루타타자 : 정형식 - 스트라이크(루킹)타자 : 정형식 - 볼2루주자 : 조동찬 - 도루성공타자 : 정형식 - 볼3루주자 : 조동찬 - 일반 진루1루주자 : 진갑용* - 일반 진루타자 : 정형식 - 2루타타자 : 김상수 - 볼타자 : 김상수 - 파울플라이-아웃타자 : 박한이 - 헛스윙(스트라이크)타자 : 박한이 - 볼타자 : 박한이 - 볼타자 : 박한이 - 볼타자 : 박한이 - 포볼타자 : 박석민 - 파울타자 : 박석민 - 파울타자 : 박석민 - 볼타자 : 박석민 - 볼타자 : 박석민 - 볼타자 : 박석민 - 포볼1루주자 : 박한이 - 볼넷 진루2루주자 : 정형식 - 볼넷 진루3루주자 : 진갑용* - 볼넷 진루타자 : 최형우 - 볼타자 : 최형우 - 볼타자 : 최형우 - 스트라이크(루킹)타자 : 최형우 - 파울타자 : 최형우 - 파울타자 : 최형우 - 아웃타자 : 김민성 - 볼타자 : 김민성 - 스트라이크(루킹)타자 : 김민성 - 볼타자 : 김민성 - 2루타2루주자 : 김민성 - 일반 진루타자 : 김민우 - 희생 번트타자 : 장민석 - 헛스윙(스트라이크)타자 : 장민석 - 아웃3루주자 : 김민성 - 태그아웃타자 : 채태인 - 헛스윙(스트라이크)타자 : 채태인 - 스트라이크(루킹)타자 : 채태인 - 스윙 스트라이크-아웃타자 : 강봉규 - 아웃타자 : 조동찬 - 볼타자 : 조동찬 - 볼타자 : 조동찬 - 아웃타자 : 유한준 - 볼타자 : 유한준 - 볼타자 : 유한준 - 스트라이크(루킹)타자 : 유한준 - 스트라이크(루킹)타자 : 유한준 - 파울타자 : 유한준 - 볼타자 : 유한준 - 홈런타자 : 알드리지 - 볼타자 : 알드리지 - 스트라이크(루킹)타자 : 알드리지 - 볼타자 : 알드리지 - 아웃타자 : 박병호 - 파울타자 : 박병호 - 볼타자 : 박병호 - 헛스윙(스트라이크)타자 : 박병호 - 볼타자 : 박병호 - 2루타타자 : 강정호 - 파울타자 : 강정호 - 헛스윙(스트라이크)타자 : 강정호 - 볼2루주자 : 박병호 - 일반 진루타자 : 강정호 - 1루타타자 : 송지만 - 볼타자 : 송지만 - 파울타자 : 송지만 - 볼타자 : 송지만 - 헛스윙(스트라이크)타자 : 송지만 - 땅볼출루(무안타)1루주자 : 강정호 - 포스아웃타자 : 허도환 - 번트-아웃타자 : 진갑용* - 볼타자 : 진갑용* - 볼타자 : 진갑용* - 스트라이크(루킹)타자 : 진갑용* - 파울타자 : 진갑용* - 아웃타자 : 정형식 - 번트안타타자 : 김상수 - 스트라이크(루킹)타자 : 김상수 - 볼1루주자 : 정형식 - 태그아웃타자 : 김상수 - 스트라이크(루킹)타자 : 김상수 - 볼타자 : 김상수 - 볼타자 : 김상수 - 아웃타자 : 김민성 - 스트라이크(루킹)타자 : 김민성 - 스트라이크(루킹)타자 : 김민성 - 파울타자 : 김민성 - 아웃타자 : 김민우 - 볼타자 : 김민우 - 스트라이크(루킹)타자 : 김민우 - 볼타자 : 김민우 - 스트라이크(루킹)타자 : 김민우 - 파울플라이-아웃타자 : 장민석 - 스트라이크(루킹)타자 : 장민석 - 1루타1루주자 : 장민석 - 포스아웃타자 : 유한준 - 땅볼출루(무안타)타자 : 박한이 - 볼타자 : 박한이 - 스트라이크(루킹)타자 : 박한이 - 볼타자 : 박한이 - 헛스윙(스트라이크)타자 : 박한이 - 파울타자 : 박한이 - 볼타자 : 박한이 - 아웃타자 : 박석민 - 스트라이크(루킹)타자 : 박석민 - 볼타자 : 박석민 - 파울타자 : 박석민 - 볼타자 : 박석민 - 아웃타자 : 최형우 - 볼타자 : 최형우 - 볼타자 : 최형우 - 파울타자 : 최형우 - 볼타자 : 최형우 - 파울타자 : 최형우 - 포볼타자 : 채태인 - 볼타자 : 채태인 - 스트라이크(루킹)1루주자 : 최형우 - 일반 진루타자 : 채태인 - 1루타타자 : 강봉규 - 땅볼출루(무안타)1루주자 : 채태인 - 포스아웃타자 : 알드리지 - 아웃타자 : 박병호 - 볼타자 : 박병호 - 볼타자 : 박병호 - 헛스윙(스트라이크)타자 : 박병호 - 아웃타자 : 강정호 - 볼타자 : 강정호 - 아웃타자 : 조동찬 - 볼타자 : 조동찬 - 스트라이크(루킹)타자 : 조동찬 - 헛스윙(스트라이크)타자 : 조동찬 - 파울타자 : 조동찬 - 스윙 스트라이크-아웃타자 : 진갑용* - 스트라이크(루킹)타자 : 진갑용* - 볼타자 : 진갑용* - 볼타자 : 진갑용* - 볼타자 : 진갑용* - 포볼타자 : 정형식 - 파울타자 : 정형식 - 파울타자 : 정형식 - 볼타자 : 정형식 - 볼타자 : 정형식 - 볼타자 : 정형식 - 포볼1루주자 : 진갑용* - 태그아웃타자 : 김상수 - 아웃타자 : 송지만 - 파울플라이-아웃타자 : 허도환 - 볼타자 : 허도환 - 스트라이크(루킹)타자 : 허도환 - 1루타타자 : 김민성 - 볼타자 : 김민성 - 볼타자 : 김민성 - 스트라이크(루킹)타자 : 김민성 - 헛스윙(스트라이크)타자 : 김민성 - 스윙 스트라이크-아웃타자 : 김민우 - 스트라이크(루킹)타자 : 김민우 - 볼타자 : 김민우 - 파울타자 : 김민우 - 파울타자 : 김민우 - 볼타자 : 김민우 - 스윙 스트라이크-아웃타자 : 박한이 - 볼타자 : 박한이 - 스트라이크(루킹)타자 : 박한이 - 헛스윙(스트라이크)타자 : 박한이 - 아웃타자 : 박석민 - 볼타자 : 박석민 - 볼타자 : 박석민 - 볼타자 : 박석민 - 스트라이크(루킹)타자 : 박석민 - 스트라이크(루킹)타자 : 박석민 - 아웃타자 : 최형우 - 볼타자 : 최형우 - 볼타자 : 최형우 - 스트라이크(루킹)타자 : 최형우 - 볼타자 : 최형우 - 아웃타자 : 장민석 - 스트라이크(루킹)타자 : 장민석 - 스트라이크(루킹)타자 : 장민석 - 볼타자 : 장민석 - 볼타자 : 장민석 - 스윙 스트라이크-아웃타자 : 유한준 - 파울플라이-아웃타자 : 알드리지 - 스트라이크(루킹)타자 : 알드리지 - 스트라이크(루킹)타자 : 알드리지 - 볼타자 : 알드리지 - 볼타자 : 알드리지 - 스윙 스트라이크-아웃타자 : 채태인 - 스트라이크(루킹)타자 : 채태인 - 볼타자 : 채태인 - 헛스윙(스트라이크)타자 : 채태인 - 볼타자 : 채태인 - 루킹스트라이크-아웃타자 : 조영훈 - 스트라이크(루킹)타자 : 조영훈 - 2루타타자 : 조동찬 - 볼타자 : 조동찬 - 볼타자 : 조동찬 - 아웃타자 : 진갑용* - 스트라이크(루킹)타자 : 진갑용* - 볼타자 : 진갑용* - 스트라이크(루킹)타자 : 진갑용* - 파울타자 : 진갑용* - 아웃타자 : 박병호 - 스트라이크(루킹)타자 : 박병호 - 파울타자 : 박병호 - 볼타자 : 박병호 - 스윙 스트라이크-아웃타자 : 강정호 - 볼타자 : 강정호 - 볼타자 : 강정호 - 파울플라이-아웃타자 : 송지만 - 파울타자 : 송지만 - 파울타자 : 송지만 - 스윙 스트라이크-아웃 \ No newline at end of file diff --git a/lineup_only_playwright.py b/lineup_only_playwright.py new file mode 100644 index 0000000..4a51b5d --- /dev/null +++ b/lineup_only_playwright.py @@ -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/_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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..dcdbeab --- /dev/null +++ b/main.py @@ -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) diff --git a/mapping_overrides/README.md b/mapping_overrides/README.md new file mode 100644 index 0000000..1e66bea --- /dev/null +++ b/mapping_overrides/README.md @@ -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. 그 다음 코드에 반영합니다. diff --git a/mapping_overrides/batter_result_map.json b/mapping_overrides/batter_result_map.json new file mode 100644 index 0000000..b179499 --- /dev/null +++ b/mapping_overrides/batter_result_map.json @@ -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": "고의사구" + } + ] +} diff --git a/mapping_overrides/pitch_result_map.json b/mapping_overrides/pitch_result_map.json new file mode 100644 index 0000000..002c976 --- /dev/null +++ b/mapping_overrides/pitch_result_map.json @@ -0,0 +1,10 @@ +{ + "볼": "볼", + "스트라이크": "스트라이크(루킹)", + "헛스윙": "헛스윙(스트라이크)", + "번트헛스윙": "번트시도-스트라이크", + "파울": "파울", + "번트파울": "번트-파울", + "고의사구": "고의사구", + "자동 고의사구": "고의사구" +} diff --git a/mapping_overrides/review_item_map.json b/mapping_overrides/review_item_map.json new file mode 100644 index 0000000..fc278b6 --- /dev/null +++ b/mapping_overrides/review_item_map.json @@ -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": "체크스윙은 헛스윙 항목으로 처리한다. 최종 판정이 스윙이면 인정, 아니면 불인정이다." + } +} diff --git a/mapping_overrides/runner_event_map.json b/mapping_overrides/runner_event_map.json new file mode 100644 index 0000000..2b65922 --- /dev/null +++ b/mapping_overrides/runner_event_map.json @@ -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": "기타 진루" + } + ] +} diff --git a/mapping_overrides/special_rules.md b/mapping_overrides/special_rules.md new file mode 100644 index 0000000..8f17d7c --- /dev/null +++ b/mapping_overrides/special_rules.md @@ -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`처럼 별도 취급한다. diff --git a/mapping_overrides/standard_schema.md b/mapping_overrides/standard_schema.md new file mode 100644 index 0000000..1fa0ebf --- /dev/null +++ b/mapping_overrides/standard_schema.md @@ -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`는 마지막 규칙만 허용한다. + +## 표준값 규칙 + +- 저장값은 항상 하나의 표준 문자열이어야 한다. +- 표준 문자열은 코드에서 다시 해석하지 않아도 되도록 짧고 일관되게 유지한다. +- 별칭, 공백, 한국어/영문 혼용은 입력 단계에서만 흡수한다. + diff --git a/record_game_playwright.py b/record_game_playwright.py new file mode 100644 index 0000000..0fb8839 --- /dev/null +++ b/record_game_playwright.py @@ -0,0 +1,3002 @@ +from __future__ import annotations + +import argparse +import hashlib +import math +import re +from pathlib import Path +from time import time, sleep +from typing import Any +import os + +from playwright.sync_api import Page, 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, +) + + +PITCH_TYPE_LABEL_MAP = { + "직구": "패스트볼", + "패스트볼": "패스트볼", + "커브": "커브", + "체인지업": "체인지업", + "슬라이더": "슬라이더", + "커터": "커터", + "스플리터": "스플리터", + "포크": "포크볼", + "포크볼": "포크볼", + "투심": "투심", + "싱커": "싱커", + "너클": "너클", +} + +PITCH_RESULT_LABEL_MAP = { + "볼": "볼", + "스트라이크": "스트라이크(루킹)", + "헛스윙": "헛스윙(스트라이크)", + "헛스윙 번트": "번트시도-스트라이크", + "번트 헛스윙": "번트시도-스트라이크", + "파울": "파울", + "번트파울": "번트-파울", + "몸에 맞는 볼": "몸에 맞는 볼", + "몸에 맞는 공": "몸에 맞는 볼", + "사구": "몸에 맞는 볼", + "고의사구": "고의사구", + "자동 고의사구": "고의사구", + "폭투-볼": "폭투-볼", + "포일-볼": "포일-볼", +} + +BATTER_RESULT_LABEL_MAP = { + "walk": "포볼", + "intentional_walk": "고의사구", + "strikeout": "루킹스트라이크-아웃", + "bunt_strikeout": "번트-삼진", + "hit_by_pitch": "몸에 맞는 볼", + "single": "1루타", + "double": "2루타", + "triple": "3루타", + "home_run": "홈런", + "single_runner_out": "1루타 후 주루아웃", + "double_runner_out": "2루타 후 주루아웃", + "triple_runner_out": "3루타 후 주루아웃", + "single_error_advance": "1루타 후 수비실책진루", + "double_error_advance": "2루타 후 수비실책진루", + "triple_error_advance": "3루타 후 수비실책진루", + "sacrifice_fly": "희생 플라이", + "sacrifice_bunt": "희생 번트", + "reach_on_error": "수비실책", + "reach_on_fielder_choice": "야수선택", + "reach_on_grounder": "땅볼출루(무안타)", + "bunt_hit": "번트안타", + "out": "아웃", +} + +RUNNER_EVENT_LABEL_MAP = { + "advance": "일반 진루", + "score": "일반 진루", + "steal": "도루성공", + "steal_fail": "도루시도 아웃", + "force_out": "포스아웃", + "pickoff_out": "견제 아웃", + "error_advance": "수비 실책", + "wild_pitch_advance": "폭투-진루성공", + "passed_ball_advance": "포일-진루성공", +} + +FOUL_FLY_LEFT = (2, 70) +FOUL_FLY_RIGHT = (98, 70) + +REVIEW_ITEM_RESULT_GROUP_MAP = { + "홈런타구 페어 파울": ("type1", "페어", "파울"), + "외야타구 페어 파울": ("type1", "페어", "파울"), + "포수/태그플레이 아웃/세이프": ("type2", "아웃", "세이프"), + "야수의 포구": ("type2", "아웃", "세이프"), + "몸에 맞는 공": ("type3", "인정", "불인정"), + "파울": ("type3", "인정", "불인정"), + "헛스윙": ("type3", "인정", "불인정"), + "기타": ("type3", "인정", "불인정"), +} + +POSITION_NUMBER_MAP = { + "투수": "1", + "포수": "2", + "1루수": "3", + "2루수": "4", + "3루수": "5", + "유격수": "6", + "좌익수": "7", + "중견수": "8", + "우익수": "9", +} +POSITION_LABEL_MAP = {v: k for k, v in POSITION_NUMBER_MAP.items()} + +HIT_BALL_TYPE_MAP = { + "땅볼": "0", + "일반바운드": "1", + "플라이": "2", + "라인드라이브": "3", + "펜스타구": "4", + "홈런성타구": "5", + "번트타구": "6", +} + +FIELD_COORDINATES = { + "투수": (50, 80), + "포수": (50, 93), + "1루수": (63, 77), + "2루수": (60, 65), + "3루수": (37, 77), + "유격수": (40, 65), + "좌익수": (22, 42), + "중견수": (50, 24), + "우익수": (78, 42), + "좌전": (30, 50), + "중전": (50, 35), + "우전": (70, 50), + "좌중간": (34, 34), + "우중간": (66, 34), + "좌월": (20, 30), + "중월": (50, 14), + "우월": (80, 30), +} + +DEFENSE_BUTTON_ID_MAP = { + "투수": "input[name='defenseNumberBtn']#picher", + "포수": "input[name='defenseNumberBtn']#catcher", + "1루수": "input[name='defenseNumberBtn']#runner_1", + "2루수": "input[name='defenseNumberBtn']#runner_2", + "3루수": "input[name='defenseNumberBtn']#runner_3", + "유격수": "input[name='defenseNumberBtn']#shortStop", + "중견수": "input[name='defenseNumberBtn']#centerFielder", + "우익수": "input[name='defenseNumberBtn']#rightFielder", + "좌익수": "input[name='defenseNumberBtn']#leftFielder", +} + +POSITION_TO_DEFENSE_NO = { + "투수": "1", + "포수": "2", + "1루수": "3", + "2루수": "4", + "3루수": "5", + "유격수": "6", + "좌익수": "7", + "중견수": "8", + "우익수": "9", + "지명타자": "10", +} + +def click_defense_button_robustly(page: Page, position: str, click_count: int = 1) -> bool: + # 1) 기존 ID 맵 기반 검색 + button_selector = DEFENSE_BUTTON_ID_MAP.get(position) + # 2) 값(value) 기반 검색 (1~9번) + pos_no = POSITION_TO_DEFENSE_NO.get(position) + value_selector = f"input[name='defenseNumberBtn'][value='{pos_no}']" if pos_no else None + + selectors = [s for s in [button_selector, value_selector] if s] + + for _ in range(click_count): + clicked_this_round = False + for selector in selectors: + try: + defense_button = wait_for_visible_locator(page, selector, timeout_ms=1500) + if defense_button: + defense_button.click(force=True) + clicked_this_round = True + page.wait_for_timeout(100) + break + except Exception: + continue + + # 3) 폴백: 자바스크립트로 직접 클릭 (텍스트 기반) + if not clicked_this_round: + try: + page.evaluate(f"""(pos) => {{ + const buttons = [...document.querySelectorAll("input[name='defenseNumberBtn'], button, a")]; + const target = buttons.find(b => b.value === pos || b.id === pos || b.innerText.includes(pos)); + if (target) {{ + target.click(); + target.dispatchEvent(new Event('change', {{ bubbles: true }})); + }} + }}""", position) + page.wait_for_timeout(100) + clicked_this_round = True + except: pass + + if not clicked_this_round: + return False + + return True + + +# 초기화 대상 히든 필드 ID 목록 (name 속성이 없어 ID로만 접근 가능) +_DEFENSE_HIDDEN_FIELD_IDS = [ + "putout", "assist", "error", "upstruction", + "dat_putout_hitter", "dat_assist_hitter", "dat_error_hitter", "dat_upstruction_hitter", + "dat_putout_runner1", "dat_assist_runner1", "dat_error_runner1", "dat_upstruction_runner1", + "dat_putout_runner2", "dat_assist_runner2", "dat_error_runner2", "dat_upstruction_runner2", + "dat_putout_runner3", "dat_assist_runner3", "dat_error_runner3", "dat_upstruction_runner3", + "dat_error_type", "dat_error_type1", "dat_error_type2", "dat_error_type3", + "dat_hitball_speed", "dat_hitball_type", "dat_hitball_xy", + "dat_multiplay_type", + "dat_hit_x", "dat_hit_y", "hitPoints", +] + +_DEFENSE_CLEAR_JS = """ +() => { + // 1) 수비수 버튼(라디오) 체크 해제 - 실제 name 속성이 있는 요소 + document.querySelectorAll("input[name='defenseNumberBtn']").forEach(el => { el.checked = false; }); + // 2) 타구 종류 라디오 해제 - 실제 name 속성이 있는 요소 + document.querySelectorAll("input[name='hitBallType']").forEach(el => { el.checked = false; }); + + // 3) 자살/보살/실책/방해 dat_ 계열 히든 필드 초기화 (실제 기록에 영향을 주는 필드들) + const datIds = [ + "putout", "assist", "error", "upstruction", + "dat_putout_hitter", "dat_assist_hitter", "dat_error_hitter", "dat_upstruction_hitter", + "dat_putout_runner1", "dat_assist_runner1", "dat_error_runner1", "dat_upstruction_runner1", + "dat_putout_runner2", "dat_assist_runner2", "dat_error_runner2", "dat_upstruction_runner2", + "dat_putout_runner3", "dat_assist_runner3", "dat_error_runner3", "dat_upstruction_runner3", + "dat_error_type", "dat_error_type1", "dat_error_type2", "dat_error_type3", + "dat_multiplay_type", "multiplay_type", + "hitBallXY", "hitBallDistance", + "dat_hitball_speed", "dat_hitball_type", "dat_hitball_x", "dat_hitball_y", "dat_hitball_xy", "dat_hitball_distance", + "hitball_speed", "hitball_type", "hitball_xy", "hitball_distance" + ]; + datIds.forEach(id => { + const el = document.getElementById(id); + if (el) el.value = ""; + }); + + // 4) 타구 종류 라디오 버튼 및 좌표 초기화 (id^= 스타일) + document.querySelectorAll("input[name='hitBallType'], [id^='hitBallSpeed'], [id^='hitBallType']").forEach(el => { + if (el.type === 'radio' || el.type === 'checkbox') { + el.checked = false; + } else { + el.value = ""; + } + }); +} +""" + + +def clear_defense_selections(page: Page) -> None: + """수비 가담 및 타구 관련 필드(체크박스/라디오/히든 필드) 선택 상태를 초기화합니다. + + 주의: 해당 히든 필드들은 name 속성이 없고 id로만 식별 가능합니다. + """ + page.evaluate(_DEFENSE_CLEAR_JS) + + +def click_defense_sequence_in_popup(page: Page, sequence: list[str], complete_button_selector: str | None = None) -> None: + popup_field = None + for _ in range(15): + popup_field = get_last_visible_locator(page, "#defenseDiv") + if popup_field is not None and popup_field.bounding_box() is not None: + break + page.wait_for_timeout(150) + + if popup_field is None: + return + + for position in sequence: + click_defense_button_robustly(page, position) + + complete_button = None + if complete_button_selector: + complete_button = get_last_visible_locator(page, complete_button_selector) + if complete_button is None: + complete_button = get_last_visible_locator(page, "#btnNext") + if complete_button is None: + complete_button = get_last_visible_locator(page, "#btnAdd") + + if complete_button: + try: + complete_button.click(force=True) + page.wait_for_timeout(200) + except Exception: + pass + + +def fill_runner_out_defense(page: Page, text: str, sequence_override: list[str] | None = None) -> None: + # 아웃 관련 팝업이 뜰 때까지 대기 (도루자, 태그아웃 등) + page.wait_for_timeout(300) + sequence = sequence_override or extract_defense_sequence(text) + if sequence: + click_defense_sequence_in_popup(page, sequence) + + +def normalize_lineup_text(text: str) -> str: + text = (text or "").strip() + text = text.replace("*", "") + # [10] 문보경 or 문보경 [10번] 등 다양하게 나올 수 있음 + text = re.sub(r"\[\d+(?:번)?\]", "", text) + text = re.sub(r"\s*\(.*?\)\s*", "", text) + # 한글/영문 이름만 남김 + text = "".join(re.findall(r"[가-힣A-Za-z]+", text)) + return text.strip() + + +def extract_change_actor(text: str) -> tuple[str | None, int | None, str]: + lhs = (text or "").split(" : ", 1)[0].strip() + batter_match = re.search(r"(\d+)번타자\s+(.+)$", lhs) + if batter_match: + return "batter", int(batter_match.group(1)), batter_match.group(2).strip() + for role in ("대타", "대주자", "1루주자", "2루주자", "3루주자", "주자", "투수", "포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"): + if lhs.startswith(role + " "): + return role, None, lhs[len(role):].strip() + return None, None, lhs + + +def is_merged_pitcher_substitution(actor_role: str | None, in_role: str | None) -> bool: + field_roles = {"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수"} + return actor_role in field_roles and in_role == "투수" + + +def normalize_change_event(change_event: dict[str, Any]) -> dict[str, Any]: + if change_event.get("actor_name") or change_event.get("player_name"): + return change_event + + text = change_event.get("text") or "" + normalized = dict(change_event) + normalized["change_type"] = "position_change" if "수비위치 변경" in text else "substitution" + actor_role, bat_order, actor_name = extract_change_actor(text) + normalized["actor_role"] = actor_role + normalized["actor_name"] = actor_name + if bat_order is not None: + normalized["bat_order"] = bat_order + + if normalized["change_type"] == "position_change": + rhs = text.split(" : ", 1)[1] if " : " in text else "" + normalized["player_name"] = actor_name + normalized["to_position"] = rhs.split("(으)로", 1)[0].strip() + return normalized + + rhs = text.split(" : ", 1)[1] if " : " in text else "" + rhs = rhs.split("(으)로 교체", 1)[0].strip() + in_role, _, in_name = extract_change_actor(rhs) + normalized["out_player"] = actor_name + normalized["in_player"] = in_name + normalized["in_role"] = in_role + if is_merged_pitcher_substitution(actor_role, in_role): + normalized["change_type"] = "merged_pitcher_substitution" + normalized["player_name"] = actor_name + normalized["to_position"] = "지명타자" + normalized["pitcher_in_player"] = in_name + return normalized + if in_role in POSITION_TO_DEFENSE_NO: + normalized["to_position"] = in_role + return normalized + + +def get_lineup_state(page: Page) -> dict[str, Any]: + return page.evaluate( + """() => { + const buildSide = (side) => { + const rows = []; + for (let idx = 0; idx <= 9; idx += 1) { + const player = document.querySelector(`#${side}_player_id_${idx}`); + const defense = document.querySelector(`#${side}_defense_no_${idx}`); + const orgPlayer = document.querySelector(`#org_${side}_player_id_${idx}`); + const orgDefense = document.querySelector(`#org_${side}_defense_no_${idx}`); + if (!player && !defense) continue; + rows.push({ + idx, + playerText: player ? player.options[player.selectedIndex]?.text || '' : '', + playerValue: player ? player.value : '', + defenseValue: defense ? defense.value : '', + orgPlayerValue: orgPlayer ? orgPlayer.value : '', + orgDefenseValue: orgDefense ? orgDefense.value : '', + }); + } + return rows; + }; + return { home: buildSide('home'), away: buildSide('away') }; + }""" + ) + + +def detect_change_side(half_inning: dict[str, Any], change_event: dict[str, Any], lineup_state: dict[str, Any]) -> str: + actor_role = change_event.get("actor_role") + actor_name = normalize_lineup_text(change_event.get("actor_name") or change_event.get("player_name") or "") + offense_side = "away" if half_inning.get("half") == "top" else "home" + defense_side = "home" if offense_side == "away" else "away" + + matched_sides: list[str] = [] + for side in ("home", "away"): + for row in lineup_state.get(side, []): + if normalize_lineup_text(row.get("playerText") or "") == actor_name: + matched_sides.append(side) + break + if len(matched_sides) == 1: + return matched_sides[0] + + if actor_role in {"batter", "대타", "대주자", "1루주자", "2루주자", "3루주자", "주자"}: + return offense_side + + return defense_side + + +def find_change_row(side_rows: list[dict[str, Any]], change_event: dict[str, Any]) -> int | None: + if change_event.get("bat_order") is not None: + return int(change_event["bat_order"]) + + actor_name_raw = change_event.get("actor_name") or change_event.get("player_name") or change_event.get("out_player") or "" + actor_name = normalize_lineup_text(actor_name_raw) + + # 1단계: 정규화된 이름 완전 일치 + for row in side_rows: + if normalize_lineup_text(row.get("playerText") or "") == actor_name: + return int(row["idx"]) + + # 2단계: 부분 일치 (포함 관계) + for row in side_rows: + player_text = normalize_lineup_text(row.get("playerText") or "") + if actor_name and (actor_name in player_text or player_text in actor_name): + return int(row["idx"]) + + # 3단계: 역할(defenseValue) 실치 + actor_role = change_event.get("actor_role") + if actor_role in POSITION_TO_DEFENSE_NO: + defense_no = POSITION_TO_DEFENSE_NO[actor_role] + for row in side_rows: + if str(row.get("defenseValue") or "") == defense_no: + return int(row["idx"]) + + # 4단계: '1루주자 문보경' 처럼 actor_name_raw에 역할이 섞여있을 경우 이름만 다시 추출 시도 + if " " in actor_name_raw: + potential_name = normalize_lineup_text(actor_name_raw.split()[-1]) + for row in side_rows: + if normalize_lineup_text(row.get("playerText") or "") == potential_name: + return int(row["idx"]) + + return None + + +def find_pitcher_row(side_rows: list[dict[str, Any]]) -> int | None: + for row in side_rows: + if str(row.get("defenseValue") or "") == POSITION_TO_DEFENSE_NO["투수"]: + return int(row["idx"]) + return None + + +def select_lineup_player(page: Page, side: str, row_idx: int, player_name: str) -> None: + select_id = f"#{side}_player_id_{row_idx}" + options = page.locator(f"{select_id} option").evaluate_all( + """(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))""" + ) + target_value = None + normalized_target = normalize_lineup_text(player_name) + for option in options: + if normalize_lineup_text(option["text"]) == normalized_target: + target_value = option["value"] + break + if not target_value: + raise ValueError(f"{side} {row_idx}번 행에서 선수 '{player_name}' 옵션을 찾지 못했습니다.") + page.locator(select_id).select_option(value=target_value) + + +def set_lineup_defense(page: Page, side: str, row_idx: int, position: str | None) -> None: + if not position: + return + defense_no = POSITION_TO_DEFENSE_NO.get(position) + if not defense_no: + return + page.locator(f"#{side}_defense_no_{row_idx}").select_option(value=defense_no) + + +def get_current_lineup_selection(page: Page, side: str, row_idx: int) -> tuple[str, str]: + player_value = page.locator(f"#{side}_player_id_{row_idx}").input_value() + defense_value = page.locator(f"#{side}_defense_no_{row_idx}").input_value() + return player_value, defense_value + + +def get_target_player_value(page: Page, side: str, row_idx: int, player_name: str | None) -> str | None: + if not player_name: + return None + select_id = f"#{side}_player_id_{row_idx}" + options = page.locator(f"{select_id} option").evaluate_all( + """(nodes) => nodes.map((node) => ({ value: node.value, text: node.textContent.trim() }))""" + ) + normalized_target = normalize_lineup_text(player_name) + for option in options: + if normalize_lineup_text(option["text"]) == normalized_target: + return option["value"] + return None + + +def get_target_defense_value(position: str | None) -> str | None: + if not position: + return None + return POSITION_TO_DEFENSE_NO.get(position) + + +def apply_change_event(page: Page, half_inning: dict[str, Any], change_event: dict[str, Any], change_cache: dict[str, tuple[str, int]]) -> None: + change_event = normalize_change_event(change_event) + cache_key = normalize_lineup_text(change_event.get("actor_name") or change_event.get("player_name") or change_event.get("out_player") or "") + actor_name = normalize_lineup_text(change_event.get("actor_name") or change_event.get("player_name") or change_event.get("out_player") or "") + lineup_state = get_lineup_state(page) + cached = change_cache.get(cache_key) + side = cached[0] if cached else detect_change_side(half_inning, change_event, lineup_state) + side_rows = lineup_state.get(side, []) + row_idx = find_change_row(side_rows, change_event) + + if row_idx is None and cached and cached[0] == side: + row_idx = cached[1] + + if row_idx is None: + all_players = [normalize_lineup_text(r.get("playerText", "")) for r in side_rows] + raise ValueError(f"{side} 교체 행을 찾지 못했습니다 (Target: {actor_name}, Candidates: {all_players}): {change_event.get('text')}") + + page.evaluate("""() => { window.alert = () => {}; window.confirm = () => true; }""") + + current_player_value, current_defense_value = get_current_lineup_selection(page, side, row_idx) + target_player_value = current_player_value + target_defense_value = current_defense_value + + def trigger_lineup_save(idx: int): + home_away_gb = 2 if side == "home" else 1 + # 1) f_lineup 호출 (기본) + page.evaluate( + """({ batterNo, homeAwayGb }) => { + if (typeof window.f_lineup === 'function') { + window.f_lineup(batterNo, homeAwayGb); + } + }""", + {"batterNo": idx, "homeAwayGb": home_away_gb}, + ) + page.wait_for_timeout(200) + + # 2) 행 옆의 'V' 또는 '저장' 버튼 클릭 시도 (row_idx 기반) + page.evaluate( + """({ side, idx }) => { + const row = document.querySelector(`#${side}_player_id_${idx}`)?.parentElement?.parentElement; + if (row) { + const saveBtn = [...row.querySelectorAll("input[type=button], button, a")].find(el => + el.value === 'V' || el.innerText.includes('V') || el.innerText.includes('저장') || el.id.includes('save') + ); + if (saveBtn) { + saveBtn.click(); + return true; + } + } + return false; + }""", + {"side": side, "idx": idx} + ) + page.wait_for_timeout(200) + + if change_event.get("change_type") == "merged_pitcher_substitution": + actor_player_name = change_event.get("player_name") or change_event.get("actor_name") + pitcher_in_player = change_event.get("pitcher_in_player") or change_event.get("in_player") + pitcher_row_idx = find_pitcher_row(side_rows) + if pitcher_row_idx is None or not pitcher_in_player: + raise ValueError(f"투수 교체 행을 찾지 못했습니다: {change_event.get('text')}") + + set_lineup_defense(page, side, row_idx, "지명타자") + trigger_lineup_save(row_idx) + trigger_lineup_save(row_idx + 1) # 인덱스 보정 처리 + + select_lineup_player(page, side, pitcher_row_idx, pitcher_in_player) + set_lineup_defense(page, side, pitcher_row_idx, "투수") + trigger_lineup_save(pitcher_row_idx) + trigger_lineup_save(pitcher_row_idx + 1) + + if actor_player_name: change_cache[normalize_lineup_text(actor_player_name)] = (side, int(row_idx)) + change_cache[normalize_lineup_text(pitcher_in_player)] = (side, int(pitcher_row_idx)) + return + + if change_event.get("change_type") == "substitution": + in_player = change_event.get("in_player") + if not in_player: return + select_lineup_player(page, side, row_idx, in_player) + set_lineup_defense(page, side, row_idx, change_event.get("to_position")) + else: + set_lineup_defense(page, side, row_idx, change_event.get("to_position")) + + # 최종 저장 트리거 + trigger_lineup_save(row_idx) + trigger_lineup_save(row_idx + 1) # 사이트 특성(1-based)인 경우 대비 + + # 검증 로직 추가: 실제로 바뀌었는지 확인 + page.wait_for_timeout(300) + final_state = get_lineup_state(page) + final_rows = final_state.get(side, []) + final_row = next((r for r in final_rows if r["idx"] == row_idx), None) + + if final_row: + in_name = normalize_lineup_text(change_event.get("in_player") or change_event.get("player_name") or "") + current_name_on_site = normalize_lineup_text(final_row.get("playerText") or "") + if in_name and in_name not in current_name_on_site: + # 실패 시 최후의 수단: 전역 '라인업저장' 버튼이라도 찾아 누름 + page.evaluate("""() => { + const btn = [...document.querySelectorAll("input[type=button], button, a")].find(el => + el.value?.includes('라인업') || el.innerText.includes('라인업저장') || el.innerText.includes('엔트리저장') + ); + if (btn) btn.click(); + }""") + page.wait_for_timeout(300) + + # 캐시 업데이트 + if change_event.get("change_type") == "substitution": + in_player_name = normalize_lineup_text(change_event.get("in_player") or "") + if in_player_name: change_cache[in_player_name] = (side, int(row_idx)) + else: + player_name = normalize_lineup_text(change_event.get("player_name") or change_event.get("actor_name") or "") + if player_name: change_cache[player_name] = (side, int(row_idx)) + + +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/_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("--headless", action="store_true", help="헤드리스 모드") + parser.add_argument("--write-events", dest="write_events", action="store_true", help="실제 입력완료 버튼까지 누름") + parser.add_argument("--no-write-events", dest="write_events", action="store_false", help="페이지 진입 후 입력은 하지 않음") + parser.add_argument("--close", action="store_true", help="작업 후 브라우저를 닫음") + parser.add_argument("--job-id", help="DB 로깅용 작업 ID (UUID)") + parser.add_argument("--review-only", action="store_true", help="경기기록 대신 합의판정만 일괄 등록") + parser.set_defaults(write_events=True) + return parser.parse_args() + + +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 find_status_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"]) + target_game_type = normalize_game_type(game_info["game_type"]) + + 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 statusLink = [...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: statusLink ? statusLink.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"] or matched["href"].startswith("javascript:"): + raise ValueError(f"관리자 게임번호 {manager_game_no} 행의 게임기록 링크를 찾지 못했습니다.") + return matched["href"] + + candidates = [ + row + for row in rows + if row["href"] + and not row["href"].startswith("javascript:") + and row["date"] == target_date + and (not row["gameType"] or normalize_game_type(row["gameType"]) == target_game_type) + 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_game_status_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/status?game_no={manager_game_no}", wait_until="domcontentloaded") + page.wait_for_selector("#eventWriteBtn", timeout=10000) + return + page.goto(f"{base_url}/manager/game/list", wait_until="domcontentloaded") + page.wait_for_selector("table.gclist", timeout=10000) + status_href = find_status_href(page, report, manager_game_no) + with page.expect_navigation(wait_until="domcontentloaded"): + page.locator(f"a[href='{status_href}']").first.click() + page.wait_for_selector("#eventWriteBtn", timeout=10000) + + +def get_radio_map(page: Page, name: str) -> dict[str, str]: + rows = page.locator(f"input[type=radio][name='{name}']").evaluate_all( + """(nodes) => nodes.map((node) => { + let label = ''; + let current = node.nextSibling; + while (current) { + if (current.nodeType === Node.TEXT_NODE && current.textContent.trim()) { + label = current.textContent.trim(); + break; + } + if (current.nodeType === Node.ELEMENT_NODE && current.textContent.trim()) { + label = current.textContent.trim(); + break; + } + current = current.nextSibling; + } + return { label, id: node.id, value: node.value }; + })""" + ) + return {row["label"]: row["id"] for row in rows if row["label"] and row["id"]} + + +def set_radio_by_label(page: Page, radio_name: str, label: str) -> None: + locator = find_visible_radio_by_label(page, radio_name, label) + if locator is None: + radio_map = get_radio_map(page, radio_name) + radio_id = radio_map.get(label) + if not radio_id: + raise ValueError(f"{radio_name}에서 '{label}' 라디오를 찾지 못했습니다.") + locator = page.locator(f"#{radio_id}") + for _ in range(3): + try: + locator.click(force=True) + page.wait_for_timeout(50) + if locator.is_checked(): + return + except Exception: + locator.evaluate( + """(node) => { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new Event('click', { bubbles: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + page.wait_for_timeout(50) + if locator.is_checked(): + return + + # 최종적으로 선택되었는지 확인 (강제성) + if not locator.is_checked(): + locator.evaluate("node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles:true})); }") + + +def click_radio_by_label(page: Page, radio_name: str, label: str) -> None: + locator = find_visible_radio_by_label(page, radio_name, label) + if locator is None: + radio_map = get_radio_map(page, radio_name) + radio_id = radio_map.get(label) + if not radio_id: + raise ValueError(f"{radio_name}에서 '{label}' 라디오를 찾지 못했습니다.") + locator = page.locator(f"#{radio_id}") + + for _ in range(3): + try: + locator.click(force=True) + page.wait_for_timeout(50) + if locator.is_checked(): + return + except Exception: + locator.evaluate( + """(node) => { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new Event('click', { bubbles: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + page.wait_for_timeout(50) + if locator.is_checked(): + return + + # 최종적으로 선택되었는지 확인 (강제성) + if not locator.is_checked(): + locator.evaluate("node => { node.checked = true; node.dispatchEvent(new Event('change', {bubbles:true})); }") + + +def get_checked_batter_defense_type(page: Page) -> str: + return page.evaluate( + """() => { + const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']:checked")]; + for (const node of nodes) { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) { + return node.getAttribute('defenseType') || ''; + } + } + return nodes.length > 0 ? (nodes[0].getAttribute('defenseType') || '') : ''; + }""" + ) + + +def infer_review_item(detail_text: str) -> str: + """리포트 텍스트 분석을 통해 사이트 표준 판독항목 추출""" + dt = detail_text.replace(" ", "") + if "홈런" in dt: + return "홈런타구 페어 파울" + if "아웃" in dt or "세이프" in dt or "포스" in dt or "태그" in dt or "견제" in dt or "도루" in dt: + return "포수/태그플레이 아웃/세이프" + if "페어" in dt or "파울" in dt: + return "외야타구 페어 파울" + if "포구" in dt or "노바운드" in dt or "바운드" in dt: + return "야수의 포구" + if "몸에맞" in dt or "데드볼" in dt: + return "몸에 맞는 볼" + if "헛스윙" in dt or "스윙" in dt: + return "헛스윙" + return "기타" + + +def set_select_by_partial_text(page_or_popup: Page, selector: str, partial_text: str) -> None: + """텍스트 일부분만 맞아도 셀렉트 박스에서 선택 (정규식 지원)""" + if not partial_text: + return + page_or_popup.wait_for_selector(selector, timeout=3000) + + # 사이트 내의 모든 옵션 텍스트를 가져옴 + options = page_or_popup.locator(f"{selector} option").all_text_contents() + + # 1단계: 정확히 일치하는지 확인 + target = partial_text.strip() + for opt in options: + if opt.strip() == target: + page_or_popup.select_option(selector, label=opt) + return + + # 2단계: 부분 일치 확인 (공백 제거 후 비교) + target_clean = target.replace(" ", "").replace("/", ",").replace("-", ",") + for opt in options: + opt_clean = opt.strip().replace(" ", "").replace("/", ",").replace("-", ",") + if target_clean in opt_clean or opt_clean in target_clean: + page_or_popup.select_option(selector, label=opt) + return + + # 실패 시 로그만 남기고 에러는 내지 않음 + print(f"DEBUG: '{selector}'에서 '{partial_text}'와 일치하는 옵션을 찾지 못함. (기존 선택 유지)") + + +def normalize_review_result_token(token: str, review_item: str) -> str | None: + token = (token or "").strip() + if not token: + return None + if review_item in {"홈런타구 페어 파울", "외야타구 페어 파울"}: + if "페어" in token: + return "페어" + if "파울" in token: + return "파울" + elif review_item in {"포수/태그플레이 아웃/세이프", "야수의 포구"}: + if "아웃" in token: + return "아웃" + if "세이프" in token: + return "세이프" + elif review_item == "헛스윙": + # 반드시 "노스윙"을 먼저 체크 ("노스윙"에도 "스윙"이 포함되어 있으므로) + if "불인정" in token or "노스윙" in token or "공포" in token or "노 스윙" in token: + return "불인정" + if "스윙" in token or "인정" in token: + return "인정" + else: + if "불인정" in token or "실패" in token: + return "불인정" + if "인정" in token: + return "인정" + return token # 모르는 키워드는 원문 그대로 반환 + return None + + +def parse_review_event_text(text: str) -> dict[str, Any]: + inning_match = re.search(r"(\d+)회(초|말)", text) + request_team_match = re.search(r"([가-힣A-Za-z]+)요청\s*(?:비디오 판독|합의 판정)", text) + # "→노 스윙"처럼 결과 토큰에 공백이 낀 경우를 정규화 (예: "노 스윙" → "노스윙") + normalized = re.sub(r"→([가-힣]+)\s+([가-힣]+)", r"→\1\2", text) + detail_match = re.search(r"(?:비디오 판독|합의 판정):\s*(.+?)\s*([가-힣]+)→([가-힣]+)\s*$", normalized) + detail_text = detail_match.group(1).strip() if detail_match else text + review_item = infer_review_item(detail_text) + before_result = normalize_review_result_token(detail_match.group(2), review_item) if detail_match else None + after_result = normalize_review_result_token(detail_match.group(3), review_item) if detail_match else None + return { + "type": "video_review", + "text": text, + "requestInningLabel": f"{inning_match.group(1)}{'초' if inning_match.group(2) == '초' else '말'}" if inning_match else None, + "requestTeam": request_team_match.group(1) if request_team_match else None, + "reviewItem": review_item, + "beforeResult": before_result, + "finalResult": after_result, + "isSuccess": "성공" if before_result and after_result and before_result != after_result else "실패", + "timing": "before_pitch" if "초구 전" in text else "after_pitch", + } + + +def normalize_review_event(review_event: dict[str, Any]) -> dict[str, Any]: + # beforeResult/finalResult가 누락되어 있으면 다시 파싱 + has_results = review_event.get("beforeResult") is not None and review_event.get("finalResult") is not None + if review_event.get("requestInningLabel") and review_event.get("reviewItem") and has_results: + return review_event + text = review_event.get("text") or "" + parsed = parse_review_event_text(text) + parsed.update({k: v for k, v in review_event.items() if k not in parsed}) + return parsed + + +def open_challenge_popup(page: Page): + with page.expect_popup(timeout=5000) as popup_info: + page.locator("#challengeBtn").click() + popup = popup_info.value + popup.wait_for_load_state("domcontentloaded") + popup.wait_for_selector("#requestInning_0", timeout=5000) + return popup + + +def set_select_by_text_or_value(page: Page, selector: str, desired: str) -> None: + locator = page.locator(selector) + try: + locator.select_option(label=desired) + return + except Exception: + pass + try: + locator.select_option(value=desired) + return + except Exception: + pass + locator.select_option(index=0) + + +def select_review_final_result(popup: Page, row_index: int, review_item: str, final_result: str | None) -> None: + group_key, default_a, _ = REVIEW_ITEM_RESULT_GROUP_MAP.get(review_item, REVIEW_ITEM_RESULT_GROUP_MAP["기타"]) + result_value = final_result or default_a + select_selector = f"#finalResult_{group_key}_{row_index}" + if not popup.locator(select_selector).count(): + select_selector = f"#finalResult_{group_key[-1]}_{row_index}" + if not popup.locator(select_selector).count(): + select_selector = f"#finalResult_type{group_key[-1]}_{row_index}" + set_select_by_text_or_value(popup, select_selector, result_value) + try: + popup.locator(f"#finalResult_{row_index}").evaluate( + """(node, value) => { + node.value = value; + node.dispatchEvent(new Event('change', { bubbles: true })); + }""", + result_value, + ) + except Exception: + pass + + +def fill_review_row(popup: Page, row_index: int, review_event: dict[str, Any]) -> None: + request_inning = review_event.get("requestInningLabel") or "1초" + request_team = review_event.get("requestTeam") or popup.locator(f"#requestTeamId_{row_index} option").first.text_content() or "" + review_item = review_event.get("reviewItem") or "기타" + final_result = review_event.get("finalResult") + is_success_val = "Y" if (review_event.get("isSuccess") == "성공") else "N" + + popup.wait_for_selector(f"#requestInning_{row_index}", timeout=3000) + set_select_by_text_or_value(popup, f"#requestInning_{row_index}", request_inning) + + # 팀 선택: 부분 일치 지원 + try: + set_select_by_partial_text(popup, f"#requestTeamId_{row_index}", request_team) + except Exception: + pass + + # 판독 항목 선택: 부분 일치 지원 (예: '아웃/세이프' -> '아웃,세이프') + if popup.is_closed(): return + try: + set_select_by_partial_text(popup, f"#forWhat_{row_index}", review_item) + except Exception: + set_select_by_text_or_value(popup, f"#forWhat_{row_index}", "기타") + popup.wait_for_timeout(100) + + if popup.is_closed(): return + select_review_final_result(popup, row_index, review_item, final_result) + + if popup.is_closed(): return + set_select_by_text_or_value(popup, f"#isSuccess_{row_index}", is_success_val) + + +def append_review_row(popup: Page) -> int: + before_count = popup.locator("select[id^='requestInning_']").count() + popup.get_by_role("button", name="신규추가").click() + popup.wait_for_function( + """(expectedCount) => { + return document.querySelectorAll("select[id^='requestInning_']").length > expectedCount; + }""", + arg=before_count, + timeout=3000, + ) + row_index = popup.evaluate( + """() => { + const ids = [...document.querySelectorAll("select[id^='requestInning_']")] + .map((el) => el.id) + .map((id) => Number(id.split("_").pop())) + .filter((num) => Number.isFinite(num)); + return ids.length ? Math.max(...ids) : 0; + }""" + ) + popup.wait_for_selector(f"#requestInning_{row_index}", timeout=5000) + return int(row_index) + + +def can_reuse_initial_review_row(popup: Page) -> bool: + try: + row_count = popup.locator("select[id^='requestInning_']").count() + if row_count != 1: + return False + hidden_id = (popup.locator("#id_0").input_value() or "").strip() + if hidden_id: + return False + request_inning = popup.locator("#requestInning_0").input_value() + request_team = popup.locator("#requestTeamId_0").input_value() + review_item = popup.locator("#forWhat_0").input_value() + final_result = (popup.locator("#finalResult_0").input_value() or "").strip() + is_success = popup.locator("#isSuccess_0").input_value() + return ( + request_inning == "1" + and review_item == "홈런타구 페어 파울" + and final_result == "페어" + and is_success == "Y" + and bool(request_team) + ) + except Exception: + return False + + +def save_review_popup(popup: Page) -> None: + if popup.is_closed(): + return + + # 알림/확인 자동 수락 + popup.evaluate("""() => { + window.confirm = () => true; + window.alert = () => {}; + }""") + + # #saveLog 버튼 클릭 + AJAX 응답 대기 + saved = False + try: + with popup.expect_response( + re.compile(r"/manager/game/status/challenge/ajax"), + timeout=3000, + ) as response_info: + # saveLog 버튼 클릭 + save_btn = popup.locator("#saveLog") + if save_btn.count() > 0: + save_btn.click(force=True) + else: + # fallback: 다른 저장 버튼 시도 + popup.evaluate("""() => { + const btn = document.querySelector('#btnAdd') + || document.querySelector('#btnSave') + || [...document.querySelectorAll('button, a')].find( + el => el.innerText.includes('입력완료') || el.innerText.includes('저장') + ); + if (btn) btn.click(); + }""") + response = response_info.value + try: + body = response.text().strip() + if body in {"1", '"1"'}: + saved = True + except Exception: + saved = True # 응답 읽기 실패해도 AJAX 자체는 완료됨 + except Exception: + # AJAX 응답 대기 실패 시 JS로 직접 저장 시도 + try: + popup.evaluate("""() => { + const btn = document.querySelector('#saveLog') + || document.querySelector('#btnAdd'); + if (btn) btn.click(); + }""") + popup.wait_for_timeout(1000) + except Exception: + pass + + # 저장 후 팝업 닫기 + try: + if not popup.is_closed(): + popup.close() + except Exception: + pass + + + +def record_review_events(page: Page, review_events: list[dict[str, Any]]) -> None: + normalized_events = [normalize_review_event(event) for event in (review_events or [])] + if not normalized_events: + return + popup = open_challenge_popup(page) + reuse_initial_row = can_reuse_initial_review_row(popup) + for index, review_event in enumerate(normalized_events): + if index == 0 and reuse_initial_row: + row_index = 0 + else: + row_index = append_review_row(popup) + fill_review_row(popup, row_index, review_event) + save_review_popup(popup) + page.wait_for_timeout(300) + try: + page.bring_to_front() + except Exception: + pass + + +def open_game_end_popup(page: Page) -> None: + page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""") + page.locator("#gameEndBtn").click(force=True) + page.wait_for_selector("#btnGameEnd", timeout=5000) + page.wait_for_selector("input[name^='homeTeamPitcher_'], input[name^='awayTeamPitcher_']", timeout=5000) + + +def get_game_end_pitcher_rows(page: Page) -> dict[str, list[dict[str, Any]]]: + return page.evaluate( + """() => { + const rowsFor = (nameAttr) => { + return [...document.querySelectorAll(`input[name='${nameAttr}']`)].map((input, idx) => { + const tr = input.closest('tr'); + const firstTd = tr ? tr.querySelector('td') : null; + return { + idx, + name: firstTd ? firstTd.textContent.trim() : '', + }; + }); + }; + return { + home: rowsFor('home_player_id'), + away: rowsFor('away_player_id'), + }; + }""" + ) + + +def select_game_end_role(page: Page, side: str, idx: int, role_value: str) -> None: + selector = f"input[name='{side}TeamPitcher_{idx}'][value='{role_value}']" + ok = page.evaluate( + """(selector) => { + const node = document.querySelector(selector); + if (!node) return false; + node.disabled = false; + node.checked = true; + node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + return node.checked === true; + }""", + selector, + ) + if not ok: + page.locator(selector).click(force=True) + + +def check_game_end_blown_save(page: Page, side: str, idx: int) -> None: + selector = f"input[name='{side}BlownSave_{idx}']" + ok = page.evaluate( + """(selector) => { + const node = document.querySelector(selector); + if (!node) return false; + node.disabled = false; + node.checked = true; + node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + return node.checked === true; + }""", + selector, + ) + if not ok: + page.locator(selector).check(force=True) + + +def fill_game_end_pitching(page: Page, report: dict[str, Any]) -> None: + open_game_end_popup(page) + rows = get_game_end_pitcher_rows(page) + summary = report.get("pitching_summary") or {} + home_starter = normalize_lineup_text((((report.get("lineups") or {}).get("home_team") or {}).get("starter_pitcher") or {}).get("name") or "") + away_starter = normalize_lineup_text((((report.get("lineups") or {}).get("away_team") or {}).get("starter_pitcher") or {}).get("name") or "") + + winners = {normalize_lineup_text(name) for name in (summary.get("승리투수") or [])} + losers = {normalize_lineup_text(name) for name in (summary.get("패전투수") or [])} + holds = {normalize_lineup_text(name) for name in (summary.get("홀드") or [])} + saves = {normalize_lineup_text(name) for name in (summary.get("세이브") or [])} + blown_saves = {normalize_lineup_text(name) for name in (summary.get("블론세이브") or [])} + fixed_roles = winners | losers | holds | saves + + for side, side_rows in rows.items(): + starter_name = home_starter if side == "home" else away_starter + for row in side_rows: + name = normalize_lineup_text(row.get("name") or "") + idx = int(row["idx"]) + if name in winners: + select_game_end_role(page, side, idx, "wins") + elif name in losers: + select_game_end_role(page, side, idx, "loses") + elif name in saves: + select_game_end_role(page, side, idx, "save") + elif name in holds: + select_game_end_role(page, side, idx, "holds") + elif name and name != starter_name and name not in fixed_roles: + select_game_end_role(page, side, idx, "re") + + if name in blown_saves: + check_game_end_blown_save(page, side, idx) + page.wait_for_timeout(300) + show_debug_overlay( + page, + [ + "게임종료 팝업 입력 완료", + f"승리: {', '.join(summary.get('승리투수') or []) or '-'}", + f"패전: {', '.join(summary.get('패전투수') or []) or '-'}", + f"홀드: {', '.join(summary.get('홀드') or []) or '-'}", + f"세이브: {', '.join(summary.get('세이브') or []) or '-'}", + f"블론세이브: {', '.join(summary.get('블론세이브') or []) or '-'}", + ], + ) + + +def submit_game_end(page: Page) -> None: + page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""") + page.locator("#btnGameEnd").click(force=True) + page.wait_for_timeout(1500) + + +def set_ball_count(page: Page, balls: int, strikes: int, outs: int) -> None: + # 볼카운트는 시스템에서 자동 관리되므로 수동 클릭 제거 + # page.locator(f"#evt_ballscore{balls}").check(force=True) + # page.locator(f"#evt_strikescore{strikes}").check(force=True) + # page.locator(f"#evt_outscore{outs}").check(force=True) + pass + + +def show_debug_overlay(page: Page, lines: list[str]) -> None: + page.evaluate( + """(lines) => { + let box = document.querySelector('#codex-debug-overlay'); + if (!box) { + window.codexControl = { paused: false, proceed: 0 }; + box = document.createElement('div'); + box.id = 'codex-debug-overlay'; + box.style.position = 'fixed'; + box.style.top = '12px'; + box.style.right = '12px'; + box.style.zIndex = '999999'; + box.style.background = 'rgba(0, 0, 0, 0.82)'; + box.style.color = '#fff'; + box.style.padding = '10px 12px'; + box.style.borderRadius = '8px'; + box.style.fontSize = '14px'; + box.style.lineHeight = '1.5'; + box.style.maxWidth = '360px'; + box.style.whiteSpace = 'pre-wrap'; + box.style.boxShadow = '0 4px 16px rgba(0,0,0,0.35)'; + const controls = document.createElement('div'); + controls.style.marginBottom = '8px'; + controls.style.display = 'flex'; + controls.style.gap = '8px'; + + const pauseBtn = document.createElement('button'); + pauseBtn.id = 'codex-pause-btn'; + pauseBtn.textContent = '일시정지'; + pauseBtn.onclick = () => { + window.codexControl.paused = !window.codexControl.paused; + pauseBtn.textContent = window.codexControl.paused ? '재개' : '일시정지'; + }; + + const nextBtn = document.createElement('button'); + nextBtn.id = 'codex-next-btn'; + nextBtn.textContent = '다음'; + nextBtn.onclick = () => { + window.codexControl.proceed += 1; + }; + + controls.appendChild(pauseBtn); + controls.appendChild(nextBtn); + + const body = document.createElement('div'); + body.id = 'codex-debug-body'; + + box.appendChild(controls); + box.appendChild(body); + document.body.appendChild(box); + } + const body = document.querySelector('#codex-debug-body'); + if (body) { + body.textContent = lines.join('\\n'); + } + }""", + lines, + ) + + +def wait_for_operator_control(page: Page) -> None: + state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }") + last_proceed = state.get("proceed", 0) + while True: + state = page.evaluate("() => window.codexControl || { paused: false, proceed: 0 }") + if not state.get("paused"): + return + if state.get("proceed", 0) > last_proceed: + page.evaluate("(prev) => { if (window.codexControl && window.codexControl.proceed > prev) { window.codexControl.proceed = prev; } }", last_proceed) + return + sleep(0.1) + + +def get_checked_event_name(page: Page, radio_name: str) -> str: + return page.evaluate( + """(radioName) => { + const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']:checked`)]; + for (const node of nodes) { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) { + return node.getAttribute('eventName') || ''; + } + } + return nodes.length > 0 ? (nodes[0].getAttribute('eventName') || '') : ''; + }""", + radio_name, + ) + + +def set_radio_by_label(page: Page, radio_name: str, label: str) -> None: + radios = page.locator(f"input[type=radio][name='{radio_name}']").all() + target_radio = None + + # 1. 정확히 일치하는 라벨 우선 탐색 + for rb in radios: + if rb.get_attribute("eventname") == label: + target_radio = rb + break + + # 2. 일치하는 게 없으면 포함 관계로 탐색 + if not target_radio: + for rb in radios: + if label in (rb.get_attribute("eventname") or ""): + target_radio = rb + break + + if target_radio: + target_radio.click(force=True) + + +def get_last_visible_locator(page: Page, selector: str): + marker = page.evaluate( + """(selector) => { + const nodes = [...document.querySelectorAll(selector)]; + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const node = nodes[i]; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden') { + const marker = `codex-visible-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + return null; + }""", + selector, + ) + if marker: + return page.locator(f"[data-codex-marker='{marker}']") + return None + + +def get_last_visible_enabled_locator(page: Page, selector: str): + marker = page.evaluate( + """(selector) => { + const nodes = [...document.querySelectorAll(selector)]; + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const node = nodes[i]; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled) { + const marker = `codex-enabled-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + return null; + }""", + selector, + ) + if marker: + return page.locator(f"[data-codex-marker='{marker}']") + return None + + +def find_visible_radio_by_label(page: Page, radio_name: str, label: str): + marker = page.evaluate( + """({ radioName, expectedLabel }) => { + const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)]; + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const node = nodes[i]; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) { + continue; + } + let current = node.nextSibling; + let text = ''; + while (current) { + if (current.textContent && current.textContent.trim()) { + text = current.textContent.trim(); + break; + } + current = current.nextSibling; + } + if (text === expectedLabel) { + const marker = `codex-radio-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + return null; + }""", + {"radioName": radio_name, "expectedLabel": label}, + ) + if marker: + return page.locator(f"[data-codex-marker='{marker}']") + return None + + +def wait_for_visible_locator(page: Page, selector: str, timeout_ms: int = 5000): + deadline = time() + timeout_ms / 1000 + while time() < deadline: + candidate = get_last_visible_locator(page, selector) + if candidate is not None: + return candidate + sleep(0.1) + raise TimeoutError(f"{selector} visible locator not found within {timeout_ms}ms") + + +def get_history_count(page: Page) -> int: + try: + return int( + page.evaluate( + """() => document.querySelectorAll("div[name='historyView']").length""" + ) + ) + except Exception: + return 0 + + +def get_last_history_text(page: Page) -> str: + try: + return ( + page.evaluate( + """() => { + const nodes = document.querySelectorAll("div[name='historyView']"); + const lastNode = nodes[nodes.length - 1]; + return lastNode ? lastNode.textContent.trim() : ''; + }""" + ) + or "" + ).strip() + except Exception: + return "" + + +def wait_for_history_increment(page: Page, before_count: int, timeout_ms: int = 2500) -> bool: + deadline = time() + timeout_ms / 1000 + while time() < deadline: + if get_history_count(page) > before_count: + return True + sleep(0.1) + return False + + +def submit_input_complete(page: Page, debug_label: str = "", clear_defense: bool = False, log_info: dict[str, Any] | None = None) -> None: + from time import time + t0 = time() + try: + # 팝업이나 다이얼로그가 떠 있으면 닫음 + page.evaluate("""() => { + window.confirm = () => true; + window.alert = () => {}; + const defenseDiv = document.querySelector('#defenseDiv'); + if (defenseDiv && defenseDiv.style.display !== 'none') { + const btnAdd = document.querySelector('#btnAdd'); + if (btnAdd) btnAdd.click(); + } + }""") + + # clear_defense=True 일 때만 수비/타구 필드 초기화 + # (볼/스트라이크 등 타격 없는 일반 투구만 해당) + # 타격 결과(안타, 아웃 등)는 팝업이 이미 히든 필드를 채운 상태이므로 지우면 안 됨! + if clear_defense: + page.evaluate(_DEFENSE_CLEAR_JS) + + prev_history = get_history_count(page) + # 최대 40번 시도 (약 6~8초) - 간격 세분화로 빠른 응답 포착 + for i in range(40): + curr_history = get_history_count(page) + if curr_history > prev_history: + # 입력 성공 후 안정화 대기 + page.wait_for_timeout(30) + _try_log_pitch(log_info, True, "", "", time() - t0) + return + + # 매 루프마다 클릭하면 오히려 부하가 될 수 있으므로 주기적으로 시도 + if i % 8 == 0: + submit_btn = get_last_visible_locator(page, "#eventWriteBtn") + if not submit_btn: + submit_btn = page.get_by_role("button", name="입력완료").last + + if submit_btn: + try: + submit_btn.click(force=True, timeout=500) + except Exception: + page.evaluate("document.querySelector('#eventWriteBtn')?.click() || [...document.querySelectorAll('a, button')].find(el => el.innerText.includes('입력완료'))?.click()") + + page.wait_for_timeout(50) + page.evaluate("() => { window.confirm = () => true; window.alert = () => {}; }") + + raise TimeoutError(f"입력완료가 반영되지 않았습니다 (버튼 클릭 후 이력 불일치): {debug_label}") + except Exception as e: + _try_log_pitch(log_info, False, type(e).__name__, str(e), time() - t0) + raise e + +def _try_log_pitch(log_info: dict[str, Any] | None, is_success: bool, error_code: str, error_detail: str, duration: float) -> None: + if log_info and log_info.get("job_id"): + try: + from db_logging import log_pitch + log_pitch( + log_info["job_id"], log_info.get("inning", ""), log_info.get("batter", ""), + log_info.get("pitch_no", 0), log_info.get("target_value", ""), log_info.get("selected_value", ""), + is_success, error_code, error_detail, duration + ) + except Exception: + pass + + +def _try_log_event(log_info: dict[str, Any] | None, is_success: bool, error_msg: str = "") -> None: + if log_info and log_info.get("job_id"): + try: + from db_logging import log_event + log_event( + log_info["job_id"], log_info.get("inning", ""), log_info.get("event_type", ""), + log_info.get("target_player", ""), log_info.get("actual_player", ""), + is_success, error_msg + ) + except Exception: + pass + + +def set_runner_advance(page: Page, from_base: int, to_base: int | None) -> None: + if to_base is None: + return + selector = f"input[name='dat_evt_runner_{from_base}_advance'][value='{to_base}']" + deadline = time() + 3 + locator = None + while time() < deadline: + locator = get_last_visible_enabled_locator(page, selector) + if locator is not None: + break + sleep(0.1) + if locator is None: + fallback = page.locator(selector) + if fallback.count(): + locator = fallback.last + else: + raise TimeoutError(f"{selector} enabled locator not found within 3000ms") + try: + locator.click(force=True) + except Exception: + locator.evaluate( + """(node) => { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new Event('click', { bubbles: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + + +def set_runner_action(page: Page, from_base: int, label: str) -> None: + radio_name = f"evt_runner_{from_base}" + locator = page.evaluate( + """({ radioName, eventName }) => { + const nodes = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)]; + // 1단계: eventName 속성으로 정확히 매칭 시도 + for (const node of nodes) { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) { + continue; + } + if ((node.getAttribute('eventName') || '') === eventName) { + const marker = `codex-runner-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + // 2단계: 라벨 텍스트로 부분 매칭 시도 (태그아웃 vs 태그 아웃 등 대응) + for (const node of nodes) { + let text = ''; + let p = node.parentElement; + if (p) text = p.textContent.trim(); + if (!text && node.nextSibling) text = node.nextSibling.textContent || ''; + + if (eventName && text.replace(/\s/g, '').includes(eventName.replace(/\s/g, ''))) { + const marker = `codex-runner-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + return null; + }""", + {"radioName": radio_name, "eventName": label}, + ) + if locator: + candidate = page.locator(f"[data-codex-marker='{locator}']") + for _ in range(5): + try: + # 가시성 이슈가 있을 수 있으므로 force=True와 evaluate(JS click) 병행 + candidate.click(force=True, timeout=500) + except Exception: + pass + + try: + candidate.evaluate( + """(node) => { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new Event('click', { bubbles: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + except Exception: + pass + + page.wait_for_timeout(100) + if get_checked_event_name(page, radio_name) == label: + return + else: + for _ in range(5): + set_radio_by_label(page, radio_name, label) + page.wait_for_timeout(50) + if get_checked_event_name(page, radio_name) == label: + return + + +def infer_runner_action_label(event: dict[str, Any], runner_event: dict[str, Any]) -> str | None: + # 0. 리포트에 명시된 라벨이 있으면 최우선으로 사용 (가장 정확함) + if "action_label" in runner_event: + return runner_event["action_label"] + + event_type = runner_event.get("type") or "" + event_text = runner_event.get("text") or "" + result_type = ((event.get("result") or {}).get("type") or "") + result_text = ((event.get("result") or {}).get("text") or "") + + if "이중도루 실패" in event_text and "진루" in event_text: + return "기타 진루" + if "도루" in event_text and "실패" in event_text and "진루" in event_text: + return "기타 진루" + + if event_type == "pickoff_out" or "견제사" in event_text: + return "견제 아웃" + + if event_type == "steal_fail": + return "도루시도 아웃" + if "이중도루 실패" in event_text and "아웃" in event_text: + return "도루시도 아웃" + if "도루" in event_text and "실책" in event_text and ("진루" in event_text or event_type == "error_advance"): + return "도루성공&실책" + if "도루" in event_text: + if "실패" in event_text: + return "도루시도 아웃" + return "도루성공" + if "낫아웃" in result_text and event_type == "wild_pitch_advance": + return "폭투 낫아웃 진루" + if "낫아웃" in result_text and event_type == "passed_ball_advance": + return "포일 낫아웃 진루" + if "포일" in event_text and ("진루" in event_text or event_type == "passed_ball_advance"): + return "포일-진루성공" + + if "포일" in event_text and ("진루" in event_text or event_type == "passed_ball_advance"): + return "포일-진루성공" + + # 1. 수비 실책: 텍스트에 '실책으로'가 명시된 경우 최우선 + if "실책으로" in event_text: + return "수비 실책" + + # 2. 안타/아웃/타격 관련 상황이면 무조건 일반 진루 (볼넷 진루 방지) + play_types = {"single", "double", "triple", "home_run", "out", "strikeout", "play", "sacrifice_fly", "sacrifice_bunt", "ground_out", "fly_out"} + if result_type in play_types and event_type in {"advance", "score"}: + return "일반 진루" + + # 3. 볼넷 진루: 오직 포볼, 고의사구, 몸에 맞는 볼 상황서만 허용 + walk_types = {"walk", "intentional_walk", "hit_by_pitch"} + if result_type in walk_types and event_type in {"advance", "score"}: + return "볼넷 진루" + + # 4. 나머지는 일반 진루 + if event_type in {"advance", "score"}: + return "일반 진루" + + return RUNNER_EVENT_LABEL_MAP.get(event_type) + + +def get_runner_area_type(event: dict[str, Any], runner_event: dict[str, Any]) -> int: + event_text = runner_event.get("text") or "" + + # 도루, 견제, 폭투, 포일, 아웃 등 버튼 조작이 필요한 특수 상황은 액션 영역(2) + action_keywords = ["도루", "견제", "폭투", "포일", "태그아웃", "포스아웃"] + if any(k in event_text for k in action_keywords): + return 2 + + return 1 + + +def open_runner_area(page: Page, from_base: int, area_type: int) -> None: + function_name = f"changRunnerArea{from_base}" + page.evaluate( + """({ functionName, areaType }) => { + const fn = window[functionName]; + if (typeof fn === 'function') { + fn(areaType); + } + }""", + {"functionName": function_name, "areaType": area_type}, + ) + deadline = time() + 2 + radio_name = f"evt_runner_{from_base}" + advance_name = f"dat_evt_runner_{from_base}_advance" + while time() < deadline: + ready = page.evaluate( + """({ radioName, advanceName }) => { + const hasEnabledVisibleRadio = [...document.querySelectorAll(`input[type=radio][name='${radioName}']`)].some((node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled); + }); + const hasEnabledVisibleAdvance = [...document.querySelectorAll(`input[type=radio][name='${advanceName}']`)].some((node) => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return !!(rect.width && rect.height && style.display !== 'none' && style.visibility !== 'hidden' && !node.disabled); + }); + return hasEnabledVisibleRadio || hasEnabledVisibleAdvance; + }""", + {"radioName": radio_name, "advanceName": advance_name}, + ) + if ready: + return + sleep(0.1) + + +def split_complex_runner_event(runner_event: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: + text = runner_event.get("text") or "" + + # [중요] '실책' 혹은 '/'가 포함된 복합 상황만 분할함. + if "/" not in text or ("실책" not in text and "아웃" not in text): + return runner_event, None + + parts = [p.strip() for p in text.split("/") if p.strip()] + if len(parts) < 2: + return runner_event, None + + # 텍스트에서 중간 베이스 정보를 추출하여 toBase 보정 + # 예: "1루주자 ... 2루까지 진루" -> intermediate = 2 + def extract_base(t: str) -> int | None: + m = re.search(r"([123])루", t) + return int(m.group(1)) if m else None + + # 1차 이벤트: 첫 번째 분절의 행동 + primary = dict(runner_event) + primary["text"] = parts[0] + + intermediate_to = extract_base(parts[0]) + if intermediate_to: + primary["toBase"] = intermediate_to + + # 2차 이벤트: 두 번째 분절의 행동 (지연 처리용) + secondary = dict(runner_event) + secondary["fromBase"] = primary["toBase"] # 1차의 목적지가 2차의 시작점 + secondary["text"] = parts[1] + + if "실책" in parts[1]: + secondary["type"] = "error_advance" + elif "태그" in parts[1]: + secondary["type"] = "tag_out" + elif "포스" in parts[1]: + secondary["type"] = "force_out" + else: + secondary["type"] = "out" + + return primary, secondary + +def set_runner_events(page: Page, event: dict[str, Any], runner_events: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: + is_double_play = is_double_play_result(((event.get("result") or {}).get("text") or "").strip()) + if runner_events is None: + runner_events = (event.get("runnerEvents") or []).copy() + + late_events = [] + filtered_events = [] + for re_item in runner_events: + primary, secondary = split_complex_runner_event(re_item) + filtered_events.append(primary) + if secondary: + late_events.append(secondary) + + for runner_event in filtered_events: + from_base = runner_event.get("fromBase") + if from_base not in {1, 2, 3}: + continue + label = infer_runner_action_label(event, runner_event) + if not label: + continue + + # 라벨에 따른 영역 결정 (도루, 견제 등 특수 액션만 2번 영역) + if any(k in label for k in ["도루", "견제", "폭투", "포일", "아웃"]): + area_type = 2 + else: + area_type = 1 + + # [강제] 일반/볼넷 진루 및 실책은 무조건 1번(진루) 영역 + if any(k in label for k in ["일반 진루", "볼넷 진루", "수비 실책"]): + area_type = 1 + + open_runner_area(page, from_base, area_type) + set_runner_action(page, from_base, label) + + runner_text = runner_event.get("text") or "" + is_error_related_label = ( + (label and "실책" in label) + or label in {"견제 에러", "수비 실책", "도루성공&실책"} + or "실책" in runner_text + ) + if is_error_related_label: + # 주자 텍스트에 구체적인 수비 위치(예: 유격수)가 명시된 경우만 별도 실책 팝업 처리 + if extract_error_position(runner_text): + fill_error_defense_popup(page, runner_text) + + if label in {"태그아웃", "도루시도 아웃", "포스아웃"}: + fill_runner_out_defense(page, runner_event.get("text") or "") + + page.wait_for_timeout(150) + set_runner_advance(page, from_base, runner_event.get("toBase")) + + try: + extra_advance = runner_event.get("extra_advance") + if extra_advance and extra_advance > 0: + locator = get_last_visible_enabled_locator(page, f"select[name='runner{from_base}_running_add']") + if locator is not None: + locator.select_option(value=str(extra_advance)) + except Exception: + pass + + page.wait_for_timeout(150) + + return late_events + + +def handle_late_runner_events(page: Page, event: dict[str, Any], late_events: list[dict[str, Any]], write_events: bool, job_id: str | None = None) -> None: + if not late_events or not write_events: + return + + # 1차 입력완료 후 사이트 갱신을 위해 대기 (이전보다 단축) + page.wait_for_timeout(800) + + # 이미 반영되어 있는지 확인 (중복 입력 방지) + current_history = get_last_history_text(page) + all_matched = True + for le in late_events: + le_text = le.get("text", "") + if le_text and le_text not in current_history: + all_matched = False + break + if all_matched: + return + + # 2차(지연된) 주루 이벤트 입력 + new_late = set_runner_events(page, event, late_events) + + # 2차 입력완료 클릭 + submit_input_complete( + page, + f"지연 주루 처리: {', '.join(e.get('text', '') for e in late_events)}", + clear_defense=True, + log_info={"job_id": job_id} if job_id else None + ) + + if new_late: + handle_late_runner_events(page, event, new_late, write_events, job_id) + + +def build_runner_event_lines(event: dict[str, Any]) -> list[str]: + lines: list[str] = [] + for runner_event in (event.get("runnerEvents") or []): + r_text = runner_event.get("text", "") + from_b = runner_event.get("fromBase", "?") + to_b = runner_event.get("toBase", "?") + label = infer_runner_action_label(event, runner_event) + line = f"🏃 {from_b}루주자 -> {to_b}루 : {r_text}" + if label: + line += f" | 라벨: {label}" + lines.append(line) + return lines + + +def get_pitch_runner_events(pitch: dict[str, Any], event: dict[str, Any] | None = None) -> list[dict[str, Any]]: + pitch_runner_events = list(pitch.get("runnerEvents") or []) + if pitch_runner_events: + return pitch_runner_events + if event and event.get("runnerEvents"): + return list(event.get("runnerEvents") or []) + return [] + + +def set_pitch(page: Page, pitch: dict[str, Any], event: dict[str, Any] | None = None) -> None: + pitch_type = PITCH_TYPE_LABEL_MAP.get(pitch.get("pitchType") or "") + pitch_result_text = (pitch.get("pitchResultText") or "").strip() + normalized_pitch_result_text = pitch_result_text.replace(" ", "") + if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text: + pitch_result_text = "볼" + + # 폭투/포일 체크 (투구 단위) + runner_events = get_pitch_runner_events(pitch, event) + is_wild_pitch = any( + re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") + for re in runner_events + ) + is_passed_ball = any( + re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") + for re in runner_events + ) + + if is_wild_pitch: + pitch_result = "폭투-볼" + elif is_passed_ball: + pitch_result = "포일-볼" + else: + if "번트" in normalized_pitch_result_text and "헛스윙" in normalized_pitch_result_text: + pitch_result = "번트시도-스트라이크" + elif "번트" in normalized_pitch_result_text and "파울" in normalized_pitch_result_text: + pitch_result = "번트-파울" + else: + pitch_result = PITCH_RESULT_LABEL_MAP.get(pitch_result_text) + if not pitch_result and pitch.get("pitchResult") in {"BS", "V"}: + pitch_result = "번트시도-스트라이크" + if not pitch_result and pitch.get("pitchResult") == "BF": + pitch_result = "번트-파울" + if not pitch_result and "고의사구" in pitch_result_text: + pitch_result = "고의사구" + if not pitch_result and "파울플라이" in pitch_result_text and "실책" in pitch_result_text: + pitch_result = "파울플라이-실책" + + if pitch_type: + set_radio_by_label(page, "evt_ballType", pitch_type) + + if pitch_result: + set_radio_by_label(page, "evt_batter", pitch_result) + + speed_input = page.locator("#ballspeed") + speed_input.fill(str(pitch.get("speedKmh") or 0)) + speed_input.evaluate("node => node.dispatchEvent(new Event('change', {bubbles:true}))") + page.wait_for_timeout(50) + + +def set_pitch_meta_only(page: Page, pitch: dict[str, Any]) -> None: + """구종/구속만 세팅. 인플레이(H) 마지막 구에서 팝업이 일찍 열리지 않도록 evt_batter는 건드리지 않음.""" + pitch_type = PITCH_TYPE_LABEL_MAP.get(pitch.get("pitchType") or "") + if pitch_type: + set_radio_by_label(page, "evt_ballType", pitch_type) + page.locator("#ballspeed").fill(str(pitch.get("speedKmh") or 0)) + + +def normalize_pitch_result_code(pitch: dict[str, Any], event: dict[str, Any] | None = None) -> str: + pitch_result = (pitch.get("pitchResult") or "").strip() + pitch_result_text = (pitch.get("pitchResultText") or "").strip() + normalized_text = pitch_result_text.replace(" ", "") + if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text: + return "B" + if "번트" in normalized_text and "헛스윙" in normalized_text: + return "BS" + runner_events = get_pitch_runner_events(pitch, event) + if any(re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") for re in runner_events): + return "B" + if any(re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") for re in runner_events): + return "B" + return pitch_result + + +def set_batter_result_type(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None: + """타격 결과 종류(1루타, 수비실책 등)만 세팅. 이 작업은 보통 구장 이미지 팝업을 엶.""" + if not result: + return + label = infer_batter_result_label(result, event) + if not label: + return + + if label == "병살-아웃": + forced = page.evaluate( + """(eventName) => { + const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")]; + for (const node of nodes) { + const name = (node.getAttribute('eventName') || '').trim(); + if (name === eventName) { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + } + return false; + }""", + label, + ) + if forced: + page.wait_for_timeout(120) + if get_checked_event_name(page, "evt_batter") == label: + return + + marker = page.evaluate( + """(eventName) => { + const nodes = [...document.querySelectorAll("input[type=radio][name='evt_batter']")]; + for (let i = nodes.length - 1; i >= 0; i -= 1) { + const node = nodes[i]; + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + if (!rect.width || !rect.height || style.display === 'none' || style.visibility === 'hidden' || node.disabled) { + continue; + } + if ((node.getAttribute('eventName') || '') === eventName) { + const marker = `codex-batter-${Math.random().toString(36).slice(2)}`; + node.setAttribute('data-codex-marker', marker); + return marker; + } + } + return null; + }""", + label, + ) + if marker: + candidate = page.locator(f"[data-codex-marker='{marker}']") + for _ in range(3): + try: + candidate.click(force=True) + except Exception: + candidate.evaluate( + """(node) => { + node.disabled = false; + node.checked = true; + node.dispatchEvent(new Event('click', { bubbles: true })); + node.dispatchEvent(new Event('change', { bubbles: true })); + }""" + ) + checked_name = get_checked_event_name(page, "evt_batter") + if checked_name == label: + return + page.wait_for_timeout(100) + + for _ in range(3): + click_radio_by_label(page, "evt_batter", label) + if get_checked_event_name(page, "evt_batter") == label: + break + page.wait_for_timeout(100) + + +def set_batter_advancement(page: Page, result: dict[str, Any] | None) -> None: + """타자의 최종 루(1루, 2루 등)와 주루가산 세팅. 구장 팝업이 닫힌 후 메인 폼에서 처리해야 함.""" + if not result: + return + + # 타자 최종 루 (1루, 2루, 3루, 홈) + to_base = result.get("toBase") + + # 1루타, 수비실책, 야수선택, 몸에 맞는 볼, 볼넷 등은 명시적인 toBase가 없더라도 기본적으로 1루로 진루함 + if to_base is None: + r_type = result.get("type") + if r_type in {"single", "bunt_hit", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "hit_by_pitch", "walk", "intentional_walk"}: + to_base = 1 + elif r_type == "double": + to_base = 2 + elif r_type == "triple": + to_base = 3 + elif r_type == "home_run": + to_base = 4 + + if to_base is not None: + try: + selector = f"input[name='dat_evt_batter_advance'][value='{to_base}']" + locator = get_last_visible_enabled_locator(page, selector) + if locator is not None: + locator.check(force=True) + else: + fallback = page.locator(selector) + if fallback.count() > 0: + fallback.first.check(force=True) + except Exception: + pass + + # 주루가산 (Extra Advance) + try: + extra_advance = result.get("extra_advance") + if extra_advance is not None and extra_advance > 0: + locator = get_last_visible_enabled_locator(page, "#batterRunningAdd") + if locator is not None: + locator.select_option(value=str(extra_advance)) + else: + fallback = page.locator("#batterRunningAdd") + if fallback.count() > 0: + fallback.first.select_option(value=str(extra_advance)) + except Exception: + pass + + +def set_batter_result(page: Page, result: dict[str, Any] | None, event: dict[str, Any] | None = None) -> None: + """하위 호환성을 위해 유지. 결과 종류와 진루를 모두 처리 시도.""" + set_batter_result_type(page, result, event) + set_batter_advancement(page, result) + + +def get_last_pitch_result_text(event: dict[str, Any] | None) -> str: + pitches = (event or {}).get("pitches") or [] + if not pitches: + return "" + return ((pitches[-1] or {}).get("pitchResultText") or "").strip() + + +def is_simple_terminal_result_type(result_type: str) -> bool: + return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"} + + +def infer_batter_result_label(result: dict[str, Any], event: dict[str, Any] | None = None) -> str | None: + result_type = result.get("type") or "" + result_text = (result.get("text") or "").strip() + runner_events = (event or {}).get("runnerEvents") or [] + last_pitch_result_text = get_last_pitch_result_text(event) + + if result_type == "strikeout_not_out" or "낫아웃" in result_text: + if "폭투" in result_text: + return "폭투 낫아웃 진루" + if "포일" in result_text: + return "포일 낫아웃 진루" + if "아웃" in result_text: + return "스트라이크-낫아웃" + return "낫아웃-출루" + + if result_type == "strikeout": + if "헛스윙" in last_pitch_result_text or "헛스윙" in result_text: + return "스윙 스트라이크-아웃" + return "루킹스트라이크-아웃" + + if "희생 번트" in result_text or "희생번트" in result_text: + return "희생 번트" + + if "번트 아웃" in result_text or "번트아웃" in result_text: + return "번트-아웃" + + if any("보크" in (runner_event.get("text") or "") and "진루" in (runner_event.get("text") or "") for runner_event in runner_events): + if "볼" in last_pitch_result_text: + return "보크-볼" + return "보크" + # 폭투-볼 처리 (최우선) + if any((re.get("type") == "wild_pitch_advance") for re in runner_events): + return "폭투-볼" + + # 포볼(Ball 4) 처리 + if result_type == "walk": + if any(re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") for re in runner_events): + return "폭투-포볼" + if any(re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") for re in runner_events): + return "포일-포볼" + return "포볼" + if any((runner_event.get("type") or "") == "passed_ball_advance" for runner_event in runner_events): + if "볼" in last_pitch_result_text: + return "포일-볼" + return "포일-스트라이크" + if result_type == "reach_on_error" or "실책" in result_text: + return "수비실책" + + if result_type in {"reach_on_fielder_choice"}: + return "야수선택" + + if result_type in {"reach_on_grounder"}: + return "땅볼출루(무안타)" + + if result_type == "double_play": + if "번트" in result_text: + return "번트-병살" + return "병살-아웃" + if result_type == "single_runner_out": + return "1루타 후 주루아웃" + if result_type == "double_runner_out": + return "2루타 후 주루아웃" + if result_type == "triple_runner_out": + return "3루타 후 주루아웃" + if result_type == "single_error_advance": + return "1루타 후 수비실책진루" + if result_type == "double_error_advance": + return "2루타 후 수비실책진루" + if result_type == "triple_error_advance": + return "3루타 후 수비실책진루" + + if "파울희생플라이" in result_text or "파울 희생플라이" in result_text: + return "희생 플라이" + + if result_type == "out": + if "병살" in result_text: + if "번트" in result_text: + return "번트-병살" + return "병살-아웃" + if "희생 플라이" in result_text: + return "희생 플라이" + if "희생플라이" in result_text: + return "희생 플라이" + if "인필드플라이" in result_text: + return "인필드플라이" + if "파울플라이" in result_text: + return "파울플라이-아웃" + return "아웃" + + if result_type == "bunt_hit": + return "번트안타" + if result_type == "single": + if "번트안타" in result_text: + return "번트안타" + if "내야안타" in result_text: + return "내야안타" + if result_type == "hit_by_pitch" or "헤드샷" in result_text: + return "몸에 맞는 볼" + return BATTER_RESULT_LABEL_MAP.get(result_type) + + +def is_ball_in_play_event(event: dict[str, Any]) -> bool: + pitches = event.get("pitches") or [] + result = event.get("result") or {} + if not pitches or not result: + return False + return pitches[-1].get("pitchResult") == "H" + + +def infer_hit_ball_type(result_text: str) -> str: + if "번트" in result_text: + return "번트타구" + if "몸에 맞는 타구" in result_text: + return "땅볼" + if "파울희생플라이" in result_text or "파울 희생플라이" in result_text: + return "플라이" + if "파울플라이" in result_text: + return "플라이" + if "라인드라이브" in result_text or "직선타" in result_text: + return "라인드라이브" + if "플라이" in result_text: + return "플라이" + if "땅볼" in result_text: + return "땅볼" + if "홈런" in result_text: + return "홈런성타구" + return "일반바운드" + + +def infer_field_zone(result_text: str) -> str: + if "몸에 맞는 타구" in result_text: + return "1루수" + ordered_zones = ( + "좌중간", + "우중간", + "좌전", + "중전", + "우전", + "좌월", + "중월", + "우월", + "좌익수", + "중견수", + "우익수", + "유격수", + "3루수", + "2루수", + "1루수", + "투수", + "포수", + ) + for zone in ordered_zones: + if zone in result_text: + return zone + return "중견수" + + +def extract_direction_offsets(result_text: str) -> tuple[int, int]: + x_delta = 0 + y_delta = 0 + + if "왼쪽" in result_text: + x_delta -= 1 + if "오른쪽" in result_text: + x_delta += 1 + if "앞" in result_text: + y_delta += 1 + if "뒤" in result_text: + y_delta -= 1 + + return x_delta, y_delta + + +def is_infield_zone(zone: str) -> bool: + return zone in {"투수", "포수", "1루수", "2루수", "3루수", "유격수"} + + +def extract_defense_sequence(result_text: str) -> list[str]: + # 1) '2-6', '2-5-3' 같은 숫자 패턴 처리 + num_seq_match = re.search(r"(\d+(?:-\d+)+)", result_text) + if num_seq_match: + nums = num_seq_match.group(1).split("-") + pos_names = [] + for n in nums: + # 1:투수, 2:포수, ..., 9:우익수 매핑 + name = POSITION_LABEL_MAP.get(n) + if name: pos_names.append(name) + if pos_names: return pos_names + + parenthetical_match = re.search(r"\(([^)]*)\)", result_text) + if parenthetical_match: + sequence = re.findall(r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", parenthetical_match.group(1)) + if sequence: + return sequence + leading_text = result_text.split("(", 1)[0] + sequence = re.findall(r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", leading_text) + if sequence: + return sequence + zone = infer_field_zone(result_text) + if zone in POSITION_NUMBER_MAP: + return [zone] + return [] + + +def is_double_play_result(result_text: str) -> bool: + return "병살" in result_text + + +def build_double_play_first_sequence(event: dict[str, Any]) -> list[str]: + result_text = ((event.get("result") or {}).get("text") or "").strip() + return extract_defense_sequence(result_text) + + +def is_error_result(result_text: str) -> bool: + return "실책" in result_text + + +def is_throwing_error(result_text: str) -> bool: + keywords = ("송구실책", "송구 실책", "악송구", "throwing error", "송구에러") + return any(keyword in result_text for keyword in keywords) + + +def extract_error_position(result_text: str) -> str | None: + parenthetical_match = re.search(r"\(([^)]*실책[^)]*)\)", result_text) + search_texts = [parenthetical_match.group(1)] if parenthetical_match else [] + search_texts.append(result_text) + for text in search_texts: + positions = re.findall(r"(투수|포수|1루수|2루수|3루수|유격수|좌익수|중견수|우익수)", text) + if positions: + return positions[0] + return None + + +def infer_error_position_fallback(text: str) -> str: + if "야수선택" in text: + return "야수선택" + if "도루" in text: + return "포수" + if "포구" in text: + return "포수" + if "송구" in text: + return "투수" + return "포수" + + +def fill_error_defense_popup(page: Page, text: str) -> None: + defense_sequence = extract_defense_sequence(text) + if len(defense_sequence) >= 2: + click_defense_sequence_in_popup(page, defense_sequence) + else: + error_position = extract_error_position(text) + if not error_position: + error_position = infer_error_position_fallback(text) + click_count = 2 if is_throwing_error(text) else 1 + click_defense_button_robustly(page, error_position, click_count) + + complete_button = get_last_visible_locator(page, "#btnNext") + if complete_button is None: + complete_button = get_last_visible_locator(page, "#btnAdd") + if complete_button: + complete_button.click() + page.wait_for_timeout(120) + + +def build_hit_ball_payload(page: Page, result_text: str) -> dict[str, str]: + zone = infer_field_zone(result_text) + x, y = FIELD_COORDINATES.get(zone, FIELD_COORDINATES["중견수"]) + meter_per_px_text = page.locator("#dat_meterPerPx").input_value() or "0" + try: + meter_per_px = float(meter_per_px_text) + except ValueError: + meter_per_px = 0.0 + + result_type = classify_result_text_type(result_text) + hit_ball_type_label = infer_hit_ball_type(result_text) + x, y = apply_hit_ball_variation(result_text, result_type, zone, x, y) + px_x = math.floor(650 * x / 100) + px_y = math.floor(621 * y / 100) + distance = math.floor(math.sqrt((50 - x) ** 2 + (95 - y) ** 2) * math.hypot(6.5, 6.21) * meter_per_px / 100) if meter_per_px else 0 + return { + "type": HIT_BALL_TYPE_MAP.get(hit_ball_type_label, "1"), + "label": hit_ball_type_label, + "x": str(px_x), + "y": str(px_y), + "xy": f"{x},{y}", + "distance": str(distance), + } + + +def classify_result_text_type(result_text: str) -> str: + if "홈런" in result_text: + return "home_run" + if "아웃" in result_text or "희생" in result_text: + return "out" + return "safe" + + +def deterministic_offset(seed_text: str, radius: int) -> tuple[int, int]: + digest = hashlib.md5(seed_text.encode("utf-8")).digest() + x_offset = (digest[0] % (radius * 2 + 1)) - radius + y_offset = (digest[1] % (radius * 2 + 1)) - radius + return x_offset, y_offset + + +def apply_hit_ball_variation(result_text: str, result_type: str, zone: str, x: int, y: int) -> tuple[int, int]: + dir_x, dir_y = extract_direction_offsets(result_text) + + if "파울플라이" in result_text or "파울희생플라이" in result_text or "파울 희생플라이" in result_text: + is_left = any(token in result_text for token in ("좌", "3루", "유격")) + foul_x, foul_y = FOUL_FLY_LEFT if is_left else FOUL_FLY_RIGHT + x_offset, y_offset = deterministic_offset(result_text, 2) + return ( + max(0, min(100, foul_x + x_offset)), + max(50, min(100, foul_y + y_offset)), + ) + + if result_type == "home_run": + x_offset, y_offset = deterministic_offset(result_text, 2) + return ( + max(15, min(85, x + x_offset)), + max(12, min(22, y + y_offset)), + ) + + if is_infield_zone(zone): + base_shift = 3 + random_radius = 2 if result_type == "out" else 3 + else: + base_shift = 12 + random_radius = 5 if result_type == "out" else 7 + + x_offset, y_offset = deterministic_offset(result_text, random_radius) + return ( + max(10, min(90, x + dir_x * base_shift + x_offset)), + max(18, min(96, y + dir_y * base_shift + y_offset)), + ) + + +def wait_for_user_confirmation(event: dict[str, Any], stage: str = "확인") -> None: + result_text = ((event.get("result") or {}).get("text") or "").strip() + prompt = f"[{stage} 대기] {result_text or '타구 결과'} 확인 후 Enter를 누르세요: " + try: + input(prompt) + except EOFError: + pass + + +def set_hit_ball_and_defense(page: Page, event: dict[str, Any]) -> bool: + result = event.get("result") or {} + result_text = (result.get("text") or "").strip() + if not result_text: + return False + + if is_double_play_result(result_text): + first_popup_sequence = build_double_play_first_sequence(event) + if first_popup_sequence: + click_defense_sequence_in_popup(page, first_popup_sequence, "#btnNext") + page.wait_for_timeout(250) + + hit_ball = build_hit_ball_payload(page, result_text) + popup_field = None + for _ in range(10): + popup_field = get_last_visible_locator(page, "#defenseDiv") + if popup_field is not None and popup_field.bounding_box() is not None: + break + page.wait_for_timeout(100) + + if popup_field is None: + return False + + defense_box = popup_field.bounding_box() + if not defense_box: + return False + + page.mouse.click(defense_box["x"] + int(hit_ball["x"]), defense_box["y"] + int(hit_ball["y"])) + page.wait_for_timeout(100) + + # hitBallType 라디오 버튼 선택: 값(value)으로 강제 체크 + # value가 없으면 라벨 텍스트로 찾아서 click + hit_type_val = hit_ball["type"] + hit_type_label = hit_ball.get("label", "") + selected = page.evaluate( + """([val, label]) => { + const nodes = [...document.querySelectorAll("input[name='hitBallType']")]; + for (const node of nodes) { + if (node.value === val) { + node.checked = true; + node.dispatchEvent(new Event('change', {bubbles: true})); + return true; + } + } + // 라벨 텍스트로 재시도 + for (const node of nodes) { + let text = ''; + let p = node.parentElement; + if (p) text = p.textContent.trim(); + if (!text && node.nextSibling) text = node.nextSibling.textContent || ''; + if (label && text.includes(label)) { + node.checked = true; + node.dispatchEvent(new Event('change', {bubbles: true})); + return true; + } + } + return false; + }""", + [hit_type_val, hit_type_label], + ) + page.wait_for_timeout(150) + + if is_error_result(result_text): + error_position = extract_error_position(result_text) + if error_position: + click_count = 2 if is_throwing_error(result_text) else 1 + click_defense_button_robustly(page, error_position, click_count) + + next_button = get_last_visible_locator(page, "#btnNext") + if next_button is not None: + next_button.click() + page.wait_for_timeout(400) + + complete_button = get_last_visible_locator(page, "#btnAdd") + # 세이프 결과(안타 등) 혹은 실책 처리가 #btnNext 로 종료될 수 있으므로 체크 + if complete_button is None: + return True + + if result.get("type") in {"out", "sacrifice_fly", "sacrifice_bunt", "reach_on_fielder_choice", "reach_on_grounder", "double_play", "reach_on_error"}: + positions = extract_defense_sequence(result_text) + if "직선타" in result_text or "라인드라이브" in result_text or "플라이" in result_text: + if positions: + positions = [positions[0]] + elif result.get("type") == "double_play": + # 병살타의 경우: 두 번째 상세 팝업에서는 첫 번째 수비수를 제외한 나머지 구간(예: 6-3)만 입력 + if len(positions) > 1: + positions = positions[1:] + elif result.get("type") == "reach_on_error": + # 실책의 경우: 이미 2285번 라인에서 처리했을 수 있으나, 여기서도 positions가 있다면 보장 + if not positions: + err_pos = extract_error_position(result_text) + if err_pos: + positions = [err_pos] + + for position in positions: + click_defense_button_robustly(page, position) + + if complete_button: + complete_button.click(force=True) + page.wait_for_timeout(400) # 팝업이 완전히 닫힐 때까지 대기 + return True + + +def advance_count(balls: int, strikes: int, pitch_result: str) -> tuple[int, int]: + if pitch_result == "B": + return min(balls + 1, 3), strikes + if pitch_result in {"T", "S"}: + return balls, min(strikes + 1, 2) + if pitch_result == "F": + return balls, strikes if strikes >= 2 else strikes + 1 + return balls, strikes + + +def process_only_reviews(page: Page, report: dict[str, Any], write_events: bool, job_id: str = None) -> None: + """리포트 전체에서 합의판정 전용 데이터만 추출하여 일괄 등록""" + all_reviews = [] + for half_inning in report["game_contents"]: + for event in half_inning.get("events", []): + if event.get("event_type") == "at_bat": + reviews = event.get("reviewEvents") or [] + for r in reviews: + all_reviews.append(normalize_review_event(r)) + + if not all_reviews: + show_debug_overlay(page, ["입력할 합의판정 기록이 없습니다."]) + page.wait_for_timeout(2000) + return + + show_debug_overlay(page, [f"합의판정 {len(all_reviews)}건 일괄 등록 시작"]) + if write_events: + record_review_events(page, all_reviews) + show_debug_overlay(page, ["합의판정 일괄 등록 완료"]) + page.wait_for_timeout(1000) + + +def process_report(page: Page, report: dict[str, Any], write_events: bool, job_id: int = 0) -> None: + outs = 0 + change_cache: dict[str, tuple[str, int]] = {} + applied_change_texts: set[str] = set() + for half_inning in report["game_contents"]: + inning = half_inning.get("inning", "") + outs = 0 + for event in half_inning["events"]: + if event.get("event_type") == "change": + change_text = (event.get("text") or "").strip() + show_debug_overlay( + page, + [ + f"교체 입력: {change_text or '-'}", + ], + ) + wait_for_operator_control(page) + if write_events: + if change_text and change_text in applied_change_texts: + show_debug_overlay( + page, + [ + "교체 중복 건너뜀", + change_text, + ], + ) + page.wait_for_timeout(250) + continue + try: + # job_id는 이미 함수의 인자로 전달받으므로 별도로 다시 구할 필요 없음 + log_id = job_id or os.environ.get("JOB_ID") + log_info_event = { + "job_id": log_id, + "inning": inning, + "event_type": event.get("change_type", "change"), + "target_player": event.get("in_player") or event.get("to_position", ""), + "actual_player": event.get("actor_name") or event.get("player_name", "") + } if job_id else None + + apply_change_event(page, half_inning, event, change_cache) + _try_log_event(log_info_event, True) + except Exception as e: + _try_log_event(log_info_event, False, str(e)) + raise e + if change_text: + applied_change_texts.add(change_text) + show_debug_overlay( + page, + [ + "교체 완료", + f"{change_text or '-'}", + ], + ) + page.wait_for_timeout(120) + continue + if event.get("event_type") != "at_bat": + continue + + clear_defense_selections(page) + balls = 0 + strikes = 0 + pitches = event.get("pitches") or [] + result = event.get("result") or {} + for pitch_index, pitch in enumerate(pitches): + pitch_result_text = (pitch.get("pitchResultText") or "").strip() + normalized_pitch_result = normalize_pitch_result_code(pitch, event) + is_balk_strike = "보크" in pitch_result_text and ("스트라이크" in pitch_result_text or "헛스윙" in pitch_result_text) + if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text: + pitch_result_text = "피치클락 투수위반 볼" + show_debug_overlay( + page, + [ + f"다음 카운트: {balls}볼 {strikes}스트 {outs}아웃", + f"다음 공: {pitch.get('pitchNo')}구 {pitch_result_text}", + f"구종/구속: {(pitch.get('pitchType') or '-')} / {(pitch.get('speedKmh') or '-')}", + f"타석: {event.get('batter') or '-'}", + ], + ) + wait_for_operator_control(page) + # set_ball_count(page, balls, strikes, outs) # 시스템 자동 관리로 제거 + is_last_pitch = pitch_index == len(pitches) - 1 + # 인플레이 타격(H), 몸에 맞는 볼, 삼진, 볼넷 등 타석을 종료시키는 모든 결과는 '인플레이/액션'으로 간주하여 + # 루프 끝난 뒤 타석 결과 블록에서 일괄 처리합니다. (팝업 및 타석 결과 라디오 선택을 위함) + is_action_result = is_last_pitch and result.get("type") in { + "hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple", + "home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "bunt_hit", + "single_runner_out", "double_runner_out", "triple_runner_out" + } + is_in_play = (pitch.get("pitchResult") == "H") or is_action_result + # 인플레이(H)이거나 볼넷/삼진 등 타석이 종료되는 마지막 구인 경우: + # 개별 투구 조작(볼/스트라이크 버튼 클릭)을 생략하고 + # 루프 밖의 타석 결과 처리 블록에서 한 번에 처리합니다. + if is_last_pitch and is_in_play: + continue + + if is_balk_strike: + if write_events: + current_late = [] + # 1) 투구별 주루 이벤트 (도루 등) + p_runner_events = pitch.get("runnerEvents") + if p_runner_events: + current_late.extend(set_runner_events(page, event, p_runner_events)) + + # 2) 타석 종료 주루 이벤트 (보크로 인한 진루 등) + if is_last_pitch and event.get("runnerEvents"): + current_late.extend(set_runner_events(page, event)) + + submit_input_complete( + page, + f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크", + clear_defense=True, + log_info={ + "job_id": job_id, "inning": inning, "batter": event.get("batter", ""), + "pitch_no": pitch.get("pitchNo", 0), "target_value": "보크", + "selected_value": "보크" + } if job_id else None + ) + # 지연된 이벤트가 있다면 사후 처리 + if current_late: + handle_late_runner_events(page, event, current_late, True, job_id) + + page.wait_for_timeout(80) + set_pitch_meta_only(page, pitch) + if "헛스윙" in pitch_result_text: + set_radio_by_label(page, "evt_batter", "헛스윙(스트라이크)") + else: + set_radio_by_label(page, "evt_batter", "스트라이크(루킹)") + if write_events: + submit_input_complete( + page, + f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 보크 후 {'헛스윙' if '헛스윙' in pitch_result_text else '스트라이크'}", + clear_defense=True, + log_info={ + "job_id": job_id, "inning": inning, "batter": event.get("batter", ""), + "pitch_no": pitch.get("pitchNo", 0), "target_value": pitch_result_text, + "selected_value": pitch_result_text + } if job_id else None + ) + else: + set_pitch(page, pitch, event) + if write_events: + current_late = [] + # 폭투/포일 로그 보강 + p_runner_events = get_pitch_runner_events(pitch, event) + is_wild_pitch = any( + re.get("type") == "wild_pitch_advance" or "폭투" in (re.get("text") or "") + for re in p_runner_events + ) + is_passed_ball = any( + re.get("type") == "passed_ball_advance" or "포일" in (re.get("text") or "") + for re in p_runner_events + ) + + extra_log = "" + if is_wild_pitch: + extra_log = " (폭투)" + elif is_passed_ball: + extra_log = " (포일)" + + if p_runner_events: + current_late.extend(set_runner_events(page, event, p_runner_events)) + + # 2) 타석 종료 주루 이벤트 (폭투 낫아웃 등) + if is_last_pitch and event.get("runnerEvents"): + current_late.extend(set_runner_events(page, event)) + + if "파울플라이" in pitch_result_text and "실책" in pitch_result_text: + fill_error_defense_popup(page, pitch_result_text) + + submit_input_complete( + page, + f"{event.get('batter') or '-'} / {pitch.get('pitchNo')}구 {pitch_result_text or '-'}{extra_log}", + clear_defense=True, + log_info={ + "job_id": job_id, "inning": inning, "batter": event.get("batter", ""), + "pitch_no": pitch.get("pitchNo", 0), "target_value": f"{pitch_result_text}{extra_log}", + "selected_value": "폭투-볼" if is_wild_pitch else "포일-볼" if is_passed_ball else pitch_result_text + } if job_id else None + ) + # 지연된 이벤트 사후 처리 + if current_late: + handle_late_runner_events(page, event, current_late, True, job_id) + balls, strikes = advance_count(balls, strikes, normalized_pitch_result) + + if result: + last_pitch = pitches[-1] if pitches else {} + # 타격(H)이거나 타석 종료 결과(삼진, 볼넷 등)가 있는 마지막 구 처리 + action_result_types = { + "hit_by_pitch", "strikeout", "strikeout_not_out", "walk", "intentional_walk", "out", "single", "double", "triple", + "home_run", "sacrifice_fly", "sacrifice_bunt", "double_play", "reach_on_error", "reach_on_fielder_choice", "reach_on_grounder", "bunt_hit", + "single_runner_out", "double_runner_out", "triple_runner_out", "play" + } + if last_pitch.get("pitchResult") == "H" or result.get("type") in action_result_types: + runner_lines = [] + for runner_event in (event.get("runnerEvents") or []): + r_text = runner_event.get("text", "") + from_b = runner_event.get("fromBase", "?") + to_b = runner_event.get("toBase", "?") + line = f"🏃 {from_b}루주자 -> {to_b}루 : {r_text}" + label = infer_runner_action_label(event, runner_event) + if label in {"태그아웃", "도루시도 아웃", "포스아웃"}: + r_seq = extract_defense_sequence(r_text) + if r_seq: + line += f" | ⚾ 수비 클릭됨: {' -> '.join(r_seq)}" + runner_lines.append(line) + + result_text = result.get('text') or '' + def_seq = [] + if is_error_result(result_text): + err_pos = extract_error_position(result_text) + if err_pos: + click_count = 2 if is_throwing_error(result_text) else 1 + def_seq = [err_pos] * click_count + elif result.get("type") in {"out", "double_play", "sacrifice_fly", "sacrifice_bunt", "strikeout", "reach_on_fielder_choice", "reach_on_grounder", + "single_runner_out", "double_runner_out", "triple_runner_out", "play"}: + def_seq = extract_defense_sequence(result_text) + if "직선타" in result_text or "라인드라이브" in result_text or "플라이" in result_text: + if def_seq: + def_seq = [def_seq[0]] + + defense_lines = [] + if def_seq: + seq_str = ", ".join(def_seq) + defense_lines = [f"⚾ 누를 수비수: {seq_str}"] + + show_debug_overlay( + page, + [ + f"📌 타격 결과: {result_text or '-'}", + f"🎯 현재 카운트: {balls}B {strikes}S {outs}O", + *defense_lines, + *runner_lines, + ], + ) + wait_for_operator_control(page) + # set_ball_count(page, balls, strikes, outs) # 시스템 자동 관리로 제거 + # 구종/구속만 세팅 (evt_batter는 여기서 건드리지 않음) + # → set_batter_result가 실제 결과(1루타 등)를 선택할 때 팝업이 올바르게 열림 + set_pitch_meta_only(page, last_pitch) + if write_events: + page.wait_for_timeout(120) + simple_terminal_result = is_simple_terminal_result_type(result.get("type") or "") + expected_batter_event = infer_batter_result_label(result, event) or "" + + simple_terminal_result = is_simple_terminal_result_type(result.get("type") or "") + expected_batter_event = infer_batter_result_label(result, event) or "" + + # [필독] 모든 결과에 대해 라디오 버튼(포볼, 삼진 등)은 반드시 선택해야 함 + if expected_batter_event: + for _ in range(5): + set_batter_result_type(page, result, event) + page.wait_for_timeout(50) + if get_checked_event_name(page, "evt_batter") == expected_batter_event: + break + else: + set_batter_result_type(page, result, event) + page.wait_for_timeout(50) + + popup_defense_used = False + if not simple_terminal_result: + # ① 팝업 처리 (타격 시에만 팝업이 열림) + # 실책(reach_on_error)이나 병살(double_play)은 defenseType 속성이 없어도 팝업이 뜨는 경우가 있으므로 조건에 추가 + if result.get("type") in {"reach_on_error", "double_play"} or get_checked_batter_defense_type(page): + set_hit_ball_and_defense(page, event) + popup_defense_used = True + + # ② 타자 진루 및 주루가산 (팝업 닫힌 후 메인폼에서 처리) + set_batter_advancement(page, result) + + # ③ 주루 이벤트 (타자 외 주자들 - 공통) + current_late = [] + all_runner_events = (event.get("runnerEvents") or []).copy() + if last_pitch.get("runnerEvents"): + all_runner_events.extend(last_pitch["runnerEvents"]) + + if all_runner_events: + current_late.extend(set_runner_events(page, event, all_runner_events)) + page.wait_for_timeout(30) + + # ④ 최종 전송 (모든 결과 공통) + submit_input_complete( + page, + f"{event.get('batter') or '-'} / {result_text or '-'}", + clear_defense=not popup_defense_used, + log_info={ + "job_id": job_id, "inning": inning, "batter": event.get("batter", ""), + "pitch_no": (last_pitch.get("pitchNo", 0) if isinstance(last_pitch, dict) else 0), + "target_value": result_text, + "selected_value": expected_batter_event + } if job_id else None + ) + # 타석 종료 후 지연된 주루 아웃이 있다면 별도로 처리 (실책 진루 후 아웃 등) + if current_late: + handle_late_runner_events(page, event, current_late, True, job_id) + + + result_type = result.get("type") + result_text = (result.get("text") or "").strip() + if result_type == "double_play": + outs += 2 + if outs >= 3: + break + elif "낫아웃" in result_text and not any(token in result_text for token in ("폭투", "포일", "진루", "출루", "세이프")): + outs += 1 + if outs >= 3: + break + elif result_type in {"out", "strikeout", "sacrifice_fly", "sacrifice_bunt", "single_runner_out", "double_runner_out", "triple_runner_out"}: + outs += 1 + if outs >= 3: + break + + +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() + + import os + job_id = getattr(args, "job_id", None) or os.environ.get("JOB_ID") + + if args.write_events and not job_id: + try: + import db_logging + import uuid + # 독립 실행 시 새 ID 생성 및 등록 + job_id = f"standalone-{uuid.uuid4().hex[:8]}" + db_logging.start_job(job_id, args.game_id, getattr(args, "start_inning", ""), getattr(args, "end_inning", "")) + except Exception: + job_id = None + + try: + open_game_status_page(page, args.base_url, report, args.manager_game_no) + if getattr(args, "review_only", False): + process_only_reviews(page, report, args.write_events, job_id=job_id) + else: + process_report(page, report, args.write_events, job_id=job_id) + if not args.close: + page.wait_for_timeout(3600 * 1000) + finally: + if args.write_events and job_id: + try: + import db_logging + db_logging.finish_job(job_id) + except Exception: + pass + if args.close: + try: + browser.close() + except Exception: + pass + + +def main() -> None: + args = parse_args() + if args.game_id: + args.game_id = "".join(args.game_id.split()) + report = load_report(get_report_path(args)) + with sync_playwright() as playwright: + run(playwright, args, report) + + +if __name__ == "__main__": + main() diff --git a/record_gdate.json b/record_gdate.json new file mode 100644 index 0000000..47b4d88 --- /dev/null +++ b/record_gdate.json @@ -0,0 +1,1367 @@ +{ + "code": 200, + "success": true, + "result": { + "recordData": { + "etcRecords": [ + { + "result": "없음", + "how": "결승타" + }, + { + "result": "최정(9회) \r\n\r\n", + "how": "3루타" + }, + { + "result": "이호준(7회) \r\n\r\n", + "how": "2루타" + }, + { + "result": "이지영(1회) 임훈(3회) \r\n\r\n", + "how": "실책" + }, + { + "result": "박한이(1회) 최정(1회) \r\n\r\n", + "how": "도루" + }, + { + "result": "이호준(4회) \r\n\r\n", + "how": "도루자" + }, + { + "result": "윤희상2(1회) \r\n\r\n", + "how": "폭투" + }, + { + "result": "문승훈 나광남 최수원 오석환 최규순 김풍기", + "how": "심판" + } + ], + "teamPitchingBoxscore": { + "away": { + "kk": 2, + "pa": 32, + "ab": 28, + "hit": 5, + "r": 2, + "bf": 110, + "bbhp": 3, + "inn": "8", + "hr": 0, + "er": 1 + }, + "home": { + "kk": 9, + "pa": 34, + "ab": 33, + "hit": 6, + "r": 1, + "bf": 140, + "bbhp": 1, + "inn": "9", + "hr": 0, + "er": 1 + } + }, + "homeTeamNextGames": [ + { + "gameId": "77771101SSSK0", + "aName": "삼성", + "gmkey": "77771101SSSK0", + "gyear": 2012, + "gweek": "목", + "aCode": "SS", + "stadium": "잠실", + "hCode": "SK", + "hName": "SK", + "gdate": 20121101 + }, + { + "gameId": "88881109SSLM0", + "aName": "삼성", + "gmkey": "88881109SSLM0", + "gyear": 2012, + "gweek": "금", + "aCode": "SS", + "stadium": "사직", + "hCode": "LM", + "hName": "라미고", + "gdate": 20121109 + }, + { + "gameId": "88881110CSSS0", + "aName": "차이나", + "gmkey": "88881110CSSS0", + "gyear": 2012, + "gweek": "토", + "aCode": "CS", + "stadium": "사직", + "hCode": "SS", + "hName": "삼성", + "gdate": 20121110 + } + ], + "gameInfo": { + "aFullName": "SSG 랜더스", + "hPCode": "74454", + "hCode": "SS", + "hName": "삼성", + "cancelFlag": "N", + "gdate": 20121031, + "aPCode": "74838", + "round": 5, + "gtime": "18:00", + "aName": "SK", + "gameFlag": "7", + "hFullName": "삼성 라이온즈", + "stadium": "잠실", + "aCode": "SK", + "optionFlag": 1, + "ptsFlag": "N", + "statusCode": "4" + }, + "awayStandings": { + "d": 3, + "era": 3.82, + "hra": 0.258, + "w": 71, + "wra": 0.546, + "name": "SK", + "rank": 2, + "hr": 108, + "l": 59, + "seriesOutcome": { + "d": null, + "w": null, + "l": null + } + }, + "awayTeamNextGames": [ + { + "gameId": "77771101SSSK0", + "aName": "삼성", + "gmkey": "77771101SSSK0", + "gyear": 2012, + "gweek": "목", + "aCode": "SS", + "stadium": "잠실", + "hCode": "SK", + "hName": "SK", + "gdate": 20121101 + } + ], + "pitchingResult": [ + { + "s": 0, + "pCode": "72463", + "w": 0, + "name": "안지만", + "l": 1, + "wls": "H" + }, + { + "s": 0, + "pCode": "74454", + "w": 2, + "name": "윤성환", + "l": 0, + "wls": "W" + }, + { + "s": 0, + "pCode": "74838", + "w": 0, + "name": "윤희상", + "l": 2, + "wls": "L" + }, + { + "s": 2, + "pCode": "75421", + "w": 0, + "name": "오승환", + "l": 0, + "wls": "S" + } + ], + "pitchersBoxscore": { + "away": [ + { + "bb": 3, + "kk": 2, + "seasonLose": 0, + "ab": 25, + "gameCount": 3, + "bf": 98, + "seasonWin": 0, + "pcode": "74838", + "bbhp": 3, + "inn": "7", + "hr": 0, + "l": 2, + "er": 1, + "hasPlayerEnd": true, + "tb": "T", + "pa": 29, + "hit": 5, + "r": 2, + "s": 0, + "era": "2.14", + "w": 0, + "name": "윤희상", + "wls": "패" + }, + { + "bb": 0, + "kk": 0, + "seasonLose": 0, + "ab": 3, + "gameCount": 7, + "bf": 12, + "seasonWin": 0, + "pcode": "76858", + "bbhp": 0, + "inn": "1", + "hr": 0, + "l": 0, + "er": 0, + "hasPlayerEnd": true, + "tb": "T", + "pa": 3, + "hit": 0, + "r": 0, + "s": 0, + "era": "0.00", + "w": 0, + "name": "박희수", + "wls": "" + } + ], + "home": [ + { + "bb": 0, + "kk": 3, + "seasonLose": 0, + "ab": 22, + "gameCount": 2, + "bf": 95, + "seasonWin": 0, + "pcode": "74454", + "bbhp": 0, + "inn": "6", + "hr": 0, + "l": 0, + "er": 1, + "hasPlayerEnd": true, + "tb": "B", + "pa": 22, + "hit": 5, + "r": 1, + "s": 0, + "era": "0.79", + "w": 2, + "name": "윤성환", + "wls": "승" + }, + { + "bb": 0, + "kk": 0, + "seasonLose": 0, + "ab": 1, + "gameCount": 3, + "bf": 1, + "seasonWin": 0, + "pcode": "72447", + "bbhp": 0, + "inn": "0", + "hr": 0, + "l": 0, + "er": 0, + "hasPlayerEnd": true, + "tb": "B", + "pa": 1, + "hit": 0, + "r": 0, + "s": 0, + "era": "13.50", + "w": 0, + "name": "권혁", + "wls": "" + }, + { + "bb": 0, + "kk": 3, + "seasonLose": 0, + "ab": 5, + "gameCount": 3, + "bf": 18, + "seasonWin": 0, + "pcode": "72463", + "bbhp": 0, + "inn": "1 ⅔", + "hr": 0, + "l": 1, + "er": 0, + "hasPlayerEnd": true, + "tb": "B", + "pa": 5, + "hit": 0, + "r": 0, + "s": 0, + "era": "9.00", + "w": 0, + "name": "안지만", + "wls": "홀" + }, + { + "bb": 1, + "kk": 3, + "seasonLose": 0, + "ab": 5, + "gameCount": 2, + "bf": 26, + "seasonWin": 0, + "pcode": "75421", + "bbhp": 1, + "inn": "1 ⅓", + "hr": 0, + "l": 0, + "er": 0, + "hasPlayerEnd": true, + "tb": "B", + "pa": 6, + "hit": 1, + "r": 0, + "s": 2, + "era": "0.00", + "w": 0, + "name": "오승환", + "wls": "세" + } + ] + }, + "battersBoxscore": { + "homeTotal": { + "ab": 28, + "hit": 5, + "hra": "0.219", + "rbi": 1, + "run": 2 + }, + "awayTotal": { + "ab": 33, + "hit": 6, + "hra": "0.240", + "rbi": 1, + "run": 1 + }, + "away": [ + { + "bb": 0, + "inn9": "", + "batOrder": 1, + "playerCode": "75808", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "二", + "hra": "0.395", + "inn25": "", + "inn2": "", + "inn1": "유땅", + "inn4": "", + "inn3": "유땅", + "inn6": "삼진", + "inn20": "", + "inn5": "", + "inn8": "포파", + "inn7": "", + "kk": 1, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "정근우", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 2, + "playerCode": "71857", + "run": 1, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 1, + "inn21": "", + "pos": "좌", + "hra": "0.229", + "inn25": "", + "inn2": "", + "inn1": "1땅", + "inn4": "2안", + "inn3": "", + "inn6": "1땅", + "inn20": "", + "inn5": "", + "inn8": "삼진", + "inn7": "", + "kk": 1, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "박재상", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "중3", + "batOrder": 3, + "playerCode": "75847", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 3, + "inn21": "", + "pos": "三", + "hra": "0.343", + "inn25": "", + "inn2": "", + "inn1": "좌안", + "inn4": "유안", + "inn3": "", + "inn6": "포파", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 0, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "최정", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "유땅", + "batOrder": 4, + "playerCode": "94629", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 2, + "inn21": "", + "pos": "지", + "hra": "0.200", + "inn25": "", + "inn2": "", + "inn1": "삼진", + "inn4": "우안", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "우2", + "kk": 1, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "이호준", + "rbi": 1 + }, + { + "bb": 1, + "inn9": "4구", + "batOrder": 5, + "playerCode": "74846", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "一", + "hra": "0.167", + "inn25": "", + "inn2": "좌비", + "inn1": "", + "inn4": "3땅", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "야선", + "kk": 0, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "박정권", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "삼진", + "batOrder": 6, + "playerCode": "71837", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "중", + "hra": "0.263", + "inn25": "", + "inn2": "중비", + "inn1": "", + "inn4": "유땅", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "삼진", + "kk": 2, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "김강민", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "삼진", + "batOrder": 7, + "playerCode": "96307", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "유", + "hra": "0.286", + "inn25": "", + "inn2": "중비", + "inn1": "", + "inn4": "", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "우비", + "inn8": "", + "inn7": "삼진", + "kk": 2, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": false, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "박진만", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 8, + "playerCode": "98144", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "포", + "hra": "0.192", + "inn25": "", + "inn2": "", + "inn1": "", + "inn4": "", + "inn3": "우비", + "inn6": "", + "inn20": "", + "inn5": "삼진", + "inn8": "", + "inn7": "", + "kk": 1, + "ab": 2, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "조인성", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 8, + "playerCode": "76812", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "교", + "hra": "0.200", + "inn25": "", + "inn2": "", + "inn1": "", + "inn4": "", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "유땅", + "kk": 0, + "ab": 1, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "이재원", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 8, + "playerCode": "71842", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "교", + "hra": "0.000", + "inn25": "", + "inn2": "", + "inn1": "", + "inn4": "", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 0, + "ab": 0, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "정상호", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 9, + "playerCode": "74823", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "우", + "hra": "0.118", + "inn25": "", + "inn2": "", + "inn1": "", + "inn4": "", + "inn3": "2땅", + "inn6": "", + "inn20": "", + "inn5": "좌비", + "inn8": "삼진", + "inn7": "", + "kk": 1, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "임훈", + "rbi": 0 + } + ], + "home": [ + { + "bb": 0, + "inn9": "", + "batOrder": 1, + "playerCode": "79465", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "좌", + "hra": "0.353", + "inn25": "", + "inn2": "유땅", + "inn1": "우비", + "inn4": "", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "1땅", + "inn8": "", + "inn7": "삼진", + "kk": 1, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "배영섭", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 2, + "playerCode": "79459", + "run": 1, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 1, + "inn21": "", + "pos": "중", + "hra": "0.200", + "inn25": "", + "inn2": "", + "inn1": "좌안", + "inn4": "", + "inn3": "유땅", + "inn6": "", + "inn20": "", + "inn5": "우비", + "inn8": "", + "inn7": "좌비", + "kk": 0, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": false, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "정형식", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 3, + "playerCode": "95436", + "run": 1, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 2, + "inn21": "", + "pos": "一", + "hra": "0.389", + "inn25": "", + "inn2": "", + "inn1": "우안", + "inn4": "", + "inn3": "우안", + "inn6": "", + "inn20": "", + "inn5": "2땅", + "inn8": "3땅", + "inn7": "", + "kk": 0, + "ab": 4, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "이승엽", + "rbi": 0 + }, + { + "bb": 1, + "inn9": "", + "batOrder": 4, + "playerCode": "72443", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 1, + "inn21": "", + "pos": "지", + "hra": "0.158", + "inn25": "", + "inn2": "", + "inn1": "포파", + "inn4": "", + "inn3": "우안", + "inn6": "4구", + "inn20": "", + "inn5": "", + "inn8": "1땅", + "inn7": "", + "kk": 0, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "최형우", + "rbi": 0 + }, + { + "bb": 1, + "inn9": "", + "batOrder": 5, + "playerCode": "71432", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "우", + "hra": "0.214", + "inn25": "", + "inn2": "", + "inn1": "4구", + "inn4": "", + "inn3": "유땅", + "inn6": "투희번", + "inn20": "", + "inn5": "", + "inn8": "3직", + "inn7": "", + "kk": 0, + "ab": 2, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "박한이", + "rbi": 1 + }, + { + "bb": 1, + "inn9": "", + "batOrder": 6, + "playerCode": "74465", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "三", + "hra": "0.071", + "inn25": "", + "inn2": "", + "inn1": "2땅", + "inn4": "", + "inn3": "중비", + "inn6": "4구", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 0, + "ab": 2, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "박석민", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 7, + "playerCode": "72466", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 1, + "inn21": "", + "pos": "二", + "hra": "0.176", + "inn25": "", + "inn2": "좌안", + "inn1": "", + "inn4": "삼진", + "inn3": "", + "inn6": "2비", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 1, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "조동찬", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 8, + "playerCode": "79456", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "포", + "hra": "0.300", + "inn25": "", + "inn2": "우비", + "inn1": "", + "inn4": "2비", + "inn3": "", + "inn6": "유땅", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 0, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "이지영", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 8, + "playerCode": "97202", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "교", + "hra": "0.167", + "inn25": "", + "inn2": "", + "inn1": "", + "inn4": "", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "", + "kk": 0, + "ab": 0, + "inn19": "", + "inn18": "", + "hasPlayerEnd": false, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "진갑용", + "rbi": 0 + }, + { + "bb": 0, + "inn9": "", + "batOrder": 9, + "playerCode": "79402", + "run": 0, + "hr": 0, + "inn24": "", + "inn23": "", + "inn22": "", + "hit": 0, + "inn21": "", + "pos": "유", + "hra": "0.154", + "inn25": "", + "inn2": "중비", + "inn1": "", + "inn4": "중비", + "inn3": "", + "inn6": "", + "inn20": "", + "inn5": "", + "inn8": "", + "inn7": "2비", + "kk": 0, + "ab": 3, + "inn19": "", + "inn18": "", + "hasPlayerEnd": true, + "inn13": "", + "inn12": "", + "inn11": "", + "inn10": "", + "inn17": "", + "inn16": "", + "inn15": "", + "inn14": "", + "name": "김상수", + "rbi": 0 + } + ] + }, + "todayKeyStats": { + "away": { + "kk": 9, + "hit": 6, + "err": 1, + "hr": 0, + "gd": 0, + "tb": "T", + "sb": 1 + }, + "home": { + "kk": 2, + "hit": 5, + "err": 1, + "hr": 0, + "gd": 0, + "tb": "B", + "sb": 1 + } + }, + "games": [ + { + "isSdTvEnable": "on", + "baseInfo": { + "b2": "", + "b3": "3", + "b1": "1" + }, + "gyear": 2012, + "hName": "삼성", + "cancelFlag": "N", + "gdate": 20121031, + "isEnable": "Y", + "score": { + "hScore": 2, + "aScore": 1 + }, + "isTvOnAir": "off", + "gtime": "18:00", + "dheader": "0", + "aCode": "SK", + "gameId": "77771031SKSS0", + "aFullName": "SSG 랜더스", + "isTvOnAirSd": "off", + "gmkey": "77771031SKSS0", + "inn": "9회초", + "hCode": "SS", + "suspendedInfo": "", + "isTvEnable": "on", + "aName": "SK", + "gweek": "수", + "hFullName": "삼성 라이온즈", + "isVod": "Y", + "statusCode": "4" + } + ], + "scoreBoard": { + "rheb": { + "away": { + "r": 1, + "b": 1, + "e": 1, + "h": 6 + }, + "home": { + "r": 2, + "b": 3, + "e": 1, + "h": 5 + } + }, + "inn": { + "away": [ + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ], + "home": [ + 1, + 0, + 1, + 0, + 0, + 0, + 0, + 0 + ] + } + }, + "recentVsGames": [ + { + "gameId": "77771029SSSK0", + "gmkey": "77771029SSSK0", + "gyear": 2012, + "index": 0, + "hCode": "SK", + "hName": "SK", + "gdate": 20121029, + "hScore": 0, + "aName": "삼성", + "aScore": 0, + "gweek": "월", + "aCode": "SS", + "stadium": "문학", + "statusCode": "4" + }, + { + "gameId": "77771031SKSS0", + "gmkey": "77771031SKSS0", + "gyear": 2012, + "index": 1, + "hCode": "SS", + "hName": "삼성", + "gdate": 20121031, + "hScore": 0, + "aName": "SK", + "aScore": 0, + "gweek": "수", + "aCode": "SK", + "stadium": "잠실", + "statusCode": "4" + }, + { + "gameId": "77771101SSSK0", + "gmkey": "77771101SSSK0", + "gyear": 2012, + "index": 2, + "hCode": "SK", + "hName": "SK", + "gdate": 20121101, + "hScore": 0, + "aName": "삼성", + "aScore": 0, + "gweek": "목", + "aCode": "SS", + "stadium": "잠실", + "statusCode": "4" + } + ], + "currentInning": "9", + "homeStandings": { + "d": 2, + "era": 3.39, + "hra": 0.272, + "w": 80, + "wra": 0.611, + "name": "삼성", + "rank": 1, + "hr": 89, + "l": 51, + "seriesOutcome": { + "d": null, + "w": null, + "l": null + } + } + } + } +} \ No newline at end of file diff --git a/register_game_basic_playwright.py b/register_game_basic_playwright.py new file mode 100644 index 0000000..ff87e0b --- /dev/null +++ b/register_game_basic_playwright.py @@ -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/_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() diff --git a/register_game_playwright.py b/register_game_playwright.py new file mode 100644 index 0000000..3127f5c --- /dev/null +++ b/register_game_playwright.py @@ -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/_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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..845a23d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/update_and_run.bat b/update_and_run.bat new file mode 100644 index 0000000..87b01ab --- /dev/null +++ b/update_and_run.bat @@ -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 diff --git a/update_game_post_playwright.py b/update_game_post_playwright.py new file mode 100644 index 0000000..ac180e9 --- /dev/null +++ b/update_game_post_playwright.py @@ -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/_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() diff --git a/verify_runner_sync.py b/verify_runner_sync.py new file mode 100644 index 0000000..a804b64 --- /dev/null +++ b/verify_runner_sync.py @@ -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')}") diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..b8495ab --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1,2 @@ +"""Web application package for the baseball automation project.""" + diff --git a/webapp/app.py b/webapp/app.py new file mode 100644 index 0000000..e124e65 --- /dev/null +++ b/webapp/app.py @@ -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/") +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/") +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/") +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//status") +def job_status(job_id: str): + try: + return jsonify(load_job(job_id)) + except FileNotFoundError: + abort(404) + + +@app.get("/jobs//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) diff --git a/webapp/static/style.css b/webapp/static/style.css new file mode 100644 index 0000000..26bd504 --- /dev/null +++ b/webapp/static/style.css @@ -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; +} diff --git a/webapp/templates/index.html b/webapp/templates/index.html new file mode 100644 index 0000000..69f1f68 --- /dev/null +++ b/webapp/templates/index.html @@ -0,0 +1,422 @@ + + + + + + 야구 자동화 + + + + +
+
+
+

야구 자동화

+

경기 ID를 직접 넣거나 날짜와 팀을 고른 뒤, 라인업 또는 특정 이닝만 바로 입력합니다.

+
+
+ 현재 저장 대상 경기 ID + {{ defaults.game_id or '-' }} +
+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+
+

경기 선택

+ 라인업은 자동으로 라인업 전용 리포트를 만들고, 경기기록은 선택한 이닝만 담아 실행합니다. +
+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ + + +
+ + +
+ + + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+
+ +
+ + + + + + + +
+
+ +

+ 경기 ID 형식은 `경기구분4 + 월일4 + 어웨이2 + 홈2 + 더블헤더1 + 연도4`입니다. + 정규경기는 앞 4자리에 연도를 쓰고, 와일드카드 `4444`, 준PO `3333`, PO `5555`, 한국시리즈 `7777`을 씁니다. +

+
+
+ +
+
+

최근 작업

+ JSON 보기 +
+
+ {% if jobs %} + {% for job in jobs %} +
+
+ {{ job.type }} + {{ job.status }} +
+
+
경기 ID: {{ job.game_id }}
+
이닝: {{ job.inning_no or '-' }}
+
게임번호: {{ job.manager_game_no or '-' }}
+
생성: {{ job.created_at }}
+
+ +
+ {% endfor %} + {% else %} +

아직 작업이 없습니다.

+ {% endif %} +
+
+ +
+
+

편의 기능

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + diff --git a/webapp/templates/job.html b/webapp/templates/job.html new file mode 100644 index 0000000..7250beb --- /dev/null +++ b/webapp/templates/job.html @@ -0,0 +1,40 @@ + + + + + + 작업 상세 + + + +
+
+
+

작업 상세

+ 처음으로 +
+
+
작업 ID{{ job.job_id }}
+
타입{{ job.type }}
+
상태{{ job.status }}
+
경기 ID{{ job.game_id }}
+
게임번호{{ job.manager_game_no or '-' }}
+
리포트 경로{{ job.report_path }}
+
생성{{ job.created_at }}
+
시작{{ job.started_at or '-' }}
+
종료{{ job.finished_at or '-' }}
+
에러{{ job.error or '-' }}
+
+ +
+
+ {% if job.status in ['queued', 'running'] %} + + {% endif %} + + diff --git a/webapp/templates/logs.html b/webapp/templates/logs.html new file mode 100644 index 0000000..38e218b --- /dev/null +++ b/webapp/templates/logs.html @@ -0,0 +1,134 @@ + + + + + 통합 실행 로그: {{ job_id }} + + + + +
+ + +
+

경기 통합 액션 로그

+

Job ID: {{ job_id }}

+
+ +
+
+ + 실패한 로그만 보기 +
+
+ 총 {{ logs|length }}개의 액션이 기록됨 +
+
+ + + + + + + + + + + + + + + + {% if logs %} + {% for log in logs %} + + + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
유형이닝대상(타자/교체)동작 상세실제 입력/결과상태에러/비고시간
+ {{ '투구' if log.type == 'pitch' else '교체' }} + {{ log.inning }}{{ log.target_name }}{{ log.action_desc }}{{ log.actual_desc or '-' }} +
+ {{ '✔ 성공' if log.is_success else '✖ 실패' }} +
+
+ {% if log.error_msg %} +
{{ log.error_msg }}
+ {% else %} + - + {% endif %} +
{{ log.log_time.split(' ')[1] }}
기록된 로그가 없습니다.
+
+ + + +