From 3c6df12e70eec0d48d9ec48808160c3e81cb3d0b Mon Sep 17 00:00:00 2001 From: legojeon Date: Sat, 2 May 2026 16:36:13 +0900 Subject: [PATCH] Add refactoring.md --- cli.py | 76 ++- commands/lineup.py | 212 +++++++ refactoring.md | 1432 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1688 insertions(+), 32 deletions(-) create mode 100644 commands/lineup.py create mode 100644 refactoring.md 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 +```