Files
baseball-automation/refactoring.md
2026-05-02 16:36:13 +09:00

51 KiB

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 예시

# ── 팀명 정규화 ──
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 예시

# ── 구종 매핑 (네이버 텍스트 → 사이트 라벨) ──
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

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

"""모든 정규화 함수를 한 곳에 집중"""
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.pyclassify_pitch_result, infer_batter_result_label
  3. core/runner_classifier.pyinfer_runner_action_label, parse_runner_event
  4. core/change_parser.pyparse_change_event, extract_change_actor
  5. core/review_parser.pyparse_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.pybuild_relay_events, build_half_inning
  3. crawler/lineup_builder.py — 라인업 관련 함수
  4. crawler/report_builder.pybuild_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.pycommands/*.py로 이동
  3. 공통 패턴 추출 (parse_args, resolve_report_path, run 등)

효과: 새 명령 추가 시 boilerplate 최소화


4. 추가 개선 제안

4.1 데이터 클래스 도입 (core/models.py)

현재 모든 데이터가 dict[str, Any]로 전달되어 타입 안전성이 없음.

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가 바뀌면 전체 코드를 검색해야 함.

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 패턴이 많음. 커스텀 예외 클래스 도입 권장:

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. 의존성 그래프 (리팩토링 후)

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 설계 원칙

graph LR
    TEMPLATE["입력 템플릿<br/>(사이트 허용값 Closed Set)"]
    NAVER["네이버 API<br/>textRelays"]
    RULES["매핑 규칙<br/>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 네이버 → 템플릿 매핑 규칙

구종 매핑 (네이버 stuffevt_ballType)

pitch_type_mapping:
  직구: "01"       # 패스트볼
  커브: "02"
  체인지업: "03"
  슬라이더: "04"
  커터: "05"
  스플리터: "06"
  너클: "07"
  투심: "09"
  싱커: "10"
  포크: "11"       # 포크볼
  포크볼: "11"
  _fallback: "12"  # 기타

투구결과 매핑 (네이버 pitchResultevtEvent)

pitch_result_mapping:
  B: "EV01"   # 볼
  T: "EV02"   # 스트라이크(루킹)
  S: "EV03"   # 스트라이크(헛스윙) — 또는 EV05(헛스윙)
  F: "EV04"   # 파울
  BS: "EV08"  # 번트시도-스트라이크
  BF: "EV04"  # 번트파울 → 파울
  H: null     # 인플레이 → 타자결과로 처리

타석 결과 매핑 (네이버 type=13 텍스트 → evtHitterOut / evtHitterSafe)

# 텍스트 패턴 → 템플릿 코드 (우선순위 순)
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)

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 검증 흐름

# 의사코드
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

"""
관리자 사이트 게임기록 입력 폼에서 모든 라디오/셀렉트 옵션을 추출하여
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()

실행 방법

# 기본 실행 (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)

_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

"""
네이버 스포츠 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()

실행 방법

# 전체 이닝 요약 (특수 이벤트만 상세)
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