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

1433 lines
51 KiB
Markdown

# 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["입력 템플릿<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 네이버 → 템플릿 매핑 규칙
#### 구종 매핑 (네이버 `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
```