Files
baseball-automation/core/pitch_classifier.py
2026-05-02 16:24:42 +09:00

274 lines
9.4 KiB
Python

"""
core/pitch_classifier.py — 투구/타자 결과 분류
네이버 리포트 데이터를 기반으로 관리자 사이트에서 선택해야 할
라디오 버튼의 라벨(eventName)을 결정합니다.
Playwright 의존성 없이 순수 파이썬 로직만 포함합니다.
"""
from __future__ import annotations
import re
from typing import Any
from core.config_loader import (
pitch_type_map,
pitch_result_map,
batter_result_map,
)
# ──────────────────────────────────────────────
# 구종 분류
# ──────────────────────────────────────────────
def classify_pitch_type(pitch_type_text: str) -> str | None:
"""네이버 구종 텍스트 → 사이트 구종 라벨
예: '직구''패스트볼', '포크''포크볼'
"""
return pitch_type_map().get(pitch_type_text or "")
# ──────────────────────────────────────────────
# 투구 결과 분류
# ──────────────────────────────────────────────
def classify_pitch_result(pitch_result_text: str) -> str | None:
"""네이버 투구결과 텍스트 → 사이트 투구결과 라벨
예: '''', '스트라이크''스트라이크(루킹)'
"""
return pitch_result_map().get(pitch_result_text or "")
def normalize_pitch_result_code(pitch: dict[str, Any], event: dict[str, Any] | None = None) -> str:
"""투구의 pitchResult 코드를 정규화
피치클락, 번트헛스윙, 폭투/포일 등 특수 상황 처리
"""
pitch_result = (pitch.get("pitchResult") or "").strip()
pitch_result_text = (pitch.get("pitchResultText") or "").strip()
normalized_text = pitch_result_text.replace(" ", "")
# 피치클락 투수위반 → 볼
if "피치클락" in pitch_result_text and "투수위반" in pitch_result_text:
return "B"
# 번트 헛스윙 → BS
if "번트" in normalized_text and "헛스윙" in normalized_text:
return "BS"
# 폭투/포일 진루 시 → 볼
runner_events = _get_pitch_runner_events(pitch, event)
if any(
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
for re_ in runner_events
):
return "B"
if any(
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
for re_ in runner_events
):
return "B"
return pitch_result
# ──────────────────────────────────────────────
# 타자 결과 분류
# ──────────────────────────────────────────────
def classify_batter_result(result_type: str) -> str | None:
"""결과 타입 코드 → 사이트 타자결과 라벨 (기본 매핑)
더 복잡한 추론이 필요한 경우 infer_batter_result_label 사용.
"""
return batter_result_map().get(result_type or "")
def infer_batter_result_label(
result: dict[str, Any],
event: dict[str, Any] | None = None,
) -> str | None:
"""타석 결과를 종합적으로 추론하여 사이트 라벨 반환
result.type, result.text, 주루이벤트, 마지막 투구 등을 모두 분석.
"""
result_type = result.get("type") or ""
result_text = (result.get("text") or "").strip()
runner_events = (event or {}).get("runnerEvents") or []
last_pitch_result_text = get_last_pitch_result_text(event)
# 낫아웃
if result_type == "strikeout_not_out" or "낫아웃" in result_text:
if "폭투" in result_text:
return "폭투 낫아웃 진루"
if "포일" in result_text:
return "포일 낫아웃 진루"
if "아웃" in result_text:
return "스트라이크-낫아웃"
return "낫아웃-출루"
# 삼진
if result_type == "strikeout":
if "헛스윙" in last_pitch_result_text or "헛스윙" in result_text:
return "스윙 스트라이크-아웃"
return "루킹스트라이크-아웃"
# 희생 번트
if "희생 번트" in result_text or "희생번트" in result_text:
return "희생 번트"
# 번트 아웃
if "번트 아웃" in result_text or "번트아웃" in result_text:
return "번트-아웃"
# 보크
if any(
"보크" in (re_.get("text") or "") and "진루" in (re_.get("text") or "")
for re_ in runner_events
):
if "" in last_pitch_result_text:
return "보크-볼"
return "보크"
# 폭투-볼
if any(re_.get("type") == "wild_pitch_advance" for re_ in runner_events):
return "폭투-볼"
# 포볼
if result_type == "walk":
if any(
re_.get("type") == "wild_pitch_advance" or "폭투" in (re_.get("text") or "")
for re_ in runner_events
):
return "폭투-포볼"
if any(
re_.get("type") == "passed_ball_advance" or "포일" in (re_.get("text") or "")
for re_ in runner_events
):
return "포일-포볼"
return "포볼"
# 포일-볼/스트라이크
if any(
(re_.get("type") or "") == "passed_ball_advance"
for re_ in runner_events
):
if "" in last_pitch_result_text:
return "포일-볼"
return "포일-스트라이크"
# 수비실책
if result_type == "reach_on_error" or "실책" in result_text:
return "수비실책"
# 야수선택
if result_type == "reach_on_fielder_choice":
return "야수선택"
# 땅볼출루
if result_type == "reach_on_grounder":
return "땅볼출루(무안타)"
# 병살
if result_type == "double_play":
if "번트" in result_text:
return "번트-병살"
return "병살-아웃"
# N루타 후 주루아웃
if result_type == "single_runner_out":
return "1루타 후 주루아웃"
if result_type == "double_runner_out":
return "2루타 후 주루아웃"
if result_type == "triple_runner_out":
return "3루타 후 주루아웃"
# N루타 후 수비실책진루
if result_type == "single_error_advance":
return "1루타 후 수비실책진루"
if result_type == "double_error_advance":
return "2루타 후 수비실책진루"
if result_type == "triple_error_advance":
return "3루타 후 수비실책진루"
# 파울희생플라이
if "파울희생플라이" in result_text or "파울 희생플라이" in result_text:
return "희생 플라이"
# 아웃 상세
if result_type == "out":
if "병살" in result_text:
if "번트" in result_text:
return "번트-병살"
return "병살-아웃"
if "희생 플라이" in result_text or "희생플라이" in result_text:
return "희생 플라이"
if "인필드플라이" in result_text:
return "인필드플라이"
if "파울플라이" in result_text:
return "파울플라이-아웃"
return "아웃"
# 번트안타
if result_type == "bunt_hit":
return "번트안타"
# 내야안타
if result_type == "single":
if "번트안타" in result_text:
return "번트안타"
if "내야안타" in result_text:
return "내야안타"
# 몸에 맞는 볼
if result_type == "hit_by_pitch" or "헤드샷" in result_text:
return "몸에 맞는 볼"
# 기본 매핑 폴백
return classify_batter_result(result_type)
def is_simple_terminal_result_type(result_type: str) -> bool:
"""팝업 없이 즉시 완료되는 결과 타입인지 확인"""
return result_type in {"strikeout", "strikeout_not_out", "walk", "intentional_walk", "hit_by_pitch"}
def is_ball_in_play_event(event: dict[str, Any]) -> bool:
"""인플레이 이벤트인지 확인 (마지막 투구가 H)"""
pitches = event.get("pitches") or []
result = event.get("result") or {}
if not pitches or not result:
return False
return pitches[-1].get("pitchResult") == "H"
# ──────────────────────────────────────────────
# 내부 헬퍼
# ──────────────────────────────────────────────
def _get_pitch_runner_events(
pitch: dict[str, Any],
event: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""투구에 연결된 주루이벤트 반환"""
if pitch.get("runnerEvents"):
return pitch["runnerEvents"]
if event:
pitch_num = pitch.get("pitchNum")
for re_ in (event.get("runnerEvents") or []):
if re_.get("pitchNum") == pitch_num:
return [re_]
return []
def get_last_pitch_result_text(event: dict[str, Any] | None) -> str:
"""이벤트의 마지막 투구 결과 텍스트 반환"""
if not event:
return ""
pitches = event.get("pitches") or []
if not pitches:
return ""
return (pitches[-1].get("pitchResultText") or "").strip()