diff --git a/cli.py b/cli.py
index 41443a7..9af9ace 100644
--- a/cli.py
+++ b/cli.py
@@ -15,6 +15,8 @@ from playwright.sync_api import sync_playwright
from commands.base import add_common_arguments, load_report
from commands.record import run as run_record
+DEFAULT_BASE_URL = "http://58.229.253.168:8089"
+
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Baseball Automation CLI")
@@ -39,40 +41,42 @@ def _parse_args() -> argparse.Namespace:
def interactive_mode() -> int:
print("=== Baseball Automation CLI ===")
print("1. 네이버 데이터 크롤링 (crawl)")
- print("2. 관리자 사이트 기록 자동 입력 (record)")
- print("3. 크롤링 + 기록 입력 한 번에 실행 (crawl & record)")
+ print("2. 관리자 사이트 라인업 입력 (lineup)")
+ print("3. 관리자 사이트 기록 자동 입력 (record)")
+ print("4. 크롤링 + 라인업 + 기록 한 번에 실행 (crawl -> lineup -> record)")
- choice = input("\n실행할 작업을 선택하세요 (1/2/3) [3]: ").strip() or "3"
- if choice not in {"1", "2", "3"}:
- print("잘못된 선택입니다.")
+ choice = input("\n실행할 작업을 선택하세요 (1/2/3/4) [4]: ").strip() or "4"
+ if choice not in {"1", "2", "3", "4"}:
+ print("잘못된 입력입니다.")
return 1
-
+
game_id = input("경기 ID를 입력하세요 (예: 20260501NCLG02026): ").strip()
if not game_id:
print("경기 ID가 필요합니다.")
return 1
- game_id = "".join(game_id.split())
-
- base_url = ""
- user_data_dir = ""
- if choice in {"2", "3"}:
- base_url = input("기록 사이트 기본 URL을 입력하세요 (엔터 시 site.txt 자동 참조): ").strip()
- if not base_url:
- site_txt = Path("site.txt")
- if site_txt.exists():
- lines = site_txt.read_text(encoding="utf-8").splitlines()
- if lines and lines[0].startswith("http"):
- from urllib.parse import urlparse
- parsed = urlparse(lines[0])
- base_url = f"{parsed.scheme}://{parsed.netloc}"
- print(f"👉 [자동 설정] 기록 사이트 URL: {base_url}")
-
- if not base_url:
- base_url = input("URL을 찾을 수 없습니다. 직접 입력하세요: ").strip()
- if not base_url:
- print("URL이 필요합니다.")
- return 1
-
+
+ base_url = DEFAULT_BASE_URL
+ user_data_dir = None
+ manager_game_no = ""
+
+ if choice in {"2", "3", "4"}:
+ base_url_input = input("기록 사이트 기본 URL을 입력하세요 (엔터 시 site.txt 자동 참조): ").strip()
+ if base_url_input:
+ base_url = base_url_input
+ else:
+ try:
+ site_txt_path = Path("site.txt")
+ if site_txt_path.exists():
+ lines = site_txt_path.read_text(encoding="utf-8").splitlines()
+ if lines and lines[0].startswith("http"):
+ from urllib.parse import urlparse
+ parsed = urlparse(lines[0])
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
+ print(f"👉 [자동 설정] 기록 사이트 URL: {base_url}")
+ except Exception as e:
+ print(f"site.txt 파일을 읽는 중 오류가 발생했습니다: {e}")
+ return 1
+
user_data_dir = input("크롬 프로필 경로를 입력하세요 (엔터 시 임시 세션): ").strip()
manager_game_no = input("관리자 사이트 게임번호를 입력하세요 (예: 11211, 모르면 엔터): ").strip()
@@ -92,9 +96,10 @@ def interactive_mode() -> int:
start_inning=None,
end_inning=None,
output_dir="output",
+ save=True, # for lineup
)
- if choice in {"1", "3"}:
+ if choice in {"1", "4"}:
from crawler.report_builder import build_report, filter_report, save_report
print(f"\n[{args.game_id}] 데이터 크롤링 시작...")
report = build_report(args.game_id)
@@ -102,12 +107,19 @@ def interactive_mode() -> int:
out_path = save_report(filtered, Path(args.output_dir))
print(f"✅ 크롤링 및 리포트 저장 완료: {out_path}")
- if choice in {"2", "3"}:
+ if choice in {"2", "3", "4"}:
report_path = Path(args.output_dir) / f"{args.game_id}_report.json"
report = load_report(report_path)
- print(f"\n[{args.game_id}] 관리자 사이트 자동 입력 시작...")
+
with sync_playwright() as playwright:
- run_record(playwright, args, report)
+ if choice in {"2", "4"}:
+ from commands.lineup import run as run_lineup
+ run_lineup(playwright, args, report)
+
+ if choice in {"3", "4"}:
+ from commands.record import run as run_record
+ print(f"\n[{args.game_id}] 관리자 사이트 기록 자동 입력 시작...")
+ run_record(playwright, args, report)
return 0
diff --git a/commands/lineup.py b/commands/lineup.py
new file mode 100644
index 0000000..2946a1b
--- /dev/null
+++ b/commands/lineup.py
@@ -0,0 +1,212 @@
+from __future__ import annotations
+
+import argparse
+import re
+from typing import Any
+
+from playwright.sync_api import Page, Playwright
+
+from core.config_loader import position_to_defense_no
+from core.normalizer import normalize_stadium_name, normalize_team_name
+from commands.base import launch_browser_context
+
+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_to_defense_no().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_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"] 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 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 run(playwright: Playwright, args: argparse.Namespace, report: dict[str, Any]) -> None:
+ browser = launch_browser_context(playwright, args.user_data_dir, args.channel, args.headless)
+ page = browser.pages[0] if browser.pages else browser.new_page()
+
+ try:
+ print(f"[{args.game_id}] 관리자 사이트 라인업 입력 시작...")
+ open_edit_page(page, args.base_url, report, args.manager_game_no)
+ fill_lineup_form(page, report)
+
+ page.evaluate("""() => { window.confirm = () => true; window.alert = () => {}; }""")
+ page.locator("#lineupWriteBtn").click()
+ page.wait_for_timeout(1000)
+ print(f"✅ 라인업 저장 완료")
+
+ finally:
+ if args.close:
+ try:
+ browser.close()
+ except Exception:
+ pass
diff --git a/refactoring.md b/refactoring.md
new file mode 100644
index 0000000..9b1d69c
--- /dev/null
+++ b/refactoring.md
@@ -0,0 +1,1432 @@
+# Baseball-Automation 리팩토링 계획
+
+## 1. 현재 구조 분석
+
+### 1.1 파일 현황
+
+| 파일 | 라인 수 | 역할 | 문제점 |
+|------|---------|------|--------|
+| `record_game_playwright.py` | **3,003** | 게임기록 자동입력 (투구/타석/주루/교체/판독) | 한 파일에 70+개 함수, 매핑 dict 12개 포함 |
+| `game_report.py` | 1,091 | 네이버 API 크롤링 → JSON 리포트 생성 | 매핑 dict 8개 하드코딩, 파싱/빌드/렌더 혼재 |
+| `register_game_playwright.py` | 487 | 경기 등록 + 라인업 입력 | `STADIUM_NAME_MAP` 등 매핑 dict 4개 하드코딩 |
+| `finish_game_playwright.py` | 91 | 게임종료 팝업 처리 | `record_game`에서 import하여 의존 |
+| `lineup_only_playwright.py` | 108 | 라인업만 입력 | `register_game`에서 import |
+| `register_game_basic_playwright.py` | 117 | 경기 기본정보만 등록 | `register_game`에서 import |
+| `update_game_post_playwright.py` | 130 | 경기 후 메타정보 수정 | `register_game`에서 import |
+| `compare_history_with_report.py` | 197 | 히스토리 비교 검증 | `record_game`에서 매핑 import |
+| `browser_launch.py` | 59 | Playwright 브라우저 런처 | 양호 |
+| `db_logging.py` | 160 | SQLite 로깅 | 양호 |
+| `main.py` | 8 | Flask webapp 진입점 | 양호 |
+| `mapping_overrides/` | - | 참조용 매핑 JSON/문서 | **코드에서 미사용**, 참조 문서로만 존재 |
+
+### 1.2 핵심 문제 요약
+
+#### 🔴 하드코딩된 매핑/규칙의 중복
+
+동일한 매핑이 여러 파일에 중복 정의되어 불일치 위험:
+
+```
+POSITION_NUMBER_MAP → record_game (L109), register_game (L28)
+STADIUM_NAME_MAP → register_game (L41) 에만 존재
+TEAM_NAME_MAP → register_game (L19)
+TEAM_CODE_MAP → game_report (L17)
+GAME_TYPE_MAP → game_report (L43), register_game (L25)
+REVIEW_RESULT_GROUPS → game_report (L62), record_game (L98)
+PITCH_TYPE_LABEL_MAP → record_game (L26)
+PITCH_RESULT_LABEL_MAP → record_game (L41)
+BATTER_RESULT_LABEL_MAP → record_game (L58)
+RUNNER_EVENT_LABEL_MAP → record_game (L83)
+FIELD_COORDINATES → record_game (L132)
+HIT_BALL_TYPE_MAP → record_game (L122)
+DEFENSE_BUTTON_ID_MAP → record_game (L152)
+POSITION_TO_DEFENSE_NO → record_game (L164)
+```
+
+#### 🔴 `record_game_playwright.py`의 과부하 (3,003줄)
+
+한 파일 안에 아래 기능이 모두 혼재:
+- 투구 입력 (`set_pitch`, `set_pitch_meta_only`)
+- 타자 결과 추론/입력 (`infer_batter_result_label`, `set_batter_result_type`, `set_hit_ball_and_defense`)
+- 주루 이벤트 처리 (`set_runner_events`, `infer_runner_action_label`, `handle_late_runner_events`)
+- 라인업/교체 처리 (`apply_change_event`, `normalize_change_event`, `get_lineup_state`)
+- 합의판정 팝업 (`record_review_events`, `fill_review_row`)
+- 게임종료 팝업 (`fill_game_end_pitching`, `submit_game_end`)
+- 수비 팝업 조작 (`click_defense_sequence_in_popup`, `clear_defense_selections`)
+- UI 유틸리티 (`get_last_visible_locator`, `wait_for_visible_locator`, `show_debug_overlay`)
+- 메인 오케스트레이션 (`process_report`, `run`)
+
+#### 🔴 `mapping_overrides/` 미활용
+
+이미 `mapping_overrides/`에 표준 스키마(`standard_schema.md`)와 JSON 매핑 파일이 존재하지만, **코드에서 전혀 읽지 않음**. 코드 내 하드코딩된 dict와 이 폴더의 JSON이 별도 관리됨.
+
+#### 🟡 `game_report.py`의 복합 책임
+
+크롤링(HTTP) + 파싱 + 빌드 + 텍스트 렌더링 + 파일 저장이 한 파일에 혼재.
+
+#### 🟡 반복되는 boilerplate
+
+각 `*_playwright.py` 파일마다 `parse_args`, `resolve_report_path`, `run`, `main` 패턴이 거의 동일하게 반복.
+
+---
+
+## 2. 리팩토링 제안
+
+### 2.1 디렉토리 구조 (제안)
+
+```
+baseball-automation/
+├── config/ # 📋 모든 매핑/규칙을 YAML로 통합
+│ ├── mappings.yaml # 팀명, 구장, 포지션 등 alias 매핑
+│ ├── pitch_rules.yaml # 구종/투구결과/타자결과 매핑 규칙
+│ ├── runner_rules.yaml # 주루 이벤트 매핑 규칙
+│ ├── review_rules.yaml # 합의판정 매핑 규칙
+│ ├── field_coordinates.yaml # 구장 좌표/타구 종류
+│ └── site_selectors.yaml # 사이트 CSS 셀렉터/히든필드 ID
+│
+├── core/ # 🧠 순수 비즈니스 로직 (Playwright 무관)
+│ ├── __init__.py
+│ ├── config_loader.py # YAML 로딩 + 캐싱 + 검증
+│ ├── models.py # 데이터 클래스 (Pitch, AtBat, RunnerEvent 등)
+│ ├── normalizer.py # 팀명/구장/선수명/포지션 정규화 함수
+│ ├── pitch_classifier.py # 투구/타자 결과 분류 로직
+│ ├── runner_classifier.py # 주루 이벤트 분류/라벨 추론 로직
+│ ├── review_parser.py # 합의판정 파싱 로직
+│ ├── change_parser.py # 선수 교체 파싱 로직
+│ └── field_calculator.py # 타구 좌표/거리 계산
+│
+├── crawler/ # 🌐 네이버 API 크롤링
+│ ├── __init__.py
+│ ├── naver_api.py # HTTP 요청 래퍼
+│ ├── relay_parser.py # textRelay → 이닝/타석 이벤트 빌드
+│ ├── lineup_builder.py # 라인업 구성
+│ ├── report_builder.py # 최종 리포트 JSON 생성
+│ └── kbo_meta.py # KBO 사이트 메타(종료시간/관중) 크롤링
+│
+├── automation/ # 🤖 Playwright 사이트 자동화
+│ ├── __init__.py
+│ ├── browser.py # 브라우저 런치 (기존 browser_launch.py)
+│ ├── page_helpers.py # 공통 UI 유틸 (라디오 선택, 가시성 체크 등)
+│ ├── pitch_input.py # 투구 입력 자동화
+│ ├── batter_input.py # 타자 결과 입력 + 타구 팝업 처리
+│ ├── runner_input.py # 주루 이벤트 입력 자동화
+│ ├── lineup_input.py # 라인업/교체 입력 자동화
+│ ├── review_input.py # 합의판정 팝업 자동화
+│ ├── game_end_input.py # 게임종료 팝업 자동화
+│ ├── defense_popup.py # 수비 팝업 (자살/보살/실책) 조작
+│ └── debug_overlay.py # 디버그 오버레이 + 일시정지 제어
+│
+├── commands/ # 🚀 CLI 진입점 (각 스크립트)
+│ ├── __init__.py
+│ ├── base.py # 공통 argparse/브라우저/리포트 로딩 boilerplate
+│ ├── crawl.py # game_report 실행 (네이버 → JSON)
+│ ├── register.py # 경기 등록 + 라인업
+│ ├── register_basic.py # 경기 기본정보만 등록
+│ ├── record.py # 경기기록 자동입력
+│ ├── finish.py # 게임종료 처리
+│ ├── lineup_only.py # 라인업만 입력
+│ ├── update_post.py # 경기 후 메타 수정
+│ └── compare.py # 히스토리 비교 검증
+│
+├── logging/ # 📊 DB 로깅
+│ ├── __init__.py
+│ └── db_logger.py # 기존 db_logging.py
+│
+├── webapp/ # 🌍 Flask 웹앱 (기존 유지)
+│ ├── __init__.py
+│ ├── app.py
+│ ├── static/
+│ └── templates/
+│
+├── mapping_overrides/ # 📖 참조 문서 (삭제 또는 config/에 흡수)
+├── main.py # Flask 진입점
+├── requirements.txt
+└── README.md
+```
+
+### 2.2 YAML vs JSON 선택 근거
+
+| 기준 | JSON | YAML |
+|------|------|------|
+| 주석 | ❌ 불가 | ✅ `#` 주석 가능 |
+| 가독성 | 중첩 시 복잡 | 들여쓰기 기반으로 직관적 |
+| 한글 지원 | `ensure_ascii=False` 필요 | 기본 지원 |
+| Python 라이브러리 | 내장 `json` | `pyyaml` 추가 필요 |
+| 규칙 설명 첨부 | 별도 필드 필요 | 인라인 주석으로 가능 |
+
+> **추천: YAML** — 매핑 규칙에 한글 주석을 달 수 있어 유지보수에 유리. `mapping_overrides/`의 기존 JSON도 YAML로 통합.
+
+### 2.3 `config/mappings.yaml` 예시
+
+```yaml
+# ── 팀명 정규화 ──
+team_name:
+ 키움: Hero
+ 키움 히어로즈: Hero
+ Hero: Hero
+
+# ── 팀 코드 → 팀명 ──
+team_code:
+ HH: 한화
+ HT: KIA
+ KT: KT
+ LG: LG
+ LT: 롯데
+ NC: NC
+ OB: 두산
+ SK: SSG
+ SS: 삼성
+ WO: 키움
+
+# ── 구장 정규화 ──
+stadium_name:
+ 고척: 고척돔
+ 고척스카이돔: 고척돔
+ 잠실: 잠실
+ 잠실야구장: 잠실
+ 대구 삼성 라이온즈 파크: 대구라팍
+ # ... (기존 STADIUM_NAME_MAP 전체)
+
+# ── 경기 유형 정규화 ──
+game_type:
+ 와일드카드: 와일드카드 결정전
+ kbo_r: 정규경기
+ # ...
+
+# ── 포지션 ──
+position_number:
+ 투수: "1"
+ 포수: "2"
+ 1루수: "3"
+ 2루수: "4"
+ 3루수: "5"
+ 유격수: "6"
+ 좌익수: "7"
+ 중견수: "8"
+ 우익수: "9"
+ 지명타자: "10"
+```
+
+### 2.4 `config/pitch_rules.yaml` 예시
+
+```yaml
+# ── 구종 매핑 (네이버 텍스트 → 사이트 라벨) ──
+pitch_type:
+ 직구: 패스트볼
+ 패스트볼: 패스트볼
+ 커브: 커브
+ 체인지업: 체인지업
+ 슬라이더: 슬라이더
+ # ...
+
+# ── 투구 결과 매핑 ──
+pitch_result:
+ 볼: 볼
+ 스트라이크: "스트라이크(루킹)"
+ 헛스윙: "헛스윙(스트라이크)"
+ 번트헛스윙: 번트시도-스트라이크
+ 파울: 파울
+ 번트파울: 번트-파울
+ # ...
+
+# ── 타자 결과 (type → label 기본 매핑) ──
+batter_result_by_type:
+ walk: 포볼
+ intentional_walk: 고의사구
+ strikeout: "루킹스트라이크-아웃"
+ hit_by_pitch: 몸에 맞는 볼
+ single: 1루타
+ double: 2루타
+ triple: 3루타
+ home_run: 홈런
+ # ...
+
+# ── 타자 결과 우선순위 규칙 (텍스트/상황 기반) ──
+batter_result_priority:
+ - when: { text_contains_all: [낫아웃, 폭투] }
+ label: 폭투 낫아웃 진루
+ - when: { text_contains_all: [낫아웃, 포일] }
+ label: 포일 낫아웃 진루
+ - when: { text_contains: 병살 }
+ label: 병살-아웃
+ # ...
+```
+
+### 2.5 모듈 분리 상세
+
+#### `core/config_loader.py`
+```python
+"""YAML 설정 로딩 + 싱글턴 캐싱"""
+import yaml
+from functools import lru_cache
+from pathlib import Path
+
+CONFIG_DIR = Path(__file__).parent.parent / "config"
+
+@lru_cache(maxsize=None)
+def load_config(name: str) -> dict:
+ path = CONFIG_DIR / f"{name}.yaml"
+ with open(path, encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+def get_mapping(config_name: str, key: str) -> dict:
+ return load_config(config_name).get(key, {})
+```
+
+#### `core/normalizer.py`
+```python
+"""모든 정규화 함수를 한 곳에 집중"""
+from .config_loader import get_mapping
+
+def normalize_team_name(name: str) -> str:
+ return get_mapping("mappings", "team_name").get(name, name)
+
+def normalize_stadium_name(name: str) -> str:
+ return get_mapping("mappings", "stadium_name").get(name, name)
+
+def normalize_game_type(name: str) -> str:
+ return get_mapping("mappings", "game_type").get(name, name)
+
+def normalize_player_name(text: str) -> str:
+ # 기존 normalize_lineup_text / normalize_player_name_text 통합
+ ...
+```
+
+#### `record_game_playwright.py` → 6개 모듈로 분리
+
+| 원본 함수 그룹 | 이동 대상 | 줄 수 (대략) |
+|---------------|----------|------------|
+| `set_pitch`, `set_pitch_meta_only`, `normalize_pitch_result_code` | `automation/pitch_input.py` | ~100 |
+| `infer_batter_result_label`, `set_batter_result_type`, `set_hit_ball_and_defense`, `build_hit_ball_payload` | `automation/batter_input.py` | ~400 |
+| `set_runner_events`, `infer_runner_action_label`, `handle_late_runner_events`, `open_runner_area` | `automation/runner_input.py` | ~350 |
+| `apply_change_event`, `normalize_change_event`, `get_lineup_state`, `select_lineup_player` | `automation/lineup_input.py` | ~300 |
+| `record_review_events`, `fill_review_row`, `open_challenge_popup` | `automation/review_input.py` | ~200 |
+| `fill_game_end_pitching`, `submit_game_end` | `automation/game_end_input.py` | ~100 |
+| `get_last_visible_locator`, `wait_for_visible_locator`, `show_debug_overlay` 등 | `automation/page_helpers.py` | ~250 |
+| `click_defense_sequence_in_popup`, `clear_defense_selections` 등 | `automation/defense_popup.py` | ~150 |
+| 매핑 dict 12개 전체 | `config/*.yaml` | 제거 |
+| `process_report`, `run`, `main` | `commands/record.py` | ~200 |
+
+---
+
+## 3. 리팩토링 순서 (제안)
+
+### Phase 1: 설정 외부화 (config 분리)
+1. `config/` 디렉토리 생성 + YAML 파일 작성
+2. `core/config_loader.py` 구현
+3. 각 파일의 하드코딩 dict를 YAML 로딩으로 교체
+4. `mapping_overrides/` 내용을 `config/`로 흡수
+
+> **효과**: 매핑 수정 시 코드 변경 불필요, 중복 제거
+
+### Phase 2: 순수 로직 분리 (core 모듈)
+1. `core/normalizer.py` — 정규화 함수 통합
+2. `core/pitch_classifier.py` — `classify_pitch_result`, `infer_batter_result_label` 등
+3. `core/runner_classifier.py` — `infer_runner_action_label`, `parse_runner_event` 등
+4. `core/change_parser.py` — `parse_change_event`, `extract_change_actor` 등
+5. `core/review_parser.py` — `parse_review_event`, `infer_review_item` 등
+6. `core/field_calculator.py` — 좌표 계산, `build_hit_ball_payload` 등
+
+> **효과**: Playwright 없이 단위 테스트 가능
+
+### Phase 3: 크롤러 분리 (crawler 모듈)
+1. `crawler/naver_api.py` — HTTP 클라이언트 래퍼
+2. `crawler/relay_parser.py` — `build_relay_events`, `build_half_inning` 등
+3. `crawler/lineup_builder.py` — 라인업 관련 함수
+4. `crawler/report_builder.py` — `build_report`, `save_outputs`
+
+> **효과**: `game_report.py` (1,091줄) → 4개 모듈로 분산
+
+### Phase 4: 자동화 분리 (automation 모듈)
+1. `automation/page_helpers.py` — 공통 Playwright 유틸리티
+2. `automation/pitch_input.py` — 투구 입력
+3. `automation/batter_input.py` — 타자 결과 입력 + 팝업
+4. `automation/runner_input.py` — 주루 이벤트 입력
+5. `automation/lineup_input.py` — 라인업/교체 입력
+6. `automation/defense_popup.py` — 수비 팝업 조작
+7. `automation/review_input.py` — 합의판정 팝업
+8. `automation/game_end_input.py` — 게임종료 팝업
+
+> **효과**: `record_game_playwright.py` (3,003줄) → 8개 모듈로 분산
+
+### Phase 5: CLI 진입점 정리 (commands 모듈)
+1. `commands/base.py` — 공통 argparse/브라우저 boilerplate
+2. 각 `*_playwright.py` → `commands/*.py`로 이동
+3. 공통 패턴 추출 (parse_args, resolve_report_path, run 등)
+
+> **효과**: 새 명령 추가 시 boilerplate 최소화
+
+---
+
+## 4. 추가 개선 제안
+
+### 4.1 데이터 클래스 도입 (`core/models.py`)
+
+현재 모든 데이터가 `dict[str, Any]`로 전달되어 타입 안전성이 없음.
+
+```python
+from dataclasses import dataclass
+
+@dataclass
+class Pitch:
+ pitch_no: int
+ pitch_type: str | None
+ pitch_result: str
+ pitch_result_text: str
+ speed_kmh: int | None
+ runner_events: list['RunnerEvent']
+
+@dataclass
+class AtBatResult:
+ type: str # "single", "out", "strikeout" 등
+ text: str
+ to_base: int | None
+ extra_advance: int
+```
+
+> **효과**: IDE 자동완성, 필드 누락 방지, 디버깅 시 구조 명확
+
+### 4.2 사이트 셀렉터 외부화 (`config/site_selectors.yaml`)
+
+현재 CSS 셀렉터가 코드 곳곳에 하드코딩 (`#eventWriteBtn`, `#defenseDiv`, `#btnNext` 등). 사이트 UI가 바뀌면 전체 코드를 검색해야 함.
+
+```yaml
+buttons:
+ event_write: "#eventWriteBtn"
+ game_end: "#gameEndBtn"
+ challenge: "#challengeBtn"
+ defense_next: "#btnNext"
+ defense_add: "#btnAdd"
+
+forms:
+ ball_speed: "#ballspeed"
+ lineup_player: "#{side}_player_id_{idx}"
+ lineup_defense: "#{side}_defense_no_{idx}"
+```
+
+### 4.3 에러 핸들링 체계화
+
+현재 에러 처리가 `try/except: pass` 패턴이 많음. 커스텀 예외 클래스 도입 권장:
+
+```python
+class AutomationError(Exception): ...
+class MappingNotFoundError(AutomationError): ...
+class ElementNotFoundError(AutomationError): ...
+class LineupMismatchError(AutomationError): ...
+```
+
+### 4.4 단위 테스트 구조
+
+`core/` 모듈은 Playwright 의존성이 없으므로 순수 단위 테스트 가능:
+
+```
+tests/
+├── test_normalizer.py
+├── test_pitch_classifier.py
+├── test_runner_classifier.py
+├── test_change_parser.py
+└── test_field_calculator.py
+```
+
+---
+
+## 5. 의존성 그래프 (리팩토링 후)
+
+```mermaid
+graph TD
+ CONFIG[config/*.yaml] --> LOADER[core/config_loader]
+ LOADER --> NORM[core/normalizer]
+ LOADER --> PITCH_CLS[core/pitch_classifier]
+ LOADER --> RUN_CLS[core/runner_classifier]
+ LOADER --> FIELD[core/field_calculator]
+
+ NORM --> CRAWLER[crawler/]
+ NORM --> AUTO[automation/]
+
+ PITCH_CLS --> AUTO
+ RUN_CLS --> AUTO
+ FIELD --> AUTO
+
+ CRAWLER --> CMD[commands/]
+ AUTO --> CMD
+
+ CMD --> MAIN[main.py / CLI]
+
+ style CONFIG fill:#ffd700
+ style LOADER fill:#87ceeb
+ style AUTO fill:#98fb98
+ style CRAWLER fill:#ffb6c1
+ style CMD fill:#dda0dd
+```
+
+핵심 원칙: **config → core → crawler/automation → commands** 방향으로만 의존. 역방향 의존 금지.
+
+---
+
+## 6. 네이버 API 데이터 구조 분석 (실측)
+
+> 아래는 `20260414LTLG02026` (롯데 vs LG) 경기를 실제 크롤링한 결과입니다.
+
+### 6.1 API 계층 구조
+
+```
+GET /schedule/games/{game_id}/relay?inning={N}
+└── result.textRelayData.textRelays[] ← "relay 블록" 배열
+ ├── homeOrAway: 0(원정=초) / 1(홈=말)
+ ├── inn: 이닝 번호
+ ├── title: "5번타자 김민성" 또는 "2회초 롯데 공격"
+ └── textOptions[] ← 이벤트 배열 (seqno 순)
+ ├── type: 이벤트 종류 코드
+ ├── text: 텍스트 내용
+ ├── pitchNum / pitchResult / speed / stuff ← type=1일 때
+ ├── playerChange: {inPlayer, outPlayer} ← type=2일 때
+ └── currentGameState: {homeScore, awayScore, ...}
+```
+
+### 6.2 type 코드 매핑 (실측 확인)
+
+| type | 의미 | 예시 |
+|------|------|------|
+| **0** | 이닝 제목 | `1회초 롯데 공격` |
+| **1** | 투구 | `1구 볼` (pitchNum=1, pitchResult=B, speed=144, stuff=직구) |
+| **2** | 선수 교체 | `투수 김OO : 투수 박OO(으)로 교체` |
+| **7** | 기타 이벤트 | `코칭스태프 마운드 방문`, `투수 투수판 이탈`, `비디오 판독:...` |
+| **8** | 타자 제목 | `5번타자 김민성` |
+| **13** | 타석 결과 | `김민성 : 우익수 플라이 아웃` |
+| **14** | 주루 이벤트 (투구 중) | `1루주자 문보경 : 2루까지 진루` |
+| **24** | 주루 이벤트 (타석 후) | `2루주자 OO : 홈인` |
+| **98/99** | 무시 | 시스템 내부 |
+
+### 6.3 실제 데이터 흐름 예시 (3회말)
+
+```
+[3회말] relay 블록 7개
+
+relay[0] "5번타자 오지환"
+ [BATTER_TITLE] 5번타자 오지환
+ [TYPE_7] 코칭스태프 마운드 방문 ← 무시 대상
+ [PITCH #1] 1구 스트라이크 134km 포크 [T]
+ [PITCH #2] 2구 볼 125km 커브 [B]
+ [PITCH #3] 3구 타격 132km 포크 [H] ← 인플레이
+ [TYPE_13] 오지환 : 2루수 땅볼 아웃 ← 타석 결과
+
+relay[1] "4번타자 문보경"
+ [BATTER_TITLE] 4번타자 문보경
+ [PITCH #1~#6] ... 6구까지 (3B 1S 2F)
+ [TYPE_13] 문보경 : 볼넷 ← 포볼
+
+relay[2] "3번타자 오스틴"
+ [BATTER_TITLE] 3번타자 오스틴
+ [PITCH #1~#2] 2구 타격 [H]
+ [TYPE_13] 오스틴 : 좌익수 뒤 2루타
+ [RUNNER] 1루주자 문성주 : 3루까지 진루 ← 주루 이벤트
+```
+
+### 6.4 핵심 발견
+
+1. **relay 1블록 = 타자 1명** (대부분의 경우)
+2. **type=8 → type=1(반복) → type=13** 이 기본 패턴 (타자제목 → 투구들 → 결과)
+3. **type=14/24** 주루 이벤트는 마지막 투구 또는 결과 뒤에 붙음
+4. **type=7** 코칭방문/투수판이탈/비디오판독 등 기타 이벤트
+5. **pitchResult 코드**: `B`(볼), `T`(루킹), `S`(헛스윙), `F`(파울), `H`(인플레이), `BS`(번트헛스윙), `BF`(번트파울)
+6. **이닝 구분**: `homeOrAway=0`이면 초(원정 공격), `1`이면 말(홈 공격)
+
+---
+
+## 7. 관리자 사이트 입력 폼 분석 (실측)
+
+> `http://58.229.253.168:8089/manager/game/status?game_no=11205` 실제 폼 분석
+
+### 7.1 입력 영역 구조
+
+```
+┌─────────────────────────────────────────────┐
+│ 카운트: B(0-3) S(0-2) O(0-2) │
+├─────────────────────────────────────────────┤
+│ 구종 (evt_ballType): 12가지 라디오 │
+│ 직구/커브/슬라이더/체인지업/포크/투심/ │
+│ 커터/스플리터/너클/폭투/싱커/기타 │
+├─────────────────────────────────────────────┤
+│ 구속: #ballspeed (숫자 입력) │
+├─────────────────────────────────────────────┤
+│ 투구결과 (evt_batter): 18가지 라디오 │
+│ 볼/스트라이크(루킹)/헛스윙/파울/ │
+│ 번트-파울/보크/폭투-볼/포일-볼/... │
+├─────────────────────────────────────────────┤
+│ 타자결과-아웃 (evt_batter): ~25가지 │
+│ 루킹삼진/스윙삼진/아웃/희생플라이/ │
+│ 희생번트/병살/번트아웃/인필드플라이/... │
+├─────────────────────────────────────────────┤
+│ 타자결과-세이프 (evt_batter): ~38가지 │
+│ 1루타/2루타/3루타/홈런/번트안타/ │
+│ 내야안타/야수선택/포볼/수비실책/... │
+├─────────────────────────────────────────────┤
+│ 타자 진루: 1루/2루/3루/홈 라디오 │
+│ 주루가산: select (0~3) │
+├─────────────────────────────────────────────┤
+│ 주자 (1루/2루/3루 각각): │
+│ 진루: 세이프/아웃/진루 라디오 그룹 │
+│ 목적지: 1루/2루/3루/홈 라디오 │
+├─────────────────────────────────────────────┤
+│ [입력완료] [게임종료] [합의판정] │
+└─────────────────────────────────────────────┘
+```
+
+### 7.2 주요 폼 요소 ID/name 정리
+
+| 영역 | name/ID | 값/타입 |
+|------|---------|---------|
+| 구종 | `evt_ballType` | 라디오 1~12 |
+| 구속 | `#ballspeed` | 텍스트 입력 |
+| 투구결과 | `evt_batter` | 라디오 (eventName 속성으로 식별) |
+| 타자결과 | `evt_batter` | 라디오 (동일 name, defenseType 속성 구분) |
+| 타자진루 | `dat_evt_batter_advance` | 라디오 value=1~4 |
+| 주루가산 | `#batterRunningAdd` | select |
+| N루주자 액션 | `evt_runner_{N}` | 라디오 (eventName 속성) |
+| N루주자 진루 | `dat_evt_runner_{N}_advance` | 라디오 value=1~4 |
+| 수비자 클릭 | `defenseNumberBtn` | 라디오 (picher/catcher/...) |
+| 타구종류 | `hitBallType` | 라디오 value=0~6 |
+| 입력완료 | `#eventWriteBtn` | 버튼 |
+| 라인업 선수 | `#{side}_player_id_{idx}` | select |
+| 라인업 포지션 | `#{side}_defense_no_{idx}` | select |
+
+---
+
+## 8. 관리자 사이트 입력 템플릿 정규화
+
+> 핵심 아이디어: **관리자 사이트가 허용하는 입력값(Closed Set)을 먼저 정의**하고,
+> 네이버 데이터는 이 템플릿의 값으로만 매핑되도록 강제한다.
+> 매핑 결과가 템플릿에 없는 값이면 **오류**로 처리한다.
+
+### 8.1 설계 원칙
+
+```mermaid
+graph LR
+ TEMPLATE["입력 템플릿
(사이트 허용값 Closed Set)"]
+ NAVER["네이버 API
textRelays"]
+ RULES["매핑 규칙
config/*.yaml"]
+
+ NAVER -->|크롤링| RAW[원본 데이터]
+ RAW -->|매핑| MAPPED[정규화된 입력 데이터]
+ RULES --> MAPPED
+ MAPPED -->|검증| TEMPLATE
+ TEMPLATE -->|통과 시| SITE[관리자 사이트 폼 입력]
+ TEMPLATE -->|불일치 시| ERROR[❌ 매핑 오류 로그]
+```
+
+1. **템플릿이 진실의 원천**: 사이트가 받아들이는 값만 정의. 이 밖의 값은 입력 불가
+2. **네이버 데이터는 항상 매핑을 거침**: 원본 텍스트 → 매핑 규칙 → 템플릿 값으로 변환
+3. **검증 단계**: 매핑 결과가 템플릿에 존재하는지 확인. 없으면 오류 로그 + 중단
+
+### 8.2 입력 템플릿 (사이트에서 실측 추출)
+
+#### 구종 (`evt_ballType`)
+
+| 코드 | 라벨 |
+|------|------|
+| `01` | 패스트볼 |
+| `02` | 커브 |
+| `03` | 체인지업 |
+| `04` | 슬라이더 |
+| `05` | 커터 |
+| `06` | 스플리터 |
+| `07` | 너클 |
+| `08` | 폭투 |
+| `09` | 투심 |
+| `10` | 싱커 |
+| `11` | 포크볼 |
+| `12` | 기타 |
+
+#### 투구결과 (`evtEvent`)
+
+| 코드 | 라벨 |
+|------|------|
+| `EV01` | 볼 |
+| `EV02` | 스트라이크(루킹) |
+| `EV03` | 스트라이크(헛스윙) |
+| `EV04` | 파울 |
+| `EV05` | 헛스윙 |
+| `EV06` | 번트-볼 |
+| `EV07` | 번트시도-볼 |
+| `EV08` | 번트시도-스트라이크 |
+| `EV09` | 보크 |
+| `EV10` | 파울팁 |
+| `EV11` | 폭투-볼 |
+| `EV12` | 포일-볼 |
+| `EV13` | 파울플라이-실책 |
+| `EV14` | 폭투-스트라이크 |
+| `EV15` | 포일-스트라이크 |
+| `EV16` | 보크-볼 |
+| `EV17` | 노카운트 |
+| `EV18` | 승부치기1 |
+| `EV19` | 승부치기2 |
+
+#### 타자결과-아웃 (`evtHitterOut`)
+
+| 코드 | 라벨 |
+|------|------|
+| `HTO01` | 삼진 |
+| `HTO02` | 땅볼아웃 |
+| `HTO03` | 플라이아웃 |
+| `HTO04` | 직선타 |
+| `HTO05` | 희생번트 |
+| `HTO06` | 희생플라이 |
+| `HTO11` | 삼진(루킹) |
+| `HTO12` | 삼진(스윙) |
+| `HTO17` | 파울플라이-아웃 |
+| `HTO19` | 인필드플라이 |
+| `HTO22` | 기타아웃 |
+
+#### 타자결과-세이프 (`evtHitterSafe`)
+
+| 코드 | 라벨 |
+|------|------|
+| `HTS01` | 1루타 |
+| `HTS02` | 2루타 |
+| `HTS03` | 3루타 |
+| `HTS04` | 홈런 |
+| `HTS07` | 포볼 |
+| `HTS13` | 수비실책 |
+| `HTS18` | 낫아웃 진루 |
+| `HTS19` | 고의사구 |
+
+#### 주루결과 (`evt_runner_N`)
+
+| 코드 | 라벨 | 구분 |
+|------|------|------|
+| `S01` | 견제-세이프 | 세이프 |
+| `S02` | 도루성공 | 세이프 |
+| `S03` | 진루성공 | 세이프 |
+| `O01` | 견제-아웃 | 아웃 |
+| `O02` | 도루사 | 아웃 |
+| `O03` | 주루사 | 아웃 |
+
+#### 타구종류 (`hitBallType`)
+
+| value | 라벨 |
+|-------|------|
+| `1` ~ `9` | 타구 방향/종류 (필드 영역별) |
+
+#### 수비자 (`defenseNumberBtn`)
+
+| value | 포지션 |
+|-------|--------|
+| `1` | 투수 |
+| `2` | 포수 |
+| `3` | 1루수 |
+| `4` | 2루수 |
+| `5` | 3루수 |
+| `6` | 유격수 |
+| `7` | 좌익수 |
+| `8` | 중견수 |
+| `9` | 우익수 |
+
+### 8.3 네이버 → 템플릿 매핑 규칙
+
+#### 구종 매핑 (네이버 `stuff` → `evt_ballType`)
+
+```yaml
+pitch_type_mapping:
+ 직구: "01" # 패스트볼
+ 커브: "02"
+ 체인지업: "03"
+ 슬라이더: "04"
+ 커터: "05"
+ 스플리터: "06"
+ 너클: "07"
+ 투심: "09"
+ 싱커: "10"
+ 포크: "11" # 포크볼
+ 포크볼: "11"
+ _fallback: "12" # 기타
+```
+
+#### 투구결과 매핑 (네이버 `pitchResult` → `evtEvent`)
+
+```yaml
+pitch_result_mapping:
+ B: "EV01" # 볼
+ T: "EV02" # 스트라이크(루킹)
+ S: "EV03" # 스트라이크(헛스윙) — 또는 EV05(헛스윙)
+ F: "EV04" # 파울
+ BS: "EV08" # 번트시도-스트라이크
+ BF: "EV04" # 번트파울 → 파울
+ H: null # 인플레이 → 타자결과로 처리
+```
+
+#### 타석 결과 매핑 (네이버 type=13 텍스트 → `evtHitterOut` / `evtHitterSafe`)
+
+```yaml
+# 텍스트 패턴 → 템플릿 코드 (우선순위 순)
+batter_result_mapping:
+ - pattern: "삼진 아웃"
+ when_text_contains: "루킹" # 루킹삼진 구분
+ code: "HTO11"
+ - pattern: "삼진 아웃"
+ code: "HTO12" # 기본: 스윙삼진
+ - pattern: "병살타"
+ code: "HTO02" # 땅볼아웃 (병살)
+ - pattern: "플라이 아웃"
+ code: "HTO03"
+ - pattern: "파울플라이 아웃"
+ code: "HTO17"
+ - pattern: "희생 플라이"
+ code: "HTO06"
+ - pattern: "희생 번트"
+ code: "HTO05"
+ - pattern: "직선타"
+ code: "HTO04"
+ - pattern: "인필드플라이"
+ code: "HTO19"
+ - pattern: "땅볼 아웃"
+ code: "HTO02"
+ - pattern: "1루타"
+ code: "HTS01"
+ - pattern: "2루타"
+ code: "HTS02"
+ - pattern: "3루타"
+ code: "HTS03"
+ - pattern: "홈런"
+ code: "HTS04"
+ - pattern: "볼넷"
+ code: "HTS07"
+ - pattern: "고의사구"
+ code: "HTS19"
+ - pattern: "몸에 맞는 볼"
+ code: "HTS13" # 수비실책? → 별도 확인 필요
+ - pattern: "실책"
+ code: "HTS13"
+ - pattern: "낫아웃"
+ code: "HTS18"
+```
+
+#### 주루 이벤트 매핑 (네이버 type=14/24 텍스트 → `evt_runner_N`)
+
+```yaml
+runner_event_mapping:
+ - pattern: "진루"
+ code: "S03" # 진루성공
+ - pattern: "도루"
+ not_contains: "실패"
+ code: "S02" # 도루성공
+ - pattern: "무관심도루"
+ code: "S02" # 도루성공
+ - pattern: "견제"
+ contains: "세이프"
+ code: "S01" # 견제-세이프
+ - pattern: "견제"
+ not_contains: "세이프"
+ code: "O01" # 견제-아웃
+ - pattern: "도루실패"
+ code: "O02" # 도루사
+ - pattern: "태그아웃"
+ code: "O03" # 주루사
+ - pattern: "포스아웃"
+ code: "O03" # 주루사
+ - pattern: "홈인"
+ code: "S03" # 진루성공 (홈)
+```
+
+### 8.4 검증 흐름
+
+```python
+# 의사코드
+def validate_against_template(mapped_value: str, template_group: str) -> bool:
+ """매핑된 값이 해당 그룹의 Closed Set에 존재하는지 검증"""
+ allowed = TEMPLATE[template_group] # e.g. TEMPLATE["evtEvent"]
+ if mapped_value not in allowed:
+ log_error(f"매핑 오류: '{mapped_value}' not in {template_group}")
+ return False
+ return True
+
+# 투구 입력 시
+pitch_type_code = pitch_type_mapping.get(naver_stuff, "_fallback")
+assert validate_against_template(pitch_type_code, "evt_ballType")
+
+result_code = map_pitch_result(naver_pitchResult)
+assert validate_against_template(result_code, "evtEvent")
+```
+
+### 8.5 네이버 API → 관리자 사이트 전체 매핑 요약
+
+```
+네이버 API 관리자 사이트 입력
+─────────────────────────────────────────────────────────
+textOption.stuff (직구/커브/...) → evt_ballType (01~12)
+textOption.speed (144) → #ballspeed (숫자)
+textOption.pitchResult (B/T/S/F) → evtEvent (EV01~EV19)
+type=13 결과 텍스트 → evtHitterOut (HTO*) 또는
+ evtHitterSafe (HTS*)
+type=14/24 주루 텍스트 → evt_runner_N (S01~S03, O01~O03)
+type=2 교체 playerChange → 라인업 select 변경
+type=7 비디오판독 텍스트 → 합의판정 팝업
+```
+
+### 8.6 기존 코드 vs 제안 비교
+
+| 항목 | 현재 | 제안 |
+|------|------|------|
+| 매핑 기준 | 코드 내 dict + 텍스트 분석 | 사이트 폼 Closed Set이 진실의 원천 |
+| 검증 | 없음 (런타임 오류) | 입력 전 템플릿 검증 |
+| 새 규칙 추가 | 코드 수정 필요 | YAML 파일만 수정 |
+| 사이트 변경 대응 | 전체 코드 검색 | 템플릿 YAML만 업데이트 |
+| 디버깅 | 어떤 값이 입력됐는지 추적 어려움 | 원본→매핑→검증 로그 체계 |
+
+---
+
+## 9. 리스크 및 주의사항
+
+| 리스크 | 대응 |
+|--------|------|
+| 리팩토링 중 기존 기능 깨짐 | Phase별로 진행, 각 Phase 후 실제 경기 데이터로 E2E 테스트 |
+| YAML 파싱 오류 | `config_loader.py`에 스키마 검증 로직 추가 |
+| import 경로 변경 시 webapp 등 외부 참조 깨짐 | `__init__.py`에서 하위 호환 re-export 유지 |
+| `mapping_overrides/` 기존 JSON과 새 YAML 불일치 | Phase 1에서 JSON → YAML 마이그레이션 스크립트 작성 |
+| 사이트 폼 옵션 추가/변경 | 템플릿을 사이트에서 주기적으로 재추출하는 스크립트 마련 |
+| 네이버 API 응답 구조 변경 | 크롤러에 스키마 검증 추가, 필드 누락 시 경고 |
+
+---
+
+## 부록 A: 관리자 사이트 입력 템플릿 추출 스크립트
+
+> 관리자 사이트의 게임기록 폼에서 모든 허용값(Closed Set)을 자동으로 추출하여 YAML로 저장합니다.
+> 사이트 UI가 변경될 때마다 이 스크립트를 실행하면 템플릿이 자동 갱신됩니다.
+
+### `extract_site_template.py`
+
+```python
+"""
+관리자 사이트 게임기록 입력 폼에서 모든 라디오/셀렉트 옵션을 추출하여
+config/site_template.yaml 로 저장합니다.
+
+사용법:
+ python extract_site_template.py --game-no 11205
+ python extract_site_template.py --game-no 11205 --output config/site_template.yaml
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import yaml
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+from browser_launch import launch_browser_context
+
+DEFAULT_BASE_URL = "http://58.229.253.168:8089"
+DEFAULT_OUTPUT = Path("config/site_template.yaml")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="관리자 사이트 입력 폼 템플릿 추출")
+ parser.add_argument("--game-no", required=True, help="게임번호 (예: 11205)")
+ parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
+ parser.add_argument("--user-data-dir", default="playwright-user-data")
+ parser.add_argument("--channel", default="chrome")
+ return parser.parse_args()
+
+
+# ── 브라우저에서 실행할 JS: 모든 라디오 버튼 옵션 추출 ──
+EXTRACT_RADIOS_JS = """
+() => {
+ const groups = {};
+ document.querySelectorAll('input[type=radio]').forEach(radio => {
+ const name = radio.name;
+ if (!name) return;
+ if (!groups[name]) groups[name] = [];
+
+ // 라벨 텍스트 추출 (인접 텍스트 노드 또는 parent label)
+ let label = '';
+ const labelEl = radio.closest('label') || document.querySelector(`label[for="${radio.id}"]`);
+ if (labelEl) {
+ label = labelEl.textContent.trim();
+ } else {
+ let sibling = radio.nextSibling;
+ while (sibling) {
+ if (sibling.nodeType === 3 && sibling.textContent.trim()) {
+ label = sibling.textContent.trim();
+ break;
+ }
+ if (sibling.nodeType === 1 && sibling.textContent.trim()) {
+ label = sibling.textContent.trim();
+ break;
+ }
+ sibling = sibling.nextSibling;
+ }
+ }
+
+ groups[name].push({
+ id: radio.id || null,
+ value: radio.value,
+ eventName: radio.getAttribute('eventName') || null,
+ defenseType: radio.getAttribute('defenseType') || null,
+ label: label.substring(0, 50),
+ checked: radio.checked,
+ });
+ });
+ return groups;
+}
+"""
+
+# ── 브라우저에서 실행할 JS: 모든 select 옵션 추출 ──
+EXTRACT_SELECTS_JS = """
+() => {
+ const selects = {};
+ document.querySelectorAll('select').forEach(sel => {
+ const id = sel.id || sel.name;
+ if (!id) return;
+ const options = [];
+ sel.querySelectorAll('option').forEach(opt => {
+ options.push({
+ value: opt.value,
+ label: opt.textContent.trim(),
+ selected: opt.selected,
+ });
+ });
+ selects[id] = options;
+ });
+ return selects;
+}
+"""
+
+# ── 브라우저에서 실행할 JS: 모든 버튼 추출 ──
+EXTRACT_BUTTONS_JS = """
+() => {
+ const buttons = [];
+ document.querySelectorAll('button, input[type=button], input[type=submit]').forEach(btn => {
+ buttons.push({
+ id: btn.id || null,
+ type: btn.type || null,
+ text: (btn.textContent || btn.value || '').trim().substring(0, 50),
+ onclick: btn.getAttribute('onclick')?.substring(0, 80) || null,
+ });
+ });
+ return buttons;
+}
+"""
+
+
+def build_template(radios: dict, selects: dict, buttons: list) -> dict:
+ """추출한 raw 데이터를 구조화된 템플릿으로 변환"""
+ template = {
+ "_meta": {
+ "description": "관리자 사이트 게임기록 입력 폼에서 자동 추출한 허용값 목록",
+ "source": "extract_site_template.py",
+ "note": "이 파일은 자동 생성됩니다. 직접 수정하지 마세요.",
+ },
+ "pitch_type": {
+ "_name": "evt_ballType",
+ "_description": "구종 선택",
+ "options": [],
+ },
+ "pitch_result": {
+ "_name": "evt_batter (투구결과 영역)",
+ "_description": "투구 결과",
+ "options": [],
+ },
+ "batter_result_out": {
+ "_name": "evt_batter (타자결과-아웃 영역)",
+ "_description": "타자 결과 (아웃)",
+ "options": [],
+ },
+ "batter_result_safe": {
+ "_name": "evt_batter (타자결과-세이프 영역)",
+ "_description": "타자 결과 (세이프)",
+ "options": [],
+ },
+ "runner_event": {
+ "_name": "evt_runner_N",
+ "_description": "주루 이벤트 (1루/2루/3루 공통)",
+ "options": [],
+ },
+ "hit_ball_type": {
+ "_name": "hitBallType",
+ "_description": "타구 종류",
+ "options": [],
+ },
+ "defense_position": {
+ "_name": "defenseNumberBtn",
+ "_description": "수비자 포지션",
+ "options": [],
+ },
+ "batter_advance": {
+ "_name": "dat_evt_batter_advance",
+ "_description": "타자 진루",
+ "options": [],
+ },
+ }
+
+ # 구종
+ for item in radios.get("evt_ballType", []):
+ template["pitch_type"]["options"].append({
+ "value": item["value"],
+ "label": item["label"],
+ "eventName": item["eventName"],
+ })
+
+ # 투구결과 / 타자결과 분류 (evt_batter는 하나의 name에 투구+타자 결과가 혼재)
+ for item in radios.get("evt_batter", []):
+ defense_type = item.get("defenseType")
+ entry = {
+ "value": item["value"],
+ "label": item["label"],
+ "eventName": item["eventName"],
+ "defenseType": defense_type,
+ }
+ if not defense_type:
+ # defenseType이 없으면 투구결과 (볼/스트라이크/파울 등)
+ template["pitch_result"]["options"].append(entry)
+ elif defense_type in ("out", "o"):
+ template["batter_result_out"]["options"].append(entry)
+ else:
+ template["batter_result_safe"]["options"].append(entry)
+
+ # 주루 이벤트 (evt_runner_1 기준, 2/3도 동일한 옵션)
+ runner_name = "evt_runner_1"
+ if runner_name not in radios:
+ # fallback: evt_runner_로 시작하는 아무거나
+ for name in radios:
+ if name.startswith("evt_runner_"):
+ runner_name = name
+ break
+ for item in radios.get(runner_name, []):
+ template["runner_event"]["options"].append({
+ "value": item["value"],
+ "label": item["label"],
+ "eventName": item["eventName"],
+ })
+
+ # 타구 종류
+ for item in radios.get("hitBallType", []):
+ template["hit_ball_type"]["options"].append({
+ "value": item["value"],
+ "label": item["label"],
+ })
+
+ # 수비자
+ for item in radios.get("defenseNumberBtn", []):
+ template["defense_position"]["options"].append({
+ "value": item["value"],
+ "label": item["label"],
+ "id": item["id"],
+ })
+
+ # 타자 진루
+ for item in radios.get("dat_evt_batter_advance", []):
+ template["batter_advance"]["options"].append({
+ "value": item["value"],
+ "label": item["label"],
+ })
+
+ # select 요소 (주루가산, 라인업 등)
+ template["selects"] = {}
+ for sel_id, options in selects.items():
+ if not options or len(options) <= 1:
+ continue
+ template["selects"][sel_id] = [
+ {"value": o["value"], "label": o["label"]}
+ for o in options
+ if o["value"] # 빈 value 제외
+ ]
+
+ # 버튼
+ template["buttons"] = [
+ {"id": b["id"], "text": b["text"]}
+ for b in buttons
+ if b["id"]
+ ]
+
+ return template
+
+
+def main():
+ args = parse_args()
+ url = f"{args.base_url}/manager/game/status?game_no={args.game_no}"
+
+ with sync_playwright() as pw:
+ browser = launch_browser_context(
+ playwright=pw,
+ user_data_dir=args.user_data_dir,
+ channel=args.channel,
+ headless=False,
+ )
+ page = browser.pages[0] if browser.pages else browser.new_page()
+ page.goto(url)
+ page.wait_for_load_state("networkidle")
+ page.wait_for_timeout(2000)
+
+ # 추출
+ radios = page.evaluate(EXTRACT_RADIOS_JS)
+ selects = page.evaluate(EXTRACT_SELECTS_JS)
+ buttons = page.evaluate(EXTRACT_BUTTONS_JS)
+
+ browser.close()
+
+ # 템플릿 구성
+ template = build_template(radios, selects, buttons)
+
+ # 저장
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with open(args.output, "w", encoding="utf-8") as f:
+ yaml.dump(template, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
+
+ print(f"✅ 템플릿 추출 완료: {args.output}")
+
+ # 요약 출력
+ for group_name, group_data in template.items():
+ if isinstance(group_data, dict) and "options" in group_data:
+ print(f" {group_name}: {len(group_data['options'])}개 옵션")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+### 실행 방법
+
+```bash
+# 기본 실행 (game_no 지정 필수)
+python extract_site_template.py --game-no 11205
+
+# 출력 경로 지정
+python extract_site_template.py --game-no 11205 --output config/site_template.yaml
+
+# 결과 확인
+cat config/site_template.yaml
+```
+
+### 출력 예시 (`config/site_template.yaml`)
+
+```yaml
+_meta:
+ description: 관리자 사이트 게임기록 입력 폼에서 자동 추출한 허용값 목록
+ source: extract_site_template.py
+ note: 이 파일은 자동 생성됩니다. 직접 수정하지 마세요.
+
+pitch_type:
+ _name: evt_ballType
+ options:
+ - { value: "1", label: "패스트볼", eventName: "패스트볼" }
+ - { value: "2", label: "커브", eventName: "커브" }
+ - { value: "3", label: "체인지업", eventName: "체인지업" }
+ # ... 12개
+
+pitch_result:
+ _name: evt_batter
+ options:
+ - { value: "101", label: "볼", eventName: "볼" }
+ - { value: "102", label: "스트라이크(루킹)", eventName: "스트라이크(루킹)" }
+ # ... 19개
+
+batter_result_out:
+ options:
+ - { value: "201", label: "삼진", eventName: "삼진", defenseType: "out" }
+ # ...
+
+batter_result_safe:
+ options:
+ - { value: "301", label: "1루타", eventName: "1루타", defenseType: "safe" }
+ # ...
+
+runner_event:
+ options:
+ - { value: "401", label: "견제-세이프", eventName: "견제-세이프" }
+ # ...
+```
+
+---
+
+## 부록 B: 네이버 API 크롤링 데이터 구조 확인 스크립트
+
+> 네이버 스포츠 API에서 특정 경기의 relay 데이터를 크롤링하여 구조를 분석합니다.
+
+### `inspect_naver_relay.py`
+
+```python
+"""
+네이버 스포츠 API에서 경기 relay 데이터를 크롤링하여
+이닝/타석/투구/주루/교체 구조를 요약 출력합니다.
+
+사용법:
+ python inspect_naver_relay.py 20260501NCLG02026
+ python inspect_naver_relay.py 20260501NCLG02026 --innings 1-3
+ python inspect_naver_relay.py 20260501NCLG02026 --output output/relay_dump.json
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import defaultdict
+from pathlib import Path
+
+import httpx
+
+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",
+}
+
+# type 코드 → 한글 라벨
+TYPE_LABELS = {
+ 0: "INNING", # 이닝 제목
+ 1: "PITCH", # 투구
+ 2: "CHANGE", # 선수 교체
+ 7: "EVENT", # 기타 (마운드방문, 투수판이탈, 비디오판독 등)
+ 8: "BATTER", # 타자 제목
+ 13: "RESULT", # 타석 결과
+ 14: "RUNNER", # 주루 이벤트 (투구 중)
+ 23: "RESULT2", # 타석 결과 (다른 형태)
+ 24: "RUNNER2", # 주루 이벤트 (타석 후)
+ 98: "SKIP",
+ 99: "SKIP",
+}
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="네이버 relay 데이터 구조 분석")
+ parser.add_argument("game_id", help="예: 20260501NCLG02026")
+ parser.add_argument("--innings", default="1-12", help="이닝 범위 (예: 1-9, 5-7)")
+ parser.add_argument("--output", type=Path, help="JSON 덤프 저장 경로")
+ parser.add_argument("--verbose", "-v", action="store_true", help="모든 이벤트 출력")
+ return parser.parse_args()
+
+
+def parse_inning_range(s: str) -> range:
+ parts = s.split("-")
+ return range(int(parts[0]), int(parts[-1]) + 1)
+
+
+def fetch_game_info(client: httpx.Client, game_id: str) -> dict:
+ r = client.get(f"https://api-gw.sports.naver.com/schedule/games/{game_id}")
+ return r.json().get("result", {}).get("game", {})
+
+
+def fetch_relay(client: httpx.Client, game_id: str, inning: int) -> list[dict]:
+ r = client.get(
+ f"https://api-gw.sports.naver.com/schedule/games/{game_id}/relay",
+ params={"inning": inning},
+ )
+ trd = r.json().get("result", {}).get("textRelayData", {})
+ return trd.get("textRelays", [])
+
+
+def format_option(opt: dict) -> str:
+ """textOption 한 줄 포맷"""
+ t = opt.get("type", -1)
+ label = TYPE_LABELS.get(t, f"T{t}")
+ txt = (opt.get("text") or "").strip()
+
+ parts = [f"{label:8s}"]
+
+ if t == 1: # PITCH
+ parts.append(f"#{opt.get('pitchNum','')} [{opt.get('pitchResult','')}]")
+ parts.append(f"{opt.get('speed','')}km ({opt.get('stuff','')})")
+
+ if t == 2: # CHANGE
+ pc = opt.get("playerChange", {})
+ out_p = pc.get("outPlayer", {}).get("playerName", "")
+ in_p = pc.get("inPlayer", {}).get("playerName", "")
+ pos = pc.get("inPlayer", {}).get("playerPos", "")
+ parts.append(f"[{out_p} → {in_p} ({pos})]")
+
+ parts.append(txt[:70])
+ return " ".join(parts)
+
+
+def main():
+ args = parse_args()
+ inning_range = parse_inning_range(args.innings)
+ all_data = []
+
+ with httpx.Client(headers=HEADERS, timeout=20.0) as client:
+ # 게임 정보
+ gi = fetch_game_info(client, args.game_id)
+ print(f"{'='*60}")
+ print(f"GAME: {gi.get('awayTeamName','?')} vs {gi.get('homeTeamName','?')}")
+ print(f"DATE: {gi.get('gameDate','?')} | STATUS: {gi.get('statusCode','?')}")
+ print(f"{'='*60}")
+
+ for inning in inning_range:
+ relays = fetch_relay(client, args.game_id, inning)
+ if not relays:
+ break
+
+ all_data.extend(relays)
+
+ # homeOrAway로 초/말 분류
+ grouped = defaultdict(list)
+ for relay in relays:
+ grouped[int(relay.get("homeOrAway", -1))].append(relay)
+
+ for half in (0, 1):
+ half_label = "초" if half == 0 else "말"
+ half_relays = grouped.get(half, [])
+ if not half_relays:
+ continue
+
+ print(f"\n[{inning}회{half_label}] relay 블록: {len(half_relays)}개")
+
+ for ri, relay in enumerate(half_relays):
+ opts = sorted(
+ relay.get("textOptions", []),
+ key=lambda o: int(o.get("seqno", -1)),
+ )
+ title = relay.get("title", "")
+
+ # 교체/주루/판독 등 특수 이벤트가 있는 블록은 항상 출력
+ has_special = any(
+ o.get("type") in (2, 7, 14, 24) for o in opts
+ )
+ if has_special or args.verbose:
+ print(f"\n relay[{ri}] \"{title}\"")
+ for o in opts:
+ print(f" {format_option(o)}")
+ else:
+ # 간략 출력 (타자명 + 결과만)
+ result_opts = [
+ o for o in opts if o.get("type") in (13, 23)
+ ]
+ result_text = result_opts[0].get("text", "").strip() if result_opts else ""
+ pitch_count = sum(1 for o in opts if o.get("type") == 1)
+ print(f" [{ri}] {title} | {pitch_count}구 | {result_text}")
+
+ # 구조 통계
+ type_counts = defaultdict(int)
+ for relay in all_data:
+ for opt in relay.get("textOptions", []):
+ t = opt.get("type", -1)
+ type_counts[TYPE_LABELS.get(t, f"T{t}")] += 1
+
+ print(f"\n{'='*60}")
+ print("type 코드별 발생 횟수:")
+ for label, count in sorted(type_counts.items(), key=lambda x: -x[1]):
+ print(f" {label:12s}: {count:4d}회")
+
+ # JSON 덤프
+ if args.output:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with open(args.output, "w", encoding="utf-8") as f:
+ json.dump(all_data, f, ensure_ascii=False, indent=2)
+ print(f"\n✅ relay 덤프 저장: {args.output}")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+### 실행 방법
+
+```bash
+# 전체 이닝 요약 (특수 이벤트만 상세)
+python inspect_naver_relay.py 20260501NCLG02026
+
+# 1~3회만 모든 이벤트 상세 출력
+python inspect_naver_relay.py 20260501NCLG02026 --innings 1-3 -v
+
+# JSON 덤프 저장 (리팩토링 시 테스트 데이터로 활용)
+python inspect_naver_relay.py 20260501NCLG02026 --output output/relay_dump.json
+```