# 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 ```